package web
import (
"fmt"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// findServer is a repository of six trunk check-ins, three of whose
// comments mention "stylesheet": c1, c3 and c5.
func findServer(t *testing.T) *httptest.Server {
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
for i := range 6 {
comment := fmt.Sprintf("plain work %d", i)
if i%2 == 1 {
comment = fmt.Sprintf("the stylesheet, part %d", i)
}
art := &artifact.Artifact{Type: artifact.Manifest,
Date: fmt.Sprintf("2026-09-17T10:%02d:00", i), User: "dan", Comment: comment}
if i > 0 {
art.Parents = []string{batch[i-1].Hash}
}
batch = append(batch, index.Parsed{Hash: fmt.Sprintf("c%d", i) + strings.Repeat("0", 39), Art: art})
}
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)
return srv
}
// c= opens the timeline centred on one check-in, ringed and brought into
// view, with the bar that says so. A centred page is a place in the
// history, not the live head, so it does not stream.
func TestTimelineCentresOnACheckin(t *testing.T) {
srv := activityServer(t)
code, body := getPage(t, srv, "/r/alpha/timeline?c="+strings.Repeat("22", 6))
if code != 200 {
t.Fatalf("status %d", code)
}
if n := strings.Count(body, `data-hook="timeline-found"`); n != 1 {
t.Errorf("FAIL: %d ringed cards, want 1", n)
}
if !strings.Contains(body, `data-hook="timeline-centre"`) {
t.Error("FAIL: no centre bar")
}
if strings.Contains(body, "timeline.rev") {
t.Error("FAIL: a centred page streams like the live head")
}
}
// On the timeline the header box searches the timeline, and what it
// accepts decides where the page opens: words centre on the newest
// matching check-in, a hash prefix on that check-in, a date on the
// newest check-in that day.
func TestTimelineBoxFindsOnTheTimeline(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/timeline")
if !strings.Contains(body, `data-hook="timeline-find"`) || !strings.Contains(body, `action="/r/alpha/timeline"`) {
t.Error("FAIL: the timeline's header box does not search the timeline")
}
for _, c := range []struct{ q, want string }{
{"trunk", strings.Repeat("22", 6)},
{"1111", strings.Repeat("11", 6)},
{"2026-08-03", strings.Repeat("22", 6)},
} {
_, body := getPage(t, srv, "/r/alpha/timeline?q="+c.q)
i := strings.Index(body, `data-hook="timeline-found"`)
if i < 0 {
t.Errorf("FAIL: q=%s rings nothing", c.q)
continue
}
card := body[i:min(len(body), i+3000)]
if !strings.Contains(card, c.want) {
t.Errorf("FAIL: q=%s rings the wrong check-in", c.q)
}
}
}
// A search with no match, or a centre that names nothing, still opens
// the timeline and says what happened: never an empty page or a 404
// that loses the reader's filters.
func TestTimelineFindMissesSayWhatHappened(t *testing.T) {
srv := activityServer(t)
for _, path := range []string{"/r/alpha/timeline?q=nothinglikeit", "/r/alpha/timeline?c=ffffff"} {
code, body := getPage(t, srv, path)
if code != 200 {
t.Errorf("FAIL: %s answers %d", path, code)
}
if !strings.Contains(body, `data-hook="timeline-centre"`) || strings.Contains(body, `data-hook="timeline-found"`) {
t.Errorf("FAIL: %s does not say it found nothing", path)
}
if strings.Count(body, `<div class="tl-card"`) == 0 {
t.Errorf("FAIL: %s shows no timeline", path)
}
}
}
// Stepping through matches is links, so it works with no script: from
// the newest of three matches "older" leads on and "newer" is not
// offered; from the middle one, both are.
func TestTimelineMatchesStep(t *testing.T) {
srv := findServer(t)
hasHref := func(body, hook string) bool {
i := strings.Index(body, `data-hook="`+hook+`"`)
if i < 0 {
t.Fatalf("FAIL: no %s", hook)
}
j := strings.LastIndex(body[:i], "<a ")
return strings.Contains(body[j:i], "href=")
}
_, body := getPage(t, srv, "/r/alpha/timeline?q=stylesheet")
if !hasHref(body, "match-next") || hasHref(body, "match-prev") {
t.Error("FAIL: the newest match should step older only")
}
if !strings.Contains(body, `data-hook="match-count" data-at="1" data-of="3"`) {
t.Error("FAIL: the bar does not count the matches")
}
_, body = getPage(t, srv, "/r/alpha/timeline?q=stylesheet&c=c3")
if !hasHref(body, "match-next") || !hasHref(body, "match-prev") {
t.Error("FAIL: the middle match should step both ways")
}
}
// As the reader types, the box lists what Enter would find: links that
// open the timeline centred there with the search kept, and the way out
// to the whole repository's search. An empty box lists nothing.
func TestTimelineBoxSuggests(t *testing.T) {
srv := findServer(t)
code, body := getPage(t, srv, "/r/alpha/timeline.suggest?q=stylesheet")
if code != 200 || !strings.Contains(body, `data-hook="timeline-suggest"`) {
t.Fatalf("FAIL: %d, no list", code)
}
if n := strings.Count(body, "/r/alpha/timeline?c="); n != 3 {
t.Errorf("FAIL: %d suggestions, want the 3 matches", n)
}
if !strings.Contains(body, "/r/alpha/search?q=stylesheet") {
t.Error("FAIL: no way to the whole repository's search")
}
_, empty := getPage(t, srv, "/r/alpha/timeline.suggest?q=")
if strings.Contains(empty, "timeline?c=") || strings.Contains(empty, "/search?q") {
t.Error("FAIL: an empty box suggests things")
}
}
// The timeline's panel finds on the timeline by Enter, and its other
// scopes send the same words to the repository's search or every
// repository's.
func TestTheTimelinePanelWidensToSearch(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/timeline")
i := strings.Index(body, `data-hook="find-in"`)
if i < 0 {
t.Fatal("FAIL: the timeline's panel has no scopes")
}
in := body[i:]
in = in[:strings.Index(in, "</div>")]
if !strings.Contains(in, `<button type="submit" class="on">`) ||
!strings.Contains(in, `formaction="/r/alpha/search">`) ||
!strings.Contains(in, `formaction="/search" name="kind" value="">`) {
t.Errorf("FAIL: the timeline's scopes are wrong: %s", in)
}
}