Skip to content
quarry Artifact [39bd769a7a]
Login

Artifact [39bd769a7a]

Artifact 39bd769a7a7a25b63eebdd66b71e019e7b60a4e172234d80f900e59f582121d6:


package index

import (
	"craftdesign.group/quarry/internal/idn"
	"fmt"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/artifact"
)

// ciu is ci with an explicit author. The shared helper hardcodes one
// user, and every claim in this file is about attributing work to the
// right person.
func ciu(t *testing.T, ix *Index, hash, user, branch, date string, parents ...string) {
	t.Helper()
	if _, err := ix.db.Exec(`INSERT OR REPLACE INTO checkin (hash, date, user, comment, branch)
		VALUES (?, ?, ?, '', ?)`, hash, date, user, branch); err != nil {
		t.Fatal(err)
	}
	for i, p := range parents {
		if _, err := ix.db.Exec(`INSERT OR REPLACE INTO checkin_parent (child, parent, ord)
			VALUES (?, ?, ?)`, hash, p, i); err != nil {
			t.Fatal(err)
		}
	}
}

// changed runs the real fold so the dir flag under test is the one
// production writes, not one the test invented.
func changed(t *testing.T, ix *Index, hash, date string, paths ...string) {
	t.Helper()
	if err := ix.FoldChangesBulk([]BulkChanges{{
		Hash: hash, Art: &artifact.Artifact{Date: date}, Changes: paths,
	}}); err != nil {
		t.Fatal(err)
	}
}

func release(t *testing.T, ix *Index, hash, date, name string) {
	t.Helper()
	if _, err := ix.db.Exec(`INSERT OR REPLACE INTO checkin_tag (src, date, checkin, name, sign, value)
		VALUES (?, ?, ?, ?, '+', '')`, hash, date, hash, name); err != nil {
		t.Fatal(err)
	}
}

// mergeDAG: dan writes one file on trunk, eve writes one on a branch,
// dan merges. The merge's manifest carries three paths — two of them
// eve's work, one of them a file neither of them touched in the window
// — which is the shape that inflates a merger's footprint on every
// forge that counts them.
func mergeDAG(t *testing.T) *Index {
	t.Helper()
	ix := openIndex(t)
	ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
	ciu(t, ix, "b1", "eve", "feat", "2026-06-02T10:00:00", "a1")
	ciu(t, ix, "m1", "dan", "trunk", "2026-06-03T11:00:00", "a1", "b1")
	changed(t, ix, "a1", "2026-06-01T09:00:00", "src/one.go")
	changed(t, ix, "b1", "2026-06-02T10:00:00", "src/two.go")
	changed(t, ix, "m1", "2026-06-03T11:00:00", "src/two.go", "src/three.go", "doc/four.md")
	return ix
}

func june(t *testing.T, ix *Index, q ActivityQuery) Activity {
	t.Helper()
	q.Since, q.Until = "2026-06-01", "2026-06-30"
	got, err := ix.Activity(q)
	if err != nil {
		t.Fatal(err)
	}
	return got
}

func rowFor(t *testing.T, a Activity, hash string) ActivityRow {
	t.Helper()
	for _, r := range a.Rows {
		if r.Hash == hash {
			return r
		}
	}
	t.Fatalf("no row for %s in %+v", hash, a.Rows)
	return ActivityRow{}
}

func authorFor(t *testing.T, as []ActivityAuthor, name string) ActivityAuthor {
	t.Helper()
	for _, a := range as {
		if a.Name == name {
			return a
		}
	}
	t.Fatalf("no author %s in %+v", name, as)
	return ActivityAuthor{}
}

// The invariant the whole feature rests on: a merge's file list is the
// absorbed branch's work. It counts as a check-in — it happened — and
// as no files at all.
func TestActivityMergeFilesAreInheritedNotAuthored(t *testing.T) {
	got := june(t, mergeDAG(t), ActivityQuery{})

	if len(got.Rows) != 3 {
		t.Fatalf("got %d rows, want 3: %+v", len(got.Rows), got.Rows)
	}
	m := rowFor(t, got, "m1")
	if m.Merge != 1 {
		t.Fatalf("m1 Merge = %d, want 1 — a second parent is the whole test", m.Merge)
	}
	if m.Files != 0 {
		t.Fatalf("m1 Files = %d, want 0 — the merge carried three inherited paths", m.Files)
	}
	if r := rowFor(t, got, "a1"); r.Merge != 0 || r.Files != 1 {
		t.Fatalf("a1 = %+v, want Merge 0 Files 1", r)
	}

	// Five leaf touches: one each for a1 and b1, three inherited by m1.
	if len(got.Touches) != 5 {
		t.Fatalf("got %d touches, want 5: %+v", len(got.Touches), got.Touches)
	}
	inherited := 0
	for _, tc := range got.Touches {
		if tc.Inherited == 1 {
			inherited++
		}
	}
	if inherited != 3 {
		t.Fatalf("%d inherited touches, want 3", inherited)
	}

	dan := authorFor(t, got.Authors, "dan")
	if dan.Checkins != 2 {
		t.Fatalf("dan Checkins = %d, want 2 — the merge is still a check-in", dan.Checkins)
	}
	if dan.Files != 1 {
		t.Fatalf("dan Files = %d, want 1 — the merge's three paths are eve's work", dan.Files)
	}
	if eve := authorFor(t, got.Authors, "eve"); eve.Checkins != 1 || eve.Files != 1 {
		t.Fatalf("eve = %+v, want Checkins 1 Files 1", eve)
	}
	if got.Authors[0].Name != "dan" {
		t.Fatalf("authors not ranked by check-ins: %+v", got.Authors)
	}
}

// Directories are in filechange so a directory's last change is one
// lookup. They are not files, and the tree axis is files.
func TestActivityExcludesDirectoryRows(t *testing.T) {
	got := june(t, mergeDAG(t), ActivityQuery{})
	for _, p := range got.Paths {
		if p == "src" || p == "doc" {
			t.Fatalf("directory %q reached Paths: %v", p, got.Paths)
		}
	}
	// src/three.go and doc/four.md were only ever inherited by the
	// merge, so they fold into their directories' rows (below); the
	// folded row sorts first in its directory.
	want := []string{"doc/", "src/", "src/one.go", "src/two.go"}
	if len(got.Paths) != len(want) {
		t.Fatalf("paths = %v, want %v", got.Paths, want)
	}
	for i := range want {
		if got.Paths[i] != want[i] {
			t.Fatalf("paths = %v, want %v — sorted by (dirname, lowercase path)", got.Paths, want)
		}
	}
}

// A path nobody authored in the window — every touch of it came through
// a merge — is one row per directory, named "dir/", with one inherited
// touch per merge that brought files into that directory. A vendored
// tree landing by merge is then one ghost on one row, not a row per
// file. Paths someone did author keep their own row; the merge's touch
// of that path stays on it.
func TestActivityInheritedOnlyPathsFoldIntoDirectoryRows(t *testing.T) {
	ix := mergeDAG(t)
	// A second merge carries a whole vendored tree nobody authored.
	ciu(t, ix, "m2", "dan", "trunk", "2026-06-05T12:00:00", "m1", "b1")
	changed(t, ix, "m2", "2026-06-05T12:00:00",
		"compat/zlib/adler32.c", "compat/zlib/crc32.c", "compat/zlib/zlib.h", "src/three.go")
	got := june(t, ix, ActivityQuery{})

	want := []string{"compat/", "doc/", "src/", "src/one.go", "src/two.go"}
	if strings.Join(got.Paths, " ") != strings.Join(want, " ") {
		t.Fatalf("paths = %v, want %v", got.Paths, want)
	}
	idx := map[string]int{}
	for i, p := range got.Paths {
		idx[p] = i
	}
	m2 := rowFor(t, got, "m2")
	var zlib, srcFold, two int
	for _, tc := range got.Touches {
		switch {
		case tc.Path == idx["compat/"]:
			zlib++
			if tc.Inherited != 1 || tc.Day != m2.Day {
				t.Fatalf("compat/ touch %+v, want inherited on m2's day", tc)
			}
		case tc.Path == idx["src/"]:
			srcFold++
		case tc.Path == idx["src/two.go"]:
			two++
		}
	}
	if zlib != 1 {
		t.Fatalf("compat/ has %d touches, want 1 — three files fold to one ghost", zlib)
	}
	if srcFold != 2 {
		t.Fatalf("src/ has %d touches, want 2 — m1 and m2 each inherited src/three.go", srcFold)
	}
	if two != 2 {
		t.Fatalf("src/two.go has %d touches, want 2 — authored by b1, inherited by m1", two)
	}
}

// A sweep — one check-in touching sweepFiles or more paths — is not
// work on each of those files. Files touched only by sweeps fold like
// inherited ones; the sweep is one authored touch on the folded row.
// A file the sweep touched that someone also edited keeps its row.
func TestActivitySweptPathsFoldIntoDirectoryRows(t *testing.T) {
	ix := mergeDAG(t)
	var ps []string
	for i := 0; i < sweepFiles; i++ {
		ps = append(ps, fmt.Sprintf("vendor/lib/sub%d/f%02d.c", i%3, i))
	}
	ps = append(ps, "src/one.go")
	ciu(t, ix, "s1", "dan", "trunk", "2026-06-05T12:00:00", "m1")
	changed(t, ix, "s1", "2026-06-05T12:00:00", ps...)
	got := june(t, ix, ActivityQuery{})

	// vendor/ has no authored file anywhere beneath it, so its three
	// swept subdirectories roll up to one row.
	want := []string{"doc/", "src/", "src/one.go", "src/two.go", "vendor/"}
	if strings.Join(got.Paths, " ") != strings.Join(want, " ") {
		t.Fatalf("paths = %v, want %v", got.Paths, want)
	}
	s1 := rowFor(t, got, "s1")
	if s1.Files != sweepFiles+1 {
		t.Fatalf("s1 Files = %d, want %d — the fold changes rows, not counts", s1.Files, sweepFiles+1)
	}
	var vendor, one int
	for _, tc := range got.Touches {
		if tc.Day != s1.Day {
			continue
		}
		switch got.Paths[tc.Path] {
		case "vendor/":
			vendor++
			if tc.Inherited != 0 {
				t.Fatalf("sweep touch marked inherited: %+v", tc)
			}
		case "src/one.go":
			one++
		}
	}
	if vendor != 1 || one != 1 {
		t.Fatalf("s1 touches: vendor/ %d, src/one.go %d; want 1 and 1", vendor, one)
	}
}

// day is days since Since, and hour is the check-in's own UTC hour.
func TestActivityDayAndHourAreRelativeToTheWindow(t *testing.T) {
	got := june(t, mergeDAG(t), ActivityQuery{})
	for _, c := range []struct {
		hash      string
		day, hour int
	}{{"a1", 0, 9}, {"b1", 1, 10}, {"m1", 2, 11}} {
		r := rowFor(t, got, c.hash)
		if r.Day != c.day || r.Hour != c.hour {
			t.Fatalf("%s = day %d hour %d, want day %d hour %d", c.hash, r.Day, r.Hour, c.day, c.hour)
		}
	}
}

// A path the check-in names directly stays a leaf (dir=0) even when it
// is also an ancestor of another named path — ancestorPaths decides the
// leaf set from the check-in's changes before it walks anybody's
// prefixes.
func TestActivityKeepsALeafPathThatIsAlsoADirectoryPrefix(t *testing.T) {
	ix := openIndex(t)
	ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
	changed(t, ix, "a1", "2026-06-01T09:00:00", "src", "src/one.go")

	got := june(t, ix, ActivityQuery{})
	found := false
	for _, p := range got.Paths {
		if p == "src" {
			found = true
		}
	}
	if !found {
		t.Fatalf("src missing from Paths: %v", got.Paths)
	}
	if r := rowFor(t, got, "a1"); r.Files != 2 {
		t.Fatalf("a1 Files = %d, want 2 — src is a named leaf, not just an ancestor", r.Files)
	}
	var dir int
	if err := ix.db.QueryRow(`SELECT dir FROM filechange WHERE path = 'src' AND checkin = 'a1'`).
		Scan(&dir); err != nil {
		t.Fatal(err)
	}
	if dir != 0 {
		t.Fatalf("src dir = %d, want 0 — it was named directly, not synthesised", dir)
	}
}

func TestActivityFiltersByBranchAndUser(t *testing.T) {
	ix := mergeDAG(t)

	onFeat := june(t, ix, ActivityQuery{Branch: "feat"})
	if len(onFeat.Rows) != 1 || onFeat.Rows[0].Hash != "b1" {
		t.Fatalf("branch filter = %+v, want only b1", onFeat.Rows)
	}
	if len(onFeat.Touches) != 1 || onFeat.Paths[0] != "src/two.go" {
		t.Fatalf("branch filter touches = %+v paths %v", onFeat.Touches, onFeat.Paths)
	}

	byEve := june(t, ix, ActivityQuery{User: "eve"})
	if len(byEve.Rows) != 1 || byEve.Rows[0].Hash != "b1" {
		t.Fatalf("user filter = %+v, want only b1", byEve.Rows)
	}
	if len(byEve.Authors) != 1 || byEve.Authors[0].Name != "eve" {
		t.Fatalf("user filter authors = %+v", byEve.Authors)
	}
}

// A given Since is honoured verbatim and says so; the client needs to
// know whether the window it sees is the one it asked for.
func TestActivityWindowGivenIsHonoured(t *testing.T) {
	got := june(t, mergeDAG(t), ActivityQuery{})
	if got.Window != "given" {
		t.Fatalf("Window = %q, want given", got.Window)
	}
	if got.Since != "2026-06-01" || got.Until != "2026-06-30" {
		t.Fatalf("window = %s..%s, want the dates asked for", got.Since, got.Until)
	}
}

// A small repository sees all of itself: 900 days back from its own
// last check-in covers everything, and the window is the server's.
func TestActivityWindowPickedCoversASmallHistory(t *testing.T) {
	got, err := mergeDAG(t).Activity(ActivityQuery{})
	if err != nil {
		t.Fatal(err)
	}
	if got.Window != "picked" {
		t.Fatalf("Window = %q, want picked", got.Window)
	}
	if len(got.Rows) != 3 {
		t.Fatalf("picked window holds %d rows, want the whole 3-commit history", len(got.Rows))
	}
	if got.Since != "2026-06-01" || got.Until != "2026-06-03" {
		t.Fatalf("window = %s..%s, want it clamped to the history", got.Since, got.Until)
	}
}

// bulkHistory writes n check-ins two per day ending on the newest day,
// in one transaction — the window rule needs thousands of rows and the
// test needs to stay fast.
func bulkHistory(t *testing.T, ix *Index, days int, last time.Time) {
	t.Helper()
	tx, err := ix.db.Begin()
	if err != nil {
		t.Fatal(err)
	}
	defer tx.Rollback()
	st, err := tx.Prepare(`INSERT INTO checkin (hash, date, user, comment, branch)
		VALUES (?, ?, 'dan', '', 'trunk')`)
	if err != nil {
		t.Fatal(err)
	}
	defer st.Close()
	for d := range days {
		day := last.AddDate(0, 0, d-days+1).Format("2006-01-02")
		for k := range 2 {
			if _, err := st.Exec(fmt.Sprintf("h%d-%d", d, k),
				fmt.Sprintf("%sT%02d:00:00", day, 8+k)); err != nil {
				t.Fatal(err)
			}
		}
	}
	if err := tx.Commit(); err != nil {
		t.Fatal(err)
	}
}

// 900 days is a floor, not a ceiling. A repository whose recent 900
// days hold fewer than 2,000 check-ins gets the window widened until
// they do, so the chart is never a handful of dots on an empty field.
func TestActivityWindowPickedExtendsBackwardsForVolume(t *testing.T) {
	ix := openIndex(t)
	last := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)
	bulkHistory(t, ix, 1200, last) // 2,400 check-ins, two a day

	got, err := ix.Activity(ActivityQuery{})
	if err != nil {
		t.Fatal(err)
	}
	if got.Window != "picked" {
		t.Fatalf("Window = %q, want picked", got.Window)
	}
	if len(got.Rows) < 2000 {
		t.Fatalf("picked window holds %d rows, want at least 2000", len(got.Rows))
	}
	// 900 days at two a day is 1,800 — short. The 2,000th newest
	// check-in sits on the 1,000th newest day.
	want := last.AddDate(0, 0, -999).Format("2006-01-02")
	if got.Since != want {
		t.Fatalf("Since = %s, want %s — the window did not extend past 900 days", got.Since, want)
	}
}

// The rule ActivityDays honours the same way Activity does: a merge's
// touches are the absorbed branch's work, not the merger's, so they
// never count against the merging author's Files.
func TestActivityDaysAuthorFilesExcludeMergeTouches(t *testing.T) {
	got, err := mergeDAG(t).ActivityDays("")
	if err != nil {
		t.Fatal(err)
	}
	if dan := authorFor(t, got.Authors, "dan"); dan.Files != 1 {
		t.Fatalf("dan Files = %d, want 1 — the merge's three paths are eve's inherited work", dan.Files)
	}
	if eve := authorFor(t, got.Authors, "eve"); eve.Files != 1 {
		t.Fatalf("eve Files = %d, want 1", eve.Files)
	}
}

// Only Until named still honours the 900-day/2000-check-in floor rule —
// the widening math anchors on Until, not on the repository's actual
// last check-in.
func TestActivityWindowUntilWithoutSinceHonoursTheFloorRule(t *testing.T) {
	ix := openIndex(t)
	last := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)
	bulkHistory(t, ix, 1200, last) // 2,400 check-ins, two a day

	until := last.AddDate(0, 0, -100).Format("2006-01-02")
	got, err := ix.Activity(ActivityQuery{Until: until})
	if err != nil {
		t.Fatal(err)
	}
	if got.Window != "picked" {
		t.Fatalf("Window = %q, want picked — only Until was given", got.Window)
	}
	if got.Until != until {
		t.Fatalf("Until = %s, want %s", got.Until, until)
	}
	untilT, perr := time.Parse("2006-01-02", until)
	if perr != nil {
		t.Fatal(perr)
	}
	want := untilT.AddDate(0, 0, -999).Format("2006-01-02")
	if got.Since != want {
		t.Fatalf("Since = %s, want %s — the floor rule did not honour Until", got.Since, want)
	}
}

func TestActivityDaysLeadAuthorMergesAndReleases(t *testing.T) {
	ix := openIndex(t)
	// One day, three check-ins: dan twice (one of them a merge), eve
	// once. dan leads, eve is second, one merge.
	ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
	ciu(t, ix, "b1", "eve", "trunk", "2026-06-01T10:00:00", "a1")
	ciu(t, ix, "s1", "eve", "side", "2026-05-31T10:00:00")
	ciu(t, ix, "m1", "dan", "trunk", "2026-06-01T11:00:00", "b1", "s1")
	changed(t, ix, "a1", "2026-06-01T09:00:00", "src/one.go", "src/two.go")
	changed(t, ix, "b1", "2026-06-01T10:00:00", "doc/x.md")
	changed(t, ix, "m1", "2026-06-01T11:00:00", "doc/a.md", "doc/b.md", "doc/c.md")
	release(t, ix, "m1", "2026-06-01T10:00:00", "sym-release")
	release(t, ix, "m1", "2026-06-01T11:00:00", "sym-version-1.2")

	got, err := ix.ActivityDays("")
	if err != nil {
		t.Fatal(err)
	}
	if got.First != "2026-05-31" || got.Last != "2026-06-01" {
		t.Fatalf("span = %s..%s", got.First, got.Last)
	}
	d, ok := got.Days["2026-06-01"]
	if !ok {
		t.Fatalf("no day row: %+v", got.Days)
	}
	if d.N != 3 || d.Merges != 1 || d.Authors != 2 {
		t.Fatalf("day = %+v, want N 3 Merges 1 Authors 2", d)
	}
	if len(d.Top) < 2 {
		t.Fatalf("Top = %v, want at least two authors", d.Top)
	}
	if got.Authors[d.Top[0][0]].Name != "dan" || d.Top[0][1] != 2 {
		t.Fatalf("lead = %+v (%v), want dan with 2", got.Authors[d.Top[0][0]], d.Top[0])
	}
	if got.Authors[d.Top[1][0]].Name != "eve" || d.Top[1][1] != 1 {
		t.Fatalf("second = %+v (%v), want eve with 1", got.Authors[d.Top[1][0]], d.Top[1])
	}
	// The merge's three doc/ paths are inherited: src, with two
	// authored touches, is the day's directory.
	if d.Path < 0 || got.Paths[d.Path] != "src" {
		t.Fatalf("day path = %d (%v), want src — merges do not choose it", d.Path, got.Paths)
	}
	// The version wins the day over the bare release tag, whichever
	// came first.
	if got.Releases["2026-06-01"] != "version-1.2" {
		t.Fatalf("releases = %v, want version-1.2 on 2026-06-01", got.Releases)
	}
}

// After a fold the memo is served stale while the refresh runs, and
// refreshed off the request path: a reader never waits on the scan, and
// the new day arrives with the one background recompute. That is the
// contract (ActivityDays may lag by one recompute); being stale is not
// promised once the recompute has finished, so the refresh is held
// while the stale read is checked.
func TestActivityDaysRefreshAfterAFold(t *testing.T) {
	ix := openIndex(t)
	ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
	first, err := ix.ActivityDays("")
	if err != nil {
		t.Fatal(err)
	}
	if len(first.Days) != 1 {
		t.Fatalf("got %d days, want 1", len(first.Days))
	}
	hold := make(chan struct{})
	ix.beforeRewarm = func() { <-hold }
	ciu(t, ix, "a2", "dan", "trunk", "2026-06-02T09:00:00")
	ix.invalidateCounts()
	read := make(chan ActivityDays, 1)
	go func() {
		now, err := ix.ActivityDays("")
		if err != nil {
			t.Error(err)
		}
		read <- now
	}()
	select {
	case now := <-read:
		if len(now.Days) != 1 {
			t.Fatalf("FAIL: got %d days while the refresh is held, want the stale 1", len(now.Days))
		}
	case <-time.After(10 * time.Second):
		close(hold)
		t.Fatal("FAIL: the reader waited on the held refresh")
	}
	close(hold)
	ix.warmWG.Wait()
	again, err := ix.ActivityDays("")
	if err != nil {
		t.Fatal(err)
	}
	if len(again.Days) != 2 {
		t.Fatalf("FAIL: got %d days after the refresh, want 2: the fold never reached the day-fold", len(again.Days))
	}
}

func TestActivityDaysFiltersByBranch(t *testing.T) {
	ix := openIndex(t)
	ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
	ciu(t, ix, "s1", "eve", "side", "2026-06-02T09:00:00")
	got, err := ix.ActivityDays("side")
	if err != nil {
		t.Fatal(err)
	}
	if len(got.Days) != 1 {
		t.Fatalf("got %d days, want 1: %+v", len(got.Days), got.Days)
	}
	if _, ok := got.Days["2026-06-02"]; !ok {
		t.Fatalf("wrong day kept: %+v", got.Days)
	}
	if len(got.Authors) != 1 || got.Authors[0].Name != "eve" {
		t.Fatalf("authors = %+v, want only eve", got.Authors)
	}
}

// Colour is a pure function of the login so every renderer — Go SVG,
// browser canvas, a third-party client — draws the same person the
// same way. Pinning the arithmetic is what makes that a guarantee.
func TestHueIsDeterministic(t *testing.T) {
	for _, name := range []string{"dan", "eve", "drh", ""} {
		if hue(name) != hue(name) {
			t.Fatalf("hue(%q) is not stable", name)
		}
		if h := hue(name); h < 0 || h >= 360 {
			t.Fatalf("hue(%q) = %d, outside 0..359", name, h)
		}
	}
	if got, want := hue("dan"), idn.Hue("dan"); got != want {
		t.Fatalf("hue(dan) = %d, want the identicon hue %d", got, want)
	}
	if hue("dan") == hue("eve") {
		t.Fatal("two different logins share a hue; the fixture needs different names")
	}
}

// Two people whose hues land within 14° would be the same colour on
// screen. The later-ranked one steps its lightness instead.
func TestShadeStepsCollidingHues(t *testing.T) {
	// Hand-built so the collision is the fixture, not a coincidence of
	// real logins: three authors at the same hue, ranked.
	as := []ActivityAuthor{
		{Name: "a", Checkins: 3, Hue: 100},
		{Name: "b", Checkins: 2, Hue: 107},
		{Name: "c", Checkins: 1, Hue: 113},
		{Name: "d", Checkins: 1, Hue: 200},
	}
	assignShades(as)
	if as[0].Shade != 0 {
		t.Fatalf("the top-ranked author took a shade: %+v", as[0])
	}
	if as[1].Shade != 1 || as[2].Shade != 2 {
		t.Fatalf("shades = %d %d, want 1 2", as[1].Shade, as[2].Shade)
	}
	if as[3].Shade != 0 {
		t.Fatalf("a distinct hue took a shade: %+v", as[3])
	}
}

// The wrap-around case: 358° and 4° are 6° apart, not 354°.
func TestShadeCollisionIsCircular(t *testing.T) {
	as := []ActivityAuthor{
		{Name: "a", Checkins: 2, Hue: 358},
		{Name: "b", Checkins: 1, Hue: 4},
	}
	assignShades(as)
	if as[1].Shade != 1 {
		t.Fatalf("shade = %d, want 1 — 358 and 4 are 6 degrees apart", as[1].Shade)
	}
}

// Every author the ops hand out carries a colour; a renderer must never
// have to invent one.
func TestActivityAuthorsCarryColour(t *testing.T) {
	got := june(t, mergeDAG(t), ActivityQuery{})
	for _, a := range got.Authors {
		if a.Hue != hue(a.Name) {
			t.Fatalf("%s Hue = %d, want %d", a.Name, a.Hue, hue(a.Name))
		}
	}
	days, err := mergeDAG(t).ActivityDays("")
	if err != nil {
		t.Fatal(err)
	}
	for _, a := range days.Authors {
		if a.Hue != hue(a.Name) {
			t.Fatalf("days %s Hue = %d, want %d", a.Name, a.Hue, hue(a.Name))
		}
	}
}

// Simulates a store folded before the dir column existed: every row —
// including the ancestor directory the fold synthesises — reset to 0
// (all a pre-migration store could ever write), then the column itself
// dropped so applySchema's ALTER has real work to do. Reopening through
// applySchema must correct the directory row in place; the file rows it
// shares a check-in with must stay untouched.
func TestFilechangeDirBackfillsExistingRows(t *testing.T) {
	ix := openIndex(t)
	changed(t, ix, "a1", "2026-06-01T09:00:00", "src/one.go", "doc/two.md")

	if _, err := ix.db.Exec(`UPDATE filechange SET dir = 0`); err != nil {
		t.Fatal(err)
	}
	if _, err := ix.db.Exec(`ALTER TABLE filechange DROP COLUMN dir`); err != nil {
		t.Fatal(err)
	}

	if err := applySchema(ix.db); err != nil {
		t.Fatal(err)
	}

	rows, err := ix.db.Query(`SELECT path, dir FROM filechange ORDER BY path`)
	if err != nil {
		t.Fatal(err)
	}
	defer rows.Close()
	got := map[string]int{}
	for rows.Next() {
		var path string
		var dir int
		if err := rows.Scan(&path, &dir); err != nil {
			t.Fatal(err)
		}
		got[path] = dir
	}
	if err := rows.Err(); err != nil {
		t.Fatal(err)
	}
	want := map[string]int{"src": 1, "doc": 1, "src/one.go": 0, "doc/two.md": 0}
	for path, wantDir := range want {
		if got[path] != wantDir {
			t.Fatalf("%s dir = %d, want %d after backfill: %v", path, got[path], wantDir, got)
		}
	}
}