String to byte slice conversion inside loop allocates a new slice each iteration; convert once before the loop
if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {
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}
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