package index
import (
"testing"
"craftdesign.group/quarry/internal/artifact"
)
// countsFixture folds three check-ins the two ways the index learns
// them. c1 (crosslink) edits src/a.go +12 -3 and adds logo.png, a
// binary. c2 (crosslink, a merge) edits src/b.go +5 -0 and docs/x.md,
// which zit could not count. c3 (a live fold) edits src/a.go +1 -1.
func countsFixture(t *testing.T) *Index {
t.Helper()
ix := openIndex(t)
if err := ix.FoldCheckins([]CheckinRecord{
{Hash: "c1", Date: "2026-10-01T10:00:00", User: "u", Comment: "one",
Changes: []string{"src/a.go", "logo.png"},
Lines: map[string]LineCount{"src/a.go": {12, 3}, "logo.png": {-1, -1}}},
{Hash: "c2", Date: "2026-10-02T10:00:00", User: "u", Comment: "two", Parents: []string{"c1", "side"},
Changes: []string{"src/b.go", "docs/x.md"},
Lines: map[string]LineCount{"src/b.go": {5, 0}}},
}); err != nil {
t.Fatal(err)
}
art := &artifact.Artifact{Type: artifact.Manifest, Date: "2026-10-03T10:00:00", User: "u", Comment: "three",
Parents: []string{"c2"}}
if err := ix.FoldNow([]Parsed{{Hash: "c3", Art: art, Changes: []string{"src/a.go"},
Lines: map[string]LineCount{"src/a.go": {1, 1}}}}); err != nil {
t.Fatal(err)
}
return ix
}
// A check-in's size is its files and, when all were counted, its lines;
// a binary file is a file with no lines, and one uncounted file makes
// the lines unknown rather than short.
func TestChangeStatsSizeACheckin(t *testing.T) {
ix := countsFixture(t)
got, err := ix.ChangeStats([]string{"c1", "c2", "c3", "nope"}, "")
if err != nil {
t.Fatal(err)
}
for h, want := range map[string]ChangeStat{
"c1": {Files: 2, Added: 12, Removed: 3, Counted: true},
"c2": {Files: 2, Counted: false},
"c3": {Files: 1, Added: 1, Removed: 1, Counted: true},
} {
if got[h] != want {
t.Errorf("FAIL: %s: %+v, want %+v", h, got[h], want)
}
}
if _, ok := got["nope"]; ok {
t.Error("FAIL: a check-in with no rows was sized")
}
}
// A path's history counts that path alone, and a folder's everything
// below it.
func TestChangeStatsForAPath(t *testing.T) {
ix := countsFixture(t)
file, _ := ix.ChangeStats([]string{"c1", "c3"}, "src/a.go")
if file["c1"] != (ChangeStat{Files: 1, Added: 12, Removed: 3, Counted: true}) {
t.Errorf("FAIL: src/a.go in c1: %+v", file["c1"])
}
dir, _ := ix.ChangeStats([]string{"c1", "c2"}, "src")
if dir["c1"] != (ChangeStat{Files: 1, Added: 12, Removed: 3, Counted: true}) ||
dir["c2"] != (ChangeStat{Files: 1, Added: 5, Counted: true}) {
t.Errorf("FAIL: src/ in c1 %+v, c2 %+v", dir["c1"], dir["c2"])
}
// "src" must not take in a sibling that merely starts the same.
if err := ix.FoldCheckins([]CheckinRecord{{Hash: "c4", Date: "2026-10-04T10:00:00", Comment: "x",
Changes: []string{"srcx/y.go"}, Lines: map[string]LineCount{"srcx/y.go": {9, 9}}}}); err != nil {
t.Fatal(err)
}
if got, _ := ix.ChangeStats([]string{"c4"}, "src"); len(got) != 0 {
t.Errorf("FAIL: srcx/ counted as part of src/: %+v", got)
}
}
// The backfill fills rows that have no counts and leaves counted ones
// alone; a re-fold without counts does not erase them.
func TestFillLineCountsOnlyFills(t *testing.T) {
ix := countsFixture(t)
if err := ix.FillLineCounts(map[string]map[string]LineCount{
"c2": {"docs/x.md": {4, 4}, "src/b.go": {99, 99}},
}); err != nil {
t.Fatal(err)
}
got, _ := ix.ChangeStats([]string{"c2"}, "")
if got["c2"] != (ChangeStat{Files: 2, Added: 9, Removed: 4, Counted: true}) {
t.Errorf("FAIL: after the fill c2 is %+v", got["c2"])
}
if err := ix.FoldCheckins([]CheckinRecord{{Hash: "c1", Date: "2026-10-01T10:00:00", User: "u", Comment: "one",
Changes: []string{"src/a.go", "logo.png"}}}); err != nil {
t.Fatal(err)
}
if got, _ := ix.ChangeStats([]string{"c1"}, ""); !got["c1"].Counted {
t.Error("FAIL: a re-fold without counts erased them")
}
if ix.LineCountsDone() {
t.Error("FAIL: a fresh index claims complete counts")
}
if err := ix.MarkLineCountsDone(); err != nil || !ix.LineCountsDone() {
t.Errorf("FAIL: marking the counts done: %v", err)
}
}