Skip to content
quarry Artifact [6dab474fc2]
Login

Artifact [6dab474fc2]

Artifact 6dab474fc2d98039b4b985fdd21a54a4ca5ca1a60680af84b6e60bad80435914:


package web

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"regexp"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/errlog"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

func testRegistry(t *testing.T) *ops.Registry {
	t.Helper()
	// An empty (but real) index so entity lookups answer not-found
	// rather than "no store configured".
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	return r
}

func TestHomePageRendersThroughVersionOp(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "quarry 9.9.9-test") {
		t.Fatalf("footer missing op-served version:\n%s", body)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
		t.Fatalf("content-type %q", ct)
	}
	// Principle 3: the ONLY script is the vendored Datastar module (the
	// one allowed framework, pure enhancement); no inline code.
	if n := strings.Count(body, "<script"); n != 1 || !strings.Contains(body, `src="/assets/datastar.js"`) {
		t.Fatalf("script policy violated (%d script tags):\n%s", n, body)
	}
	if strings.Contains(body, "<script>") {
		t.Fatal("inline script on the base page")
	}
	if !strings.Contains(body, "prefers-color-scheme") {
		t.Fatal("dark theme missing")
	}
}

func TestUnknownPathIs404(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/nope", nil))
	if rec.Code != 404 {
		t.Fatalf("status %d", rec.Code)
	}
}

func TestThemed404Page(t *testing.T) {
	for _, path := range []string{"/nope", "/r/test/wiki/NoSuchPage", "/r/test/checkin/deadbeefdead", "/r/test/ticket/deadbeef"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 404 {
			t.Errorf("%s: status %d", path, rec.Code)
			continue
		}
		body := rec.Body.String()
		if !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
			t.Errorf("%s: content-type %q — bare default 404?", path, rec.Header().Get("Content-Type"))
		}
		if !strings.Contains(body, "quarry 9.9.9-test") {
			t.Errorf("%s: 404 page missing version footer", path)
		}
		if !strings.Contains(body, `href="/"`) {
			t.Errorf("%s: 404 page has no way back into the site", path)
		}
	}
}

func TestPersistentNavOnEveryPage(t *testing.T) {
	// Ticket 010: every repo page carries that repo's nav and search
	// form; every page (fleet and 404 included) carries the site link.
	for _, path := range []string{"/r/test/timeline", "/r/test/docs", "/r/test/ticket", "/r/test/search"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		body := rec.Body.String()
		for _, want := range []string{`<nav>`, `href="/r/test/timeline"`, `href="/r/test/docs"`, `href="/r/test/ticket"`, `action="/r/test/search"`} {
			if !strings.Contains(body, want) {
				t.Errorf("%s: missing %s in header", path, want)
			}
		}
	}
	for _, path := range []string{"/", "/nope"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if !strings.Contains(rec.Body.String(), `class="site" href="/"`) {
			t.Errorf("%s: missing site link", path)
		}
	}
}

func TestFleetPageListsRepos(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	// A repository's name opens the repository, which is its files —
	// not the timeline. Landing on what changed rather than on what the
	// project is was the old behaviour.
	if !strings.Contains(rec.Body.String(), `href="/r/test"`) {
		t.Fatalf("fleet page missing repo link:\n%s", rec.Body.String())
	}
}

func TestTablesScrollInWrapper(t *testing.T) {
	// Ticket 120: wide tables scroll inside .tw, not the page.
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/timeline", nil))
	body := rec.Body.String()
	if strings.Contains(body, "<table>") && !strings.Contains(body, `<div class="tw"><table>`) {
		t.Fatalf("timeline table not wrapped:\n%s", body)
	}
}

func TestTimelineRendersAllRowsWithBranches(t *testing.T) {
	// Regression: a template field mismatch once truncated the page at
	// the first row — Execute's error was discarded. Render a POPULATED
	// timeline and assert every row, branch labels, and a complete page.
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	var batch []index.Parsed
	for i := 0; i < 5; i++ {
		h := strings.Repeat(fmt.Sprintf("%02d", i+1), 32)
		art := &artifact.Artifact{Type: artifact.Manifest, User: "dan",
			Date:    fmt.Sprintf("2026-08-15T0%d:00:00", i),
			Comment: fmt.Sprintf("commit %d", i)}
		if i == 0 {
			art.Tags = []artifact.Tag{{Name: "*branch", Target: "*", Value: "trunk"}}
		}
		if i > 0 {
			art.Parents = []string{strings.Repeat(fmt.Sprintf("%02d", i), 32)}
		}
		batch = append(batch, index.Parsed{Hash: h, Art: art})
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	rec := httptest.NewRecorder()
	Handler(r, nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/timeline", nil))
	body := rec.Body.String()
	if got := strings.Count(body, `class="tl-card"`); got != 5 {
		t.Fatalf("rendered %d rows, want 5:\n%s", got, body)
	}
	// Count the GRAPH's layers specifically. A bare "<svg " count also
	// catches the file-listing icons, which live as data URIs in the
	// base stylesheet and have nothing to do with the timeline.
	if got := strings.Count(body, `<svg class="tl-`); got != 10 { // head + tail per row
		t.Fatalf("rendered %d graph layers, want 10", got)
	}
	if got := strings.Count(body, `<svg class="tl-head`); got != 5 {
		t.Fatalf("rendered %d head layers, want 5", got)
	}
	if !strings.Contains(body, `<span class="tl-pill">Sat · 2026-08-15</span>`) {
		t.Fatal("date pill missing")
	}
	if !strings.Contains(body, `href="/r/test/timeline?branch=trunk"><b>trunk</b></a>`) {
		t.Fatalf("branch label missing:\n%s", body)
	}
	if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
		t.Fatal("page truncated — template error swallowed")
	}
}

func TestTimelineGraphRails(t *testing.T) {
	// DAG:  m (merge of c2+b1) ← newest
	//       b1 (branch dev, parent c1)
	//       c2 (trunk, parent c1)
	//       c1 (trunk root)
	c1, c2, b1, m := strings.Repeat("aa", 32), strings.Repeat("bb", 32), strings.Repeat("cc", 32), strings.Repeat("dd", 32)
	entries := []timelineEntry{
		{Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge", IsMerge: true, Parents: []string{c2, b1}},
		{Hash: b1, Date: "2026-08-15T03:00:00", Branch: "dev", Comment: "dev work", Parents: []string{c1}},
		{Hash: c2, Date: "2026-08-15T02:00:00", Branch: "trunk", Comment: "trunk work", Parents: []string{c1}},
		{Hash: c1, Date: "2026-08-15T01:00:00", Branch: "trunk", Comment: "root"},
	}
	days := buildTimeline("test", "trunk", entries)
	if len(days) != 1 || len(days[0].Rows) != 4 {
		t.Fatalf("days = %+v", days)
	}
	svg := func(i int) string { return string(days[0].Rows[i].Head) + string(days[0].Rows[i].Tail) }
	// The merge row curves out to a second rail.
	if !strings.Contains(svg(0), "<path") {
		t.Fatalf("merge row has no curve:\n%s", svg(0))
	}
	// The dev row sits on a different x than the trunk rail and carries
	// the dev branch color.
	if !strings.Contains(svg(1), newPalette("trunk", entries).stroke("dev")) {
		t.Fatalf("dev row not dev-colored:\n%s", svg(1))
	}
	if !strings.Contains(string(days[0].Rows[1].Head), `cx="21"`) {
		t.Fatalf("dev node not on second rail:\n%s", days[0].Rows[1].Head)
	}
	// The trunk row between branch point and merge shows the dev rail
	// passing through.
	if !strings.Contains(svg(2), `x1="21"`) {
		t.Fatalf("dev rail not passing through trunk row:\n%s", svg(2))
	}
	// The root row converges both rails (a join curve).
	if !strings.Contains(svg(3), "<path") {
		t.Fatalf("root row has no join curve:\n%s", svg(3))
	}
	// Root has nothing below: no line from cy to unitH on the node rail.
	if strings.Contains(svg(3), `y1="13" x2="7" y2="26"`) {
		t.Fatalf("root row continues below:\n%s", svg(3))
	}
}

func TestTimelineGraphBranchColorContinuity(t *testing.T) {
	// The dev branch line must be dev-colored end to end: the merge
	// curve out of m, the rail between m and b1, b1's own segments, and
	// b1's join curve at the fork commit c1. No empty-branch hash hues.
	c1, c2, b1, m := strings.Repeat("aa", 32), strings.Repeat("bb", 32), strings.Repeat("cc", 32), strings.Repeat("dd", 32)
	entries := []timelineEntry{
		{Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge", IsMerge: true, Parents: []string{c2, b1}},
		{Hash: b1, Date: "2026-08-15T03:00:00", Branch: "dev", Comment: "dev work", Parents: []string{c1}},
		{Hash: c2, Date: "2026-08-15T02:00:00", Branch: "trunk", Comment: "trunk work", Parents: []string{c1}},
		{Hash: c1, Date: "2026-08-15T01:00:00", Branch: "trunk", Comment: "root"},
	}
	days := buildTimeline("test", "trunk", entries)
	rows := days[0].Rows
	dev, unknown := newPalette("trunk", entries).stroke("dev"), newPalette("trunk", nil).stroke("")

	// Merge row: the curve AND the tail rail toward b1 are dev-colored.
	mergeHead, mergeTail := string(rows[0].Head), string(rows[0].Tail)
	if !strings.Contains(mergeHead, `Q`) || !strings.Contains(mergeHead, dev) {
		t.Fatalf("merge curve not dev-colored:\n%s", mergeHead)
	}
	if !strings.Contains(mergeTail, dev) {
		t.Fatalf("rail below merge not dev-colored:\n%s", mergeTail)
	}
	// b1's node and below-rail are dev; the fork row's join curve is dev.
	if !strings.Contains(string(rows[1].Head), dev) || !strings.Contains(string(rows[1].Tail), dev) {
		t.Fatalf("dev commit segments not dev-colored")
	}
	rootHead := string(rows[3].Head)
	if !strings.Contains(rootHead, `Q`) || !strings.Contains(rootHead, dev) {
		t.Fatalf("join curve at fork not dev-colored:\n%s", rootHead)
	}
	// The pass-through row (c2) shows the dev rail in dev color.
	if !strings.Contains(string(rows[2].Head), dev) {
		t.Fatalf("pass-through dev rail not dev-colored:\n%s", rows[2].Head)
	}
	// Nothing anywhere uses the unknown-branch neutral (all on-page).
	for i, r := range rows {
		if strings.Contains(string(r.Head), unknown) || strings.Contains(string(r.Tail), unknown) {
			t.Fatalf("row %d uses unknown-branch color", i)
		}
	}
	// Uniform stroke width.
	for i, r := range rows {
		all := string(r.Head) + string(r.Tail)
		if strings.Contains(all, `stroke-width="2"`) || strings.Contains(all, "vector-effect") {
			t.Fatalf("row %d has non-uniform stroke setup", i)
		}
	}
}

func TestTimelineOffPageParentIsNeutral(t *testing.T) {
	// A merge whose second parent is beyond the page: its rail renders
	// neutral, never a hashed hue for the empty branch name.
	m := strings.Repeat("ee", 32)
	off := strings.Repeat("ff", 32)
	days := buildTimeline("test", "trunk", []timelineEntry{
		{Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge far", IsMerge: true,
			Parents: []string{strings.Repeat("aa", 32), off}},
	})
	head := string(days[0].Rows[0].Head)
	if !strings.Contains(head, newPalette("trunk", nil).stroke("")) {
		t.Fatalf("off-page merge rail not neutral:\n%s", head)
	}
}

type fakeNotifier struct{ ch chan struct{} }

func (f *fakeNotifier) Watch(string) (<-chan struct{}, func()) { return f.ch, func() {} }

func TestLiveTimelineSSE(t *testing.T) {
	// The live endpoint streams a Datastar element patch containing the
	// re-rendered #tl fragment when the notifier ticks.
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	h := strings.Repeat("99", 32)
	if err := ix.FoldNow([]index.Parsed{{Hash: h, Art: &artifact.Artifact{
		Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "live one"}}}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	// The page advertises the live hookup.
	page, _ := srv.Client().Get(srv.URL + "/r/test/timeline")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	if !strings.Contains(string(body), `data-init="@get('/r/test/timeline.sse?`) {
		t.Fatalf("page missing live hookup:\n%s", body)
	}

	resp, err := srv.Client().Get(srv.URL + "/r/test/timeline.sse")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
		t.Fatalf("content-type %q", ct)
	}
	fn.ch <- struct{}{}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	for !strings.Contains(got, "datastar-patch-elements") || !strings.Contains(got, "live one") {
		lineCh := make(chan string, 1)
		go func() {
			l, _ := rd.ReadString('\n')
			lineCh <- l
		}()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			t.Fatalf("no patch within deadline; got:\n%s", got)
		}
	}
	if !strings.Contains(got, `data: elements <div id="tl">`) {
		t.Fatalf("patch not a #tl morph:\n%s", got)
	}
}

// TestWikiHistoryPages covers the wiki history list, revision view with
// its banner, and the timeline linking wiki rows to their revision.
func TestWikiHistoryPages(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{
		{Hash: strings.Repeat("a1", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "Guide", Date: "2026-08-15T09:00:00", User: "dan",
			Mimetype: "text/x-markdown", Body: []byte("first draft\n")}},
		{Hash: strings.Repeat("b2", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "Guide", Date: "2026-08-15T10:00:00", User: "eve",
			Mimetype: "text/x-markdown", Body: []byte("polished\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)

	get := func(path string) string {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
		return rec.Body.String()
	}

	// Latest view: history link, no revision banner.
	body := get("/r/test/wiki/Guide")
	if !strings.Contains(body, "polished") || !strings.Contains(body, `/r/test/wiki/Guide/history`) {
		t.Fatalf("latest view:\n%s", body)
	}
	if strings.Contains(body, "Historical revision") {
		t.Fatal("banner on latest view")
	}

	// History lists both edits, newest first, old one linked with ?rev=.
	body = get("/r/test/wiki/Guide/history")
	if !strings.Contains(body, "?rev="+strings.Repeat("a1", 6)) {
		t.Fatalf("old revision not linked:\n%s", body)
	}
	if i, j := strings.Index(body, strings.Repeat("b2", 6)), strings.Index(body, strings.Repeat("a1", 6)); i < 0 || j < 0 || i > j {
		t.Fatalf("history order wrong (b2 at %d, a1 at %d):\n%s", i, j, body)
	}

	// Revision view: old content plus the banner back to latest.
	body = get("/r/test/wiki/Guide?rev=" + strings.Repeat("a1", 6))
	if !strings.Contains(body, "first draft") || strings.Contains(body, "polished") {
		t.Fatalf("revision content:\n%s", body)
	}
	if !strings.Contains(body, "Historical revision") {
		t.Fatalf("banner missing:\n%s", body)
	}

	// Timeline wiki rows link the revision they represent. Check-ins
	// are the timeline's DEFAULT view now, so wiki rows are asked for.
	body = get("/r/test/timeline?y=w")
	if !strings.Contains(body, "/r/test/wiki/Guide?rev="+strings.Repeat("a1", 6)) {
		t.Fatalf("timeline wiki row not revision-linked:\n%s", body)
	}
}

// Real stores carry wiki titles with slashes (tcl's "(Initi/Fin)alize");
// every generated link must escape them so the mux sees one segment.
func TestWikiTitleWithSlash(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{
		{Hash: strings.Repeat("c3", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "(Initi/Fin)alize", Date: "2026-08-15T09:00:00", User: "dan",
			Mimetype: "text/x-markdown", Body: []byte("lifecycle\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/docs", nil))
	if !strings.Contains(rec.Body.String(), `/r/test/wiki/%28Initi%2FFin%29alize`) {
		t.Fatalf("wiki list link not escaped:\n%s", rec.Body.String())
	}
	// The old address keeps working; a wiki URL is somebody's bookmark.
	rec = httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/wiki", nil))
	if rec.Code != http.StatusMovedPermanently ||
		rec.Header().Get("Location") != "/r/test/docs" {
		t.Fatalf("/wiki = %d %q, want a permanent redirect to /docs",
			rec.Code, rec.Header().Get("Location"))
	}
	for _, path := range []string{
		"/r/test/wiki/%28Initi%2FFin%29alize",
		"/r/test/wiki/%28Initi%2FFin%29alize/history",
	} {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 || !strings.Contains(rec.Body.String(), "(Initi/Fin)alize") {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
	}
}

// TestLiveFleetRevStream: the landing page is live AT LOAD, and the
// stream that makes it live carries only an invalidation signal. The
// old stream carried rendered rows and therefore the filter it was
// opened with — opened at load, that meant repainting a reader's
// filtered view with everything a few seconds after they typed. A
// revision number cannot be stale: the client re-requests the listing
// with its CURRENT form state.
func TestLiveFleetRevStream(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("77", 32), Art: &artifact.Artifact{
		Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "fleet tip"}}}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	page, _ := srv.Client().Get(srv.URL + "/")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	// Live at load: the signal stream opens with the page…
	if !strings.Contains(string(body), `data-init="@get('/fleet.rev')"`) {
		t.Fatalf("home does not open the revision stream at load:\n%s", body)
	}
	// …and every refresh goes through the form, so it carries the filter
	// as it is NOW. The && guard stops the effect's first run (rev 0)
	// from re-fetching what the server just rendered.
	if !strings.Contains(string(body), `data-effect="$fleetRev && @get('/fleet.frag', {contentType: 'form'})"`) {
		t.Fatalf("no revision-driven refresh effect:\n%s", body)
	}
	if !strings.Contains(string(body), `data-on:input__debounce.300ms="@get('/fleet.frag'`) {
		t.Fatalf("filter input does not refresh through the one-shot:\n%s", body)
	}
	// The signal stream must NEVER render rows: rendered rows are what
	// carried the stale filter.
	resp, err := srv.Client().Get(srv.URL + "/fleet.rev")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	fn.ch <- struct{}{}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	for !strings.Contains(got, "fleetRev") {
		lineCh := make(chan string, 1)
		go func() {
			l, _ := rd.ReadString('\n')
			lineCh <- l
		}()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			t.Fatalf("no signal within deadline; got:\n%s", got)
		}
	}
	if !strings.Contains(got, "datastar-patch-signals") {
		t.Fatalf("revision did not arrive as a signal patch:\n%s", got)
	}
	if strings.Contains(got, "datastar-patch-elements") {
		t.Fatalf("the signal stream rendered elements; that is the stale-filter bug reborn:\n%s", got)
	}
}

// TestForumTechnotePages renders the forum and technote read surfaces.
func TestForumTechnotePages(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	root := strings.Repeat("aa", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum,
			ThreadTitle: "First topic", Date: "2026-08-16T09:00:00", User: "dan",
			Body: []byte("opening post body\n")}},
		{Hash: strings.Repeat("bb", 20), Art: &artifact.Artifact{Type: artifact.Forum,
			ThreadRoot: root, InReplyTo: root, Date: "2026-08-16T10:00:00", User: "eve",
			Body: []byte("a considered reply\n")}},
		{Hash: strings.Repeat("cc", 20), Art: &artifact.Artifact{Type: artifact.Technote,
			EventID: strings.Repeat("dd", 20), EventTime: "2026-08-16T08:00:00",
			Date: "2026-08-16T08:00:00", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("# Note\n\ntechnote body text\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)
	get := func(path string) string {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
		return rec.Body.String()
	}

	if b := get("/r/test/forum"); !strings.Contains(b, "First topic") || !strings.Contains(b, "2 posts") {
		t.Fatalf("forum list:\n%s", b)
	}
	if b := get("/r/test/forum/" + root[:16]); !strings.Contains(b, "opening post body") || !strings.Contains(b, "a considered reply") {
		t.Fatalf("forum thread:\n%s", b)
	}
	if b := get("/r/test/technote"); !strings.Contains(b, "/r/test/technote/"+strings.Repeat("dd", 8)) {
		t.Fatalf("technote list:\n%s", b)
	}
	if b := get("/r/test/technote/" + strings.Repeat("dd", 8)); !strings.Contains(b, "technote body text") {
		t.Fatalf("technote page:\n%s", b)
	}
	// Nav links present on every repo page — forum is a tab, notes are
	// deliberately NOT. fossil's own default menu carries no technote
	// entry and libfossil, which uses them as a milestone feed and
	// overrode the menu to add TODOs and FIXMEs, did not add one
	// either. The bar already overflows on a phone; notes lose that
	// competition on every real repository.
	tl := get("/r/test/timeline")
	if !strings.Contains(tl, `/r/test/forum`) {
		t.Fatal("nav missing forum link")
	}
	if strings.Contains(tl, `href="/r/test/technote"`) {
		t.Fatal("notes are back in the nav; they are reached from the About panel, " +
			"the docs page and the timeline filter instead")
	}
	// ...but they must stay REACHABLE, or dropping the tab loses them.
	if !strings.Contains(tl, `<option value="e"`) {
		t.Fatal("timeline lost its notes filter")
	}
	// ...and the filtered view is not a dead end. Without a tab, this is
	// where a reader browsing notes is standing.
	if b := get("/r/test/timeline?y=e"); !strings.Contains(b, `href="/r/test/technote"`) {
		t.Fatalf("notes-filtered timeline has no way to the notes page:\n%s", b)
	}
	if b := get("/r/test/docs"); !strings.Contains(b, `href="/r/test/technote"`) {
		t.Fatalf("docs page has no door to notes:\n%s", b)
	}
}

// fakeChatLive delivers preset messages to the SSE handler.
type fakeChatLive struct{ ch chan ChatMessage }

func (f *fakeChatLive) SubscribeChat(string) (<-chan ChatMessage, func()) {
	return f.ch, func() {}
}

func TestChatPage(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	fc := &webChatStub{msgs: []chat.Message{{User: "dan", Text: "hello world", At: "2026-08-16T12:00:00Z"}}}
	if err := api.RegisterAll(r, api.Deps{Version: "t",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}), Chat: fc}); err != nil {
		t.Fatal(err)
	}
	live := &fakeChatLive{ch: make(chan ChatMessage, 1)}
	h := Handler(r, nil, live)

	// The page shows history + the post form + the live hookup + nav link.
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/chat", nil))
	body := rec.Body.String()
	if !strings.Contains(body, "hello world") || !strings.Contains(body, `action="/r/test/chat"`) {
		t.Fatalf("chat page:\n%s", body)
	}
	if !strings.Contains(body, `data-init="@get('/r/test/chat.sse')"`) {
		t.Fatal("chat page missing live hookup")
	}
	if !strings.Contains(body, `/r/test/chat`) || !strings.Contains(rec.Body.String(), `id="chatlog"`) {
		t.Fatal("chat log container missing")
	}

	// The SSE stream appends a new message as a datastar patch. Close
	// the response before the server so Close doesn't block on the
	// still-open stream.
	srv := httptest.NewServer(h)
	resp, err := srv.Client().Get(srv.URL + "/r/test/chat.sse")
	if err != nil {
		srv.Close()
		t.Fatal(err)
	}
	live.ch <- ChatMessage{User: "eve", Text: "live line", At: "now"}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	for !strings.Contains(got, "live line") || !strings.Contains(got, "mode append") {
		lineCh := make(chan string, 1)
		go func() { l, _ := rd.ReadString('\n'); lineCh <- l }()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			resp.Body.Close()
			srv.Close()
			t.Fatalf("no chat patch:\n%s", got)
		}
	}
	resp.Body.Close()
	srv.Close()
	if !strings.Contains(got, "selector #chatlog") {
		t.Fatalf("patch not targeting #chatlog:\n%s", got)
	}
}

type webChatStub struct{ msgs []chat.Message }

func (s *webChatStub) Post(code string, m chat.Message) error                 { s.msgs = append(s.msgs, m); return nil }
func (s *webChatStub) History(code string, limit int) ([]chat.Message, error) { return s.msgs, nil }

// The live stream re-renders the whole listing, so it has to know what
// the reader is looking at. A stream that does not carry the filter
// replaces the filtered listing with the default about a second after
// it appears — which reads as the filter simply not working.
func TestLiveFleetStreamCarriesTheFilter(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(
		map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	// The page must point the stream at its own view, not at the bare URL.
	page, _ := srv.Client().Get(srv.URL + "/?q=alpha")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	// The filter reaches the stream through the form itself now: the
	// request carries q= because Datastar serializes the surrounding
	// form, so the page need not encode it in the URL.
	if !strings.Contains(string(body), `value="alpha"`) {
		t.Fatalf("the filter is not reflected in the form:\n%s", body)
	}

	// And the one-shot must actually apply it — and CLOSE: a held
	// connection is what carried the stale filter. ReadAll returning is
	// the closure assertion.
	resp, err := srv.Client().Get(srv.URL + "/fleet.frag?q=alpha")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	done := make(chan []byte, 1)
	go func() { b, _ := io.ReadAll(resp.Body); done <- b }()
	var frag string
	select {
	case b := <-done:
		frag = string(b)
	case <-time.After(3 * time.Second):
		t.Fatal("/fleet.frag held its connection open; a one-shot must close")
	}
	if !strings.Contains(frag, "alpha") {
		t.Errorf("filtered stream dropped the match:\n%s", frag)
	}
	if strings.Contains(frag, "beta") {
		t.Errorf("filtered stream includes a repo the filter excludes:\n%s", frag)
	}
	// The controls come back with the repaint, so they must carry the
	// filter too or the box empties itself while the listing stays filtered.
	// The controls are NOT in the fragment any more — they live outside
	// the morphed region so a repaint cannot tear the input out from
	// under the reader mid-keystroke. What the fragment must carry is
	// the count, which describes the rows it is replacing.
	if strings.Contains(frag, `name="q"`) {
		t.Errorf("the filter input is inside the morphed region again:\n%s", frag)
	}
	if !strings.Contains(frag, "repositories") {
		t.Errorf("the repaint dropped the count that describes its rows:\n%s", frag)
	}
}

// The first SSE repaint arrives immediately on connect and REPLACES the
// server-rendered listing, so the fragment must be given everything the
// full page gave the same template. Any field it omits renders on load
// and then vanishes a moment later — which is what happened to the
// "23 of 23" count and the New repository link, and before them to the
// filter and sort.
//
// The admin control is per-connection: it must appear for an admin's
// stream and for nobody else's.
func TestFleetFragmentCarriesEverythingThePageDoes(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(
		map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}

	anon, err := fleetFragment(r, url.Values{}, false)
	if err != nil {
		t.Fatal(err)
	}
	// Unfiltered the listing states the total outright; the point is
	// that the number survives the repaint at all.
	if !strings.Contains(anon, "2 repositories") {
		t.Errorf("the fragment dropped the fleet total, so the count vanishes on repaint:\n%s", anon)
	}
	// Filtered, the denominator is the fleet rather than the match
	// count: "1 of 1" is true and tells a reader nothing.
	one, err := fleetFragment(r, url.Values{"q": {"alpha"}}, false)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(one, "1 of 2 repositories") {
		t.Errorf("a filtered listing does not count against the fleet:\n%s", one)
	}
	// The persistent New repository link lives with the controls now,
	// outside the morph. What the fragment still decides is the
	// empty-fleet prompt, which is admin-only — and must stay so.
	empty := api.NewRepos(map[string]*api.Repo{})
	r2 := ops.NewRegistry()
	if err := api.RegisterAll(r2, api.Deps{Version: "v", Repos: empty}); err != nil {
		t.Fatal(err)
	}
	anonEmpty, err := fleetFragment(r2, url.Values{}, false)
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(anonEmpty, "Create one") {
		t.Error("an anonymous stream is offered the admin-only create prompt")
	}
	adminEmpty, err := fleetFragment(r2, url.Values{}, true)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(adminEmpty, "Create one") {
		t.Error("an admin's stream loses the create prompt on repaint")
	}
}

// stubAccts is the AccountStore the dashboard needs; everything else
// no-ops.
type stubAccts struct{ grants map[string]map[string]string }

func (s stubAccts) Accounts() ([]api.Account, error)      { return nil, nil }
func (s stubAccts) AddAccount(string, string, bool) error { return nil }
func (s stubAccts) SetPassword(string, string) error      { return nil }
func (s stubAccts) DeleteAccount(string) error            { return nil }
func (s stubAccts) Grant(string, string, string) error    { return nil }
func (s stubAccts) Revoke(string, string) error           { return nil }
func (s stubAccts) GrantsFor(a string) (map[string]string, error) {
	return s.grants[a], nil
}

// A signed-in reader's "/" is their dashboard; anonymous readers and
// ?view=explore get the fleet unchanged — GitHub's rule. The dashboard
// must never 500 the homepage: an account with no grants falls back to
// the fleet.
func TestRootSwitchesOnSession(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("55", 32), Art: &artifact.Artifact{
		Type: artifact.Manifest, Date: "2026-08-19T09:00:00", User: "dan", Comment: "dash tip"}}}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:    api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}},
	}); err != nil {
		t.Fatal(err)
	}
	sess := fakeSessions{user: "dan"}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(sess)}))
	t.Cleanup(srv.Close)

	get := func(path string, cookie bool) string {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		if cookie {
			req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		}
		resp, err := srv.Client().Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}

	home := get("/", true)
	// Structure, not copy: the dashboard's own sections and the door to
	// the fleet. Headings are design surface and free to change.
	for _, want := range []string{`class="dash-side"`, `class="dash-feed"`, `href="/?view=explore"`} {
		if !strings.Contains(home, want) {
			t.Fatalf("signed-in home missing %q:\n%s", want, home)
		}
	}
	anon := get("/", false)
	if strings.Contains(anon, `class="dash-side"`) {
		t.Fatal("anonymous home rendered a dashboard")
	}
	if !strings.Contains(anon, "filter") {
		t.Fatalf("anonymous home is not the fleet:\n%s", anon)
	}
	explore := get("/?view=explore", true)
	if strings.Contains(explore, `class="dash-side"`) {
		t.Fatal("?view=explore did not escape the dashboard")
	}
}

// Every funnel that answers "internal error" must leave a journal
// entry: the web fail() default, and the panic recovery (which before
// this existed dropped the connection with a stack trace nobody saw).
// The /fleet page then renders the journal — that page saying "ok"
// while the web layer burned is the failure this whole surface exists
// to end.
func TestServerErrorsAreJournaled(t *testing.T) {
	before, _, _ := errlog.Default.Stats()

	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:  api.NewRepos(map[string]*api.Repo{}),
		Errors: errlog.Default}); err != nil {
		t.Fatal(err)
	}
	h := HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{
		user: "root", roles: map[string]ops.Auth{"": ops.Admin}})})
	mux := http.NewServeMux()
	mux.Handle("/", h)
	mux.Handle("GET /boom", recovered(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		panic("kaboom")
	})))
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)

	// A panic becomes a clean 500 and a journal entry.
	resp, err := srv.Client().Get(srv.URL + "/boom")
	if err != nil {
		t.Fatalf("panic dropped the connection: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("panic answered %d, want 500", resp.StatusCode)
	}
	after, _, _ := errlog.Default.Stats()
	if after <= before {
		t.Fatal("the panic left no journal entry")
	}
	found := false
	for _, e := range errlog.Default.Recent(0) {
		if e.Surface == "panic" && strings.Contains(e.Message, "kaboom") {
			found = true
		}
	}
	if !found {
		t.Fatalf("journal lacks the panic: %+v", errlog.Default.Recent(5))
	}

	// The admin fleet page renders it.
	req, _ := http.NewRequest("GET", srv.URL+"/fleet", nil)
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	fresp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer fresp.Body.Close()
	body, _ := io.ReadAll(fresp.Body)
	if !strings.Contains(string(body), "Server errors") || !strings.Contains(string(body), "kaboom") {
		t.Fatalf("/fleet does not show the journal:\n%s", body)
	}
}

// The fleet's file-ticket button and the prefilled form it leads to:
// the journal knows the message, surface and count, so the ticket
// starts written. Only a designated tracker draws the buttons — on a
// forge with no self-tracker there is nowhere honest to send them.
func TestErrorJournalFilesTickets(t *testing.T) {
	errlog.Default.Report("web", "/exploding", errors.New("prefill probe boom"))
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:  api.NewRepos(map[string]*api.Repo{"selfrepo": {Index: wtIndex(t)}}),
		Errors: errlog.Default}); err != nil {
		t.Fatal(err)
	}
	sess := fakeSessions{user: "root", roles: map[string]ops.Auth{
		"": ops.Admin, "selfrepo": ops.Token}}
	srv := httptest.NewServer(HandlerWith(r, nil, nil,
		[]Option{WithSessions(sess), WithTracker("selfrepo")}))
	t.Cleanup(srv.Close)
	get := func(path string) string {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		resp, err := srv.Client().Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}
	fleet := get("/fleet")
	if !strings.Contains(fleet, "/r/selfrepo/ticket/new?title=") || !strings.Contains(fleet, "file ticket") {
		t.Fatalf("no file-ticket button on the journal row:\n%s", fleet)
	}
	form := get("/r/selfrepo/ticket/new?title=prefill+probe+boom&subsystem=web&comment=details+here")
	for _, want := range []string{
		`value="prefill probe boom"`, `name="subsystem" value="web"`, `>details here</textarea>`,
	} {
		if !strings.Contains(form, want) {
			t.Fatalf("form not prefilled with %s:\n%s", want, form)
		}
	}
}

// wtIndex is a minimal open index for handler tests.
func wtIndex(t *testing.T) *index.Index {
	t.Helper()
	ix, err := index.Open(filepath.Join(t.TempDir(), "ix.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	return ix
}

// A chip earns its box by DISTINGUISHING a ticket from its neighbours.
// The closed listing is where this bites: every finished ticket in a
// healthy tracker resolves "Fixed", so a resolution chip on all of them
// is 34 repetitions of one word. The odd one out must still show.
func TestChipsMuteTheValueEveryRowShares(t *testing.T) {
	var ts []ticketSummary
	for i := 0; i < 9; i++ {
		ts = append(ts, ticketSummary{
			ID: fmt.Sprintf("%040d", i), Title: "t", Status: "Closed",
			Type: "Code_Defect", Subsystem: "web", Resolution: "Fixed",
		})
	}
	ts[8].Resolution = "Works_As_Designed"
	ts[8].Subsystem = "ci"

	rows := ticketRows("r", ts, map[string][]string{})
	has := func(r ticketRow, text string) bool {
		for _, c := range r.Chips {
			if c.Text == text {
				return true
			}
		}
		return false
	}
	// Shared by 8 of 9 and 9 of 9: neither says anything here.
	if has(rows[0], "Fixed") {
		t.Error("the resolution every closed ticket shares is still a chip")
	}
	if has(rows[0], "Code_Defect") {
		t.Error("the type every listed ticket shares is still a chip")
	}
	if has(rows[0], "web") {
		t.Error("the subsystem 8 of 9 share is still a chip")
	}
	// The deviations are the whole point of the row.
	if !has(rows[8], "Works_As_Designed") {
		t.Error("the one resolution that differs was muted with the rest")
	}
	if !has(rows[8], "ci") {
		t.Error("the one subsystem that differs was muted with the rest")
	}
	// Below the floor a "majority" is an accident, so nothing is muted.
	small := ticketRows("r", ts[:3], map[string][]string{})
	if !has(small[0], "Fixed") {
		t.Error("a three-row listing muted a value on majority grounds")
	}
}

// State, view, sort and the report are INDEPENDENT axes. Narrowing to a
// subsystem and then clicking Closed must show that subsystem's closed
// tickets; an axis the header silently drops is an axis a reader cannot
// trust, and every link on the page is built by this one type.
func TestTicketNavKeepsTheReportAcrossEveryAxis(t *testing.T) {
	nav := ticketNav{Repo: "alpha", Sort: "recent",
		Report: url.Values{"subsystem": {"web"}, "author": {"dan"}}}

	for _, tc := range []struct{ axis, value string }{
		{"state", "closed"}, {"state", "all"}, {"view", "board"}, {"sort", ""},
	} {
		got := nav.URL(tc.axis, tc.value)
		q, err := url.Parse(got)
		if err != nil {
			t.Fatal(err)
		}
		v := q.Query()
		if v.Get("subsystem") != "web" || v.Get("author") != "dan" {
			t.Errorf("switching %s=%q dropped the report: %s", tc.axis, tc.value, got)
		}
	}
	// Clear drops the report and keeps where you are standing.
	cleared, err := url.Parse(nav.Clear())
	if err != nil {
		t.Fatal(err)
	}
	if cleared.Query().Has("subsystem") || cleared.Query().Has("author") {
		t.Errorf("clear kept a report axis: %s", nav.Clear())
	}
	if cleared.Query().Get("sort") != "recent" {
		t.Errorf("clear threw away the sort the reader chose: %s", nav.Clear())
	}
	// A GET form replaces the whole query string, so the dropdown form
	// restates what its selects do NOT carry — and must not restate
	// what they do, or every axis is submitted twice.
	var names []string
	for _, f := range (ticketNav{Repo: "alpha", State: "closed", View: "table",
		Order: "status", Title: "crash",
		Report: url.Values{"author": {"dan"}, "type": {"bug"}}}).Hidden() {
		names = append(names, f.Name)
	}
	if strings.Join(names, ",") != "state,view,order,title" {
		t.Errorf("hidden fields = %v, want state,view,order,title", names)
	}
	for _, f := range names {
		if _, isAxis := map[string]bool{"author": true, "type": true}[f]; isAxis {
			t.Errorf("hidden field %q duplicates a dropdown that already submits it", f)
		}
	}
	// The query box's form carries presentation only: its text already
	// spells the state, the report and the free words.
	var vf []string
	for _, f := range (ticketNav{Repo: "alpha", State: "closed", View: "table",
		Order: "status", Title: "crash"}).ViewFields() {
		vf = append(vf, f.Name)
	}
	if strings.Join(vf, ",") != "view,order" {
		t.Errorf("query box hidden fields = %v, want view,order", vf)
	}
}

// A table header sorts by naming a key, and clicking the column you are
// already sorted by turns it around where the whitelist has a reverse.
func TestColumnHeadersSortAndReverse(t *testing.T) {
	if got := (ticketNav{Repo: "a", View: "table"}).OrderURL("updated"); !strings.Contains(got, "order=oldest") {
		t.Errorf("re-clicking the default column did not reverse it: %s", got)
	}
	if got := (ticketNav{Repo: "a", View: "table", Order: "oldest"}).OrderURL("updated"); !strings.Contains(got, "order=updated") {
		t.Errorf("reversing back to the default failed: %s", got)
	}
	// A column with no opposite stays put rather than pretending.
	if got := (ticketNav{Repo: "a", View: "table", Order: "status"}).OrderURL("status"); !strings.Contains(got, "order=status") {
		t.Errorf("a column with no reverse should stay put: %s", got)
	}
	// Every column the table draws must name a real order key.
	for _, c := range ticketColumns {
		if _, ok := index.TicketOrders[c.Key]; !ok {
			t.Errorf("column %q names no key of index.TicketOrders", c.Key)
		}
	}
}

func TestQueryBoxAndParamsAreTwoSpellingsOfOneReport(t *testing.T) {
	for _, tc := range []struct {
		in    string
		want  url.Values
		spell string // how the box renders it back; "" means same as in
	}{
		{in: "subsystem:web", want: url.Values{"subsystem": {"web"}}},
		{in: "is:closed subsystem:web", want: url.Values{"state": {"closed"}, "subsystem": {"web"}}},
		{in: "state:all author:dan", want: url.Values{"state": {"all"}, "author": {"dan"}},
			spell: "is:all author:dan"},
		{in: "crash on empty", want: url.Values{"title": {"crash on empty"}}},
		{in: "is:closed type:Code_Defect index loss",
			want:  url.Values{"state": {"closed"}, "type": {"Code_Defect"}, "title": {"index loss"}},
			spell: "is:closed type:Code_Defect index loss"},
		// A prefix nobody defined is text, not an error and not dropped:
		// somebody who types fix:later meant those characters.
		{in: "fix:later", want: url.Values{"title": {"fix:later"}}},
		// Quotes hold a value with a space in it together.
		{in: `subsystem:"web ui" crash`,
			want:  url.Values{"subsystem": {"web ui"}, "title": {"crash"}},
			spell: `subsystem:"web ui" crash`},
		{in: "", want: url.Values{}},
	} {
		got := ticketParseQuery(tc.in)
		if len(got) != len(tc.want) {
			t.Errorf("parse(%q) = %v, want %v", tc.in, got, tc.want)
			continue
		}
		for k, v := range tc.want {
			if got.Get(k) != v[0] {
				t.Errorf("parse(%q)[%s] = %q, want %q", tc.in, k, got.Get(k), v[0])
			}
		}
		// And back: what the box shows for the listing it produced.
		report := url.Values{}
		for _, k := range ticketAxes {
			if v := got.Get(k); v != "" {
				report.Set(k, v)
			}
		}
		want := tc.spell
		if want == "" {
			want = tc.in
		}
		if back := ticketQueryString(got.Get("state"), report, got.Get("title")); back != want {
			t.Errorf("round trip of %q rendered %q, want %q", tc.in, back, want)
		}
	}
}

// Paging is offset-based in the UI, so the one thing that must never
// happen is a reader stranded past the end of a listing they just
// narrowed. Every axis resets to the first page; only PageURL pages.
func TestOnlyPagingSetsAnOffset(t *testing.T) {
	nav := ticketNav{Repo: "alpha", View: "table", Order: "status",
		Offset: 100, Report: url.Values{"subsystem": {"web"}}}
	for _, tc := range []struct{ what, got string }{
		{"state", nav.URL("state", "closed")},
		{"view", nav.URL("view", "")},
		{"order", nav.URL("order", "title")},
		{"clear", nav.Clear()},
	} {
		if strings.Contains(tc.got, "offset=") {
			t.Errorf("changing %s kept the offset: %s", tc.what, tc.got)
		}
	}
	if got := nav.PageURL(150); !strings.Contains(got, "offset=150") {
		t.Errorf("PageURL did not set the offset: %s", got)
	}
	if got := nav.PageURL(0); strings.Contains(got, "offset=") {
		t.Errorf("the first page should carry no offset: %s", got)
	}
	if got := nav.PageURL(-20); strings.Contains(got, "offset=") {
		t.Errorf("a negative offset must clamp to the first page: %s", got)
	}
}

// The pager tells a reader where they are; it should not appear when
// that is the only thing it could say.
func TestPagerAppearsOnlyWhenThereIsSomewhereToGo(t *testing.T) {
	nav := ticketNav{Repo: "alpha"}
	if p := pagerFor(nav, 12, 12); p != nil {
		t.Errorf("a single full page drew a pager: %+v", p)
	}
	if p := pagerFor(nav, 0, 0); p != nil {
		t.Error("an empty listing drew a pager")
	}
	p := pagerFor(nav, 50, 71)
	if p == nil || p.From != 1 || p.To != 50 || p.Total != 71 {
		t.Fatalf("first page = %+v, want 1-50 of 71", p)
	}
	if p.Prev != "" || p.Next == "" {
		t.Errorf("first page should lead forward only: %+v", p)
	}
	last := pagerFor(ticketNav{Repo: "alpha", Offset: 50}, 21, 71)
	if last == nil || last.From != 51 || last.To != 71 {
		t.Fatalf("last page = %+v, want 51-71", last)
	}
	if last.Next != "" || last.Prev == "" {
		t.Errorf("last page should lead back only: %+v", last)
	}
}

// The dashboard styles its own lists. It once borrowed ul.tickets, so
// when the tracker became a bordered card the dashboard grew three
// boxes around unrelated content — a shared class is a shared fate.
func dashboardSource() string {
	var b strings.Builder
	if err := dashboardPage.ExecuteTemplate(&b, "content", pageData{Dash: &dashData{
		Repos: []struct {
			Name         string `json:"name"`
			Description  string `json:"description"`
			LastActivity string `json:"lastActivity"`
			Open         int    `json:"open"`
		}{{Name: "r"}},
		Feed: []dashEvent{{Kind: "ci", Repo: "r", Comment: "subject\n\nbody"}},
	}}); err != nil {
		panic(err)
	}
	return b.String()
}

func TestDashboardDoesNotBorrowTheTicketList(t *testing.T) {
	if strings.Contains(dashboardSource(), `class="tickets"`) {
		t.Error("the dashboard is borrowing the ticket page's list class again")
	}
	if !strings.Contains(dashboardSource(), `class="dash-list"`) {
		t.Error("the dashboard lost its own list class")
	}
}

// A feed row is one line. Rendering a whole commit message there turns
// the column into a wall of bold text.
func TestDashboardFeedShowsOnlyTheSubject(t *testing.T) {
	got := firstLine("wiki: contents and a real index\n\nLong pages were unreadable: fossil's Cookbook runs 26,000 pixels.")
	if got != "wiki: contents and a real index" {
		t.Fatalf("feed title = %q, want just the subject", got)
	}
}

// /tickets is the tracker page pointed at every repository the reader
// can reach. The two things that make it that page rather than a second
// tracker page: a row links into its OWN repository, and every control
// keeps addressing /tickets instead of falling back to one repo's URL.
func TestCrossRepoTicketListing(t *testing.T) {
	mk := func(t *testing.T, sub, title string) *index.Index {
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		if err := ix.FoldNow([]index.Parsed{{Hash: title[:4], Art: &artifact.Artifact{
			Type: artifact.Ticket, TicketID: strings.Repeat(title[:1], 40),
			Date: "2026-08-19T09:00:00", User: "dan",
			Fields: []artifact.Field{{Name: "status", Value: "Open"},
				{Name: "title", Value: title}, {Name: "subsystem", Value: sub}}}}}); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{
			"alpha": {Index: mk(t, "web", "alpha bug")},
			"beta":  {Index: mk(t, "api", "beta bug")},
			"gamma": {Index: mk(t, "web", "gamma bug")},
		}),
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write", "beta": "read"}}},
	}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})}))
	t.Cleanup(srv.Close)
	get := func(path string) string {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		resp, err := srv.Client().Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		if resp.StatusCode != 200 {
			t.Fatalf("GET %s: %d", path, resp.StatusCode)
		}
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}

	// Without a door in the chrome the only way here is a ticket that
	// already happens to be waiting — which is exactly backwards.
	if home := get("/"); !strings.Contains(home, `href="/tickets"`) {
		t.Errorf("nothing links to the cross-repository tracker:\n%s", home)
	}

	html := get("/tickets")
	// Each row reaches its own tracker.
	for _, want := range []string{`href="/r/alpha/ticket/`, `href="/r/beta/ticket/`} {
		if !strings.Contains(html, want) {
			t.Errorf("no row links into %s:\n%s", want, html)
		}
	}
	if strings.Contains(html, `/r/gamma/ticket/`) {
		t.Error("a tracker the reader was not granted appeared in the listing")
	}
	// Repository is a browse axis here, and the rail's links stay on
	// this page rather than diving into one repository's tracker.
	if !strings.Contains(html, `href="/tickets?repo=alpha"`) {
		t.Errorf("no repository facet:\n%s", html)
	}
	if strings.Contains(html, `/r//ticket`) {
		t.Errorf("a control fell back to a repository URL with no repository:\n%s", html)
	}
	// Filing needs one tracker to file into; the board needs one
	// vocabulary. Neither exists here, so neither control renders.
	if strings.Contains(html, `/ticket/new`) || strings.Contains(html, `view=board`) {
		t.Errorf("a control that cannot work here was rendered:\n%s", html)
	}

	// The facet narrows without losing the page.
	one := get("/tickets?repo=alpha")
	if strings.Contains(one, `/r/beta/ticket/`) {
		t.Error("the repository facet did not narrow the listing")
	}

	// No identity, no listing: this page is only ever somebody's.
	req, _ := http.NewRequest("GET", srv.URL+"/tickets", nil)
	srv.Client().CheckRedirect = func(*http.Request, []*http.Request) error {
		return http.ErrUseLastResponse
	}
	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("anonymous /tickets: %d, want a redirect to sign in", resp.StatusCode)
	}
}

// The landing page leads with what needs the reader, and only when
// something does. An empty band is a promise the page cannot keep, so
// the section must not render at all rather than sit there saying
// nothing is waiting.
func TestDashboardBandOnlyWhenSomethingWaits(t *testing.T) {
	mk := func(t *testing.T, author, answerer string) *index.Index {
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		id := strings.Repeat("ab", 20)
		batch := []index.Parsed{{Hash: "aa11", Art: &artifact.Artifact{
			Type: artifact.Ticket, TicketID: id, Date: "2026-08-19T09:00:00", User: author,
			Fields: []artifact.Field{{Name: "status", Value: "Open"},
				{Name: "title", Value: "a question for you"}}}}}
		if answerer != "" {
			batch = append(batch, index.Parsed{Hash: "aa22", Art: &artifact.Artifact{
				Type: artifact.Ticket, TicketID: id, Date: "2026-08-19T18:00:00", User: answerer,
				Fields: []artifact.Field{{Name: "status", Value: "Open"}}}})
		}
		if err := ix.FoldNow(batch); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	home := func(t *testing.T, ix *index.Index) string {
		t.Helper()
		r := ops.NewRegistry()
		if err := api.RegisterAll(r, api.Deps{Version: "v",
			Repos:    api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
			Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}},
		}); err != nil {
			t.Fatal(err)
		}
		srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})}))
		t.Cleanup(srv.Close)
		req, _ := http.NewRequest("GET", srv.URL+"/", nil)
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		resp, err := srv.Client().Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}

	waiting := home(t, mk(t, "dan", "eve"))
	if !strings.Contains(waiting, `class="dash-band"`) {
		t.Fatalf("a ticket somebody else answered did not reach the band:\n%s", waiting)
	}
	if !strings.Contains(waiting, `href="/tickets?awaiting=1"`) {
		t.Error("the band does not link to the rest of what is waiting")
	}
	// Nobody has answered, so nothing is owed.
	if quiet := home(t, mk(t, "dan", "")); strings.Contains(quiet, `class="dash-band"`) {
		t.Error("the band rendered with nothing waiting in it")
	}
	// Somebody else's ticket is not the reader's to answer.
	if theirs := home(t, mk(t, "eve", "dan")); strings.Contains(theirs, `class="dash-band"`) {
		t.Error("a ticket the reader did not open reached their band")
	}
}

// The feed's chips are filters over one typed event stream, which is
// what makes filtering it honest at all. Their numbers count the WHOLE
// feed rather than the page on screen: the feed pages, so a number
// taken from one window would promise less than clicking it delivers.
func TestDashboardFeedChipsAgreeWithTheirRows(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{
		{Hash: strings.Repeat("11", 32), Art: &artifact.Artifact{Type: artifact.Manifest,
			Date: "2026-08-19T09:00:00", User: "dan", Comment: "a check-in"}},
		{Hash: strings.Repeat("22", 32), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "Design", Date: "2026-08-19T10:00:00", User: "dan",
			Mimetype: "text/x-markdown", Body: []byte("page")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:    api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}},
	}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})}))
	t.Cleanup(srv.Close)
	get := func(path string) string {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		resp, err := srv.Client().Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}
	all := get("/")
	if !strings.Contains(all, `href="/?kind=ci"`) || !strings.Contains(all, `href="/?kind=w"`) {
		t.Fatalf("the feed offers no kind chips:\n%s", all)
	}
	one := get("/?kind=w")
	if strings.Contains(one, "a check-in") {
		t.Error("the wiki chip showed a check-in")
	}
	if !strings.Contains(one, "Design") {
		t.Error("the wiki chip dropped the wiki edit it counts")
	}
	// The repository filter is a plain GET form, so it works with no
	// script at all — and it must not be offered a repository the
	// reader was not granted.
	// The feed still names the repository each event came from, so this
	// looks for the repository LIST's own row, not any mention.
	if none := get("/?find=zzz"); strings.Contains(none, `class="dash-title" href="/r/alpha"`) {
		t.Error("the find box did not narrow the repository list")
	}
}

// A wiki index has two jobs beyond listing: point a reader at the front
// door, and show where the wiki is coming apart. Controls that cannot
// change what you see do not render, which is why a four-page wiki gets
// no filter box and no structure panel.
func TestWikiIndexShowsStructureOnlyWhenItMeansSomething(t *testing.T) {
	page := func(t *testing.T, pages map[string]string) string {
		t.Helper()
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		var batch []index.Parsed
		i := 0
		for title, body := range pages {
			i++
			batch = append(batch, index.Parsed{Hash: fmt.Sprintf("w%03d", i),
				Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: title,
					Date: fmt.Sprintf("2026-08-%02dT09:00:00", i), User: "dan",
					Mimetype: "text/x-markdown", Body: []byte(body)}})
		}
		if err := ix.FoldNow(batch); err != nil {
			t.Fatal(err)
		}
		r := ops.NewRegistry()
		if err := api.RegisterAll(r, api.Deps{Version: "v",
			Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
			t.Fatal(err)
		}
		srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
		t.Cleanup(srv.Close)
		resp, err := srv.Client().Get(srv.URL + "/r/alpha/wiki")
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}

	small := page(t, map[string]string{"Home": "start", "Guide": "[[Home]]"})
	if strings.Contains(small, `class="w-struct"`) {
		t.Error("a two-page wiki was asked which of its pages are unreachable")
	}
	if strings.Contains(small, `class="w-controls"`) {
		t.Error("a two-page wiki rendered a filter box that cannot narrow anything")
	}

	big := page(t, map[string]string{
		"Home":    "See [[Guide]] and [[Design]].",
		"Guide":   "Back to [[Home]]. Also [[Roadmap]].",
		"Design":  "[[Home]]",
		"Notes":   "nothing points at this page",
		"Scratch": "nor this one",
		"Extra":   "[[Design]]",
	})
	if !strings.Contains(big, `class="w-struct"`) {
		t.Fatalf("no structure panel on a wiki with holes in it:\n%s", big)
	}
	// A link nobody wrote a page for is the wiki's own to-do list, and
	// it links to creating that page rather than to a dead end.
	if !strings.Contains(big, `/r/alpha/wiki-new?name=Roadmap`) {
		t.Error("a wanted page does not offer to be written")
	}
	// Unreachable pages are named; the home page never counts as one.
	for _, want := range []string{`/r/alpha/wiki/Notes`, `/r/alpha/wiki/Scratch`} {
		if !strings.Contains(big, want) {
			t.Errorf("orphan %s not listed", want)
		}
	}
}

// Fossil's own wiki has over two hundred pages nothing links to. A
// panel that long is the page, so both lists are bounded — and a bound
// that does not say what it dropped reads as "this is everything".
func TestWikiStructurePanelSaysWhatItLeftOut(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	var batch []index.Parsed
	// One page linking many names that do not exist, and many pages
	// nothing links to: both lists overflow.
	var links strings.Builder
	for i := 0; i < wikiStructLimit+5; i++ {
		fmt.Fprintf(&links, "[[Wanted%02d]] ", i)
		batch = append(batch, index.Parsed{Hash: fmt.Sprintf("o%03d", i),
			Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: fmt.Sprintf("Lonely%02d", i),
				Date: "2026-08-01T09:00:00", User: "dan",
				Mimetype: "text/x-markdown", Body: []byte("alone")}})
	}
	batch = append(batch, index.Parsed{Hash: "home", Art: &artifact.Artifact{
		Type: artifact.Wiki, WikiTitle: "Home", Date: "2026-08-02T09:00:00", User: "dan",
		Mimetype: "text/x-markdown", Body: []byte(links.String())}})
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
	t.Cleanup(srv.Close)
	resp, err := srv.Client().Get(srv.URL + "/r/alpha/wiki")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	html := string(b)

	// The page list above names every page too, so count inside the
	// panel alone.
	_, panel, ok := strings.Cut(html, `class="w-struct"`)
	if !ok {
		t.Fatalf("no structure panel:\n%s", html)
	}
	if n := strings.Count(panel, `/r/alpha/wiki-new?name=Wanted`); n != wikiStructLimit {
		t.Errorf("wanted list shows %d rows, want it bounded at %d", n, wikiStructLimit)
	}
	if n := strings.Count(panel, `/r/alpha/wiki/Lonely`); n != wikiStructLimit {
		t.Errorf("orphan list shows %d rows, want it bounded at %d", n, wikiStructLimit)
	}
	// The bound must be visible: a silent truncation claims completeness.
	if !strings.Contains(panel, "more") {
		t.Errorf("both lists were truncated without saying so:\n%s", html)
	}
}

// A saved view is a NAME for a URL the tracker could already produce.
// It has to survive the round trip exactly — the rail entry must return
// the same listing that was saved — and it must not become a way out of
// the forge: the redirect after saving is built from the repository
// name, never taken from the form.
func TestSavedViewsRoundTripThroughTheRail(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{{Hash: "tk01", Art: &artifact.Artifact{
		Type: artifact.Ticket, TicketID: strings.Repeat("ab", 20),
		Date: "2026-08-19T09:00:00", User: "dan",
		Fields: []artifact.Field{{Name: "status", Value: "Open"},
			{Name: "title", Value: "a crash"}, {Name: "subsystem", Value: "web"}}}}}); err != nil {
		t.Fatal(err)
	}
	odb, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { odb.Close() })

	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:    api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
		Views:    odb,
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}},
	}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})}))
	t.Cleanup(srv.Close)
	client := srv.Client()
	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
	do := func(method, path string, form url.Values) *http.Response {
		t.Helper()
		var req *http.Request
		if form == nil {
			req, _ = http.NewRequest(method, srv.URL+path, nil)
		} else {
			req, _ = http.NewRequest(method, srv.URL+path, strings.NewReader(form.Encode()))
			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		}
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		resp, err := client.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		return resp
	}
	body := func(path string) string {
		resp := do("GET", path, nil)
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}

	// Nothing to name yet: an unfiltered listing needs no shortcut to
	// itself, so the control does not render.
	if strings.Contains(body("/r/alpha/ticket"), `class="t-rail-save"`) {
		t.Error("the unfiltered listing offered to save itself")
	}
	filtered := body("/r/alpha/ticket?subsystem=web")
	if !strings.Contains(filtered, `class="t-rail-save"`) {
		t.Fatalf("no way to name a report that exists:\n%s", filtered)
	}

	resp := do("POST", "/views", url.Values{
		"repo": {"alpha"}, "name": {"web crashes"}, "query": {"subsystem=web"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("saving a view: %d", resp.StatusCode)
	}
	// Back to the listing that was named, not to some other page.
	if loc := resp.Header.Get("Location"); loc != "/r/alpha/ticket?subsystem=web" {
		t.Fatalf("after saving, landed on %q", loc)
	}

	// The rail now carries it, and it points at the same listing.
	rail := body("/r/alpha/ticket")
	if !strings.Contains(rail, `href="/r/alpha/ticket?subsystem=web"`) || !strings.Contains(rail, "web crashes") {
		t.Fatalf("the saved view is not in the rail:\n%s", rail)
	}
	// It belongs to that tracker alone.
	if strings.Contains(body("/tickets"), "web crashes") {
		t.Error("a view saved against one tracker appeared on the cross-repository listing")
	}

	// A hand-made post cannot turn the redirect into a door out of the
	// forge: the path is rebuilt from the repository name.
	resp = do("POST", "/views", url.Values{
		"repo": {"https://evil.example.com"}, "name": {"x"}, "query": {""}})
	resp.Body.Close()
	if loc := resp.Header.Get("Location"); strings.Contains(loc, "evil.example.com") {
		t.Fatalf("open redirect through the repo field: %q", loc)
	}

	// Forgetting it loses the shortcut and nothing else.
	resp = do("POST", "/views/delete", url.Values{"repo": {"alpha"}, "name": {"web crashes"}})
	resp.Body.Close()
	if gone := body("/r/alpha/ticket"); strings.Contains(gone, "web crashes") {
		t.Error("the view survived being forgotten")
	}
	if still := body("/r/alpha/ticket?subsystem=web"); !strings.Contains(still, "a crash") {
		t.Error("forgetting a view took the listing with it")
	}
}

// The feed pages. For anyone whose repositories are busy a fixed window
// was one long column and then a dead end, so the page carries a
// numbered pager. What must hold: every page is a real URL a reader can
// bookmark or reach with the back button, and paging is exact across
// repositories — the merge is ordered by (date, hash), so events sharing
// a timestamp must neither repeat nor be stepped over.
func TestDashboardFeedPagesWithoutRepeatingOrSkipping(t *testing.T) {
	mk := func(t *testing.T, prefix string, n int) *index.Index {
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		var batch []index.Parsed
		for i := 0; i < n; i++ {
			// Every event in both repositories shares one timestamp, so
			// only the hash tiebreak can order them. Ordering on date
			// alone would make an offset name different rows on
			// different requests.
			batch = append(batch, index.Parsed{
				Hash: fmt.Sprintf("%s%060d", prefix, i),
				Art: &artifact.Artifact{Type: artifact.Manifest,
					Date: "2026-08-19T09:00:00", User: "dan",
					Comment: fmt.Sprintf("%s-%02d", prefix, i)}})
		}
		if err := ix.FoldNow(batch); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{
			"alpha": {Index: mk(t, "a", 30)},
			"beta":  {Index: mk(t, "b", 30)},
		}),
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write", "beta": "read"}}},
	}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})}))
	t.Cleanup(srv.Close)
	get := func(path string) string {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		resp, err := srv.Client().Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		if resp.StatusCode != 200 {
			t.Fatalf("GET %s: %d", path, resp.StatusCode)
		}
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}
	events := regexp.MustCompile(`[ab]-\d\d`)

	first := get("/")
	if !strings.Contains(first, `class="t-pager"`) {
		t.Fatalf("60 events and no pager:\n%s", first)
	}
	// Three pages of 25, 25 and 10 — and the pager offers all three as
	// addresses, not just a next.
	for _, want := range []string{`href="/?offset=25"`, `href="/?offset=50"`} {
		if !strings.Contains(first, want) {
			t.Errorf("the pager cannot reach %s", want)
		}
	}

	// Walk every page and account for all sixty events exactly once.
	seen := map[string]int{}
	for _, path := range []string{"/", "/?offset=25", "/?offset=50"} {
		page := get(path)
		rows := events.FindAllString(page, -1)
		if len(rows) == 0 {
			t.Fatalf("%s is empty", path)
		}
		for _, row := range rows {
			seen[row]++
		}
	}
	if len(seen) != 60 {
		t.Fatalf("walked the feed and saw %d of 60 events: paging skips", len(seen))
	}
	for name, n := range seen {
		if n != 1 {
			t.Fatalf("event %s appeared %d times: paging repeats", name, n)
		}
	}

	// A page is a place: asking for it twice gives the same rows, which
	// is what makes the back button and a bookmark work. The footer
	// prints how long the render took, so only the rows are compared.
	rowsOf := func(path string) string {
		return strings.Join(events.FindAllString(get(path), -1), ",")
	}
	if a, b := rowsOf("/?offset=25"), rowsOf("/?offset=25"); a != b {
		t.Errorf("the same page gave different rows:\n%s\n%s", a, b)
	}

	// The chip and the page are independent axes: narrowing must not
	// silently drop the other, in either direction.
	chip := get("/?kind=ci&offset=25")
	if !strings.Contains(chip, "kind=ci") || !strings.Contains(chip, "offset=") {
		t.Errorf("the pager dropped the chip, or the chip dropped the page:\n%s", chip)
	}
}

// A numbered pager has to stay the same shape as a reader moves through
// it, or the control jumps under the pointer. Both ends stay reachable,
// the current page keeps its neighbours, and a gap must hide more than
// one page — an ellipsis standing in for a single page has saved
// nothing and taken away a destination.
func TestPageWindow(t *testing.T) {
	show := func(cur, last int) string {
		var b strings.Builder
		for i, p := range pageWindow(cur, last) {
			if i > 0 {
				b.WriteString(" ")
			}
			if p == 0 {
				b.WriteString("…")
			} else {
				if p == cur {
					b.WriteString("[")
				}
				fmt.Fprintf(&b, "%d", p)
				if p == cur {
					b.WriteString("]")
				}
			}
		}
		return b.String()
	}
	for _, c := range []struct {
		cur, last int
		want      string
	}{
		{1, 1, "[1]"},
		// Short enough to show whole: no gap can pay for itself.
		{1, 6, "[1] 2 3 4 5 6"},
		{3, 7, "1 2 [3] 4 5 6 7"},
		// Long: both ends pinned, neighbours kept, the rest elided.
		{1, 12, "[1] 2 3 … 12"},
		{6, 12, "1 … 4 5 [6] 7 8 … 12"},
		{12, 12, "1 … 10 11 [12]"},
		// The page next to an end is not worth a gap: 1 … 3 would be
		// an ellipsis hiding page 2 alone.
		{4, 12, "1 2 3 [4] 5 6 … 12"},
		{9, 12, "1 … 7 8 [9] 10 11 12"},
		{0, 0, ""},
	} {
		if got := show(c.cur, c.last); got != c.want {
			t.Errorf("page %d of %d = %q, want %q", c.cur, c.last, got, c.want)
		}
	}
}

// A chip's count and its pages must describe the SAME events. The feed
// used to fetch a window of the timeline and then drop the kinds it did
// not want, while counting the kind across the whole repository — so a
// tracker with 234 ticket events among thousands of check-ins offered
// ten pages and had rows on two of them. The filter has to be applied
// where the rows come from, not after they arrive.
func TestFeedChipPagesAreAllReal(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	// The shape that broke it: check-ins crowd out the ticket events in
	// any recent window, and the ticket events are the OLDEST rows.
	var batch []index.Parsed
	for i := 0; i < 40; i++ {
		batch = append(batch, index.Parsed{Hash: fmt.Sprintf("t%063d", i),
			Art: &artifact.Artifact{Type: artifact.Ticket,
				TicketID: fmt.Sprintf("%040d", i), Date: "2026-08-01T09:00:00", User: "dan",
				Fields: []artifact.Field{{Name: "status", Value: "Open"},
					{Name: "title", Value: fmt.Sprintf("ticket-%02d", i)}}}})
	}
	for i := 0; i < 300; i++ {
		batch = append(batch, index.Parsed{Hash: fmt.Sprintf("c%063d", i),
			Art: &artifact.Artifact{Type: artifact.Manifest,
				Date: "2026-08-20T09:00:00", User: "dan",
				Comment: fmt.Sprintf("checkin-%03d", i)}})
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:    api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}},
	}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})}))
	t.Cleanup(srv.Close)
	get := func(path string) string {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		resp, err := srv.Client().Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}
	rows := regexp.MustCompile(`ticket-\d\d`)

	// Every page the chip's pager offers must have rows on it: the
	// count promised forty, so walking the pages must find forty.
	seen := map[string]bool{}
	for _, off := range []int{0, 25} {
		page := get(fmt.Sprintf("/?kind=t&offset=%d", off))
		found := rows.FindAllString(page, -1)
		if len(found) == 0 {
			t.Errorf("page at offset %d is empty, but the pager offers it:\n%s",
				off, page[max(0, strings.Index(page, "t-pager")-200):])
		}
		for _, f := range found {
			seen[f] = true
		}
	}
	if len(seen) != 40 {
		t.Fatalf("the chip counts 40 ticket events but paging reaches %d", len(seen))
	}
}

// A chip counts EVENTS, so it must name events. One ticket with nine
// comments is nine rows in this feed, and a chip that called that
// "tickets" reported 93,649 against a corpus of 17,000 tickets — the
// number was right and the noun was wrong. The repository sidebar's
// number counts tickets themselves, and the two must not read alike.
func TestFeedChipsNameEventsNotArtifacts(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	// One ticket, three changes to it: three feed rows, one ticket.
	id := strings.Repeat("ab", 20)
	var batch []index.Parsed
	for i, user := range []string{"dan", "eve", "dan"} {
		batch = append(batch, index.Parsed{Hash: fmt.Sprintf("t%03d", i),
			Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: id,
				Date: fmt.Sprintf("2026-08-1%dT09:00:00", i), User: user,
				Fields: []artifact.Field{{Name: "status", Value: "Open"},
					{Name: "title", Value: "one ticket"}}}})
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:    api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}},
	}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})}))
	t.Cleanup(srv.Close)
	req, _ := http.NewRequest("GET", srv.URL+"/", nil)
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	html := string(b)

	// The chip's count is of events: three, not one.
	chip := regexp.MustCompile(`kind=t"[^>]*>([^<]*)<span class="n"><span title="(\d+)"`).FindStringSubmatch(html)
	if chip == nil {
		t.Fatalf("no ticket chip:\n%s", html)
	}
	if chip[2] != "3" {
		t.Errorf("chip counts %s, want 3 events for one ticket changed three times", chip[2])
	}
	// And it says so: a chip counting changes must not be called by the
	// name of the thing that was changed.
	if label := strings.TrimSpace(chip[1]); label == "tickets" {
		t.Errorf("the chip counts %s events but calls itself %q", chip[2], label)
	}
	// The sidebar's number counts the ticket itself — one — and is
	// labelled for what it is, so the two numbers cannot be confused.
	if !strings.Contains(html, `title="open tickets"`) {
		t.Error("the repository's ticket count is unlabelled")
	}
}