Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
for i, snap := range commits {
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}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.