processor/report_test.go GO 1,053 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"bytes"7	"fmt"8	"html/template"9	"io"10	"math"11	"os"12	"path/filepath"13	"strings"14	"testing"15	"time"16)1718func writeTestFile(path, content string) error {19	return os.WriteFile(path, []byte(content), 0o644)20}2122// TestCollectReportDataOnFixtureRepo runs the orchestrator against a small23// git fixture repo and asserts every section is populated. It exercises the24// happy path of the spec: GitAvailable=true, all four git pointers non-nil,25// per-file table and cost results present.26func TestCollectReportDataOnFixtureRepo(t *testing.T) {27	ProcessConstants()2829	dir := makeFixtureRepo(t, []map[string]string{30		{"a.go": "package a\n\nfunc A() {}\n"},31		{"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n", "b.go": "package a\n\nfunc C() {}\n"},32		{"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n\nfunc D() {}\n"},33	})3435	data, err := CollectReportData(dir)36	if err != nil {37		t.Fatalf("CollectReportData: %v", err)38	}3940	if !data.GitAvailable {41		t.Errorf("expected GitAvailable=true for fixture repo, got false")42	}43	if data.RepoName == "" {44		t.Errorf("expected RepoName to be derived from path, got empty")45	}46	if data.SccVersion == "" {47		t.Errorf("expected SccVersion to be set")48	}49	if data.GeneratedAt.IsZero() {50		t.Errorf("expected GeneratedAt to be set")51	}5253	if data.Totals.Files == 0 {54		t.Errorf("expected Totals.Files > 0, got 0")55	}56	if len(data.Summary) == 0 {57		t.Errorf("expected Summary to contain at least one language")58	}59	if len(data.Files) == 0 {60		t.Errorf("expected Files slice to be populated")61	}6263	if data.ULOC == nil {64		t.Errorf("expected ULOC section, got nil")65	} else if data.ULOC.Global == 0 {66		t.Errorf("expected ULOC.Global > 0, got 0")67	}6869	if data.LineLength == nil {70		t.Errorf("expected LineLength section, got nil")71	} else {72		if len(data.LineLength.Buckets) == 0 {73			t.Errorf("expected LineLength.Buckets populated")74		}75		if data.LineLength.Max == 0 {76			t.Errorf("expected LineLength.Max > 0")77		}78	}7980	if data.Hotspots == nil {81		t.Errorf("expected Hotspots section to be populated when git available")82	}83	if data.Authors == nil {84		t.Errorf("expected Authors section to be populated when git available")85	}86	if data.LanguageTimeline == nil {87		t.Errorf("expected LanguageTimeline section to be populated when git available")88	}89	if data.AuthorTimeline == nil {90		t.Errorf("expected AuthorTimeline section to be populated when git available")91	}9293	if data.Cocomo == nil {94		t.Errorf("expected Cocomo result, got nil")95	}96	if data.Locomo == nil {97		t.Errorf("expected Locomo result, got nil")98	}99}100101// TestCollectReportDataOnNonGitDir verifies the git-less path: detect=false,102// all four git pointers nil, but the language/file/cost sections still103// populated.104func TestCollectReportDataOnNonGitDir(t *testing.T) {105	ProcessConstants()106107	dir := t.TempDir()108	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {109		t.Fatalf("write file: %v", err)110	}111112	data, err := CollectReportData(dir)113	if err != nil {114		t.Fatalf("CollectReportData: %v", err)115	}116117	if data.GitAvailable {118		t.Errorf("expected GitAvailable=false for non-git tempdir, got true")119	}120	if data.Hotspots != nil {121		t.Errorf("expected Hotspots=nil when git unavailable, got %+v", data.Hotspots)122	}123	if data.Authors != nil {124		t.Errorf("expected Authors=nil when git unavailable, got %+v", data.Authors)125	}126	if data.LanguageTimeline != nil {127		t.Errorf("expected LanguageTimeline=nil when git unavailable")128	}129	if data.AuthorTimeline != nil {130		t.Errorf("expected AuthorTimeline=nil when git unavailable")131	}132133	if data.Totals.Files == 0 {134		t.Errorf("expected Totals.Files > 0 even without git, got 0")135	}136	if data.Cocomo == nil {137		t.Errorf("expected Cocomo result even without git, got nil")138	}139}140141// TestCollectReportDataRestoresFlags asserts that the package-level flag142// vars mutated inside CollectReportData are restored to their on-entry143// values, even when the report mode flipped them on. Critical for144// in-process re-entrancy.145func TestCollectReportDataRestoresFlags(t *testing.T) {146	ProcessConstants()147148	prevUloc, prevMaxMean, prevFiles := UlocMode, MaxMean, Files149	UlocMode, MaxMean, Files = false, false, false150	t.Cleanup(func() {151		UlocMode = prevUloc152		MaxMean = prevMaxMean153		Files = prevFiles154	})155156	dir := t.TempDir()157	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {158		t.Fatalf("write file: %v", err)159	}160161	if _, err := CollectReportData(dir); err != nil {162		t.Fatalf("CollectReportData: %v", err)163	}164165	if UlocMode {166		t.Errorf("expected UlocMode restored to false, got true")167	}168	if MaxMean {169		t.Errorf("expected MaxMean restored to false, got true")170	}171	if Files {172		t.Errorf("expected Files restored to false, got true")173	}174}175176// TestCollectReportDataHonoursReportSkip verifies --report-skip nilling out177// the matching *Result pointers. Uses a tempdir (no git) so the only178// sections under test are ULOC, line-length, and cost.179func TestCollectReportDataHonoursReportSkip(t *testing.T) {180	ProcessConstants()181182	prevSkip := ReportSkipNames183	ReportSkipNames = map[string]bool{184		"uloc":       true,185		"linelength": true,186		"cocomo":     true,187		"locomo":     true,188	}189	t.Cleanup(func() { ReportSkipNames = prevSkip })190191	dir := t.TempDir()192	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {193		t.Fatalf("write file: %v", err)194	}195196	data, err := CollectReportData(dir)197	if err != nil {198		t.Fatalf("CollectReportData: %v", err)199	}200201	if data.ULOC != nil {202		t.Errorf("expected ULOC=nil under --report-skip uloc, got %+v", data.ULOC)203	}204	if data.LineLength != nil {205		t.Errorf("expected LineLength=nil under --report-skip linelength, got %+v", data.LineLength)206	}207	if data.Cocomo != nil {208		t.Errorf("expected Cocomo=nil under --report-skip cocomo, got %+v", data.Cocomo)209	}210	if data.Locomo != nil {211		t.Errorf("expected Locomo=nil under --report-skip locomo, got %+v", data.Locomo)212	}213}214215// TestRenderReportEmbedsShareCardMeta renders a full report and asserts the216// OpenGraph / Twitter Card meta tags from spec 04 are present with the217// embedded data: URL share card. Locks in the unfurl contract.218func TestRenderReportEmbedsShareCardMeta(t *testing.T) {219	ProcessConstants()220221	dir := makeFixtureRepo(t, []map[string]string{222		{"a.go": "package a\n\nfunc A() {}\n"},223		{"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n"},224	})225226	data, err := CollectReportData(dir)227	if err != nil {228		t.Fatalf("CollectReportData: %v", err)229	}230231	out := filepath.Join(t.TempDir(), "report.html")232	if err := RenderReport(data, out); err != nil {233		t.Fatalf("RenderReport: %v", err)234	}235236	body, err := os.ReadFile(out)237	if err != nil {238		t.Fatalf("read rendered report: %v", err)239	}240	html := string(body)241242	wants := []string{243		`<meta property="og:type" content="website">`,244		`<meta property="og:title" content="scc analysed `,245		`<meta property="og:description" content="`,246		`<meta property="og:image" content="data:image/svg`,247		`<meta name="twitter:card" content="summary_large_image">`,248		`<meta name="twitter:title" content="scc analysed `,249		`<meta name="twitter:description" content="`,250		`<meta name="twitter:image" content="data:image/svg`,251	}252	for _, w := range wants {253		if !strings.Contains(html, w) {254			t.Errorf("rendered report missing %q", w)255		}256	}257258	// The description should follow the spec-04 headline shape: files · SLOC.259	if !strings.Contains(html, " files · ") || !strings.Contains(html, " SLOC") {260		t.Errorf("rendered report description doesn't match headline shape; got HTML:\n%s", html)261	}262}263264// TestRenderReportSkipCardDropsImageTags asserts that --report-skip card265// suppresses both image meta tags but leaves the text ones intact.266func TestRenderReportSkipCardDropsImageTags(t *testing.T) {267	ProcessConstants()268269	prevSkip := ReportSkipNames270	ReportSkipNames = map[string]bool{"card": true}271	t.Cleanup(func() { ReportSkipNames = prevSkip })272273	dir := t.TempDir()274	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {275		t.Fatalf("write file: %v", err)276	}277278	data, err := CollectReportData(dir)279	if err != nil {280		t.Fatalf("CollectReportData: %v", err)281	}282283	out := filepath.Join(t.TempDir(), "report.html")284	if err := RenderReport(data, out); err != nil {285		t.Fatalf("RenderReport: %v", err)286	}287288	body, err := os.ReadFile(out)289	if err != nil {290		t.Fatalf("read rendered report: %v", err)291	}292	html := string(body)293294	if strings.Contains(html, `property="og:image"`) {295		t.Errorf("expected no og:image when card is skipped")296	}297	if strings.Contains(html, `name="twitter:image"`) {298		t.Errorf("expected no twitter:image when card is skipped")299	}300	// Text tags must still be present.301	for _, w := range []string{`property="og:title"`, `name="twitter:description"`} {302		if !strings.Contains(html, w) {303			t.Errorf("expected text meta tag %q to remain when card is skipped", w)304		}305	}306}307308// TestCocomoPrettyCost locks in the headline cost format ("$1,234,567" style).309func TestCocomoPrettyCost(t *testing.T) {310	r := CocomoResult{CurrencySymbol: "$", EstimatedCost: 1234567.89}311	got := r.PrettyCost()312	if got != "$1,234,567" {313		t.Errorf("PrettyCost = %q, want %q", got, "$1,234,567")314	}315}316317// withReportSkipReset snapshots ReportSkipNames around a test body and318// restores it afterward. Spec 05 tests mutate the global heavily and we want319// the test order not to matter.320func withReportSkipReset(t *testing.T) {321	t.Helper()322	prev := ReportSkipNames323	t.Cleanup(func() { ReportSkipNames = prev })324}325326// TestParseReportSkipKnownNames exercises the happy path: every recognised327// name is parsed, lower-cased, and the warning channel stays silent.328func TestParseReportSkipKnownNames(t *testing.T) {329	withReportSkipReset(t)330331	var warn bytes.Buffer332	parseReportSkipTo("Cocomo, Locomo, hotspots, AUTHORS, timeline, files, uloc, linelength, card", &warn)333334	for _, name := range []string{"cocomo", "locomo", "hotspots", "authors", "timeline", "files", "uloc", "linelength", "card"} {335		if !ReportSkipped(name) {336			t.Errorf("ReportSkipped(%q) = false, want true after parseReportSkip", name)337		}338	}339	if got := warn.String(); got != "" {340		t.Errorf("expected no warning output for recognised names, got %q", got)341	}342}343344// TestParseReportSkipUnknownNameWarns covers spec 05's "unknown names emit a345// warning on stderr, continue" rule. The unknown name is still recorded so346// future template helpers can surface "ignored" hints.347func TestParseReportSkipUnknownNameWarns(t *testing.T) {348	withReportSkipReset(t)349350	var warn bytes.Buffer351	parseReportSkipTo("cocomo, bogus, also-bogus", &warn)352353	if !ReportSkipped("cocomo") {354		t.Errorf("recognised name should be marked skipped")355	}356	out := warn.String()357	if !strings.Contains(out, "unknown section \"bogus\"") {358		t.Errorf("expected warning to name the bogus section, got %q", out)359	}360	if !strings.Contains(out, "unknown section \"also-bogus\"") {361		t.Errorf("expected a warning per unknown section, got %q", out)362	}363}364365// TestParseReportSkipEmptyClearsMap asserts that parsing an empty string366// resets the map. Important because the var is package-level and a previous367// in-process run could otherwise leak state.368func TestParseReportSkipEmptyClearsMap(t *testing.T) {369	withReportSkipReset(t)370	ReportSkipNames = map[string]bool{"cocomo": true}371372	var warn bytes.Buffer373	parseReportSkipTo("", &warn)374375	if len(ReportSkipNames) != 0 {376		t.Errorf("expected ReportSkipNames cleared by empty input, got %v", ReportSkipNames)377	}378	if warn.Len() != 0 {379		t.Errorf("expected no warning for empty input, got %q", warn.String())380	}381}382383// TestReportSkippedCaseInsensitive locks in the spec 05 contract that384// callers can pass either case.385func TestReportSkippedCaseInsensitive(t *testing.T) {386	withReportSkipReset(t)387	ReportSkipNames = map[string]bool{"cocomo": true}388389	for _, name := range []string{"cocomo", "Cocomo", "COCOMO"} {390		if !ReportSkipped(name) {391			t.Errorf("ReportSkipped(%q) = false, want true", name)392		}393	}394	if ReportSkipped("hotspots") {395		t.Errorf("ReportSkipped(\"hotspots\") = true, want false")396	}397}398399// TestSkippedTemplateHelper renders a tiny template that uses the `skipped`400// helper to gate a block. Locks in the spec 05 contract that the helper is401// registered and reads ReportSkipNames.402func TestSkippedTemplateHelper(t *testing.T) {403	withReportSkipReset(t)404	ReportSkipNames = map[string]bool{"cocomo": true}405406	tmpl, err := template.New("t").Funcs(reportFuncs).Parse(407		`{{ if not (skipped "cocomo") }}cocomo-on{{ else }}cocomo-off{{ end }};{{ if not (skipped "hotspots") }}hotspots-on{{ end }}`,408	)409	if err != nil {410		t.Fatalf("parse: %v", err)411	}412	var out bytes.Buffer413	if err := tmpl.Execute(&out, nil); err != nil {414		t.Fatalf("execute: %v", err)415	}416	got := out.String()417	want := "cocomo-off;hotspots-on"418	if got != want {419		t.Errorf("template output = %q, want %q", got, want)420	}421}422423// TestDetectRepoNameHonoursReportTitle covers step 1 of the spec 05424// auto-detection chain — an explicit --report-title wins over everything.425func TestDetectRepoNameHonoursReportTitle(t *testing.T) {426	prev := ReportTitle427	ReportTitle = "My Custom Repo"428	t.Cleanup(func() { ReportTitle = prev })429430	if got := detectRepoName(t.TempDir()); got != "My Custom Repo" {431		t.Errorf("detectRepoName = %q, want %q", got, "My Custom Repo")432	}433}434435// TestDetectRepoNameFallsBackToBasename covers step 3 of the chain: with no436// override and no git remote, the basename of the analysed path is used.437func TestDetectRepoNameFallsBackToBasename(t *testing.T) {438	prev := ReportTitle439	ReportTitle = ""440	t.Cleanup(func() { ReportTitle = prev })441442	dir := filepath.Join(t.TempDir(), "myproj")443	if err := os.Mkdir(dir, 0o755); err != nil {444		t.Fatalf("mkdir: %v", err)445	}446447	if got := detectRepoName(dir); got != "myproj" {448		t.Errorf("detectRepoName = %q, want %q", got, "myproj")449	}450}451452// TestNoCocomoEquivalentToReportSkipCocomo verifies the spec 05 row that453// --no-cocomo and --report-skip cocomo produce the same outcome (Cocomo454// section nil). Uses a non-git tempdir to keep the run fast.455func TestNoCocomoEquivalentToReportSkipCocomo(t *testing.T) {456	ProcessConstants()457	withReportSkipReset(t)458459	dir := t.TempDir()460	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {461		t.Fatalf("write file: %v", err)462	}463464	// Path 1: --no-cocomo (the Cocomo flag is "skip" when true).465	prevCocomo := Cocomo466	Cocomo = true467	t.Cleanup(func() { Cocomo = prevCocomo })468469	ReportSkipNames = map[string]bool{}470	dataA, err := CollectReportData(dir)471	if err != nil {472		t.Fatalf("CollectReportData (no-cocomo): %v", err)473	}474	if dataA.Cocomo != nil {475		t.Errorf("--no-cocomo: expected Cocomo=nil, got %+v", dataA.Cocomo)476	}477478	// Path 2: --report-skip cocomo, with the Cocomo skip flag off.479	Cocomo = false480	ReportSkipNames = map[string]bool{"cocomo": true}481	dataB, err := CollectReportData(dir)482	if err != nil {483		t.Fatalf("CollectReportData (skip cocomo): %v", err)484	}485	if dataB.Cocomo != nil {486		t.Errorf("--report-skip cocomo: expected Cocomo=nil, got %+v", dataB.Cocomo)487	}488}489490// TestReportImplicitlyEnablesULOCAndLineLength verifies that --report flips491// the ULOC and line-length analysis modes on, and --report-skip suppresses492// them — the spec 05 rows for `--uloc` / `-m` / `--character`.493func TestReportImplicitlyEnablesULOCAndLineLength(t *testing.T) {494	ProcessConstants()495	withReportSkipReset(t)496497	prevUloc, prevMaxMean, prevFiles := UlocMode, MaxMean, Files498	UlocMode, MaxMean, Files = false, false, false499	t.Cleanup(func() {500		UlocMode = prevUloc501		MaxMean = prevMaxMean502		Files = prevFiles503	})504505	dir := t.TempDir()506	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {507		t.Fatalf("write file: %v", err)508	}509510	ReportSkipNames = map[string]bool{}511	data, err := CollectReportData(dir)512	if err != nil {513		t.Fatalf("CollectReportData: %v", err)514	}515	if data.ULOC == nil {516		t.Errorf("expected ULOC populated when --report runs without --report-skip uloc")517	}518	if data.LineLength == nil {519		t.Errorf("expected LineLength populated when --report runs without --report-skip linelength")520	}521	if len(data.Files) == 0 {522		t.Errorf("expected per-file table populated when --report runs without --report-skip files")523	}524}525526// TestReportSkipRecognisedListMatchesSpec locks in the exact set of names527// spec 05 enumerates as recognised so a future code change can't silently528// add or drop one without updating the spec.529func TestReportSkipRecognisedListMatchesSpec(t *testing.T) {530	want := []string{"cocomo", "locomo", "hotspots", "coupling", "authors", "timeline", "files", "uloc", "linelength", "card"}531	if len(reportSkipRecognised) != len(want) {532		t.Errorf("reportSkipRecognised size = %d, want %d", len(reportSkipRecognised), len(want))533	}534	for _, name := range want {535		if !reportSkipRecognised[name] {536			t.Errorf("reportSkipRecognised missing %q", name)537		}538	}539}540541// TestCommaFmt locks in thousands-separator rendering for the headline542// numbers (`{{ comma .Totals.Code }}`). Covers boundaries the template hits:543// zero, < 1k, the 4- and 7-digit thresholds, and a negative.544func TestCommaFmt(t *testing.T) {545	cases := []struct {546		in   int64547		want string548	}{549		{0, "0"},550		{1, "1"},551		{12, "12"},552		{123, "123"},553		{1234, "1,234"},554		{12345, "12,345"},555		{123456, "123,456"},556		{1234567, "1,234,567"},557		{1000000000, "1,000,000,000"},558	}559	for _, tc := range cases {560		if got := commaFmt(tc.in); got != tc.want {561			t.Errorf("commaFmt(%d) = %q, want %q", tc.in, got, tc.want)562		}563	}564565	// The `comma` template helper wraps commaFmt with a negative-sign guard.566	helper := reportFuncs["comma"].(func(int64) string)567	if got := helper(-1234); got != "-1,234" {568		t.Errorf("comma(-1234) = %q, want %q", got, "-1,234")569	}570	if got := helper(0); got != "0" {571		t.Errorf("comma(0) = %q, want %q", got, "0")572	}573}574575// TestPctHelper locks in the "{{ pct n d }}" format and the zero-denominator576// guard the template relies on (e.g. when a section is skipped).577func TestPctHelper(t *testing.T) {578	pct := reportFuncs["pct"].(func(int64, int64) string)579	cases := []struct {580		num, denom int64581		want       string582	}{583		{0, 0, "0.0%"},584		{1, 0, "0.0%"},585		{0, 100, "0.0%"},586		{1, 4, "25.0%"},587		{1, 3, "33.3%"},588		{2, 3, "66.7%"},589		{100, 100, "100.0%"},590	}591	for _, tc := range cases {592		if got := pct(tc.num, tc.denom); got != tc.want {593			t.Errorf("pct(%d, %d) = %q, want %q", tc.num, tc.denom, got, tc.want)594		}595	}596}597598// TestHumanBytes locks in SI-style byte rendering used by the share card and599// per-file table.600func TestHumanBytes(t *testing.T) {601	cases := []struct {602		in   int64603		want string604	}{605		{0, "0B"},606		{1, "1B"},607		{999, "999B"},608		{1000, "1.0K"},609		{1500, "1.5K"},610		{12000, "12.0K"},611		{100000, "100K"},612		{1500000, "1.5M"},613		{1500000000, "1.5G"},614		{1500000000000, "1.5T"},615		{1500000000000000, "1.5P"},616	}617	for _, tc := range cases {618		if got := humanBytes(tc.in); got != tc.want {619			t.Errorf("humanBytes(%d) = %q, want %q", tc.in, got, tc.want)620		}621	}622}623624// TestDonutArcsMath asserts the geometry of the donut: arc dasharrays sum to625// the canonical circumference, offsets are negative cumulative segments, and626// colours flow through langColor.627func TestDonutArcsMath(t *testing.T) {628	if got := donutArcs(nil); got != nil {629		t.Errorf("donutArcs(nil) = %+v, want nil", got)630	}631	if got := donutArcs([]LanguageSummary{{Name: "Go", Code: 0}}); got != nil {632		t.Errorf("donutArcs(zero-total) = %+v, want nil", got)633	}634635	arcs := donutArcs([]LanguageSummary{636		{Name: "Go", Code: 75},637		{Name: "JavaScript", Code: 25},638	})639	if len(arcs) != 2 {640		t.Fatalf("donutArcs returned %d arcs, want 2", len(arcs))641	}642	if arcs[0].Color != "#00ADD8" {643		t.Errorf("arc[0].Color = %q, want #00ADD8 (Go)", arcs[0].Color)644	}645	if arcs[1].Color != "#f1e05a" {646		t.Errorf("arc[1].Color = %q, want #f1e05a (JavaScript)", arcs[1].Color)647	}648	if arcs[0].Dashoffset != 0 {649		t.Errorf("arc[0].Dashoffset = %f, want 0", arcs[0].Dashoffset)650	}651	// Second arc's offset is -1*first-arc-segment = -75.652	if arcs[1].Dashoffset != -75 {653		t.Errorf("arc[1].Dashoffset = %f, want -75", arcs[1].Dashoffset)654	}655	if arcs[0].Dasharray != "75.000 25.000" {656		t.Errorf("arc[0].Dasharray = %q, want %q", arcs[0].Dasharray, "75.000 25.000")657	}658	if arcs[1].Dasharray != "25.000 75.000" {659		t.Errorf("arc[1].Dasharray = %q, want %q", arcs[1].Dasharray, "25.000 75.000")660	}661}662663// TestSparklinePath covers the SVG-path generator for the timeline tables:664// empty input is "", a single point is a degenerate "M", two-point series665// renders an "M…L…" pair, fill mode closes the path back to the baseline,666// and a flat series doesn't divide by zero.667func TestSparklinePath(t *testing.T) {668	if got := sparklinePath(nil, 100, 20); got != "" {669		t.Errorf("sparklinePath(nil) = %q, want \"\"", got)670	}671	if got := sparklinePath([]int{1, 2, 3}, 0, 20); got != "" {672		t.Errorf("sparklinePath with w=0 should be empty, got %q", got)673	}674675	// Single point: x=0, span=1 → y=h (baseline).676	if got := sparklinePath([]int{5}, 100, 20); got != "M0.0,20.0" {677		t.Errorf("sparklinePath([5]) = %q, want %q", got, "M0.0,20.0")678	}679680	// Two-point ascending series spans the full box.681	got := sparklinePath([]int{0, 10}, 100, 20)682	if got != "M0.0,20.0 L100.0,0.0" {683		t.Errorf("sparklinePath([0,10]) = %q, want %q", got, "M0.0,20.0 L100.0,0.0")684	}685686	// Flat series should not divide by zero — span is forced to 1, so every687	// point lands on the baseline.688	flat := sparklinePath([]int{4, 4, 4}, 100, 20)689	if !strings.Contains(flat, "M0.0,20.0") || !strings.Contains(flat, "L100.0,20.0") {690		t.Errorf("sparklinePath(flat) = %q, want degenerate baseline path", flat)691	}692693	// Fill mode closes the path back to the baseline and back to x=0.694	fill := sparklineFill([]int{0, 10}, 100, 20)695	if !strings.HasSuffix(fill, " L100.0,20.0 L0.0,20.0 Z") {696		t.Errorf("sparklineFill should close to baseline, got %q", fill)697	}698}699700// TestNormZero covers the negative-zero normalisation that keeps rendered701// coordinates byte-identical across architectures. Go may fuse `a - b*c`702// into one multiply-add; the loong64 backend uses FNMSUBD, which computes703// -((b*c) - a), so an exactly-zero result arrives as -0.0 and formats as704// "-0.0" where amd64 emits "0.0". See issue #767.705func TestNormZero(t *testing.T) {706	negZero := math.Copysign(0, -1)707	if got := normZero(negZero); math.Signbit(got) {708		t.Errorf("normZero(-0.0) has the sign bit set, want +0.0")709	}710	if got := fmt.Sprintf("%.1f", normZero(negZero)); got != "0.0" {711		t.Errorf("normZero(-0.0) formatted = %q, want %q", got, "0.0")712	}713714	// Non-zero values pass through untouched.715	for _, v := range []float64{-22.5, -1, 1, 22.5} {716		if got := normZero(v); got != v {717			t.Errorf("normZero(%v) = %v, want unchanged", v, got)718		}719	}720721	// math.FMA is the fused operation on every platform, so this reproduces722	// the loong64 encoding of the sparkline Y expression exactly: the top of723	// the box is -0.0 before normalisation and "0.0" after it.724	h, span, delta := 20.0, 10.0, 10.0725	fused := -math.FMA(delta/span, h, -h)726	if !math.Signbit(fused) {727		t.Fatalf("fused y = %v, want the negative zero loong64 produces", fused)728	}729	if got := fmt.Sprintf("%.1f", normZero(fused)); got != "0.0" {730		t.Errorf("normZero(fused y) = %q, want %q", got, "0.0")731	}732}733734// TestSparklinePathNoNegativeZero guards the rendered paths directly: no735// coordinate may ever format with a negative-zero sign, whatever the736// compiler does with the multiply-subtract. See issue #767.737func TestSparklinePathNoNegativeZero(t *testing.T) {738	cases := [][]int{739		{0, 10},740		{4000, 8000},741		{2200, 2400},742		{48, 3800, 100, 3800},743		{0, 0, 1},744	}745	for _, values := range cases {746		for _, got := range []string{747			sparklinePath(values, 140, 22),748			sparklineFill(values, 140, 22),749		} {750			if strings.Contains(got, "-0.0") {751				t.Errorf("sparkline path for %v contains a negative zero: %q", values, got)752			}753		}754	}755}756757// TestDonutArcsSingleLanguage covers the 100%% case, where the trailing gap758// of the dasharray is an exact zero and so hits the same negative-zero759// hazard as the sparkline coordinates.760func TestDonutArcsSingleLanguage(t *testing.T) {761	arcs := donutArcs([]LanguageSummary{{Name: "Go", Code: 1234}})762	if len(arcs) != 1 {763		t.Fatalf("donutArcs returned %d arcs, want 1", len(arcs))764	}765	if arcs[0].Dasharray != "100.000 0.000" {766		t.Errorf("arc[0].Dasharray = %q, want %q", arcs[0].Dasharray, "100.000 0.000")767	}768}769770// TestDataURLCard locks in the og:image data: URL prefix and the space771// escaping the unfurl scrapers require.772func TestDataURLCard(t *testing.T) {773	got := dataURLCard(template.HTML("  <svg>hello world</svg>  "))774	want := "data:image/svg+xml;utf8,%3Csvg%3Ehello%20world%3C%2Fsvg%3E"775	if got != want {776		t.Errorf("dataURLCard = %q, want %q", got, want)777	}778779	// Empty input still yields the prefix so the template can embed it780	// unconditionally; the og:image gate is in the template, not here.781	if got := dataURLCard(""); got != "data:image/svg+xml;utf8," {782		t.Errorf("dataURLCard(\"\") = %q, want bare prefix", got)783	}784}785786// goldenReportFixture returns a hand-curated ReportData with every787// section populated and every timestamp / duration / floating-point value788// fixed. Used by TestRenderReport_Golden to catch unintended template789// changes. Keep this deterministic — never call time.Now(), never inject790// random IDs, never rely on map iteration order.791func goldenReportFixture() ReportData {792	generated := time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)793	lastCommit := time.Date(2026, 1, 14, 18, 0, 0, 0, time.UTC)794795	return ReportData{796		RepoName:     "golden-fixture",797		SccVersion:   "3.8.0-test",798		GeneratedAt:  generated,799		Duration:     1500 * time.Millisecond,800		GitAvailable: true,801		Summary: []LanguageSummary{802			{Name: "Go", Count: 12, Code: 8000, Comment: 600, Blank: 1200, Complexity: 420, ULOC: 5400},803			{Name: "JavaScript", Count: 6, Code: 2400, Comment: 180, Blank: 360, Complexity: 90, ULOC: 1800},804			{Name: "Markdown", Count: 3, Code: 240, Comment: 0, Blank: 60, Complexity: 0, ULOC: 220},805		},806		Totals: Totals{807			Files: 21, Lines: 13040, Code: 10640, Comment: 780, Blank: 1620, Complexity: 510, Bytes: 524288,808		},809		ULOC: &ULOCResult{810			Global: 7420, TotalLines: 13040, Dryness: 0.569,811			PerLanguage: []ULOCLanguage{812				{Language: "Go", ULOC: 5400},813				{Language: "JavaScript", ULOC: 1800},814				{Language: "Markdown", ULOC: 220},815			},816		},817		LineLength: &LineLengthResult{818			Mean: 38.2, Max: 220, TotalLines: 10640,819			Buckets: []LineLengthBucket{820				{Start: 0, End: 20, Label: "0–20", Count: 4200},821				{Start: 20, End: 40, Label: "20–40", Count: 3800},822				{Start: 40, End: 60, Label: "40–60", Count: 1600},823				{Start: 60, End: 80, Label: "60–80", Count: 700},824				{Start: 80, End: 100, Label: "80–100", Count: 220},825				{Start: 100, End: 120, Label: "100–120", Count: 100},826				{Start: 120, End: 0, Label: "120+", Count: 20},827			},828			Outliers: []LineLengthOutlier{829				{File: "internal/wide.go", Language: "Go", LineLength: 220},830				{File: "web/styles.js", Language: "JavaScript", LineLength: 180},831			},832		},833		Hotspots: &HotspotsResult{834			Available: true,835			TotalRaw:  3,836			Records: []HotspotRow{837				{File: "internal/server.go", Language: "Go", Complexity: 240, Commits: 64, LinesChanged: 1200, Authors: 4, Score: 92.5},838				{File: "internal/cache.go", Language: "Go", Complexity: 120, Commits: 28, LinesChanged: 420, Authors: 2, Score: 41.2},839				{File: "web/app.js", Language: "JavaScript", Complexity: 60, Commits: 14, LinesChanged: 180, Authors: 2, Score: 17.8},840			},841		},842		Coupling: &CouplingResult{843			Available:  true,844			TotalPairs: 3,845			Pairs: []CouplingPairRow{846				{FileA: "internal/cache.go", FileB: "internal/server.go", Shared: 22, Degree: 34.4},847				{FileA: "internal/server.go", FileB: "web/app.js", Shared: 9, Degree: 13.0},848				{FileA: "README.md", FileB: "internal/server.go", Shared: 6, Degree: 8.1},849			},850		},851		Authors: &AuthorsResult{852			BusFactor:    1,853			BusAuthors:   []string{"Alice"},854			BusCovered:   0.62,855			InWindowCode: 9800,856			Rows: []AuthorRow{857				{Name: "Alice", Email: "alice@example.com", Code: 6200, Files: 14, OwnsPercent: 62.4, InWindowPercent: 64.0, LastCommit: lastCommit},858				{Name: "Bob", Email: "bob@example.com", Code: 2400, Files: 8, OwnsPercent: 24.1, InWindowPercent: 22.0, LastCommit: lastCommit.Add(-48 * time.Hour)},859				{Name: "(before window)", Sentinel: true},860			},861		},862		LanguageTimeline: &LangTimelineResult{863			Buckets: 4,864			Rows: []LangTimelineRow{865				{Language: "Go", StartingLines: 4000, CodeNow: 8000, Change: 4000, SharePercent: 75.2, Trajectory: []int64{4000, 5200, 6800, 8000}},866				{Language: "JavaScript", StartingLines: 2200, CodeNow: 2400, Change: 200, SharePercent: 22.6, Trajectory: []int64{2200, 2300, 2350, 2400}},867				{Language: "Markdown", StartingLines: 200, CodeNow: 240, Change: 40, SharePercent: 2.2, Trajectory: []int64{200, 210, 220, 240}},868			},869		},870		AuthorTimeline: &AuthorTimelineResult{871			Buckets: 4,872			Rows: []AuthorTimelineRow{873				{Name: "Alice", Email: "alice@example.com", TotalCommits: 48, CodeDelta: 3800, Series: []AuthorTimelineBucket{874					{Commits: 8, CodeDelta: 500},875					{Commits: 14, CodeDelta: 1200},876					{Commits: 12, CodeDelta: 900},877					{Commits: 14, CodeDelta: 1200},878				}},879				{Name: "Bob", Email: "bob@example.com", TotalCommits: 22, CodeDelta: 400, Series: []AuthorTimelineBucket{880					{Commits: 6, CodeDelta: 200},881					{Commits: 6, CodeDelta: 100},882					{Commits: 5, CodeDelta: 50},883					{Commits: 5, CodeDelta: 50},884				}},885			},886		},887		Cocomo: &CocomoResult{888			ProjectType: "organic", SumCode: 10640,889			EstimatedEffort: 26.4, EstimatedCost: 297500, ScheduleMonths: 8.4, PeopleRequired: 3.1,890			AverageWage: 56286, Overhead: 2.4, EAF: 1.0, CurrencySymbol: "$",891		},892		Locomo: &LocomoResult{893			InputTokens: 320000, OutputTokens: 96000, Cost: 18.25,894			GenerationSeconds: 1800, ReviewHours: 12, AverageComplexityMult: 1.4,895			IterationFactor: 1.2, Preset: "medium",896		},897		Files: []*FileJob{898			{Location: "internal/server.go", Language: "Go", Lines: 3200, Code: 2600, Comment: 200, Blank: 400, Complexity: 240, Bytes: 96000, Uloc: 2200},899			{Location: "internal/cache.go", Language: "Go", Lines: 1400, Code: 1100, Comment: 120, Blank: 180, Complexity: 120, Bytes: 42000, Uloc: 900},900			{Location: "web/app.js", Language: "JavaScript", Lines: 2000, Code: 1600, Comment: 80, Blank: 320, Complexity: 60, Bytes: 60000, Uloc: 1200},901			{Location: "README.md", Language: "Markdown", Lines: 120, Code: 100, Comment: 0, Blank: 20, Complexity: 0, Bytes: 4096, Uloc: 90},902		},903	}904}905906// TestRenderReport_Golden snapshots the full HTML report output against907// testdata/golden-report.html. Run with UPDATE_GOLDEN=1 to refresh after908// an intentional template change. The fixture is hand-curated and909// deterministic — no time.Now, no random colour assignment, no map910// iteration in the input — so a stable template produces stable bytes.911func TestRenderReport_Golden(t *testing.T) {912	ProcessConstants()913	withReportSkipReset(t)914	ReportSkipNames = map[string]bool{}915916	d := goldenReportFixture()917918	var buf bytes.Buffer919	if err := renderReportTo(&buf, d); err != nil {920		t.Fatalf("renderReportTo: %v", err)921	}922	got := buf.Bytes()923924	goldenPath := filepath.Join("testdata", "golden-report.html")925	if os.Getenv("UPDATE_GOLDEN") == "1" {926		if err := os.MkdirAll(filepath.Dir(goldenPath), 0o755); err != nil {927			t.Fatalf("mkdir testdata: %v", err)928		}929		if err := os.WriteFile(goldenPath, got, 0o644); err != nil {930			t.Fatalf("write golden: %v", err)931		}932		return933	}934935	want, err := os.ReadFile(goldenPath)936	if err != nil {937		t.Fatalf("read golden (run UPDATE_GOLDEN=1 to create): %v", err)938	}939	if !bytes.Equal(got, want) {940		t.Fatalf("golden mismatch (run UPDATE_GOLDEN=1 to refresh)\n--- want (%d bytes)\n--- got  (%d bytes)\nfirst diff at byte %d",941			len(want), len(got), firstByteDiff(want, got))942	}943}944945// firstByteDiff returns the index of the first byte at which a and b946// differ, or -1 if they're equal. Used by the golden test's failure947// message so a reviewer can jump straight to the divergence.948func firstByteDiff(a, b []byte) int {949	n := min(len(b), len(a))950	for i := range n {951		if a[i] != b[i] {952			return i953		}954	}955	if len(a) != len(b) {956		return n957	}958	return -1959}960961// TestConfirmReportOverwriteExplicitPathSilent verifies the "explicit962// path = consent" branch: when the user typed --report=foo.html, we963// should not stat, not prompt, not produce a warning. The function must964// succeed even with a nil reader and writer.965func TestConfirmReportOverwriteExplicitPathSilent(t *testing.T) {966	if err := confirmReportOverwrite("anywhere.html", false, true, nil, nil); err != nil {967		t.Errorf("explicit path: expected nil, got %v", err)968	}969	if err := confirmReportOverwrite("anywhere.html", false, false, nil, nil); err != nil {970		t.Errorf("explicit path non-TTY: expected nil, got %v", err)971	}972}973974// TestConfirmReportOverwriteMissingFileProceeds covers the bare-flag975// case where the file doesn't exist yet — no prompt, no error.976func TestConfirmReportOverwriteMissingFileProceeds(t *testing.T) {977	missing := filepath.Join(t.TempDir(), "scc-report.html")978	var out bytes.Buffer979	if err := confirmReportOverwrite(missing, true, true, strings.NewReader(""), &out); err != nil {980		t.Errorf("missing file: expected nil, got %v", err)981	}982	if out.Len() != 0 {983		t.Errorf("missing file: expected no prompt, got %q", out.String())984	}985}986987// TestConfirmReportOverwritePromptAccepts walks the happy interactive988// path: file exists, stdin attached, user types "y" — proceed.989func TestConfirmReportOverwritePromptAccepts(t *testing.T) {990	path := filepath.Join(t.TempDir(), "scc-report.html")991	if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {992		t.Fatalf("seed file: %v", err)993	}994	for _, ans := range []string{"y\n", "Y\n", "yes\n", "YES\n", "  y \n"} {995		var out bytes.Buffer996		err := confirmReportOverwrite(path, true, true, strings.NewReader(ans), &out)997		if err != nil {998			t.Errorf("answer %q: expected nil, got %v", ans, err)999		}1000		if !strings.Contains(out.String(), "Overwrite?") {1001			t.Errorf("answer %q: expected prompt in stderr, got %q", ans, out.String())1002		}1003	}1004}10051006// TestConfirmReportOverwritePromptRejects covers the "default to no"1007// rule the prompt advertises in its `[y/N]` suffix. Anything that1008// isn't an affirmative aborts.1009func TestConfirmReportOverwritePromptRejects(t *testing.T) {1010	path := filepath.Join(t.TempDir(), "scc-report.html")1011	if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {1012		t.Fatalf("seed file: %v", err)1013	}1014	for _, ans := range []string{"n\n", "N\n", "no\n", "\n", "garbage\n"} {1015		var out bytes.Buffer1016		err := confirmReportOverwrite(path, true, true, strings.NewReader(ans), &out)1017		if err == nil {1018			t.Errorf("answer %q: expected abort, got nil", ans)1019			continue1020		}1021		if !strings.Contains(err.Error(), "not overwritten") {1022			t.Errorf("answer %q: expected 'not overwritten' in error, got %v", ans, err)1023		}1024	}1025}10261027// TestConfirmReportOverwriteNonTTYRefuses locks in the CI / piped-stdin1028// guard: with the default name and an existing file we refuse to1029// silently clobber, and the error tells the user how to opt in.1030func TestConfirmReportOverwriteNonTTYRefuses(t *testing.T) {1031	path := filepath.Join(t.TempDir(), "scc-report.html")1032	if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {1033		t.Fatalf("seed file: %v", err)1034	}1035	err := confirmReportOverwrite(path, true, false, strings.NewReader(""), io.Discard)1036	if err == nil {1037		t.Fatalf("non-TTY: expected error, got nil")1038	}1039	if !strings.Contains(err.Error(), "--report="+path) {1040		t.Errorf("non-TTY error should point at explicit-path form, got %v", err)1041	}1042}10431044// TestDefaultReportNameMatchesNoOptDefVal locks in the contract between1045// main.go's NoOptDefVal wiring and runReport's "did the user supply a1046// path" check. If these drift, the prompt logic silently breaks.1047func TestDefaultReportNameMatchesNoOptDefVal(t *testing.T) {1048	if DefaultReportName != "scc-report.html" {1049		t.Errorf("DefaultReportName = %q, want %q (main.go wires this as NoOptDefVal)",1050			DefaultReportName, "scc-report.html")1051	}1052}

Code quality findings 2

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(path, []byte("<html></html>"), 0o644); err != nil {
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(path, []byte("<html></html>"), 0o644); err != nil {

Get this view in your editor

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

Generated by searchcode.com · powered by code_get_file + code_get_findings