package index
import (
"fmt"
"slices"
"testing"
"craftdesign.group/quarry/internal/artifact"
)
// centreFixture is twenty check-ins on trunk, one a minute on two days,
// h00 oldest. Every fifth comment mentions "stylesheet"; the ones on
// odd minutes change a.go.
func centreFixture(t *testing.T) *Index {
t.Helper()
ix := openIndex(t)
var batch []Parsed
var changes []BulkChanges
for i := range 20 {
h := fmt.Sprintf("%02d", i)
hash := "h" + h + "000000000000000000000000000000000000000"
day := "2026-09-16"
if i >= 10 {
day = "2026-09-17"
}
comment := "work " + h
if i%5 == 0 {
comment = "the stylesheet " + h
}
art := &artifact.Artifact{Type: artifact.Manifest,
Date: fmt.Sprintf("%sT10:%02d:00", day, i), User: "dan", Comment: comment}
if i > 0 {
art.Parents = []string{batch[i-1].Hash}
}
batch = append(batch, Parsed{Hash: hash, Art: art})
files := []string{"b.go"}
if i%2 == 1 {
files = []string{"a.go"}
}
changes = append(changes, BulkChanges{Hash: hash, Art: art, Changes: files})
}
if err := ix.FoldNow(batch); err != nil {
t.Fatal(err)
}
if err := ix.FoldChangesBulk(changes); err != nil {
t.Fatal(err)
}
return ix
}
func shortHashes(es []TimelineEntry) []string {
var out []string
for _, e := range es {
out = append(out, e.Hash[:3])
}
return out
}
// A centred page has the anchor in the middle: half a page newer above
// it, the rest older below, newest first throughout like any page.
func TestTimelineAroundPutsTheAnchorInTheMiddle(t *testing.T) {
ix := centreFixture(t)
c, ok, err := ix.TimelineAround(TimelineFilter{Limit: 6, Kind: "ci"}, "h10")
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
want := []string{"h13", "h12", "h11", "h10", "h09", "h08"}
if got := shortHashes(c.Entries); !slices.Equal(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
if !c.Found || c.Anchor[:3] != "h10" || !c.Newer || !c.Older {
t.Errorf("found=%v anchor=%s newer=%v older=%v", c.Found, c.Anchor[:3], c.Newer, c.Older)
}
// At the top of the history there is nothing newer to show.
c, _, _ = ix.TimelineAround(TimelineFilter{Limit: 6, Kind: "ci"}, "h19")
if got := shortHashes(c.Entries); got[0] != "h19" || c.Newer {
t.Errorf("newest: got %v newer=%v", got, c.Newer)
}
}
// A date stands for the newest check-in at or before the end of it.
func TestTimelineAroundADate(t *testing.T) {
ix := centreFixture(t)
c, ok, err := ix.TimelineAround(TimelineFilter{Limit: 4, Kind: "ci"}, "2026-09-16")
if err != nil || !ok || c.Anchor[:3] != "h09" || !c.Found {
t.Fatalf("ok=%v err=%v anchor=%v", ok, err, c.Anchor)
}
if _, ok, _ := ix.TimelineAround(TimelineFilter{Limit: 4, Kind: "ci"}, "2001-01-01"); ok {
t.Error("a date before the history names nothing")
}
if _, ok, _ := ix.TimelineAround(TimelineFilter{Limit: 4, Kind: "ci"}, "ffff"); ok {
t.Error("an unknown hash names nothing")
}
}
// Centring keeps the filters: around h10 in a.go's history, only the
// check-ins that changed a.go, and h10 (which did not) is placed by its
// date without being claimed as found.
func TestTimelineAroundKeepsThePath(t *testing.T) {
ix := centreFixture(t)
c, ok, err := ix.TimelineAround(TimelineFilter{Limit: 4, Path: "a.go"}, "h11")
if err != nil || !ok {
t.Fatal(err)
}
if got := shortHashes(c.Entries); !slices.Equal(got, []string{"h15", "h13", "h11", "h09"}) || !c.Found {
t.Errorf("a.go around h11: %v found=%v", got, c.Found)
}
c, _, _ = ix.TimelineAround(TimelineFilter{Limit: 4, Path: "a.go"}, "h10")
if got := shortHashes(c.Entries); !slices.Equal(got, []string{"h13", "h11", "h09", "h07"}) || c.Found {
t.Errorf("a.go around h10: %v found=%v", got, c.Found)
}
}
// After is Before's mirror: the events just newer than a cursor, nearest
// first in the walk but returned newest first.
func TestTimelineAfterIsTheNewerPage(t *testing.T) {
ix := centreFixture(t)
page, _, err := ix.Timeline(TimelineFilter{Limit: 50, Kind: "ci"})
if err != nil {
t.Fatal(err)
}
var at TimelineEntry
for _, e := range page {
if e.Hash[:3] == "h05" {
at = e
}
}
got, more, err := ix.Timeline(TimelineFilter{Limit: 3, Kind: "ci", After: at.Date + "~" + at.Hash})
if err != nil {
t.Fatal(err)
}
if s := shortHashes(got); !slices.Equal(s, []string{"h08", "h07", "h06"}) || !more {
t.Errorf("after h05: %v more=%v", s, more)
}
}
// The timeline's own search lists matches in timeline order under its
// filters, so stepping through them walks down the page.
func TestTimelineFindIsInTimelineOrder(t *testing.T) {
ix := centreFixture(t)
got, err := ix.TimelineFind(TimelineFilter{}, "stylesheet", 50)
if err != nil {
t.Fatal(err)
}
short := shortHashes(got)
if !slices.Equal(short, []string{"h15", "h10", "h05", "h00"}) {
t.Errorf("matches: %v", short)
}
got, _ = ix.TimelineFind(TimelineFilter{Path: "a.go"}, "stylesheet", 50)
if s := shortHashes(got); !slices.Equal(s, []string{"h15", "h05"}) {
t.Errorf("matches in a.go's history: %v", got)
}
// The last word is matched as a prefix, as the reader types it.
if got, _ := ix.TimelineFind(TimelineFilter{}, "styles", 50); len(got) != 4 {
t.Errorf("prefix: %d matches, want 4", len(got))
}
if got, _ := ix.TimelineFind(TimelineFilter{}, "nothing-like-it", 50); len(got) != 0 {
t.Errorf("no match: %v", got)
}
}