Can cause issues on Windows consider filepath.Join instead
if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {
1// SPDX-License-Identifier: MIT23package processor45import (6 "bytes"7 "html/template"8 "io"9 "os"10 "path/filepath"11 "strings"12 "testing"13 "time"14)1516func writeTestFile(path, content string) error {17 return os.WriteFile(path, []byte(content), 0o644)18}1920// TestCollectReportDataOnFixtureRepo runs the orchestrator against a small21// git fixture repo and asserts every section is populated. It exercises the22// happy path of the spec: GitAvailable=true, all four git pointers non-nil,23// per-file table and cost results present.24func TestCollectReportDataOnFixtureRepo(t *testing.T) {25 ProcessConstants()2627 dir := makeFixtureRepo(t, []map[string]string{28 {"a.go": "package a\n\nfunc A() {}\n"},29 {"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n", "b.go": "package a\n\nfunc C() {}\n"},30 {"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n\nfunc D() {}\n"},31 })3233 data, err := CollectReportData(dir)34 if err != nil {35 t.Fatalf("CollectReportData: %v", err)36 }3738 if !data.GitAvailable {39 t.Errorf("expected GitAvailable=true for fixture repo, got false")40 }41 if data.RepoName == "" {42 t.Errorf("expected RepoName to be derived from path, got empty")43 }44 if data.SccVersion == "" {45 t.Errorf("expected SccVersion to be set")46 }47 if data.GeneratedAt.IsZero() {48 t.Errorf("expected GeneratedAt to be set")49 }5051 if data.Totals.Files == 0 {52 t.Errorf("expected Totals.Files > 0, got 0")53 }54 if len(data.Summary) == 0 {55 t.Errorf("expected Summary to contain at least one language")56 }57 if len(data.Files) == 0 {58 t.Errorf("expected Files slice to be populated")59 }6061 if data.ULOC == nil {62 t.Errorf("expected ULOC section, got nil")63 } else if data.ULOC.Global == 0 {64 t.Errorf("expected ULOC.Global > 0, got 0")65 }6667 if data.LineLength == nil {68 t.Errorf("expected LineLength section, got nil")69 } else {70 if len(data.LineLength.Buckets) == 0 {71 t.Errorf("expected LineLength.Buckets populated")72 }73 if data.LineLength.Max == 0 {74 t.Errorf("expected LineLength.Max > 0")75 }76 }7778 if data.Hotspots == nil {79 t.Errorf("expected Hotspots section to be populated when git available")80 }81 if data.Authors == nil {82 t.Errorf("expected Authors section to be populated when git available")83 }84 if data.LanguageTimeline == nil {85 t.Errorf("expected LanguageTimeline section to be populated when git available")86 }87 if data.AuthorTimeline == nil {88 t.Errorf("expected AuthorTimeline section to be populated when git available")89 }9091 if data.Cocomo == nil {92 t.Errorf("expected Cocomo result, got nil")93 }94 if data.Locomo == nil {95 t.Errorf("expected Locomo result, got nil")96 }97}9899// TestCollectReportDataOnNonGitDir verifies the git-less path: detect=false,100// all four git pointers nil, but the language/file/cost sections still101// populated.102func TestCollectReportDataOnNonGitDir(t *testing.T) {103 ProcessConstants()104105 dir := t.TempDir()106 if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {107 t.Fatalf("write file: %v", err)108 }109110 data, err := CollectReportData(dir)111 if err != nil {112 t.Fatalf("CollectReportData: %v", err)113 }114115 if data.GitAvailable {116 t.Errorf("expected GitAvailable=false for non-git tempdir, got true")117 }118 if data.Hotspots != nil {119 t.Errorf("expected Hotspots=nil when git unavailable, got %+v", data.Hotspots)120 }121 if data.Authors != nil {122 t.Errorf("expected Authors=nil when git unavailable, got %+v", data.Authors)123 }124 if data.LanguageTimeline != nil {125 t.Errorf("expected LanguageTimeline=nil when git unavailable")126 }127 if data.AuthorTimeline != nil {128 t.Errorf("expected AuthorTimeline=nil when git unavailable")129 }130131 if data.Totals.Files == 0 {132 t.Errorf("expected Totals.Files > 0 even without git, got 0")133 }134 if data.Cocomo == nil {135 t.Errorf("expected Cocomo result even without git, got nil")136 }137}138139// TestCollectReportDataRestoresFlags asserts that the package-level flag140// vars mutated inside CollectReportData are restored to their on-entry141// values, even when the report mode flipped them on. Critical for142// in-process re-entrancy.143func TestCollectReportDataRestoresFlags(t *testing.T) {144 ProcessConstants()145146 prevUloc, prevMaxMean, prevFiles := UlocMode, MaxMean, Files147 UlocMode, MaxMean, Files = false, false, false148 t.Cleanup(func() {149 UlocMode = prevUloc150 MaxMean = prevMaxMean151 Files = prevFiles152 })153154 dir := t.TempDir()155 if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {156 t.Fatalf("write file: %v", err)157 }158159 if _, err := CollectReportData(dir); err != nil {160 t.Fatalf("CollectReportData: %v", err)161 }162163 if UlocMode {164 t.Errorf("expected UlocMode restored to false, got true")165 }166 if MaxMean {167 t.Errorf("expected MaxMean restored to false, got true")168 }169 if Files {170 t.Errorf("expected Files restored to false, got true")171 }172}173174// TestCollectReportDataHonoursReportSkip verifies --report-skip nilling out175// the matching *Result pointers. Uses a tempdir (no git) so the only176// sections under test are ULOC, line-length, and cost.177func TestCollectReportDataHonoursReportSkip(t *testing.T) {178 ProcessConstants()179180 prevSkip := ReportSkipNames181 ReportSkipNames = map[string]bool{182 "uloc": true,183 "linelength": true,184 "cocomo": true,185 "locomo": true,186 }187 t.Cleanup(func() { ReportSkipNames = prevSkip })188189 dir := t.TempDir()190 if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {191 t.Fatalf("write file: %v", err)192 }193194 data, err := CollectReportData(dir)195 if err != nil {196 t.Fatalf("CollectReportData: %v", err)197 }198199 if data.ULOC != nil {200 t.Errorf("expected ULOC=nil under --report-skip uloc, got %+v", data.ULOC)201 }202 if data.LineLength != nil {203 t.Errorf("expected LineLength=nil under --report-skip linelength, got %+v", data.LineLength)204 }205 if data.Cocomo != nil {206 t.Errorf("expected Cocomo=nil under --report-skip cocomo, got %+v", data.Cocomo)207 }208 if data.Locomo != nil {209 t.Errorf("expected Locomo=nil under --report-skip locomo, got %+v", data.Locomo)210 }211}212213// TestRenderReportEmbedsShareCardMeta renders a full report and asserts the214// OpenGraph / Twitter Card meta tags from spec 04 are present with the215// embedded data: URL share card. Locks in the unfurl contract.216func TestRenderReportEmbedsShareCardMeta(t *testing.T) {217 ProcessConstants()218219 dir := makeFixtureRepo(t, []map[string]string{220 {"a.go": "package a\n\nfunc A() {}\n"},221 {"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n"},222 })223224 data, err := CollectReportData(dir)225 if err != nil {226 t.Fatalf("CollectReportData: %v", err)227 }228229 out := filepath.Join(t.TempDir(), "report.html")230 if err := RenderReport(data, out); err != nil {231 t.Fatalf("RenderReport: %v", err)232 }233234 body, err := os.ReadFile(out)235 if err != nil {236 t.Fatalf("read rendered report: %v", err)237 }238 html := string(body)239240 wants := []string{241 `<meta property="og:type" content="website">`,242 `<meta property="og:title" content="scc analysed `,243 `<meta property="og:description" content="`,244 `<meta property="og:image" content="data:image/svg`,245 `<meta name="twitter:card" content="summary_large_image">`,246 `<meta name="twitter:title" content="scc analysed `,247 `<meta name="twitter:description" content="`,248 `<meta name="twitter:image" content="data:image/svg`,249 }250 for _, w := range wants {251 if !strings.Contains(html, w) {252 t.Errorf("rendered report missing %q", w)253 }254 }255256 // The description should follow the spec-04 headline shape: files · SLOC.257 if !strings.Contains(html, " files · ") || !strings.Contains(html, " SLOC") {258 t.Errorf("rendered report description doesn't match headline shape; got HTML:\n%s", html)259 }260}261262// TestRenderReportSkipCardDropsImageTags asserts that --report-skip card263// suppresses both image meta tags but leaves the text ones intact.264func TestRenderReportSkipCardDropsImageTags(t *testing.T) {265 ProcessConstants()266267 prevSkip := ReportSkipNames268 ReportSkipNames = map[string]bool{"card": true}269 t.Cleanup(func() { ReportSkipNames = prevSkip })270271 dir := t.TempDir()272 if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {273 t.Fatalf("write file: %v", err)274 }275276 data, err := CollectReportData(dir)277 if err != nil {278 t.Fatalf("CollectReportData: %v", err)279 }280281 out := filepath.Join(t.TempDir(), "report.html")282 if err := RenderReport(data, out); err != nil {283 t.Fatalf("RenderReport: %v", err)284 }285286 body, err := os.ReadFile(out)287 if err != nil {288 t.Fatalf("read rendered report: %v", err)289 }290 html := string(body)291292 if strings.Contains(html, `property="og:image"`) {293 t.Errorf("expected no og:image when card is skipped")294 }295 if strings.Contains(html, `name="twitter:image"`) {296 t.Errorf("expected no twitter:image when card is skipped")297 }298 // Text tags must still be present.299 for _, w := range []string{`property="og:title"`, `name="twitter:description"`} {300 if !strings.Contains(html, w) {301 t.Errorf("expected text meta tag %q to remain when card is skipped", w)302 }303 }304}305306// TestCocomoPrettyCost locks in the headline cost format ("$1,234,567" style).307func TestCocomoPrettyCost(t *testing.T) {308 r := CocomoResult{CurrencySymbol: "$", EstimatedCost: 1234567.89}309 got := r.PrettyCost()310 if got != "$1,234,567" {311 t.Errorf("PrettyCost = %q, want %q", got, "$1,234,567")312 }313}314315// withReportSkipReset snapshots ReportSkipNames around a test body and316// restores it afterward. Spec 05 tests mutate the global heavily and we want317// the test order not to matter.318func withReportSkipReset(t *testing.T) {319 t.Helper()320 prev := ReportSkipNames321 t.Cleanup(func() { ReportSkipNames = prev })322}323324// TestParseReportSkipKnownNames exercises the happy path: every recognised325// name is parsed, lower-cased, and the warning channel stays silent.326func TestParseReportSkipKnownNames(t *testing.T) {327 withReportSkipReset(t)328329 var warn bytes.Buffer330 parseReportSkipTo("Cocomo, Locomo, hotspots, AUTHORS, timeline, files, uloc, linelength, card", &warn)331332 for _, name := range []string{"cocomo", "locomo", "hotspots", "authors", "timeline", "files", "uloc", "linelength", "card"} {333 if !ReportSkipped(name) {334 t.Errorf("ReportSkipped(%q) = false, want true after parseReportSkip", name)335 }336 }337 if got := warn.String(); got != "" {338 t.Errorf("expected no warning output for recognised names, got %q", got)339 }340}341342// TestParseReportSkipUnknownNameWarns covers spec 05's "unknown names emit a343// warning on stderr, continue" rule. The unknown name is still recorded so344// future template helpers can surface "ignored" hints.345func TestParseReportSkipUnknownNameWarns(t *testing.T) {346 withReportSkipReset(t)347348 var warn bytes.Buffer349 parseReportSkipTo("cocomo, bogus, also-bogus", &warn)350351 if !ReportSkipped("cocomo") {352 t.Errorf("recognised name should be marked skipped")353 }354 out := warn.String()355 if !strings.Contains(out, "unknown section \"bogus\"") {356 t.Errorf("expected warning to name the bogus section, got %q", out)357 }358 if !strings.Contains(out, "unknown section \"also-bogus\"") {359 t.Errorf("expected a warning per unknown section, got %q", out)360 }361}362363// TestParseReportSkipEmptyClearsMap asserts that parsing an empty string364// resets the map. Important because the var is package-level and a previous365// in-process run could otherwise leak state.366func TestParseReportSkipEmptyClearsMap(t *testing.T) {367 withReportSkipReset(t)368 ReportSkipNames = map[string]bool{"cocomo": true}369370 var warn bytes.Buffer371 parseReportSkipTo("", &warn)372373 if len(ReportSkipNames) != 0 {374 t.Errorf("expected ReportSkipNames cleared by empty input, got %v", ReportSkipNames)375 }376 if warn.Len() != 0 {377 t.Errorf("expected no warning for empty input, got %q", warn.String())378 }379}380381// TestReportSkippedCaseInsensitive locks in the spec 05 contract that382// callers can pass either case.383func TestReportSkippedCaseInsensitive(t *testing.T) {384 withReportSkipReset(t)385 ReportSkipNames = map[string]bool{"cocomo": true}386387 for _, name := range []string{"cocomo", "Cocomo", "COCOMO"} {388 if !ReportSkipped(name) {389 t.Errorf("ReportSkipped(%q) = false, want true", name)390 }391 }392 if ReportSkipped("hotspots") {393 t.Errorf("ReportSkipped(\"hotspots\") = true, want false")394 }395}396397// TestSkippedTemplateHelper renders a tiny template that uses the `skipped`398// helper to gate a block. Locks in the spec 05 contract that the helper is399// registered and reads ReportSkipNames.400func TestSkippedTemplateHelper(t *testing.T) {401 withReportSkipReset(t)402 ReportSkipNames = map[string]bool{"cocomo": true}403404 tmpl, err := template.New("t").Funcs(reportFuncs).Parse(405 `{{ if not (skipped "cocomo") }}cocomo-on{{ else }}cocomo-off{{ end }};{{ if not (skipped "hotspots") }}hotspots-on{{ end }}`,406 )407 if err != nil {408 t.Fatalf("parse: %v", err)409 }410 var out bytes.Buffer411 if err := tmpl.Execute(&out, nil); err != nil {412 t.Fatalf("execute: %v", err)413 }414 got := out.String()415 want := "cocomo-off;hotspots-on"416 if got != want {417 t.Errorf("template output = %q, want %q", got, want)418 }419}420421// TestDetectRepoNameHonoursReportTitle covers step 1 of the spec 05422// auto-detection chain — an explicit --report-title wins over everything.423func TestDetectRepoNameHonoursReportTitle(t *testing.T) {424 prev := ReportTitle425 ReportTitle = "My Custom Repo"426 t.Cleanup(func() { ReportTitle = prev })427428 if got := detectRepoName(t.TempDir()); got != "My Custom Repo" {429 t.Errorf("detectRepoName = %q, want %q", got, "My Custom Repo")430 }431}432433// TestDetectRepoNameFallsBackToBasename covers step 3 of the chain: with no434// override and no git remote, the basename of the analysed path is used.435func TestDetectRepoNameFallsBackToBasename(t *testing.T) {436 prev := ReportTitle437 ReportTitle = ""438 t.Cleanup(func() { ReportTitle = prev })439440 dir := filepath.Join(t.TempDir(), "myproj")441 if err := os.Mkdir(dir, 0o755); err != nil {442 t.Fatalf("mkdir: %v", err)443 }444445 if got := detectRepoName(dir); got != "myproj" {446 t.Errorf("detectRepoName = %q, want %q", got, "myproj")447 }448}449450// TestNoCocomoEquivalentToReportSkipCocomo verifies the spec 05 row that451// --no-cocomo and --report-skip cocomo produce the same outcome (Cocomo452// section nil). Uses a non-git tempdir to keep the run fast.453func TestNoCocomoEquivalentToReportSkipCocomo(t *testing.T) {454 ProcessConstants()455 withReportSkipReset(t)456457 dir := t.TempDir()458 if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {459 t.Fatalf("write file: %v", err)460 }461462 // Path 1: --no-cocomo (the Cocomo flag is "skip" when true).463 prevCocomo := Cocomo464 Cocomo = true465 t.Cleanup(func() { Cocomo = prevCocomo })466467 ReportSkipNames = map[string]bool{}468 dataA, err := CollectReportData(dir)469 if err != nil {470 t.Fatalf("CollectReportData (no-cocomo): %v", err)471 }472 if dataA.Cocomo != nil {473 t.Errorf("--no-cocomo: expected Cocomo=nil, got %+v", dataA.Cocomo)474 }475476 // Path 2: --report-skip cocomo, with the Cocomo skip flag off.477 Cocomo = false478 ReportSkipNames = map[string]bool{"cocomo": true}479 dataB, err := CollectReportData(dir)480 if err != nil {481 t.Fatalf("CollectReportData (skip cocomo): %v", err)482 }483 if dataB.Cocomo != nil {484 t.Errorf("--report-skip cocomo: expected Cocomo=nil, got %+v", dataB.Cocomo)485 }486}487488// TestReportImplicitlyEnablesULOCAndLineLength verifies that --report flips489// the ULOC and line-length analysis modes on, and --report-skip suppresses490// them — the spec 05 rows for `--uloc` / `-m` / `--character`.491func TestReportImplicitlyEnablesULOCAndLineLength(t *testing.T) {492 ProcessConstants()493 withReportSkipReset(t)494495 prevUloc, prevMaxMean, prevFiles := UlocMode, MaxMean, Files496 UlocMode, MaxMean, Files = false, false, false497 t.Cleanup(func() {498 UlocMode = prevUloc499 MaxMean = prevMaxMean500 Files = prevFiles501 })502503 dir := t.TempDir()504 if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {505 t.Fatalf("write file: %v", err)506 }507508 ReportSkipNames = map[string]bool{}509 data, err := CollectReportData(dir)510 if err != nil {511 t.Fatalf("CollectReportData: %v", err)512 }513 if data.ULOC == nil {514 t.Errorf("expected ULOC populated when --report runs without --report-skip uloc")515 }516 if data.LineLength == nil {517 t.Errorf("expected LineLength populated when --report runs without --report-skip linelength")518 }519 if len(data.Files) == 0 {520 t.Errorf("expected per-file table populated when --report runs without --report-skip files")521 }522}523524// TestReportSkipRecognisedListMatchesSpec locks in the exact set of names525// spec 05 enumerates as recognised so a future code change can't silently526// add or drop one without updating the spec.527func TestReportSkipRecognisedListMatchesSpec(t *testing.T) {528 want := []string{"cocomo", "locomo", "hotspots", "authors", "timeline", "files", "uloc", "linelength", "card"}529 if len(reportSkipRecognised) != len(want) {530 t.Errorf("reportSkipRecognised size = %d, want %d", len(reportSkipRecognised), len(want))531 }532 for _, name := range want {533 if !reportSkipRecognised[name] {534 t.Errorf("reportSkipRecognised missing %q", name)535 }536 }537}538539// TestCommaFmt locks in thousands-separator rendering for the headline540// numbers (`{{ comma .Totals.Code }}`). Covers boundaries the template hits:541// zero, < 1k, the 4- and 7-digit thresholds, and a negative.542func TestCommaFmt(t *testing.T) {543 cases := []struct {544 in int64545 want string546 }{547 {0, "0"},548 {1, "1"},549 {12, "12"},550 {123, "123"},551 {1234, "1,234"},552 {12345, "12,345"},553 {123456, "123,456"},554 {1234567, "1,234,567"},555 {1000000000, "1,000,000,000"},556 }557 for _, tc := range cases {558 if got := commaFmt(tc.in); got != tc.want {559 t.Errorf("commaFmt(%d) = %q, want %q", tc.in, got, tc.want)560 }561 }562563 // The `comma` template helper wraps commaFmt with a negative-sign guard.564 helper := reportFuncs["comma"].(func(int64) string)565 if got := helper(-1234); got != "-1,234" {566 t.Errorf("comma(-1234) = %q, want %q", got, "-1,234")567 }568 if got := helper(0); got != "0" {569 t.Errorf("comma(0) = %q, want %q", got, "0")570 }571}572573// TestPctHelper locks in the "{{ pct n d }}" format and the zero-denominator574// guard the template relies on (e.g. when a section is skipped).575func TestPctHelper(t *testing.T) {576 pct := reportFuncs["pct"].(func(int64, int64) string)577 cases := []struct {578 num, denom int64579 want string580 }{581 {0, 0, "0.0%"},582 {1, 0, "0.0%"},583 {0, 100, "0.0%"},584 {1, 4, "25.0%"},585 {1, 3, "33.3%"},586 {2, 3, "66.7%"},587 {100, 100, "100.0%"},588 }589 for _, tc := range cases {590 if got := pct(tc.num, tc.denom); got != tc.want {591 t.Errorf("pct(%d, %d) = %q, want %q", tc.num, tc.denom, got, tc.want)592 }593 }594}595596// TestHumanBytes locks in SI-style byte rendering used by the share card and597// per-file table.598func TestHumanBytes(t *testing.T) {599 cases := []struct {600 in int64601 want string602 }{603 {0, "0B"},604 {1, "1B"},605 {999, "999B"},606 {1000, "1.0K"},607 {1500, "1.5K"},608 {12000, "12.0K"},609 {100000, "100K"},610 {1500000, "1.5M"},611 {1500000000, "1.5G"},612 {1500000000000, "1.5T"},613 {1500000000000000, "1.5P"},614 }615 for _, tc := range cases {616 if got := humanBytes(tc.in); got != tc.want {617 t.Errorf("humanBytes(%d) = %q, want %q", tc.in, got, tc.want)618 }619 }620}621622// TestDonutArcsMath asserts the geometry of the donut: arc dasharrays sum to623// the canonical circumference, offsets are negative cumulative segments, and624// colours flow through langColor.625func TestDonutArcsMath(t *testing.T) {626 if got := donutArcs(nil); got != nil {627 t.Errorf("donutArcs(nil) = %+v, want nil", got)628 }629 if got := donutArcs([]LanguageSummary{{Name: "Go", Code: 0}}); got != nil {630 t.Errorf("donutArcs(zero-total) = %+v, want nil", got)631 }632633 arcs := donutArcs([]LanguageSummary{634 {Name: "Go", Code: 75},635 {Name: "JavaScript", Code: 25},636 })637 if len(arcs) != 2 {638 t.Fatalf("donutArcs returned %d arcs, want 2", len(arcs))639 }640 if arcs[0].Color != "#00ADD8" {641 t.Errorf("arc[0].Color = %q, want #00ADD8 (Go)", arcs[0].Color)642 }643 if arcs[1].Color != "#f1e05a" {644 t.Errorf("arc[1].Color = %q, want #f1e05a (JavaScript)", arcs[1].Color)645 }646 if arcs[0].Dashoffset != 0 {647 t.Errorf("arc[0].Dashoffset = %f, want 0", arcs[0].Dashoffset)648 }649 // Second arc's offset is -1*first-arc-segment = -75.650 if arcs[1].Dashoffset != -75 {651 t.Errorf("arc[1].Dashoffset = %f, want -75", arcs[1].Dashoffset)652 }653 if arcs[0].Dasharray != "75.000 25.000" {654 t.Errorf("arc[0].Dasharray = %q, want %q", arcs[0].Dasharray, "75.000 25.000")655 }656 if arcs[1].Dasharray != "25.000 75.000" {657 t.Errorf("arc[1].Dasharray = %q, want %q", arcs[1].Dasharray, "25.000 75.000")658 }659}660661// TestSparklinePath covers the SVG-path generator for the timeline tables:662// empty input is "", a single point is a degenerate "M", two-point series663// renders an "M…L…" pair, fill mode closes the path back to the baseline,664// and a flat series doesn't divide by zero.665func TestSparklinePath(t *testing.T) {666 if got := sparklinePath(nil, 100, 20); got != "" {667 t.Errorf("sparklinePath(nil) = %q, want \"\"", got)668 }669 if got := sparklinePath([]int{1, 2, 3}, 0, 20); got != "" {670 t.Errorf("sparklinePath with w=0 should be empty, got %q", got)671 }672673 // Single point: x=0, span=1 → y=h (baseline).674 if got := sparklinePath([]int{5}, 100, 20); got != "M0.0,20.0" {675 t.Errorf("sparklinePath([5]) = %q, want %q", got, "M0.0,20.0")676 }677678 // Two-point ascending series spans the full box.679 got := sparklinePath([]int{0, 10}, 100, 20)680 if got != "M0.0,20.0 L100.0,0.0" {681 t.Errorf("sparklinePath([0,10]) = %q, want %q", got, "M0.0,20.0 L100.0,0.0")682 }683684 // Flat series should not divide by zero — span is forced to 1, so every685 // point lands on the baseline.686 flat := sparklinePath([]int{4, 4, 4}, 100, 20)687 if !strings.Contains(flat, "M0.0,20.0") || !strings.Contains(flat, "L100.0,20.0") {688 t.Errorf("sparklinePath(flat) = %q, want degenerate baseline path", flat)689 }690691 // Fill mode closes the path back to the baseline and back to x=0.692 fill := sparklineFill([]int{0, 10}, 100, 20)693 if !strings.HasSuffix(fill, " L100.0,20.0 L0.0,20.0 Z") {694 t.Errorf("sparklineFill should close to baseline, got %q", fill)695 }696}697698// TestDataURLCard locks in the og:image data: URL prefix and the space699// escaping the unfurl scrapers require.700func TestDataURLCard(t *testing.T) {701 got := dataURLCard(template.HTML(" <svg>hello world</svg> "))702 want := "data:image/svg+xml;utf8,%3Csvg%3Ehello%20world%3C%2Fsvg%3E"703 if got != want {704 t.Errorf("dataURLCard = %q, want %q", got, want)705 }706707 // Empty input still yields the prefix so the template can embed it708 // unconditionally; the og:image gate is in the template, not here.709 if got := dataURLCard(""); got != "data:image/svg+xml;utf8," {710 t.Errorf("dataURLCard(\"\") = %q, want bare prefix", got)711 }712}713714// goldenReportFixture returns a hand-curated ReportData with every715// section populated and every timestamp / duration / floating-point value716// fixed. Used by TestRenderReport_Golden to catch unintended template717// changes. Keep this deterministic — never call time.Now(), never inject718// random IDs, never rely on map iteration order.719func goldenReportFixture() ReportData {720 generated := time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)721 lastCommit := time.Date(2026, 1, 14, 18, 0, 0, 0, time.UTC)722723 return ReportData{724 RepoName: "golden-fixture",725 SccVersion: "3.8.0-test",726 GeneratedAt: generated,727 Duration: 1500 * time.Millisecond,728 GitAvailable: true,729 Summary: []LanguageSummary{730 {Name: "Go", Count: 12, Code: 8000, Comment: 600, Blank: 1200, Complexity: 420, ULOC: 5400},731 {Name: "JavaScript", Count: 6, Code: 2400, Comment: 180, Blank: 360, Complexity: 90, ULOC: 1800},732 {Name: "Markdown", Count: 3, Code: 240, Comment: 0, Blank: 60, Complexity: 0, ULOC: 220},733 },734 Totals: Totals{735 Files: 21, Lines: 13040, Code: 10640, Comment: 780, Blank: 1620, Complexity: 510, Bytes: 524288,736 },737 ULOC: &ULOCResult{738 Global: 7420, TotalLines: 13040, Dryness: 0.569,739 PerLanguage: []ULOCLanguage{740 {Language: "Go", ULOC: 5400},741 {Language: "JavaScript", ULOC: 1800},742 {Language: "Markdown", ULOC: 220},743 },744 },745 LineLength: &LineLengthResult{746 Mean: 38.2, Max: 220, TotalLines: 10640,747 Buckets: []LineLengthBucket{748 {Start: 0, End: 20, Label: "0–20", Count: 4200},749 {Start: 20, End: 40, Label: "20–40", Count: 3800},750 {Start: 40, End: 60, Label: "40–60", Count: 1600},751 {Start: 60, End: 80, Label: "60–80", Count: 700},752 {Start: 80, End: 100, Label: "80–100", Count: 220},753 {Start: 100, End: 120, Label: "100–120", Count: 100},754 {Start: 120, End: 0, Label: "120+", Count: 20},755 },756 Outliers: []LineLengthOutlier{757 {File: "internal/wide.go", Language: "Go", LineLength: 220},758 {File: "web/styles.js", Language: "JavaScript", LineLength: 180},759 },760 },761 Hotspots: &HotspotsResult{762 Available: true,763 TotalRaw: 3,764 Records: []HotspotRow{765 {File: "internal/server.go", Language: "Go", Complexity: 240, Commits: 64, LinesChanged: 1200, Authors: 4, Score: 92.5},766 {File: "internal/cache.go", Language: "Go", Complexity: 120, Commits: 28, LinesChanged: 420, Authors: 2, Score: 41.2},767 {File: "web/app.js", Language: "JavaScript", Complexity: 60, Commits: 14, LinesChanged: 180, Authors: 2, Score: 17.8},768 },769 },770 Authors: &AuthorsResult{771 BusFactor: 1,772 BusAuthors: []string{"Alice"},773 BusCovered: 0.62,774 InWindowCode: 9800,775 Rows: []AuthorRow{776 {Name: "Alice", Email: "alice@example.com", Code: 6200, Files: 14, OwnsPercent: 62.4, InWindowPercent: 64.0, LastCommit: lastCommit},777 {Name: "Bob", Email: "bob@example.com", Code: 2400, Files: 8, OwnsPercent: 24.1, InWindowPercent: 22.0, LastCommit: lastCommit.Add(-48 * time.Hour)},778 {Name: "(before window)", Sentinel: true},779 },780 },781 LanguageTimeline: &LangTimelineResult{782 Buckets: 4,783 Rows: []LangTimelineRow{784 {Language: "Go", StartingLines: 4000, CodeNow: 8000, Change: 4000, SharePercent: 75.2, Trajectory: []int64{4000, 5200, 6800, 8000}},785 {Language: "JavaScript", StartingLines: 2200, CodeNow: 2400, Change: 200, SharePercent: 22.6, Trajectory: []int64{2200, 2300, 2350, 2400}},786 {Language: "Markdown", StartingLines: 200, CodeNow: 240, Change: 40, SharePercent: 2.2, Trajectory: []int64{200, 210, 220, 240}},787 },788 },789 AuthorTimeline: &AuthorTimelineResult{790 Buckets: 4,791 Rows: []AuthorTimelineRow{792 {Name: "Alice", Email: "alice@example.com", TotalCommits: 48, CodeDelta: 3800, Series: []AuthorTimelineBucket{793 {Commits: 8, CodeDelta: 500},794 {Commits: 14, CodeDelta: 1200},795 {Commits: 12, CodeDelta: 900},796 {Commits: 14, CodeDelta: 1200},797 }},798 {Name: "Bob", Email: "bob@example.com", TotalCommits: 22, CodeDelta: 400, Series: []AuthorTimelineBucket{799 {Commits: 6, CodeDelta: 200},800 {Commits: 6, CodeDelta: 100},801 {Commits: 5, CodeDelta: 50},802 {Commits: 5, CodeDelta: 50},803 }},804 },805 },806 Cocomo: &CocomoResult{807 ProjectType: "organic", SumCode: 10640,808 EstimatedEffort: 26.4, EstimatedCost: 297500, ScheduleMonths: 8.4, PeopleRequired: 3.1,809 AverageWage: 56286, Overhead: 2.4, EAF: 1.0, CurrencySymbol: "$",810 },811 Locomo: &LocomoResult{812 InputTokens: 320000, OutputTokens: 96000, Cost: 18.25,813 GenerationSeconds: 1800, ReviewHours: 12, AverageComplexityMult: 1.4,814 IterationFactor: 1.2, Preset: "medium",815 },816 Files: []*FileJob{817 {Location: "internal/server.go", Language: "Go", Lines: 3200, Code: 2600, Comment: 200, Blank: 400, Complexity: 240, Bytes: 96000, Uloc: 2200},818 {Location: "internal/cache.go", Language: "Go", Lines: 1400, Code: 1100, Comment: 120, Blank: 180, Complexity: 120, Bytes: 42000, Uloc: 900},819 {Location: "web/app.js", Language: "JavaScript", Lines: 2000, Code: 1600, Comment: 80, Blank: 320, Complexity: 60, Bytes: 60000, Uloc: 1200},820 {Location: "README.md", Language: "Markdown", Lines: 120, Code: 100, Comment: 0, Blank: 20, Complexity: 0, Bytes: 4096, Uloc: 90},821 },822 }823}824825// TestRenderReport_Golden snapshots the full HTML report output against826// testdata/golden-report.html. Run with UPDATE_GOLDEN=1 to refresh after827// an intentional template change. The fixture is hand-curated and828// deterministic — no time.Now, no random colour assignment, no map829// iteration in the input — so a stable template produces stable bytes.830func TestRenderReport_Golden(t *testing.T) {831 ProcessConstants()832 withReportSkipReset(t)833 ReportSkipNames = map[string]bool{}834835 d := goldenReportFixture()836837 var buf bytes.Buffer838 if err := renderReportTo(&buf, d); err != nil {839 t.Fatalf("renderReportTo: %v", err)840 }841 got := buf.Bytes()842843 goldenPath := filepath.Join("testdata", "golden-report.html")844 if os.Getenv("UPDATE_GOLDEN") == "1" {845 if err := os.MkdirAll(filepath.Dir(goldenPath), 0o755); err != nil {846 t.Fatalf("mkdir testdata: %v", err)847 }848 if err := os.WriteFile(goldenPath, got, 0o644); err != nil {849 t.Fatalf("write golden: %v", err)850 }851 return852 }853854 want, err := os.ReadFile(goldenPath)855 if err != nil {856 t.Fatalf("read golden (run UPDATE_GOLDEN=1 to create): %v", err)857 }858 if !bytes.Equal(got, want) {859 t.Fatalf("golden mismatch (run UPDATE_GOLDEN=1 to refresh)\n--- want (%d bytes)\n--- got (%d bytes)\nfirst diff at byte %d",860 len(want), len(got), firstByteDiff(want, got))861 }862}863864// firstByteDiff returns the index of the first byte at which a and b865// differ, or -1 if they're equal. Used by the golden test's failure866// message so a reviewer can jump straight to the divergence.867func firstByteDiff(a, b []byte) int {868 n := min(len(b), len(a))869 for i := range n {870 if a[i] != b[i] {871 return i872 }873 }874 if len(a) != len(b) {875 return n876 }877 return -1878}879880// TestConfirmReportOverwriteExplicitPathSilent verifies the "explicit881// path = consent" branch: when the user typed --report=foo.html, we882// should not stat, not prompt, not produce a warning. The function must883// succeed even with a nil reader and writer.884func TestConfirmReportOverwriteExplicitPathSilent(t *testing.T) {885 if err := confirmReportOverwrite("anywhere.html", false, true, nil, nil); err != nil {886 t.Errorf("explicit path: expected nil, got %v", err)887 }888 if err := confirmReportOverwrite("anywhere.html", false, false, nil, nil); err != nil {889 t.Errorf("explicit path non-TTY: expected nil, got %v", err)890 }891}892893// TestConfirmReportOverwriteMissingFileProceeds covers the bare-flag894// case where the file doesn't exist yet — no prompt, no error.895func TestConfirmReportOverwriteMissingFileProceeds(t *testing.T) {896 missing := filepath.Join(t.TempDir(), "scc-report.html")897 var out bytes.Buffer898 if err := confirmReportOverwrite(missing, true, true, strings.NewReader(""), &out); err != nil {899 t.Errorf("missing file: expected nil, got %v", err)900 }901 if out.Len() != 0 {902 t.Errorf("missing file: expected no prompt, got %q", out.String())903 }904}905906// TestConfirmReportOverwritePromptAccepts walks the happy interactive907// path: file exists, stdin attached, user types "y" — proceed.908func TestConfirmReportOverwritePromptAccepts(t *testing.T) {909 path := filepath.Join(t.TempDir(), "scc-report.html")910 if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {911 t.Fatalf("seed file: %v", err)912 }913 for _, ans := range []string{"y\n", "Y\n", "yes\n", "YES\n", " y \n"} {914 var out bytes.Buffer915 err := confirmReportOverwrite(path, true, true, strings.NewReader(ans), &out)916 if err != nil {917 t.Errorf("answer %q: expected nil, got %v", ans, err)918 }919 if !strings.Contains(out.String(), "Overwrite?") {920 t.Errorf("answer %q: expected prompt in stderr, got %q", ans, out.String())921 }922 }923}924925// TestConfirmReportOverwritePromptRejects covers the "default to no"926// rule the prompt advertises in its `[y/N]` suffix. Anything that927// isn't an affirmative aborts.928func TestConfirmReportOverwritePromptRejects(t *testing.T) {929 path := filepath.Join(t.TempDir(), "scc-report.html")930 if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {931 t.Fatalf("seed file: %v", err)932 }933 for _, ans := range []string{"n\n", "N\n", "no\n", "\n", "garbage\n"} {934 var out bytes.Buffer935 err := confirmReportOverwrite(path, true, true, strings.NewReader(ans), &out)936 if err == nil {937 t.Errorf("answer %q: expected abort, got nil", ans)938 continue939 }940 if !strings.Contains(err.Error(), "not overwritten") {941 t.Errorf("answer %q: expected 'not overwritten' in error, got %v", ans, err)942 }943 }944}945946// TestConfirmReportOverwriteNonTTYRefuses locks in the CI / piped-stdin947// guard: with the default name and an existing file we refuse to948// silently clobber, and the error tells the user how to opt in.949func TestConfirmReportOverwriteNonTTYRefuses(t *testing.T) {950 path := filepath.Join(t.TempDir(), "scc-report.html")951 if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {952 t.Fatalf("seed file: %v", err)953 }954 err := confirmReportOverwrite(path, true, false, strings.NewReader(""), io.Discard)955 if err == nil {956 t.Fatalf("non-TTY: expected error, got nil")957 }958 if !strings.Contains(err.Error(), "--report="+path) {959 t.Errorf("non-TTY error should point at explicit-path form, got %v", err)960 }961}962963// TestDefaultReportNameMatchesNoOptDefVal locks in the contract between964// main.go's NoOptDefVal wiring and runReport's "did the user supply a965// path" check. If these drift, the prompt logic silently breaks.966func TestDefaultReportNameMatchesNoOptDefVal(t *testing.T) {967 if DefaultReportName != "scc-report.html" {968 t.Errorf("DefaultReportName = %q, want %q (main.go wires this as NoOptDefVal)",969 DefaultReportName, "scc-report.html")970 }971}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.