processor/history_languages_test.go GO 423 lines View on github.com → Search inside
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}

Code quality findings 2

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, h := range wantHeader {

Get this view in your editor

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