processor/history_author_timeline_test.go GO 525 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"encoding/csv"7	"os"8	"path/filepath"9	"strings"10	"testing"11	"time"1213	"github.com/go-git/go-git/v5"14	"github.com/go-git/go-git/v5/plumbing/object"15	jsoniter "github.com/json-iterator/go"16)1718// timelineCommit is one step in a fixture history. Files maps relative path19// to file content; Author / Email / When override the per-commit identity20// and timestamp so tests can place commits in specific windows.21type timelineCommit struct {22	Files  map[string]string23	Author string24	Email  string25	When   time.Time26}2728// makeTimelineRepo builds a temp on-disk repo from a slice of timelineCommit29// snapshots. Used to seed historyAuthorTimelineObserver with known author /30// timestamp distributions.31func makeTimelineRepo(t *testing.T, commits []timelineCommit) string {32	t.Helper()33	ProcessConstants()34	dir := t.TempDir()3536	repo, err := git.PlainInit(dir, false)37	if err != nil {38		t.Fatalf("init repo: %v", err)39	}40	wt, err := repo.Worktree()41	if err != nil {42		t.Fatalf("worktree: %v", err)43	}4445	for i, snap := range commits {46		for path, content := range snap.Files {47			full := filepath.Join(dir, path)48			if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {49				t.Fatalf("mkdir %s: %v", full, err)50			}51			if err := os.WriteFile(full, []byte(content), 0o644); err != nil {52				t.Fatalf("write %s: %v", full, err)53			}54			if _, err := wt.Add(path); err != nil {55				t.Fatalf("add %s: %v", path, err)56			}57		}58		_, err := wt.Commit("commit "+itoa(i), &git.CommitOptions{59			Author: &object.Signature{60				Name:  snap.Author,61				Email: snap.Email,62				When:  snap.When,63			},64		})65		if err != nil {66			t.Fatalf("commit %d: %v", i, err)67		}68	}69	return dir70}7172func findTimelineRow(t *testing.T, rows []authorTimelineRow, name string) authorTimelineRow {73	t.Helper()74	for _, r := range rows {75		if r.Name == name {76			return r77		}78	}79	t.Fatalf("no timeline row for %q in %+v", name, rows)80	return authorTimelineRow{}81}8283func TestBucketingIndex(t *testing.T) {84	from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)85	to := time.Date(2025, 1, 11, 0, 0, 0, 0, time.UTC) // 10-day span86	b := NewBucketing(from, to, 10)8788	if b.N != 10 {89		t.Fatalf("N = %d, want 10", b.N)90	}91	if b.Width != 24*time.Hour {92		t.Fatalf("Width = %s, want 24h", b.Width)93	}9495	cases := []struct {96		t    time.Time97		want int98	}{99		{from, 0},100		{from.Add(1 * time.Hour), 0},101		{from.Add(24 * time.Hour), 1},102		{from.Add(24*time.Hour + time.Second), 1},103		{from.Add(5 * 24 * time.Hour), 5},104		{to, 9},                   // clamp to N-1105		{to.Add(time.Hour), 9},    // past To clamps106		{from.Add(-time.Hour), 0}, // before From clamps107	}108	for _, c := range cases {109		if got := b.Index(c.t); got != c.want {110			t.Errorf("Index(%s) = %d, want %d", c.t, got, c.want)111		}112	}113}114115func TestBucketingDegenerateWindow(t *testing.T) {116	when := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)117	b := NewBucketing(when, when, 8)118	if got := b.Index(when); got != 0 {119		t.Errorf("degenerate window: Index = %d, want 0", got)120	}121	if got := b.Index(when.Add(time.Hour)); got != 0 {122		t.Errorf("degenerate window: future Index = %d, want 0", got)123	}124}125126func TestBucketingStart(t *testing.T) {127	from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)128	to := from.Add(10 * 24 * time.Hour)129	b := NewBucketing(from, to, 10)130	if got := b.Start(0); !got.Equal(from) {131		t.Errorf("Start(0) = %s, want %s", got, from)132	}133	if got := b.Start(5); !got.Equal(from.Add(5 * 24 * time.Hour)) {134		t.Errorf("Start(5) = %s, want %s", got, from.Add(5*24*time.Hour))135	}136}137138func TestAuthorTimelineSeriesShapes(t *testing.T) {139	saveDepth, saveBuckets := HistoryDepth, HistoryBuckets140	HistoryDepth, HistoryBuckets = 100, 12141	t.Cleanup(func() {142		HistoryDepth, HistoryBuckets = saveDepth, saveBuckets143	})144145	base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)146	// Three authors, three different temporal patterns.147	// Window spans ~12 weeks; 12 buckets ≈ 1 week each.148	week := 7 * 24 * time.Hour149150	// Rising commits: Alice commits in weeks 6..11.151	// Falling: Bob commits in weeks 0..5.152	// U-shape: Carol commits in weeks 0..2 and 9..11.153	commits := []timelineCommit{}154	addCommit := func(author, email string, w int, line string) {155		path := "main.go"156		when := base.Add(time.Duration(w) * week)157		// Build incremental file content from prior commits' lines.158		content := "package x\n"159		for _, c := range commits {160			if existing, ok := c.Files[path]; ok {161				content = existing162			}163		}164		content += line + "\n"165		commits = append(commits, timelineCommit{166			Files:  map[string]string{path: content},167			Author: author, Email: email, When: when,168		})169	}170171	for i, w := range []int{0, 1, 2, 3, 4, 5} {172		addCommit("Bob", "bob@x", w, "func b"+itoa(i)+"() {}")173	}174	for i, w := range []int{6, 7, 8, 9, 10, 11} {175		addCommit("Alice", "alice@x", w, "func a"+itoa(i)+"() {}")176	}177	for i, w := range []int{0, 1, 2, 9, 10, 11} {178		addCommit("Carol", "carol@x", w, "func c"+itoa(i)+"() {}")179	}180181	dir := makeTimelineRepo(t, commits)182183	obs := newHistoryAuthorTimelineObserver(HistoryBuckets)184	if _, err := runHistory(dir, obs); err != nil {185		t.Fatalf("runHistory: %v", err)186	}187188	if len(obs.rows) != 3 {189		t.Fatalf("want 3 author rows, got %d (%+v)", len(obs.rows), obs.rows)190	}191192	alice := findTimelineRow(t, obs.rows, "Alice")193	bob := findTimelineRow(t, obs.rows, "Bob")194	carol := findTimelineRow(t, obs.rows, "Carol")195196	if alice.TotalCommits != 6 || bob.TotalCommits != 6 || carol.TotalCommits != 6 {197		t.Errorf("commit totals = A:%d B:%d C:%d, want 6 each",198			alice.TotalCommits, bob.TotalCommits, carol.TotalCommits)199	}200201	// Bob (falling) — commits should sit in the early half.202	earlyBob, lateBob := halfSums(bob.Series)203	if earlyBob <= lateBob {204		t.Errorf("Bob should have more commits early; early=%d late=%d series=%v",205			earlyBob, lateBob, sumCommits(bob.Series))206	}207	// Alice (rising) — late half should dominate.208	earlyAlice, lateAlice := halfSums(alice.Series)209	if lateAlice <= earlyAlice {210		t.Errorf("Alice should have more commits late; early=%d late=%d series=%v",211			earlyAlice, lateAlice, sumCommits(alice.Series))212	}213	// Carol (U-shape) — first and last quarters should both be non-zero,214	// middle quarter zero.215	cs := sumCommits(carol.Series)216	q := len(cs) / 4217	mid := 0218	for i := q; i < len(cs)-q; i++ {219		mid += cs[i]220	}221	if mid != 0 {222		t.Errorf("Carol should be quiet in mid window; series=%v mid=%d", cs, mid)223	}224}225226// halfSums returns (earlyHalfSum, lateHalfSum) of the commit counts.227func halfSums(series []authorTimelineBucket) (int, int) {228	half := len(series) / 2229	early, late := 0, 0230	for i, b := range series {231		if i < half {232			early += b.Commits233		} else {234			late += b.Commits235		}236	}237	return early, late238}239240func sumCommits(series []authorTimelineBucket) []int {241	out := make([]int, len(series))242	for i, b := range series {243		out[i] = b.Commits244	}245	return out246}247248func TestAuthorTimelineTagUpArrow(t *testing.T) {249	// Final bucket is the peak — should fire ↑.250	series := buildBuckets([]int{1, 1, 1, 1, 5})251	if got := authorTimelineTag(series, 24*time.Hour); got != "↑" {252		t.Errorf("rising tag = %q, want ↑", got)253	}254}255256func TestAuthorTimelineTagQuietMonths(t *testing.T) {257	// 4 trailing zero buckets, each 30 days wide → quiet 4mo.258	series := buildBuckets([]int{5, 3, 1, 0, 0, 0, 0})259	got := authorTimelineTag(series, 30*24*time.Hour)260	if !strings.HasPrefix(got, "quiet ") {261		t.Errorf("quiet tag = %q, want quiet Nmo", got)262	}263	if !strings.HasSuffix(got, "mo") {264		t.Errorf("quiet tag = %q, want suffix mo", got)265	}266}267268func TestAuthorTimelineTagEmpty(t *testing.T) {269	// Short quiet tail (< 1 month) → no tag.270	series := buildBuckets([]int{5, 1, 0, 0})271	if got := authorTimelineTag(series, 24*time.Hour); got != "" {272		t.Errorf("short quiet = %q, want empty", got)273	}274}275276func buildBuckets(commits []int) []authorTimelineBucket {277	out := make([]authorTimelineBucket, len(commits))278	for i, c := range commits {279		out[i].Commits = c280	}281	return out282}283284func TestAuthorTimelineSparklineDownsampling(t *testing.T) {285	// 60 buckets, sparkline width 24 — must produce 24 visible runes.286	series := make([]authorTimelineBucket, 60)287	for i := range series {288		series[i].Commits = i289	}290291	for _, cells := range []int{24, 12, 8} {292		out := renderAuthorTimelineSparkline(series, cells)293		if got := runeCount(out); got != cells {294			t.Errorf("sparkline cells=%d produced %d runes (%q)", cells, got, out)295		}296	}297}298299func runeCount(s string) int {300	return len([]rune(s))301}302303func TestAuthorTimelineSparklineAsciiUnderCi(t *testing.T) {304	saveCi := Ci305	Ci = true306	t.Cleanup(func() { Ci = saveCi })307308	series := buildBuckets([]int{0, 1, 2, 3, 5, 8})309	out := renderAuthorTimelineSparkline(series, 12)310	for _, r := range out {311		if r > 127 {312			t.Fatalf("CI sparkline contains non-ASCII rune %U (%q)", r, out)313		}314	}315}316317func TestAuthorTimelineCSVLongFormat(t *testing.T) {318	saveDepth, saveFormat, saveBuckets := HistoryDepth, Format, HistoryBuckets319	HistoryDepth, Format, HistoryBuckets = 100, "csv", 10320	t.Cleanup(func() {321		HistoryDepth, Format, HistoryBuckets = saveDepth, saveFormat, saveBuckets322	})323324	base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)325	dir := makeTimelineRepo(t, []timelineCommit{326		{327			Files:  map[string]string{"a.go": "package a\nfunc A() {}\n"},328			Author: "Alice", Email: "alice@x", When: base,329		},330		{331			Files:  map[string]string{"a.go": "package a\nfunc A() {}\nfunc B() {}\n"},332			Author: "Bob", Email: "bob@x", When: base.Add(48 * time.Hour),333		},334	})335336	obs := newHistoryAuthorTimelineObserver(HistoryBuckets)337	if _, err := runHistory(dir, obs); err != nil {338		t.Fatalf("runHistory: %v", err)339	}340	out, err := renderAuthorTimeline(obs)341	if err != nil {342		t.Fatalf("render: %v", err)343	}344345	if !strings.HasPrefix(out, "# window:") {346		t.Fatalf("CSV should start with '# window:' comment:\n%s", out)347	}348	if !strings.Contains(out, "# buckets: 10\n") {349		t.Errorf("CSV missing '# buckets: 10' line:\n%s", out)350	}351352	// Skip the two comment lines.353	lines := strings.SplitN(out, "\n", 3)354	body := lines[2]355	r := csv.NewReader(strings.NewReader(body))356	rows, err := r.ReadAll()357	if err != nil {358		t.Fatalf("csv parse: %v\n%s", err, body)359	}360	wantHeader := []string{"Author", "Email", "BucketStart", "Commits", "CodeDelta"}361	for i, h := range wantHeader {362		if rows[0][i] != h {363			t.Errorf("header col %d = %q, want %q", i, rows[0][i], h)364		}365	}366	// Long format: each row is (author × bucket). 2 authors × 10 buckets = 20 rows.367	if got, want := len(rows)-1, len(obs.rows)*HistoryBuckets; got != want {368		t.Errorf("CSV body rows = %d, want authors*buckets = %d", got, want)369	}370}371372func TestAuthorTimelineJSONShape(t *testing.T) {373	saveDepth, saveFormat, saveBuckets := HistoryDepth, Format, HistoryBuckets374	HistoryDepth, Format, HistoryBuckets = 100, "json", 8375	t.Cleanup(func() {376		HistoryDepth, Format, HistoryBuckets = saveDepth, saveFormat, saveBuckets377	})378379	base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)380	dir := makeTimelineRepo(t, []timelineCommit{381		{382			Files:  map[string]string{"a.go": "package a\nfunc A() {}\n"},383			Author: "Alice", Email: "alice@x", When: base,384		},385		{386			Files:  map[string]string{"a.go": "package a\nfunc A() {}\nfunc B() {}\n"},387			Author: "Bob", Email: "bob@x", When: base.Add(168 * time.Hour),388		},389	})390391	obs := newHistoryAuthorTimelineObserver(HistoryBuckets)392	if _, err := runHistory(dir, obs); err != nil {393		t.Fatalf("runHistory: %v", err)394	}395	out, err := renderAuthorTimeline(obs)396	if err != nil {397		t.Fatalf("render: %v", err)398	}399400	var doc authorTimelineJSONDoc401	if err := jsoniter.Unmarshal([]byte(out), &doc); err != nil {402		t.Fatalf("json parse: %v, body:\n%s", err, out)403	}404	if doc.Report != "author-timeline" {405		t.Errorf("report = %q, want author-timeline", doc.Report)406	}407	if doc.Buckets != 8 {408		t.Errorf("buckets = %d, want 8", doc.Buckets)409	}410	if doc.Window.Commits != 2 {411		t.Errorf("window.commits = %d, want 2", doc.Window.Commits)412	}413	if len(doc.Authors) != 2 {414		t.Fatalf("authors count = %d, want 2", len(doc.Authors))415	}416	for _, a := range doc.Authors {417		if len(a.Series) != 8 {418			t.Errorf("author %q series len = %d, want 8", a.Name, len(a.Series))419		}420	}421422	// bucketStart values should be in non-decreasing order — the date-only423	// format may collapse sub-day buckets onto the same string, but no424	// bucket should appear earlier than its predecessor. The underlying425	// Bucketing.Width is checked for evenness separately.426	first := doc.Authors[0].Series427	var prev time.Time428	for i, b := range first {429		ts, err := time.Parse(historyDateLayout, b.BucketStart)430		if err != nil {431			t.Fatalf("parse bucketStart %q: %v", b.BucketStart, err)432		}433		if i > 0 && ts.Before(prev) {434			t.Errorf("bucket %d before %d: %s vs %s", i, i-1, ts, prev)435		}436		prev = ts437	}438	// Even spacing: every bucket Start(i) - Start(i-1) is the same width.439	if obs.bucket.Width == 0 {440		t.Errorf("Bucketing.Width is zero")441	}442}443444func TestAuthorTimelineTabularContainsHeader(t *testing.T) {445	saveDepth, saveFormat, saveBuckets := HistoryDepth, Format, HistoryBuckets446	HistoryDepth, Format, HistoryBuckets = 100, "tabular", 12447	t.Cleanup(func() {448		HistoryDepth, Format, HistoryBuckets = saveDepth, saveFormat, saveBuckets449	})450451	base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)452	dir := makeTimelineRepo(t, []timelineCommit{453		{454			Files:  map[string]string{"a.go": "package a\nfunc A() {}\nfunc B() {}\n"},455			Author: "Alice", Email: "alice@x", When: base,456		},457	})458459	obs := newHistoryAuthorTimelineObserver(HistoryBuckets)460	if _, err := runHistory(dir, obs); err != nil {461		t.Fatalf("runHistory: %v", err)462	}463	out, err := renderAuthorTimeline(obs)464	if err != nil {465		t.Fatalf("render: %v", err)466	}467	if !strings.Contains(out, "Authors") {468		t.Errorf("tabular missing 'Authors' header:\n%s", out)469	}470	if !strings.Contains(out, "Activity") {471		t.Errorf("tabular missing 'Activity' column:\n%s", out)472	}473	if !strings.Contains(out, "Code±") {474		t.Errorf("tabular missing 'Code±' column:\n%s", out)475	}476}477478func TestAuthorTimelineRejectsUnsupportedFormat(t *testing.T) {479	saveFormat := Format480	Format = "xml"481	t.Cleanup(func() { Format = saveFormat })482483	obs := newHistoryAuthorTimelineObserver(8)484	if _, err := renderAuthorTimeline(obs); err == nil {485		t.Fatal("expected error for --format xml")486	}487}488489func TestAuthorTimelineMailmapFolding(t *testing.T) {490	saveDepth, saveBuckets := HistoryDepth, HistoryBuckets491	HistoryDepth, HistoryBuckets = 100, 6492	t.Cleanup(func() {493		HistoryDepth, HistoryBuckets = saveDepth, saveBuckets494	})495496	base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)497	dir := makeTimelineRepo(t, []timelineCommit{498		{499			Files: map[string]string{500				".mailmap": "Alice <alice@example.com> <alt@example.com>\n",501				"a.go":     "package a\nfunc A() {}\n",502			},503			Author: "Alice", Email: "alice@example.com", When: base,504		},505		{506			Files:  map[string]string{"a.go": "package a\nfunc A() {}\nfunc B() {}\n"},507			Author: "Alice", Email: "alt@example.com", When: base.Add(24 * time.Hour),508		},509	})510511	obs := newHistoryAuthorTimelineObserver(HistoryBuckets)512	if _, err := runHistory(dir, obs); err != nil {513		t.Fatalf("runHistory: %v", err)514	}515	count := 0516	for _, r := range obs.rows {517		if r.Name == "Alice" {518			count++519		}520	}521	if count != 1 {522		t.Errorf("Alice rows after mailmap fold = %d, want 1; rows = %+v", count, obs.rows)523	}524}

Code quality findings 13

Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, snap := range commits {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for path, content := range snap.Files {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
full := filepath.Join(dir, path)
String to byte slice conversion inside loop allocates a new slice each iteration; convert once before the loop
info correctness string-to-byte-in-loop
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
commits = append(commits, timelineCommit{
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, w := range []int{0, 1, 2, 3, 4, 5} {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, w := range []int{6, 7, 8, 9, 10, 11} {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, w := range []int{0, 1, 2, 9, 10, 11} {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, b := range series {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, b := range series {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, c := range commits {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, h := range wantHeader {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, b := range first {

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.