Multiple appends without pre-allocation; use make() with capacity when size is known
commits = append(commits, timelineCommit{
1// SPDX-License-Identifier: MIT23package processor45import (6 "encoding/csv"7 "strings"8 "testing"9 "time"1011 jsoniter "github.com/json-iterator/go"12)1314func findLanguagesRow(t *testing.T, rows []languagesTimelineRow, language string) languagesTimelineRow {15 t.Helper()16 for _, r := range rows {17 if r.Language == language {18 return r19 }20 }21 t.Fatalf("no row for language %q in %+v", language, rows)22 return languagesTimelineRow{}23}2425// TestLanguagesTimelineTSRisesJSFalls verifies the trajectory shape — when26// TypeScript code is added over time while JavaScript code is steadily27// removed, the trajectory and change sign should reflect that.28func TestLanguagesTimelineTSRisesJSFalls(t *testing.T) {29 // Set HistoryDepth so the JS-baseline commit (commit 0) sits OUTSIDE30 // the window — the engine then seeds JavaScript with that file's lines31 // via BaselineSnapshot. Subsequent commits inside the window remove32 // JS and add TS.33 saveDepth, saveBuckets := HistoryDepth, HistoryBuckets34 HistoryDepth, HistoryBuckets = 9, 1035 t.Cleanup(func() {36 HistoryDepth, HistoryBuckets = saveDepth, saveBuckets37 })3839 base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)40 day := 24 * time.Hour4142 // Commit 0 (outside window when depth=9): app.js has 20 lines.43 bigJS := "var x = 1;\n"44 for i := range 19 {45 bigJS += "var y" + itoa(i) + " = " + itoa(i) + ";\n"46 }4748 commits := []timelineCommit{49 {50 Files: map[string]string{"app.js": bigJS},51 Author: "Alice", Email: "a@x", When: base,52 },53 }5455 // Commits 1..9 (inside window): each removes 2 JS lines and adds 4 TS lines.56 currentJS := bigJS57 currentTS := ""58 for d := 1; d <= 9; d++ {59 jsLines := strings.Split(strings.TrimRight(currentJS, "\n"), "\n")60 if len(jsLines) > 2 {61 jsLines = jsLines[:len(jsLines)-2]62 }63 currentJS = strings.Join(jsLines, "\n") + "\n"64 for k := range 4 {65 currentTS += "const z" + itoa(d) + "_" + itoa(k) + ": number = " + itoa(k) + ";\n"66 }67 commits = append(commits, timelineCommit{68 Files: map[string]string{69 "app.js": currentJS,70 "app.ts": currentTS,71 },72 Author: "Bob", Email: "b@x", When: base.Add(time.Duration(d) * day),73 })74 }7576 dir := makeTimelineRepo(t, commits)7778 obs := newHistoryLanguagesObserver(HistoryBuckets)79 if _, err := runHistory(dir, obs); err != nil {80 t.Fatalf("runHistory: %v", err)81 }8283 ts := findLanguagesRow(t, obs.rows, "TypeScript")84 js := findLanguagesRow(t, obs.rows, "JavaScript")8586 if ts.Change <= 0 {87 t.Errorf("TypeScript change = %d, want positive", ts.Change)88 }89 if js.Change >= 0 {90 t.Errorf("JavaScript change = %d, want negative", js.Change)91 }92 // Trajectory: TS should end higher than its lowest point, JS should93 // end lower than its starting baseline.94 tsLast := ts.Trajectory[len(ts.Trajectory)-1]95 if tsLast == 0 {96 t.Errorf("TS trajectory should be non-zero at end; traj=%v", ts.Trajectory)97 }98 if js.CodeNow >= js.StartingLines {99 t.Errorf("JS codeNow %d should be below starting %d; traj=%v",100 js.CodeNow, js.StartingLines, js.Trajectory)101 }102}103104// TestLanguagesTimelineLanguageRemoval — a language that exists at the start105// of the window and is then wholly removed should show codeNow == 0 and a106// negative change.107func TestLanguagesTimelineLanguageRemoval(t *testing.T) {108 // depth=2 → only the last 2 commits are in the window. The first109 // commit (which establishes the JS file) sits OUTSIDE the window and110 // becomes the baseline — so JavaScript starts with code, then drops111 // to zero across the in-window commits.112 saveDepth, saveBuckets := HistoryDepth, HistoryBuckets113 HistoryDepth, HistoryBuckets = 2, 6114 t.Cleanup(func() {115 HistoryDepth, HistoryBuckets = saveDepth, saveBuckets116 })117118 base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)119120 jsContent := "var a = 1;\nvar b = 2;\nvar c = 3;\nvar d = 4;\n"121 commits := []timelineCommit{122 // Outside the window: JS exists.123 {124 Files: map[string]string{"old.js": jsContent, "keep.go": "package k\nfunc K() {}\n"},125 Author: "Alice", Email: "a@x", When: base,126 },127 // In-window: reduce JS.128 {129 Files: map[string]string{"old.js": "var a = 1;\nvar b = 2;\n"},130 Author: "Alice", Email: "a@x", When: base.Add(24 * time.Hour),131 },132 // In-window: remove all JS lines.133 {134 Files: map[string]string{"old.js": "\n"},135 Author: "Alice", Email: "a@x", When: base.Add(48 * time.Hour),136 },137 }138139 dir := makeTimelineRepo(t, commits)140 obs := newHistoryLanguagesObserver(HistoryBuckets)141 if _, err := runHistory(dir, obs); err != nil {142 t.Fatalf("runHistory: %v", err)143 }144145 js := findLanguagesRow(t, obs.rows, "JavaScript")146 if js.CodeNow != 0 {147 t.Errorf("JavaScript codeNow = %d, want 0; traj=%v", js.CodeNow, js.Trajectory)148 }149 if js.Change >= 0 {150 t.Errorf("JavaScript change = %d, want negative", js.Change)151 }152}153154// TestLanguagesTimelineSharesSumToHundred — sum of share percentages across155// all surviving languages should round to ~100.156func TestLanguagesTimelineSharesSumToHundred(t *testing.T) {157 saveDepth, saveBuckets := HistoryDepth, HistoryBuckets158 HistoryDepth, HistoryBuckets = 100, 8159 t.Cleanup(func() {160 HistoryDepth, HistoryBuckets = saveDepth, saveBuckets161 })162163 base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)164 dir := makeTimelineRepo(t, []timelineCommit{165 {166 Files: map[string]string{167 "a.go": "package a\nfunc A() {}\nfunc B() {}\n",168 "b.py": "def f():\n return 1\n",169 "c.rs": "fn main() {}\n",170 },171 Author: "Alice", Email: "a@x", When: base,172 },173 {174 Files: map[string]string{175 "a.go": "package a\nfunc A() {}\nfunc B() {}\nfunc C() {}\n",176 },177 Author: "Bob", Email: "b@x", When: base.Add(24 * time.Hour),178 },179 })180181 obs := newHistoryLanguagesObserver(HistoryBuckets)182 if _, err := runHistory(dir, obs); err != nil {183 t.Fatalf("runHistory: %v", err)184 }185186 if len(obs.rows) == 0 {187 t.Fatal("no language rows produced")188 }189190 var total float64191 for _, r := range obs.rows {192 total += r.SharePercent193 }194 if total < 99.5 || total > 100.5 {195 t.Errorf("share total = %.2f, want ~100", total)196 }197}198199// TestLanguagesTimelineCSVLongFormat — long format has one row per200// (language × bucket).201func TestLanguagesTimelineCSVLongFormat(t *testing.T) {202 saveDepth, saveFormat, saveBuckets := HistoryDepth, Format, HistoryBuckets203 HistoryDepth, Format, HistoryBuckets = 100, "csv", 10204 t.Cleanup(func() {205 HistoryDepth, Format, HistoryBuckets = saveDepth, saveFormat, saveBuckets206 })207208 base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)209 dir := makeTimelineRepo(t, []timelineCommit{210 {211 Files: map[string]string{"a.go": "package a\nfunc A() {}\n"},212 Author: "Alice", Email: "a@x", When: base,213 },214 {215 Files: map[string]string{"a.go": "package a\nfunc A() {}\nfunc B() {}\n"},216 Author: "Bob", Email: "b@x", When: base.Add(48 * time.Hour),217 },218 })219220 obs := newHistoryLanguagesObserver(HistoryBuckets)221 if _, err := runHistory(dir, obs); err != nil {222 t.Fatalf("runHistory: %v", err)223 }224 out, err := renderLanguagesTimeline(obs)225 if err != nil {226 t.Fatalf("render: %v", err)227 }228229 if !strings.HasPrefix(out, "# window:") {230 t.Fatalf("CSV should start with '# window:' comment:\n%s", out)231 }232 if !strings.Contains(out, "# buckets: 10\n") {233 t.Errorf("CSV missing '# buckets: 10' line:\n%s", out)234 }235236 lines := strings.SplitN(out, "\n", 3)237 body := lines[2]238 r := csv.NewReader(strings.NewReader(body))239 rows, err := r.ReadAll()240 if err != nil {241 t.Fatalf("csv parse: %v\n%s", err, body)242 }243 wantHeader := []string{"Language", "BucketStart", "Code", "CodeNow", "SharePercent", "Change"}244 for i, h := range wantHeader {245 if rows[0][i] != h {246 t.Errorf("header col %d = %q, want %q", i, rows[0][i], h)247 }248 }249 // Long format: nLanguages * buckets body rows.250 if got, want := len(rows)-1, len(obs.rows)*HistoryBuckets; got != want {251 t.Errorf("CSV body rows = %d, want languages*buckets = %d", got, want)252 }253}254255// TestLanguagesTimelineJSONShape — the JSON schema matches the plan.256func TestLanguagesTimelineJSONShape(t *testing.T) {257 saveDepth, saveFormat, saveBuckets := HistoryDepth, Format, HistoryBuckets258 HistoryDepth, Format, HistoryBuckets = 100, "json", 8259 t.Cleanup(func() {260 HistoryDepth, Format, HistoryBuckets = saveDepth, saveFormat, saveBuckets261 })262263 base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)264 dir := makeTimelineRepo(t, []timelineCommit{265 {266 Files: map[string]string{"a.go": "package a\nfunc A() {}\n"},267 Author: "Alice", Email: "a@x", When: base,268 },269 {270 Files: map[string]string{"a.go": "package a\nfunc A() {}\nfunc B() {}\n"},271 Author: "Bob", Email: "b@x", When: base.Add(168 * time.Hour),272 },273 })274275 obs := newHistoryLanguagesObserver(HistoryBuckets)276 if _, err := runHistory(dir, obs); err != nil {277 t.Fatalf("runHistory: %v", err)278 }279 out, err := renderLanguagesTimeline(obs)280 if err != nil {281 t.Fatalf("render: %v", err)282 }283284 var doc languagesTimelineJSONDoc285 if err := jsoniter.Unmarshal([]byte(out), &doc); err != nil {286 t.Fatalf("json parse: %v, body:\n%s", err, out)287 }288 if doc.Report != "languages-timeline" {289 t.Errorf("report = %q, want languages-timeline", doc.Report)290 }291 if doc.Buckets != 8 {292 t.Errorf("buckets = %d, want 8", doc.Buckets)293 }294 if doc.Window.Commits != 2 {295 t.Errorf("window.commits = %d, want 2", doc.Window.Commits)296 }297 if len(doc.Languages) == 0 {298 t.Fatal("expected at least one language")299 }300 for _, l := range doc.Languages {301 if len(l.Series) != 8 {302 t.Errorf("language %q series len = %d, want 8", l.Language, len(l.Series))303 }304 }305}306307// TestLanguagesTimelineSparklinePeakNotFullBlock — the unicode tick set308// excludes U+2588 (full block) so peak cells leave a 1-pixel gap at top.309// Adjacent tall cells would otherwise merge into a solid wall when a310// trajectory rises monotonically (as language line counts typically do).311func TestLanguagesTimelineSparklinePeakNotFullBlock(t *testing.T) {312 saveCi := Ci313 Ci = false314 t.Cleanup(func() { Ci = saveCi })315 // FileOutput non-empty triggers ascii fallback; ensure it's clear.316 saveFile := FileOutput317 FileOutput = ""318 t.Cleanup(func() { FileOutput = saveFile })319320 // asciiOutput() also short-circuits to true when stdout is not a TTY,321 // which is the case in `go test`. Drive renderSparkline directly via322 // the branch we care about by setting Ci=false and exercising the323 // helper through its rune output: we just verify the helper never324 // emits U+2588.325 out := renderSparkline([]float64{1, 2, 3, 4, 5, 6, 7, 8}, 8)326 if strings.ContainsRune(out, '█') {327 t.Errorf("sparkline contains U+2588 full block: %q", out)328 }329}330331// TestLanguagesTimelineSparklineAsciiUnderCi — under --ci the sparkline332// must be glyph-free ASCII.333func TestLanguagesTimelineSparklineAsciiUnderCi(t *testing.T) {334 saveCi := Ci335 Ci = true336 t.Cleanup(func() { Ci = saveCi })337338 traj := []int64{0, 1, 2, 3, 5, 8, 13}339 out := renderLanguagesTrajectorySparkline(traj, 12)340 for _, r := range out {341 if r > 127 {342 t.Fatalf("CI sparkline contains non-ASCII rune %U (%q)", r, out)343 }344 }345}346347// TestLanguagesTimelineSparklineDownsampling — sparkline produces exactly the348// number of cells requested regardless of input size.349func TestLanguagesTimelineSparklineDownsampling(t *testing.T) {350 traj := make([]int64, 60)351 for i := range traj {352 traj[i] = int64(i)353 }354 for _, cells := range []int{26, 56, 8} {355 out := renderLanguagesTrajectorySparkline(traj, cells)356 if got := runeCount(out); got != cells {357 t.Errorf("sparkline cells=%d produced %d runes (%q)", cells, got, out)358 }359 }360}361362// TestLanguagesTimelineTabularHeader — tabular header contains expected363// column labels.364func TestLanguagesTimelineTabularHeader(t *testing.T) {365 saveDepth, saveFormat, saveBuckets := HistoryDepth, Format, HistoryBuckets366 HistoryDepth, Format, HistoryBuckets = 100, "tabular", 8367 t.Cleanup(func() {368 HistoryDepth, Format, HistoryBuckets = saveDepth, saveFormat, saveBuckets369 })370371 base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)372 dir := makeTimelineRepo(t, []timelineCommit{373 {374 Files: map[string]string{"a.go": "package a\nfunc A() {}\nfunc B() {}\n"},375 Author: "Alice", Email: "a@x", When: base,376 },377 })378379 obs := newHistoryLanguagesObserver(HistoryBuckets)380 if _, err := runHistory(dir, obs); err != nil {381 t.Fatalf("runHistory: %v", err)382 }383 out, err := renderLanguagesTimeline(obs)384 if err != nil {385 t.Fatalf("render: %v", err)386 }387 for _, want := range []string{"Languages", "Trend", "Code", "Share", "Change"} {388 if !strings.Contains(out, want) {389 t.Errorf("tabular missing %q column:\n%s", want, out)390 }391 }392}393394// TestLanguagesTimelineRejectsUnsupportedFormat — unknown --format is an395// error.396func TestLanguagesTimelineRejectsUnsupportedFormat(t *testing.T) {397 saveFormat := Format398 Format = "xml"399 t.Cleanup(func() { Format = saveFormat })400401 obs := newHistoryLanguagesObserver(8)402 if _, err := renderLanguagesTimeline(obs); err == nil {403 t.Fatal("expected error for --format xml")404 }405}406407// TestLanguagesTimelineDropsAbsentLanguages — a language that never appears408// in the window (no starting lines, no observed changes) should not produce409// a row.410func TestLanguagesTimelineDropsAbsentLanguages(t *testing.T) {411 obs := newHistoryLanguagesObserver(4)412 obs.Finalise(HistoryWindow{413 Depth: 10,414 Commits: 0,415 From: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),416 To: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),417 }, emptySnapshot())418419 if len(obs.rows) != 0 {420 t.Errorf("expected no rows for empty window, got %+v", obs.rows)421 }422}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.