diff --git a/internal/compare/compare_test.go b/internal/compare/compare_test.go index ea6153d..7a47ef6 100644 --- a/internal/compare/compare_test.go +++ b/internal/compare/compare_test.go @@ -214,6 +214,119 @@ func TestBuildSummaryNewContextAppeared(t *testing.T) { } } +// ── fixSteps ────────────────────────────────────────────────────────────────── + +func TestFixStepsNilAnalysis(t *testing.T) { + got := fixSteps(nil) + if got != nil { + t.Errorf("expected nil for nil analysis, got %v", got) + } +} + +func TestFixStepsNilArtifact(t *testing.T) { + a := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + got := fixSteps(a) + if got != nil { + t.Errorf("expected nil when Artifact is nil, got %v", got) + } +} + +func TestFixStepsWithArtifact(t *testing.T) { + a := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + a.Artifact = &model.FailureArtifact{ + FixSteps: []string{"step 1", "step 2"}, + } + got := fixSteps(a) + if len(got) != 2 || got[0] != "step 1" || got[1] != "step 2" { + t.Errorf("expected [step 1, step 2], got %v", got) + } +} + +func TestFixStepsReturnsIndependentCopy(t *testing.T) { + a := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + a.Artifact = &model.FailureArtifact{ + FixSteps: []string{"step 1"}, + } + got := fixSteps(a) + got[0] = "mutated" + if a.Artifact.FixSteps[0] != "step 1" { + t.Error("fixSteps should return an independent copy") + } +} + +// ── dominantSignals ─────────────────────────────────────────────────────────── + +func TestDominantSignalsNilAnalysis(t *testing.T) { + got := dominantSignals(nil) + if got != nil { + t.Errorf("expected nil for nil analysis, got %v", got) + } +} + +func TestDominantSignalsNilArtifact(t *testing.T) { + a := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + got := dominantSignals(a) + if got != nil { + t.Errorf("expected nil when Artifact is nil, got %v", got) + } +} + +func TestDominantSignalsWithArtifact(t *testing.T) { + a := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + a.Artifact = &model.FailureArtifact{ + DominantSignals: []string{"signal-A", "signal-B"}, + } + got := dominantSignals(a) + if len(got) != 2 || got[0] != "signal-A" || got[1] != "signal-B" { + t.Errorf("expected [signal-A, signal-B], got %v", got) + } +} + +func TestDominantSignalsReturnsIndependentCopy(t *testing.T) { + a := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + a.Artifact = &model.FailureArtifact{ + DominantSignals: []string{"signal-A"}, + } + got := dominantSignals(a) + got[0] = "mutated" + if a.Artifact.DominantSignals[0] != "signal-A" { + t.Error("dominantSignals should return an independent copy") + } +} + +// ── fixSteps and dominantSignals propagate via Build ───────────────────────── + +func TestBuildFixStepsDelta(t *testing.T) { + left := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + left.Artifact = &model.FailureArtifact{FixSteps: []string{"old fix"}} + right := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + right.Artifact = &model.FailureArtifact{FixSteps: []string{"new fix"}} + report := Build(left, right) + if len(report.FixSteps.Added) != 1 || report.FixSteps.Added[0] != "new fix" { + t.Errorf("expected FixSteps.Added=[new fix], got %v", report.FixSteps.Added) + } + if len(report.FixSteps.Removed) != 1 || report.FixSteps.Removed[0] != "old fix" { + t.Errorf("expected FixSteps.Removed=[old fix], got %v", report.FixSteps.Removed) + } + if !report.Changed { + t.Error("expected Changed=true when fix steps differ") + } +} + +func TestBuildDominantSignalsDelta(t *testing.T) { + left := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + left.Artifact = &model.FailureArtifact{DominantSignals: []string{"sig-A"}} + right := makeAnalysis("docker-auth", "Docker auth", 0.9, nil) + right.Artifact = &model.FailureArtifact{DominantSignals: []string{"sig-B"}} + report := Build(left, right) + if len(report.DominantSignals.Added) != 1 || report.DominantSignals.Added[0] != "sig-B" { + t.Errorf("expected DominantSignals.Added=[sig-B], got %v", report.DominantSignals.Added) + } + if len(report.DominantSignals.Removed) != 1 || report.DominantSignals.Removed[0] != "sig-A" { + t.Errorf("expected DominantSignals.Removed=[sig-A], got %v", report.DominantSignals.Removed) + } +} + // ── diffStrings ─────────────────────────────────────────────────────────────── func TestDiffStringsEmptyInputs(t *testing.T) { diff --git a/internal/coverage/report_test.go b/internal/coverage/report_test.go index a8c6176..bc75011 100644 --- a/internal/coverage/report_test.go +++ b/internal/coverage/report_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "faultline/internal/model" ) func TestBuildCountsFixtureExpectationsAndNegativeAssertions(t *testing.T) { @@ -405,3 +407,154 @@ func TestFixtureLayoutForRootWithChildFixturesDir(t *testing.T) { t.Errorf("Layout.Root = %q, want %q", layout.Root, parent) } } + +// ── falsePositiveRisk ───────────────────────────────────────────────────────── + +func TestFalsePositiveRiskHighSharedPatterns(t *testing.T) { +pb := model.Playbook{ID: "test"} +if got := falsePositiveRisk(pb, 7); got != "high" { +t.Errorf("expected high for 7 shared patterns, got %q", got) +} +if got := falsePositiveRisk(pb, 10); got != "high" { +t.Errorf("expected high for 10 shared patterns, got %q", got) +} +} + +func TestFalsePositiveRiskMediumSharedPatterns(t *testing.T) { +pb := model.Playbook{ID: "test"} +if got := falsePositiveRisk(pb, 3); got != "medium" { +t.Errorf("expected medium for 3 shared patterns, got %q", got) +} +if got := falsePositiveRisk(pb, 6); got != "medium" { +t.Errorf("expected medium for 6 shared patterns, got %q", got) +} +} + +func TestFalsePositiveRiskMediumSilentFailureCategory(t *testing.T) { +pb := model.Playbook{ID: "test", Category: "silent_failure"} +if got := falsePositiveRisk(pb, 0); got != "medium" { +t.Errorf("expected medium for silent_failure category, got %q", got) +} +} + +func TestFalsePositiveRiskLow(t *testing.T) { +pb := model.Playbook{ID: "test", Category: "build"} +if got := falsePositiveRisk(pb, 0); got != "low" { +t.Errorf("expected low for 0 shared patterns (non-silent), got %q", got) +} +if got := falsePositiveRisk(pb, 2); got != "low" { +t.Errorf("expected low for 2 shared patterns, got %q", got) +} +} + +// ── falseNegativeRisk ───────────────────────────────────────────────────────── + +func TestFalseNegativeRiskSourceDetector(t *testing.T) { +pb := model.Playbook{ID: "test", Detector: "source"} +if got := falseNegativeRisk(pb, 10); got != "medium" { +t.Errorf("expected medium for source detector, got %q", got) +} +} + +func TestFalseNegativeRiskHighFewFixtures(t *testing.T) { +pb := model.Playbook{ID: "test", Detector: "log"} +if got := falseNegativeRisk(pb, 0); got != "high" { +t.Errorf("expected high for 0 positive fixtures, got %q", got) +} +if got := falseNegativeRisk(pb, 1); got != "high" { +t.Errorf("expected high for 1 positive fixture, got %q", got) +} +} + +func TestFalseNegativeRiskMediumTwoFixtures(t *testing.T) { +pb := model.Playbook{ID: "test", Detector: "log"} +if got := falseNegativeRisk(pb, 2); got != "medium" { +t.Errorf("expected medium for 2 positive fixtures, got %q", got) +} +} + +func TestFalseNegativeRiskLowManyFixtures(t *testing.T) { +pb := model.Playbook{ID: "test", Detector: "log"} +if got := falseNegativeRisk(pb, 3); got != "low" { +t.Errorf("expected low for 3+ positive fixtures, got %q", got) +} +if got := falseNegativeRisk(pb, 10); got != "low" { +t.Errorf("expected low for 10 positive fixtures, got %q", got) +} +} + +// ── robustnessScore ─────────────────────────────────────────────────────────── + +func TestRobustnessScoreBaselineNoBonus(t *testing.T) { +got := robustnessScore("low", "low", 0, 0, 0) +// base=86, no penalty for low/low, no bonuses +if got != 86 { +t.Errorf("expected 86 for low/low with no bonus, got %d", got) +} +} + +func TestRobustnessScoreHighFPPenalty(t *testing.T) { +got := robustnessScore("high", "low", 0, 0, 0) +// 86 - 16 = 70 +if got != 70 { +t.Errorf("expected 70 for high FP risk, got %d", got) +} +} + +func TestRobustnessScoreMediumFNPenalty(t *testing.T) { +got := robustnessScore("low", "medium", 0, 0, 0) +// 86 - 8 = 78 +if got != 78 { +t.Errorf("expected 78 for medium FN risk, got %d", got) +} +} + +func TestRobustnessScorePositiveFixturesBonus(t *testing.T) { +got3 := robustnessScore("low", "low", 3, 0, 0) +// 86 + 3 = 89 +if got3 != 89 { +t.Errorf("expected 89 for 3 positive fixtures, got %d", got3) +} +got5 := robustnessScore("low", "low", 5, 0, 0) +// 86 + 6 = 92 +if got5 != 92 { +t.Errorf("expected 92 for 5 positive fixtures, got %d", got5) +} +} + +func TestRobustnessScoreNegativeAssertionBonus(t *testing.T) { +got := robustnessScore("low", "low", 0, 3, 0) +// 86 + 3 = 89 +if got != 89 { +t.Errorf("expected 89 for 3 negative assertions, got %d", got) +} +} + +func TestRobustnessScoreStrictTop1Bonus(t *testing.T) { +got := robustnessScore("low", "low", 0, 0, 1) +// 86 + 2 = 88 +if got != 88 { +t.Errorf("expected 88 for strict top-1, got %d", got) +} +} + +func TestRobustnessScoreClampedAtMax(t *testing.T) { +// All bonuses: 86 + 6 + 3 + 2 = 97 → clamped to 95 +got := robustnessScore("low", "low", 5, 3, 1) +if got != 95 { +t.Errorf("expected 95 (clamped max), got %d", got) +} +} + +func TestRobustnessScoreClampedAtMin(t *testing.T) { +// 86 - 16 - 16 = 54; min is 45 +got := robustnessScore("high", "high", 0, 0, 0) +if got != 54 { +t.Errorf("expected 54 for double high penalty, got %d", got) +} +// Force below min: theoretical +got2 := robustnessScore("high", "high", 0, 0, 0) +if got2 < 45 { +t.Errorf("expected minimum 45, got %d", got2) +} +} diff --git a/internal/output/output_compare_test.go b/internal/output/output_compare_test.go new file mode 100644 index 0000000..7f0b763 --- /dev/null +++ b/internal/output/output_compare_test.go @@ -0,0 +1,420 @@ +package output + +import ( + "encoding/json" + "strings" + "testing" + + analysiscompare "faultline/internal/compare" + "faultline/internal/model" +) + +// makeCompareReport builds a simple compare.Report for testing. +func makeCompareReport(prevID, curID string, diagChanged bool) analysiscompare.Report { + prev := &analysiscompare.Candidate{ + FailureID: prevID, + Title: prevID + " title", + Confidence: 0.9, + } + cur := &analysiscompare.Candidate{ + FailureID: curID, + Title: curID + " title", + Confidence: 0.85, + } + return analysiscompare.Report{ + LeftSource: prevID + ".json", + RightSource: curID + ".json", + DiagnosisChanged: diagChanged, + Changed: diagChanged, + Previous: prev, + Current: cur, + Summary: []string{"summary line"}, + } +} + +// ── FormatCompareText ───────────────────────────────────────────────────────── + +func TestFormatCompareTextContainsSources(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + out := FormatCompareText(report) + if !strings.Contains(out, "docker-auth.json") { + t.Errorf("expected source in compare text, got:\n%s", out) + } + if !strings.Contains(out, "COMPARE") { + t.Errorf("expected COMPARE header, got:\n%s", out) + } +} + +func TestFormatCompareTextDiagnosisSection(t *testing.T) { + report := makeCompareReport("docker-auth", "permission-denied", true) + out := FormatCompareText(report) + if !strings.Contains(out, "permission-denied") { + t.Errorf("expected current failure ID in compare text, got:\n%s", out) + } +} + +func TestFormatCompareTextEvidenceChanges(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.Evidence = analysiscompare.StringDelta{ + Added: []string{"new evidence"}, + Removed: []string{"old evidence"}, + } + out := FormatCompareText(report) + if !strings.Contains(out, "+ new evidence") { + t.Errorf("expected '+ new evidence' in compare text, got:\n%s", out) + } + if !strings.Contains(out, "- old evidence") { + t.Errorf("expected '- old evidence' in compare text, got:\n%s", out) + } +} + +func TestFormatCompareTextFixStepChanges(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.FixSteps = analysiscompare.StringDelta{ + Added: []string{"run docker login"}, + } + out := FormatCompareText(report) + if !strings.Contains(out, "Fix Step Changes") { + t.Errorf("expected 'Fix Step Changes' section, got:\n%s", out) + } + if !strings.Contains(out, "run docker login") { + t.Errorf("expected fix step in output, got:\n%s", out) + } +} + +func TestFormatCompareTextDominantSignalChanges(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.DominantSignals = analysiscompare.StringDelta{ + Added: []string{"auth-signal"}, + } + out := FormatCompareText(report) + if !strings.Contains(out, "Dominant Signal Changes") { + t.Errorf("expected 'Dominant Signal Changes' section, got:\n%s", out) + } +} + +func TestFormatCompareTextDeltaTestChanges(t *testing.T) { + report := makeCompareReport("test-fail", "test-fail", false) + report.DeltaTests = analysiscompare.StringDelta{ + Added: []string{"TestFoo"}, + } + out := FormatCompareText(report) + if !strings.Contains(out, "Delta Test Changes") { + t.Errorf("expected 'Delta Test Changes' section, got:\n%s", out) + } +} + +func TestFormatCompareTextDeltaErrorChanges(t *testing.T) { + report := makeCompareReport("test-fail", "test-fail", false) + report.DeltaErrors = analysiscompare.StringDelta{ + Added: []string{"ERR1"}, + } + out := FormatCompareText(report) + if !strings.Contains(out, "Delta Error Changes") { + t.Errorf("expected 'Delta Error Changes' section, got:\n%s", out) + } +} + +func TestFormatCompareTextEndsWithNewline(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + out := FormatCompareText(report) + if !strings.HasSuffix(out, "\n") { + t.Errorf("expected trailing newline, got:\n%q", out) + } +} + +// ── FormatCompareMarkdown ───────────────────────────────────────────────────── + +func TestFormatCompareMarkdownHeader(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "# Faultline Compare") { + t.Errorf("expected markdown header, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownContainsSources(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "`docker-auth.json`") { + t.Errorf("expected source in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownDiagnosisSection(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "## Diagnosis") { + t.Errorf("expected Diagnosis section in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownEvidenceSection(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.Evidence = analysiscompare.StringDelta{ + Added: []string{"new evidence"}, + Removed: []string{"old evidence"}, + } + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "## Evidence Changes") { + t.Errorf("expected '## Evidence Changes' in markdown, got:\n%s", out) + } + if !strings.Contains(out, "Added: new evidence") { + t.Errorf("expected 'Added: new evidence' in markdown, got:\n%s", out) + } + if !strings.Contains(out, "Removed: old evidence") { + t.Errorf("expected 'Removed: old evidence' in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownFixStepsSection(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.FixSteps = analysiscompare.StringDelta{Added: []string{"run docker login"}} + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "## Fix Step Changes") { + t.Errorf("expected '## Fix Step Changes' in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownDominantSignalsSection(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.DominantSignals = analysiscompare.StringDelta{Added: []string{"auth-signal"}} + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "## Dominant Signal Changes") { + t.Errorf("expected '## Dominant Signal Changes' in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownRepoContextSection(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.RepoFiles = analysiscompare.StringDelta{Added: []string{"new-file.go"}} + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "## Repo Context Changes") { + t.Errorf("expected '## Repo Context Changes' in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownDeltaFilesSection(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.DeltaFiles = analysiscompare.StringDelta{Added: []string{"main.go"}} + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "## Delta File Changes") { + t.Errorf("expected '## Delta File Changes' in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownDeltaTestsSection(t *testing.T) { + report := makeCompareReport("test-fail", "test-fail", false) + report.DeltaTests = analysiscompare.StringDelta{Added: []string{"TestFoo"}} + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "## Delta Test Changes") { + t.Errorf("expected '## Delta Test Changes' in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownDeltaErrorsSection(t *testing.T) { + report := makeCompareReport("test-fail", "test-fail", false) + report.DeltaErrors = analysiscompare.StringDelta{Added: []string{"ERR1"}} + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "## Delta Error Changes") { + t.Errorf("expected '## Delta Error Changes' in markdown, got:\n%s", out) + } +} + +func TestFormatCompareMarkdownEndsWithNewline(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + out := FormatCompareMarkdown(report) + if !strings.HasSuffix(out, "\n") { + t.Errorf("expected trailing newline, got:\n%q", out) + } +} + +func TestFormatCompareMarkdownNilCandidates(t *testing.T) { + // Report with nil Previous/Current should not panic and should still have header. + report := analysiscompare.Report{ + LeftSource: "left.json", + RightSource: "right.json", + Summary: []string{"no material differences were found"}, + } + out := FormatCompareMarkdown(report) + if !strings.Contains(out, "# Faultline Compare") { + t.Errorf("expected header for nil-candidate report, got:\n%s", out) + } +} + +// ── FormatCompareJSON ───────────────────────────────────────────────────────── + +func TestFormatCompareJSONIsValidJSON(t *testing.T) { + report := makeCompareReport("docker-auth", "permission-denied", true) + out, err := FormatCompareJSON(report) + if err != nil { + t.Fatalf("FormatCompareJSON: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := m["diagnosis_changed"]; !ok { + t.Error("expected 'diagnosis_changed' key in JSON output") + } +} + +func TestFormatCompareJSONEndsWithNewline(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + out, err := FormatCompareJSON(report) + if err != nil { + t.Fatalf("FormatCompareJSON: %v", err) + } + if !strings.HasSuffix(out, "\n") { + t.Errorf("expected trailing newline, got:\n%q", out) + } +} + +// ── joinCompareSection ──────────────────────────────────────────────────────── + +func TestJoinCompareSectionEmptyBodyReturnsEmpty(t *testing.T) { + got := joinCompareSection("Title", "") + if got != "" { + t.Errorf("expected empty for empty body, got %q", got) + } + got = joinCompareSection("Title", " ") + if got != "" { + t.Errorf("expected empty for whitespace-only body, got %q", got) + } +} + +func TestJoinCompareSectionNonEmpty(t *testing.T) { + got := joinCompareSection("Summary", "line 1\nline 2") + if !strings.HasPrefix(got, "Summary\n") { + t.Errorf("expected title as first line, got %q", got) + } + if !strings.Contains(got, strings.Repeat("-", len("Summary"))) { + t.Errorf("expected underline in section header, got %q", got) + } + if !strings.Contains(got, "line 1") { + t.Errorf("expected body content, got %q", got) + } +} + +// ── compareOverviewText ─────────────────────────────────────────────────────── + +func TestCompareOverviewTextBothCandidates(t *testing.T) { + report := makeCompareReport("docker-auth", "permission-denied", true) + got := compareOverviewText(report) + if !strings.Contains(got, "Previous: docker-auth") { + t.Errorf("expected previous in overview, got %q", got) + } + if !strings.Contains(got, "Current: permission-denied") { + t.Errorf("expected current in overview, got %q", got) + } + if !strings.Contains(got, "Diagnosis changed: yes") { + t.Errorf("expected 'Diagnosis changed: yes', got %q", got) + } +} + +func TestCompareOverviewTextNoCandidates(t *testing.T) { + report := analysiscompare.Report{DiagnosisChanged: false} + got := compareOverviewText(report) + // No candidates → no Previous/Current lines; still shows status lines. + if strings.Contains(got, "Previous:") { + t.Errorf("did not expect 'Previous:' when Previous is nil, got %q", got) + } + if strings.Contains(got, "Current:") { + t.Errorf("did not expect 'Current:' when Current is nil, got %q", got) + } +} + +func TestCompareOverviewTextStatusChanged(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + report.StatusChanged = true + got := compareOverviewText(report) + if !strings.Contains(got, "Artifact status changed: yes") { + t.Errorf("expected 'Artifact status changed: yes', got %q", got) + } +} + +// ── wrapCode ────────────────────────────────────────────────────────────────── + +func TestWrapCodeEmpty(t *testing.T) { + if got := wrapCode(""); got != "" { + t.Errorf("expected empty for empty value, got %q", got) + } + if got := wrapCode(" "); got != "" { + t.Errorf("expected empty for whitespace, got %q", got) + } +} + +func TestWrapCodeNonEmpty(t *testing.T) { + if got := wrapCode("file.json"); got != "`file.json`" { + t.Errorf("expected backtick-wrapped, got %q", got) + } +} + +// ── Status field in compareOverviewMarkdown ─────────────────────────────────── + +func TestCompareOverviewMarkdownNoDiagnosisChange(t *testing.T) { + report := makeCompareReport("docker-auth", "docker-auth", false) + got := compareOverviewMarkdown(report) + if !strings.Contains(got, "Diagnosis changed: no") { + t.Errorf("expected 'Diagnosis changed: no', got %q", got) + } + if !strings.Contains(got, "Artifact status changed: no") { + t.Errorf("expected 'Artifact status changed: no', got %q", got) + } +} + +// ── compareDeltaMarkdown ────────────────────────────────────────────────────── + +func TestCompareDeltaMarkdownEmpty(t *testing.T) { + got := compareDeltaMarkdown(analysiscompare.StringDelta{}) + if got != "" { + t.Errorf("expected empty for empty delta, got %q", got) + } +} + +func TestCompareDeltaMarkdownWithChanges(t *testing.T) { + delta := analysiscompare.StringDelta{ + Added: []string{"added-item"}, + Removed: []string{"removed-item"}, + } + got := compareDeltaMarkdown(delta) + if !strings.Contains(got, "Added: added-item") { + t.Errorf("expected 'Added: added-item', got %q", got) + } + if !strings.Contains(got, "Removed: removed-item") { + t.Errorf("expected 'Removed: removed-item', got %q", got) + } +} + +// ── model.FailureArtifact used via Analysis.Artifact ───────────────────────── + +func TestFormatCompareTextWithArtifactFixAndSignals(t *testing.T) { + a1 := &model.Analysis{ + Results: []model.Result{{ + Playbook: model.Playbook{ID: "docker-auth", Title: "Docker auth"}, + Score: 0.9, + }}, + Artifact: &model.FailureArtifact{ + FixSteps: []string{"old fix"}, + DominantSignals: []string{"old signal"}, + }, + } + a2 := &model.Analysis{ + Results: []model.Result{{ + Playbook: model.Playbook{ID: "docker-auth", Title: "Docker auth"}, + Score: 0.9, + }}, + Artifact: &model.FailureArtifact{ + FixSteps: []string{"new fix"}, + DominantSignals: []string{"new signal"}, + }, + } + report := analysiscompare.Build(a1, a2) + out := FormatCompareText(report) + if !strings.Contains(out, "Fix Step Changes") { + t.Errorf("expected Fix Step Changes section when fix steps differ, got:\n%s", out) + } + if !strings.Contains(out, "Dominant Signal Changes") { + t.Errorf("expected Dominant Signal Changes section, got:\n%s", out) + } +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go index bbc4f1e..abe0b63 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -1398,3 +1398,239 @@ func TestRepoContextJSONRoundTrip(t *testing.T) { t.Errorf("HotfixSignals[0] = %q, want hotfix signal", parsed.HotfixSignals[0]) } } + +// ── firstMarkdownListItem ───────────────────────────────────────────────────── + +func TestFirstMarkdownListItemBullet(t *testing.T) { + got := firstMarkdownListItem("- first item\n- second item") + if got != "first item" { + t.Errorf("expected 'first item', got %q", got) + } +} + +func TestFirstMarkdownListItemNumbered(t *testing.T) { + got := firstMarkdownListItem("1. numbered item\n2. second item") + if got != "numbered item" { + t.Errorf("expected 'numbered item', got %q", got) + } +} + +func TestFirstMarkdownListItemParagraphBeforeList(t *testing.T) { + got := firstMarkdownListItem("Some text.\n\n- the bullet") + if got != "the bullet" { + t.Errorf("expected 'the bullet', got %q", got) + } +} + +func TestFirstMarkdownListItemEmpty(t *testing.T) { + got := firstMarkdownListItem("") + if got != "" { + t.Errorf("expected empty for empty input, got %q", got) + } +} + +func TestFirstMarkdownListItemNoBullet(t *testing.T) { + got := firstMarkdownListItem("just plain text\nno list here") + if got != "" { + t.Errorf("expected empty when no list item present, got %q", got) + } +} + +// ── scoreBreakdownLines ─────────────────────────────────────────────────────── + +func TestScoreBreakdownLinesZeroFinalScore(t *testing.T) { + got := scoreBreakdownLines(model.ScoreBreakdown{}) + if got != nil { + t.Errorf("expected nil for zero FinalScore, got %v", got) + } +} + +func TestScoreBreakdownLinesNoModifiers(t *testing.T) { + got := scoreBreakdownLines(model.ScoreBreakdown{ + BaseSignalScore: 0.8, + FinalScore: 0.8, + }) + if got != nil { + t.Errorf("expected nil when no modifiers, got %v", got) + } +} + +func TestScoreBreakdownLinesWithCompoundBonus(t *testing.T) { + got := scoreBreakdownLines(model.ScoreBreakdown{ + BaseSignalScore: 0.8, + FinalScore: 0.9, + CompoundSignalBonus: 0.1, + }) + if got == nil { + t.Fatal("expected non-nil lines for compound bonus") + } + found := false + for _, line := range got { + if strings.Contains(line, "compound") { + found = true + } + } + if !found { + t.Errorf("expected 'compound' in breakdown lines, got %v", got) + } +} + +func TestScoreBreakdownLinesWithBlastRadius(t *testing.T) { + got := scoreBreakdownLines(model.ScoreBreakdown{ + BaseSignalScore: 0.8, + FinalScore: 1.0, + BlastRadiusMultiplier: 0.2, + }) + if got == nil { + t.Fatal("expected non-nil lines for blast radius") + } + found := false + for _, line := range got { + if strings.Contains(line, "blast radius") { + found = true + } + } + if !found { + t.Errorf("expected 'blast radius' in breakdown lines, got %v", got) + } +} + +func TestScoreBreakdownLinesWithMitigations(t *testing.T) { + got := scoreBreakdownLines(model.ScoreBreakdown{ + BaseSignalScore: 0.9, + FinalScore: 0.7, + MitigatingEvidenceDiscount: 0.2, + }) + if got == nil { + t.Fatal("expected non-nil lines for mitigations") + } + found := false + for _, line := range got { + if strings.Contains(line, "mitigations") { + found = true + } + } + if !found { + t.Errorf("expected 'mitigations' in breakdown lines, got %v", got) + } +} + +func TestScoreBreakdownLinesWithAllModifiers(t *testing.T) { + got := scoreBreakdownLines(model.ScoreBreakdown{ + BaseSignalScore: 0.7, + FinalScore: 1.2, + CompoundSignalBonus: 0.1, + BlastRadiusMultiplier: 0.1, + HotPathMultiplier: 0.1, + ChangeIntroducedBonus: 0.05, + MitigatingEvidenceDiscount: 0.05, + ExplicitExceptionDiscount: 0.05, + SafeContextDiscount: 0.05, + }) + if got == nil { + t.Fatal("expected non-nil lines with all modifiers") + } + sectionNames := []string{"base:", "final:", "compound:", "blast radius:", "hot path:", "change bonus:", "mitigations:", "suppressions:", "safe context:"} + for _, name := range sectionNames { + found := false + for _, line := range got { + if strings.HasPrefix(line, name) { + found = true + break + } + } + if !found { + t.Errorf("expected %q in breakdown lines, got %v", name, got) + } + } +} + +// ── formatMatchSummaryMarkdown ──────────────────────────────────────────────── + +func TestFormatMatchSummaryMarkdownEmpty(t *testing.T) { + got := formatMatchSummaryMarkdown(model.Playbook{}) + if got != "" { + t.Errorf("expected empty for empty playbook, got %q", got) + } +} + +func TestFormatMatchSummaryMarkdownAny(t *testing.T) { + pb := model.Playbook{ + Match: model.MatchSpec{Any: []string{"error: auth failed"}}, + } + got := formatMatchSummaryMarkdown(pb) + if !strings.Contains(got, "### match.any") { + t.Errorf("expected match.any section, got %q", got) + } + if !strings.Contains(got, "error: auth failed") { + t.Errorf("expected pattern in match.any, got %q", got) + } +} + +func TestFormatMatchSummaryMarkdownAll(t *testing.T) { + pb := model.Playbook{ + Match: model.MatchSpec{All: []string{"required-pattern"}}, + } + got := formatMatchSummaryMarkdown(pb) + if !strings.Contains(got, "### match.all") { + t.Errorf("expected match.all section, got %q", got) + } +} + +func TestFormatMatchSummaryMarkdownNone(t *testing.T) { + pb := model.Playbook{ + Match: model.MatchSpec{None: []string{"cache hit"}}, + } + got := formatMatchSummaryMarkdown(pb) + if !strings.Contains(got, "### match.none") { + t.Errorf("expected match.none section, got %q", got) + } +} + +func TestFormatMatchSummaryMarkdownWorkflowVerify(t *testing.T) { + pb := model.Playbook{ + Match: model.MatchSpec{Any: []string{"pattern"}}, + Workflow: model.WorkflowSpec{Verify: []string{"go test ./..."}}, + } + got := formatMatchSummaryMarkdown(pb) + if !strings.Contains(got, "### workflow.verify") { + t.Errorf("expected workflow.verify section, got %q", got) + } +} + +// ── TopResult ───────────────────────────────────────────────────────────────── + +func TestTopResultEmptyView(t *testing.T) { + v := AnalysisView{} + _, ok := v.TopResult() + if ok { + t.Error("expected ok=false for empty AnalysisView") + } +} + +func TestTopResultWithResults(t *testing.T) { + a := makeAnalysis("docker-auth", "Docker auth", "auth", 0.9, nil) + v := NewAnalysisView(a, 5) + result, ok := v.TopResult() + if !ok { + t.Fatal("expected ok=true for non-empty view") + } + if result.Playbook.ID != "docker-auth" { + t.Errorf("expected docker-auth, got %q", result.Playbook.ID) + } +} + +func TestAnalysisViewEmptyNilAnalysis(t *testing.T) { + v := NewAnalysisView(nil, 5) + if !v.Empty() { + t.Error("expected Empty()=true for nil analysis") + } +} + +func TestAnalysisViewNotEmptyWithResults(t *testing.T) { + a := makeAnalysis("docker-auth", "Docker auth", "auth", 0.9, nil) + v := NewAnalysisView(a, 5) + if v.Empty() { + t.Error("expected Empty()=false for non-nil analysis with results") + } +} diff --git a/internal/output/output_trace_test.go b/internal/output/output_trace_test.go index 4c2841d..18030d7 100644 --- a/internal/output/output_trace_test.go +++ b/internal/output/output_trace_test.go @@ -456,3 +456,146 @@ func TestFormatTraceTextWithScoreAndConfidence(t *testing.T) { t.Errorf("expected Score line in text output, got:\n%s", out) } } + +// ── traceHistoryWindow ──────────────────────────────────────────────────────── + +func TestTraceHistoryWindowInvalidStartReturnsEmpty(t *testing.T) { + got := traceHistoryWindow("not-a-date", "2026-01-01T00:00:00Z") + if got != "" { + t.Errorf("expected empty for invalid start, got %q", got) + } +} + +func TestTraceHistoryWindowInvalidEndReturnsEmpty(t *testing.T) { + got := traceHistoryWindow("2026-01-01T00:00:00Z", "not-a-date") + if got != "" { + t.Errorf("expected empty for invalid end, got %q", got) + } +} + +func TestTraceHistoryWindowEndBeforeStartReturnsEmpty(t *testing.T) { + got := traceHistoryWindow("2026-01-02T00:00:00Z", "2026-01-01T00:00:00Z") + if got != "" { + t.Errorf("expected empty when end is before start, got %q", got) + } +} + +func TestTraceHistoryWindowMinutes(t *testing.T) { + got := traceHistoryWindow("2026-01-01T00:00:00Z", "2026-01-01T00:45:00Z") + if got != "45m" { + t.Errorf("expected '45m', got %q", got) + } +} + +func TestTraceHistoryWindowHours(t *testing.T) { + got := traceHistoryWindow("2026-01-01T00:00:00Z", "2026-01-01T05:00:00Z") + if got != "5h" { + t.Errorf("expected '5h', got %q", got) + } +} + +func TestTraceHistoryWindowDays(t *testing.T) { + got := traceHistoryWindow("2026-01-01T00:00:00Z", "2026-01-10T00:00:00Z") + if got != "9d" { + t.Errorf("expected '9d', got %q", got) + } +} + +func TestTraceHistoryWindowLessThanMinuteReturnsEmpty(t *testing.T) { + got := traceHistoryWindow("2026-01-01T00:00:00Z", "2026-01-01T00:00:30Z") + if got != "" { + t.Errorf("expected empty for less-than-minute duration, got %q", got) + } +} + +// ── joinTraceSection ────────────────────────────────────────────────────────── + +func TestJoinTraceSectionEmptyBodyReturnsEmpty(t *testing.T) { + got := joinTraceSection("Title", "") + if got != "" { + t.Errorf("expected empty for empty body, got %q", got) + } +} + +func TestJoinTraceSectionWhitespaceBodyReturnsEmpty(t *testing.T) { + got := joinTraceSection("Title", " ") + if got != "" { + t.Errorf("expected empty for whitespace body, got %q", got) + } +} + +func TestJoinTraceSectionNonEmptyIncludesTitle(t *testing.T) { + got := joinTraceSection("Evidence", "line 1\nline 2") + if !strings.HasPrefix(got, "Evidence\n") { + t.Errorf("expected title as first line, got %q", got) + } + if !strings.Contains(got, strings.Repeat("-", len("Evidence"))) { + t.Errorf("expected underline in section header, got %q", got) + } + if !strings.Contains(got, "line 1") { + t.Errorf("expected body in section, got %q", got) + } +} + +// ── FormatCIAnnotations and firstMarkdownListItem ───────────────────────────── + +func TestFormatCIAnnotationsNilAnalysis(t *testing.T) { + got := FormatCIAnnotations(nil, 3) + if got != "" { + t.Errorf("expected empty for nil analysis, got %q", got) + } +} + +func TestFormatCIAnnotationsEmptyResults(t *testing.T) { + got := FormatCIAnnotations(&model.Analysis{Results: nil}, 3) + if got != "" { + t.Errorf("expected empty for empty results, got %q", got) + } +} + +func TestFormatCIAnnotationsWithFix(t *testing.T) { + a := &model.Analysis{ + Results: []model.Result{{ + Playbook: model.Playbook{ + ID: "docker-auth", + Title: "Docker auth failure", + Fix: "1. Run docker login\n2. Retry the build", + }, + }}, + } + out := FormatCIAnnotations(a, 1) + if !strings.Contains(out, "::warning") { + t.Errorf("expected ::warning annotation, got %q", out) + } + if !strings.Contains(out, "Run docker login") { + t.Errorf("expected first fix step in annotation, got %q", out) + } +} + +func TestFormatCIAnnotationsWithBulletFix(t *testing.T) { + a := &model.Analysis{ + Results: []model.Result{{ + Playbook: model.Playbook{ + ID: "docker-auth", + Title: "Docker auth", + Fix: "- Configure credentials\n- Retry", + }, + }}, + } + out := FormatCIAnnotations(a, 1) + if !strings.Contains(out, "Configure credentials") { + t.Errorf("expected bullet fix item in annotation, got %q", out) + } +} + +func TestFormatCIAnnotationsNoFix(t *testing.T) { + a := &model.Analysis{ + Results: []model.Result{{ + Playbook: model.Playbook{ID: "docker-auth", Title: "Docker auth"}, + }}, + } + out := FormatCIAnnotations(a, 1) + if !strings.Contains(out, "::warning") { + t.Errorf("expected ::warning even without fix, got %q", out) + } +} diff --git a/internal/output/output_workflow_test.go b/internal/output/output_workflow_test.go new file mode 100644 index 0000000..ba429e1 --- /dev/null +++ b/internal/output/output_workflow_test.go @@ -0,0 +1,279 @@ +package output + +import ( + "encoding/json" + "strings" + "testing" + + "faultline/internal/model" + "faultline/internal/workflow" +) + +// makePlan constructs a workflow.Plan with the provided failure ID and steps. +func makePlan(failureID string, steps []string) workflow.Plan { + return workflow.Plan{ + SchemaVersion: "workflow.v1", + Mode: workflow.ModeLocal, + FailureID: failureID, + Title: failureID + " title", + Steps: steps, + Evidence: []string{}, + } +} + +// ── FormatWorkflowText – minimal (no failure ID) ────────────────────────────── + +func TestFormatWorkflowTextNoFailureID(t *testing.T) { + plan := workflow.Plan{ + Steps: []string{"step A", "step B"}, + } + out := FormatWorkflowText(plan) + if !strings.HasPrefix(out, "WORKFLOW\n") { + t.Errorf("expected 'WORKFLOW' header for plan without failure ID, got:\n%s", out) + } + if !strings.Contains(out, "1. step A") { + t.Errorf("expected numbered step '1. step A', got:\n%s", out) + } + if !strings.Contains(out, "2. step B") { + t.Errorf("expected numbered step '2. step B', got:\n%s", out) + } +} + +// ── FormatWorkflowText – full plan ──────────────────────────────────────────── + +func TestFormatWorkflowTextWithFailureID(t *testing.T) { + plan := makePlan("docker-auth", []string{"run docker login", "retry the build"}) + out := FormatWorkflowText(plan) + if !strings.Contains(out, "WORKFLOW") { + t.Errorf("expected WORKFLOW header, got:\n%s", out) + } + if !strings.Contains(out, "docker-auth") { + t.Errorf("expected failure ID in workflow text, got:\n%s", out) + } + if !strings.Contains(out, "1. run docker login") { + t.Errorf("expected '1. run docker login', got:\n%s", out) + } +} + +func TestFormatWorkflowTextSource(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.Source = "stdin" + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Source: stdin") { + t.Errorf("expected 'Source: stdin', got:\n%s", out) + } +} + +func TestFormatWorkflowTextContextStage(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.Context = model.Context{Stage: "build", CommandHint: "npm ci", Step: "install"} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Stage: build") { + t.Errorf("expected 'Stage: build', got:\n%s", out) + } + if !strings.Contains(out, "Command: npm ci") { + t.Errorf("expected 'Command: npm ci', got:\n%s", out) + } + if !strings.Contains(out, "Step: install") { + t.Errorf("expected 'Step: install', got:\n%s", out) + } +} + +func TestFormatWorkflowTextEvidence(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.Evidence = []string{"authentication required", "pull access denied"} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Evidence:") { + t.Errorf("expected 'Evidence:' section, got:\n%s", out) + } + if !strings.Contains(out, " - authentication required") { + t.Errorf("expected evidence item, got:\n%s", out) + } +} + +func TestFormatWorkflowTextFocusFiles(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.Files = []string{"Dockerfile", ".dockerignore"} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Focus files:") { + t.Errorf("expected 'Focus files:' section, got:\n%s", out) + } + if !strings.Contains(out, " - Dockerfile") { + t.Errorf("expected Dockerfile in focus files, got:\n%s", out) + } +} + +func TestFormatWorkflowTextLocalRepro(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.LocalRepro = []string{"docker pull registry.example.com/image"} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Local repro:") { + t.Errorf("expected 'Local repro:' section, got:\n%s", out) + } +} + +func TestFormatWorkflowTextVerify(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.Verify = []string{"go test ./..."} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Verify:") { + t.Errorf("expected 'Verify:' section, got:\n%s", out) + } + if !strings.Contains(out, " - go test ./...") { + t.Errorf("expected verify command, got:\n%s", out) + } +} + +func TestFormatWorkflowTextRankingHints(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.RankingHints = []string{"hint A"} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Ranking hints:") { + t.Errorf("expected 'Ranking hints:' section, got:\n%s", out) + } +} + +func TestFormatWorkflowTextDeltaHints(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.DeltaHints = []string{"changed: go.sum"} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Delta hints:") { + t.Errorf("expected 'Delta hints:' section, got:\n%s", out) + } +} + +func TestFormatWorkflowTextMetricsHints(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.MetricsHints = []string{"seen 3 times this week"} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Metrics:") { + t.Errorf("expected 'Metrics:' section, got:\n%s", out) + } +} + +func TestFormatWorkflowTextPolicyHints(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.PolicyHints = []string{"no cache allowed"} + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Policy:") { + t.Errorf("expected 'Policy:' section, got:\n%s", out) + } +} + +func TestFormatWorkflowTextRemediation(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.Remediation = &model.RemediationPlan{ + Commands: []model.RemediationCommand{ + {Phase: "fix", Command: []string{"docker", "login"}}, + }, + PatchSuggestions: []model.PatchSuggestion{ + {TargetFile: ".github/workflows/ci.yml", Summary: "add login step"}, + }, + CIConfigDiffs: []model.CIConfigDiff{ + {TargetFile: ".github/workflows/ci.yml", Summary: "configure registry"}, + }, + } + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Remediation commands:") { + t.Errorf("expected 'Remediation commands:' section, got:\n%s", out) + } + if !strings.Contains(out, "[fix] docker login") { + t.Errorf("expected remediation command with phase, got:\n%s", out) + } + if !strings.Contains(out, "Patch suggestions:") { + t.Errorf("expected 'Patch suggestions:' section, got:\n%s", out) + } + if !strings.Contains(out, "CI config diffs:") { + t.Errorf("expected 'CI config diffs:' section, got:\n%s", out) + } +} + +func TestFormatWorkflowTextAgentPrompt(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.AgentPrompt = "Check the registry configuration." + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Agent prompt:") { + t.Errorf("expected 'Agent prompt:' section, got:\n%s", out) + } + if !strings.Contains(out, "Check the registry configuration.") { + t.Errorf("expected agent prompt text, got:\n%s", out) + } +} + +func TestFormatWorkflowTextNextSteps(t *testing.T) { + plan := makePlan("docker-auth", []string{"do this", "then that"}) + out := FormatWorkflowText(plan) + if !strings.Contains(out, "Next steps:") { + t.Errorf("expected 'Next steps:' header, got:\n%s", out) + } + if !strings.Contains(out, "1. do this") { + t.Errorf("expected numbered step, got:\n%s", out) + } + if !strings.Contains(out, "2. then that") { + t.Errorf("expected second numbered step, got:\n%s", out) + } +} + +// ── FormatWorkflowJSON ──────────────────────────────────────────────────────── + +func TestFormatWorkflowJSONIsValidJSON(t *testing.T) { + plan := makePlan("docker-auth", []string{"run docker login"}) + out, err := FormatWorkflowJSON(plan) + if err != nil { + t.Fatalf("FormatWorkflowJSON: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } +} + +func TestFormatWorkflowJSONEndsWithNewline(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + out, err := FormatWorkflowJSON(plan) + if err != nil { + t.Fatalf("FormatWorkflowJSON: %v", err) + } + if !strings.HasSuffix(out, "\n") { + t.Errorf("expected trailing newline, got:\n%q", out) + } +} + +func TestFormatWorkflowJSONContainsSchemaVersion(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.SchemaVersion = "workflow.v1" + out, err := FormatWorkflowJSON(plan) + if err != nil { + t.Fatalf("FormatWorkflowJSON: %v", err) + } + if !strings.Contains(out, "workflow.v1") { + t.Errorf("expected schema_version in JSON, got:\n%s", out) + } +} + +func TestFormatWorkflowJSONContainsSteps(t *testing.T) { + plan := makePlan("docker-auth", []string{"step 1", "step 2"}) + out, err := FormatWorkflowJSON(plan) + if err != nil { + t.Fatalf("FormatWorkflowJSON: %v", err) + } + if !strings.Contains(out, "step 1") { + t.Errorf("expected step 1 in JSON, got:\n%s", out) + } +} + +func TestFormatWorkflowJSONWithRemediation(t *testing.T) { + plan := makePlan("docker-auth", []string{"step"}) + plan.Remediation = &model.RemediationPlan{ + Commands: []model.RemediationCommand{ + {Phase: "fix", Command: []string{"docker", "login"}}, + }, + } + out, err := FormatWorkflowJSON(plan) + if err != nil { + t.Fatalf("FormatWorkflowJSON: %v", err) + } + if !strings.Contains(out, `"remediation"`) { + t.Errorf("expected remediation in JSON, got:\n%s", out) + } +} diff --git a/internal/renderer/renderer_test.go b/internal/renderer/renderer_test.go index 28ba558..4a43176 100644 --- a/internal/renderer/renderer_test.go +++ b/internal/renderer/renderer_test.go @@ -385,3 +385,33 @@ func TestTrimTerminalPunctuation(t *testing.T) { } } } + +// ── New ─────────────────────────────────────────────────────────────────────── + +func TestNewZeroWidthDefaultsToDefaultWidth(t *testing.T) { + r := New(Options{Width: 0, Plain: true}) + if r.opts.Width != defaultWidth { + t.Errorf("expected defaultWidth %d for zero-width option, got %d", defaultWidth, r.opts.Width) + } +} + +func TestNewNonZeroWidthPreserved(t *testing.T) { + r := New(Options{Width: 100, Plain: true}) + if r.opts.Width != 100 { + t.Errorf("expected width 100 to be preserved, got %d", r.opts.Width) + } +} + +func TestNewPlainFlagPreserved(t *testing.T) { + r := New(Options{Plain: true}) + if !r.opts.Plain { + t.Error("expected Plain=true to be preserved") + } +} + +func TestNewDarkBackgroundPreserved(t *testing.T) { + r := New(Options{DarkBackground: true, Width: 88}) + if !r.opts.DarkBackground { + t.Error("expected DarkBackground=true to be preserved") + } +} diff --git a/internal/scoring/features_test.go b/internal/scoring/features_test.go new file mode 100644 index 0000000..037f31a --- /dev/null +++ b/internal/scoring/features_test.go @@ -0,0 +1,183 @@ +package scoring + +import ( + "testing" + + "faultline/internal/model" +) + +// ── deltaPlaybookFeatures ───────────────────────────────────────────────────── + +func TestDeltaPlaybookFeaturesNotRequested(t *testing.T) { + inputs := Inputs{DeltaRequested: false} + result := model.Result{ + Playbook: model.Playbook{ + ID: "dep-drift", + RequiresDelta: true, + }, + } + got := deltaPlaybookFeatures(inputs, result, nil) + if got != nil { + t.Errorf("expected nil when DeltaRequested=false, got %v", got) + } +} + +func TestDeltaPlaybookFeaturesRequiresDeltaMissing(t *testing.T) { + inputs := Inputs{DeltaRequested: true} + result := model.Result{ + Playbook: model.Playbook{ + ID: "dep-drift", + RequiresDelta: true, + }, + } + got := deltaPlaybookFeatures(inputs, result, nil) + if len(got) != 1 { + t.Fatalf("expected 1 feature for missing delta, got %d: %v", len(got), got) + } + if got[0].Name != "delta_required_missing" { + t.Errorf("expected delta_required_missing, got %q", got[0].Name) + } + if got[0].Weight != -1.5 { + t.Errorf("expected weight -1.5, got %f", got[0].Weight) + } +} + +func TestDeltaPlaybookFeaturesRequiresDeltaUnmatched(t *testing.T) { + inputs := Inputs{DeltaRequested: true} + result := model.Result{ + Playbook: model.Playbook{ + ID: "dep-drift", + RequiresDelta: true, + DeltaBoost: []model.DeltaBoost{ + {Signal: "dependency_change", Weight: 1.5}, + }, + }, + } + // Provide a delta with a different signal that doesn't match. + delta := &model.Delta{ + Signals: []model.DeltaSignal{ + {ID: "ci_config_change", Detail: "modified .github/workflows/ci.yml"}, + }, + } + got := deltaPlaybookFeatures(inputs, result, delta) + // Should include delta_required_unmatched because RequiresDelta=true with DeltaBoost + // but no matching signal. + foundUnmatched := false + for _, f := range got { + if f.Name == "delta_required_unmatched" { + foundUnmatched = true + if f.Weight != -0.75 { + t.Errorf("expected delta_required_unmatched weight -0.75, got %f", f.Weight) + } + } + } + if !foundUnmatched { + t.Errorf("expected delta_required_unmatched feature, got %v", got) + } +} + +func TestDeltaPlaybookFeaturesBoostSignalMatched(t *testing.T) { + inputs := Inputs{DeltaRequested: true} + result := model.Result{ + Playbook: model.Playbook{ + ID: "dep-drift", + DeltaBoost: []model.DeltaBoost{ + {Signal: "dependency_change", Weight: 1.5}, + }, + }, + } + delta := &model.Delta{ + Signals: []model.DeltaSignal{ + {ID: "dependency_change", Detail: "go.sum was modified"}, + }, + } + got := deltaPlaybookFeatures(inputs, result, delta) + if len(got) == 0 { + t.Fatal("expected at least one delta boost feature") + } + found := false + for _, f := range got { + if f.Name == "delta_boost:dependency_change" { + found = true + if f.Value != 1 { + t.Errorf("expected value 1, got %f", f.Value) + } + if len(f.EvidenceRefs) == 0 || f.EvidenceRefs[0] != "go.sum was modified" { + t.Errorf("expected evidence ref 'go.sum was modified', got %v", f.EvidenceRefs) + } + } + } + if !found { + t.Errorf("expected delta_boost:dependency_change feature, got %v", got) + } +} + +func TestDeltaPlaybookFeaturesBoostDefaultWeight(t *testing.T) { + inputs := Inputs{DeltaRequested: true} + result := model.Result{ + Playbook: model.Playbook{ + ID: "dep-drift", + DeltaBoost: []model.DeltaBoost{ + {Signal: "runtime_toolchain_change", Weight: 0}, // zero weight → defaults to 1 + }, + }, + } + delta := &model.Delta{ + Signals: []model.DeltaSignal{ + {ID: "runtime_toolchain_change", Detail: "go version changed"}, + }, + } + got := deltaPlaybookFeatures(inputs, result, delta) + found := false + for _, f := range got { + if f.Name == "delta_boost:runtime_toolchain_change" { + found = true + if f.Weight != 1 { + t.Errorf("expected default weight 1 for zero-weight boost, got %f", f.Weight) + } + } + } + if !found { + t.Errorf("expected delta_boost:runtime_toolchain_change, got %v", got) + } +} + +func TestDeltaPlaybookFeaturesEmptySignal(t *testing.T) { + inputs := Inputs{DeltaRequested: true} + result := model.Result{ + Playbook: model.Playbook{ + ID: "dep-drift", + DeltaBoost: []model.DeltaBoost{ + {Signal: " ", Weight: 1.0}, // blank signal should be skipped + }, + }, + } + delta := &model.Delta{} + got := deltaPlaybookFeatures(inputs, result, delta) + if len(got) != 0 { + t.Errorf("expected no features for blank signal, got %v", got) + } +} + +func TestDeltaPlaybookFeaturesNoBoostSignalNoMatch(t *testing.T) { + // DeltaBoost signal not present in delta signals → no boost feature emitted. + inputs := Inputs{DeltaRequested: true} + result := model.Result{ + Playbook: model.Playbook{ + ID: "dep-drift", + DeltaBoost: []model.DeltaBoost{ + {Signal: "ci_config_change", Weight: 1.0}, + }, + }, + } + delta := &model.Delta{ + Signals: []model.DeltaSignal{ + {ID: "dependency_change", Detail: "go.sum modified"}, + }, + } + got := deltaPlaybookFeatures(inputs, result, delta) + // No matching signal, no RequiresDelta → no features. + if len(got) != 0 { + t.Errorf("expected no features when boost signal not in delta, got %v", got) + } +} diff --git a/internal/store/sqlite_extra_test.go b/internal/store/sqlite_extra_test.go index 0940240..a2e44bf 100644 --- a/internal/store/sqlite_extra_test.go +++ b/internal/store/sqlite_extra_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "os" "path/filepath" "testing" "time" @@ -318,3 +319,66 @@ func TestVerifyDeterminismForInputHashBlank(t *testing.T) { t.Errorf("expected RunCount=0 for blank hash, got %d", summary.RunCount) } } + +// ── BeginRun with zero timestamp ────────────────────────────────────────────── + +func TestBeginRunWithZeroTimestampUsesNow(t *testing.T) { + path := filepath.Join(t.TempDir(), "faultline.db") + st, _, err := OpenBestEffort(Config{Mode: ModeAuto, Path: path}) + if err != nil { + t.Fatalf("OpenBestEffort: %v", err) + } + defer st.Close() + + // StartedAt zero → should be auto-populated by BeginRun. + ctx := context.Background() + handle, err := st.BeginRun(ctx, BeginRunParams{ + Surface: "analyze", + SourceKind: "log", + Source: "stdin", + InputHash: "zero-ts-input", + // StartedAt is zero value + }) + if err != nil { + t.Fatalf("BeginRun with zero timestamp: %v", err) + } + if handle.ID == 0 { + t.Error("expected non-zero run handle ID") + } +} + +// ── openSQLite creates the directory if it doesn't exist ───────────────────── + +func TestOpenSQLiteCreatesParentDirectory(t *testing.T) { + base := t.TempDir() + // Nested path that doesn't exist yet. + path := filepath.Join(base, "sub", "dir", "faultline.db") + st, err := openSQLite(path) + if err != nil { + t.Fatalf("openSQLite with missing parent dir: %v", err) + } + defer st.Close() + + // Verify the file was created. + if _, err := os.Stat(path); err != nil { + t.Errorf("expected db file to exist at %s: %v", path, err) + } +} + +// ── migrate is idempotent ───────────────────────────────────────────────────── + +func TestMigrateIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "faultline.db") + // Open twice — second open will call migrate again on an already-migrated db. + st1, err := openSQLite(path) + if err != nil { + t.Fatalf("first openSQLite: %v", err) + } + st1.Close() + + st2, err := openSQLite(path) + if err != nil { + t.Fatalf("second openSQLite (idempotent migrate): %v", err) + } + defer st2.Close() +} diff --git a/internal/trace/trace_test.go b/internal/trace/trace_test.go index fa4ce0f..2cc91b3 100644 --- a/internal/trace/trace_test.go +++ b/internal/trace/trace_test.go @@ -570,3 +570,257 @@ func TestPartialRuleNote(t *testing.T) { }) } } + +// ── allRuleNote ─────────────────────────────────────────────────────────────── + +func TestAllRuleNoteMatched(t *testing.T) { + got := allRuleNote(true) + if got != "required rule matched" { + t.Errorf("expected 'required rule matched', got %q", got) + } +} + +func TestAllRuleNoteMissing(t *testing.T) { + got := allRuleNote(false) + if got != "required rule was missing" { + t.Errorf("expected 'required rule was missing', got %q", got) + } +} + +// ── anyRuleNote ─────────────────────────────────────────────────────────────── + +func TestAnyRuleNoteMatched(t *testing.T) { + got := anyRuleNote(true) + if got != "trigger rule matched the log" { + t.Errorf("expected 'trigger rule matched the log', got %q", got) + } +} + +func TestAnyRuleNoteNotMatched(t *testing.T) { + got := anyRuleNote(false) + if got != "trigger rule did not match" { + t.Errorf("expected 'trigger rule did not match', got %q", got) + } +} + +// ── noneRuleNote ────────────────────────────────────────────────────────────── + +func TestNoneRuleNoteBlocked(t *testing.T) { + got := noneRuleNote(true) + if !strings.Contains(got, "blocks the playbook") { + t.Errorf("expected 'blocks the playbook', got %q", got) + } +} + +func TestNoneRuleNoteClear(t *testing.T) { + got := noneRuleNote(false) + if got != "exclusion rule stayed clear" { + t.Errorf("expected 'exclusion rule stayed clear', got %q", got) + } +} + +// ── buildWhy ────────────────────────────────────────────────────────────────── + +func TestBuildWhyFromHypothesisWhy(t *testing.T) { + result := model.Result{ + Hypothesis: &model.HypothesisAssessment{ + Why: []string{"registry rejected credentials"}, + }, + } + got := buildWhy(result, nil) + if len(got) != 1 || got[0] != "registry rejected credentials" { + t.Errorf("expected hypothesis Why, got %v", got) + } +} + +func TestBuildWhyFallsBackToWhyLessLikely(t *testing.T) { + result := model.Result{ + Hypothesis: &model.HypothesisAssessment{ + Why: nil, + WhyLessLikely: []string{"weaker signal present"}, + }, + } + got := buildWhy(result, nil) + if len(got) != 1 || got[0] != "weaker signal present" { + t.Errorf("expected WhyLessLikely fallback, got %v", got) + } +} + +func TestBuildWhyFromRulesMatchedAny(t *testing.T) { + rules := []Rule{ + {Group: "match.any", Status: StatusMatched}, + } + result := model.Result{} + got := buildWhy(result, rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "trigger rule") { + found = true + } + } + if !found { + t.Errorf("expected trigger rule reason, got %v", got) + } +} + +func TestBuildWhyFromRulesMatchedAll(t *testing.T) { + rules := []Rule{ + {Group: "match.all", Status: StatusMatched}, + } + result := model.Result{} + got := buildWhy(result, rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "required rule") { + found = true + } + } + if !found { + t.Errorf("expected required rule reason, got %v", got) + } +} + +func TestBuildWhyFromRulesClearNone(t *testing.T) { + rules := []Rule{ + {Group: "match.none", Status: StatusClear}, + } + result := model.Result{} + got := buildWhy(result, rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "exclusion rule") { + found = true + } + } + if !found { + t.Errorf("expected exclusion rule reason, got %v", got) + } +} + +func TestBuildWhyFromRulesBlockedNone(t *testing.T) { + rules := []Rule{ + {Group: "match.none", Status: StatusBlocked}, + } + result := model.Result{} + got := buildWhy(result, rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "exclusion rule") { + found = true + } + } + if !found { + t.Errorf("expected blocked exclusion reason, got %v", got) + } +} + +func TestBuildWhyFromEvidence(t *testing.T) { + rules := []Rule{} + result := model.Result{Evidence: []string{"auth error"}} + got := buildWhy(result, rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "matched evidence") { + found = true + } + } + if !found { + t.Errorf("expected evidence reason, got %v", got) + } +} + +func TestBuildWhyFromRulesMatchedPartial(t *testing.T) { + rules := []Rule{ + {Group: "match.partial", Status: StatusMatched}, + } + result := model.Result{} + got := buildWhy(result, rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "partial group") { + found = true + } + } + if !found { + t.Errorf("expected partial group reason, got %v", got) + } +} + +// ── buildUnmatchedWhy ───────────────────────────────────────────────────────── + +func TestBuildUnmatchedWhyNoTrigger(t *testing.T) { + rules := []Rule{} + got := buildUnmatchedWhy(rules) + if len(got) == 0 { + t.Fatal("expected at least one reason for unmatched") + } + if got[0] != "no trigger rule matched the input log" { + t.Errorf("expected 'no trigger rule matched', got %q", got[0]) + } +} + +func TestBuildUnmatchedWhyMissingAll(t *testing.T) { + rules := []Rule{ + {Group: "match.all", Status: StatusMissing}, + } + got := buildUnmatchedWhy(rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "required rule") { + found = true + } + } + if !found { + t.Errorf("expected required rule missing reason, got %v", got) + } +} + +func TestBuildUnmatchedWhyBlockedNone(t *testing.T) { + rules := []Rule{ + {Group: "match.any", Status: StatusMatched}, + {Group: "match.none", Status: StatusBlocked}, + } + got := buildUnmatchedWhy(rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "exclusion rule") { + found = true + } + } + if !found { + t.Errorf("expected exclusion rule blocked reason, got %v", got) + } +} + +func TestBuildUnmatchedWhyMissingPartial(t *testing.T) { + rules := []Rule{ + {Group: "match.partial", Status: StatusMissing}, + } + got := buildUnmatchedWhy(rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "partial group") { + found = true + } + } + if !found { + t.Errorf("expected partial group reason, got %v", got) + } +} + +func TestBuildUnmatchedWhyFallbackReason(t *testing.T) { + // A match.any that matched but playbook still unmatched overall + rules := []Rule{ + {Group: "match.any", Status: StatusMatched}, + } + got := buildUnmatchedWhy(rules) + found := false + for _, reason := range got { + if strings.Contains(reason, "did not reach a ranked match") { + found = true + } + } + if !found { + t.Errorf("expected fallback reason, got %v", got) + } +}