ajhahn.de
← eeco
Go 162 lines
package workflow

import (
	"strings"
	"testing"
)

func TestComputeSignals_EmptyInput(t *testing.T) {
	if got := ComputeSignals(nil); len(got) != 0 {
		t.Errorf("nil input: got %v, want empty", got)
	}
	if got := ComputeSignals([]string{}); len(got) != 0 {
		t.Errorf("empty slice: got %v, want empty", got)
	}
	if got := ComputeSignals([]string{"", "   "}); len(got) != 0 {
		t.Errorf("blank lines: got %v, want empty", got)
	}
}

func TestComputeSignals_BelowThresholdIsDropped(t *testing.T) {
	lines := []string{
		"abc1234 fix: one",
		"abc1235 fix: two",
		// only two "fix:" — below threshold of 3
		"abc1236 docs: unrelated",
	}
	got := ComputeSignals(lines)
	if len(got) != 0 {
		t.Errorf("below-threshold counts must drop, got %v", got)
	}
}

func TestComputeSignals_AtAndAboveThreshold(t *testing.T) {
	lines := []string{
		"abc1234 fix: one",
		"abc1235 fix: two",
		"abc1236 fix: three", // hits threshold
	}
	got := ComputeSignals(lines)
	if len(got) != 1 || got[0].Key != "fix" || got[0].Count != 3 || got[0].Kind != SignalCommitType {
		t.Errorf("threshold hit: got %v, want one fix=3", got)
	}
}

func TestComputeSignals_OrderingCountDescThenKeyAsc(t *testing.T) {
	lines := []string{
		// 4 fix
		"a1 fix: a", "a2 fix: b", "a3 fix: c", "a4 fix: d",
		// 3 chore
		"b1 chore: a", "b2 chore: b", "b3 chore: c",
		// 3 docs
		"c1 docs: a", "c2 docs: b", "c3 docs: c",
	}
	got := ComputeSignals(lines)
	if len(got) != 3 {
		t.Fatalf("expected 3 signals, got %v", got)
	}
	if got[0].Key != "fix" || got[0].Count != 4 {
		t.Errorf("rank 1: got %v, want fix=4", got[0])
	}
	// Tie at count=3: alphabetical → chore before docs.
	if got[1].Key != "chore" || got[2].Key != "docs" {
		t.Errorf("tie-break order: got %v, want chore then docs", got)
	}
}

func TestComputeSignals_ConventionalShapes(t *testing.T) {
	lines := []string{
		"a1 feat: one",
		"a2 feat(ui): two",     // scope → still counts as feat
		"a3 feat(api)!: three", // breaking → still counts as feat
		"a4 not a commit subject",
		"a5 RANDOM CAPITALS: ignored",
	}
	got := ComputeSignals(lines)
	if len(got) != 1 || got[0].Key != "feat" || got[0].Count != 3 {
		t.Errorf("conventional shapes: got %v, want feat=3", got)
	}
}

func TestComputeSignals_NonConventionalIgnored(t *testing.T) {
	lines := []string{
		"a1 fix something",
		"a2 fix something else",
		"a3 fix one more thing", // no colon — not a conventional subject
	}
	got := ComputeSignals(lines)
	if len(got) != 0 {
		t.Errorf("non-conventional must be ignored, got %v", got)
	}
}

func TestProposeCandidates_TitleAndReason(t *testing.T) {
	signals := []Signal{
		{Kind: SignalCommitType, Key: "fix", Count: 5},
	}
	got := ProposeCandidates(signals)
	if len(got) != 1 {
		t.Fatalf("expected 1 candidate, got %v", got)
	}
	if got[0].Title != "fix-workflow" {
		t.Errorf("title: got %q, want fix-workflow", got[0].Title)
	}
	if !workflowNameRE.MatchString(got[0].Title) {
		t.Errorf("title %q must satisfy workflowNameRE", got[0].Title)
	}
	if !strings.Contains(got[0].Reason, "fix") || !strings.Contains(got[0].Reason, "5") {
		t.Errorf("reason should mention type+count: got %q", got[0].Reason)
	}
	if len(got[0].Signals) != 1 || got[0].Signals[0].Key != "fix" {
		t.Errorf("signals should carry source signal, got %v", got[0].Signals)
	}
}

func TestProposeCandidates_CapAtFive(t *testing.T) {
	signals := []Signal{
		{Kind: SignalCommitType, Key: "a", Count: 10},
		{Kind: SignalCommitType, Key: "b", Count: 9},
		{Kind: SignalCommitType, Key: "c", Count: 8},
		{Kind: SignalCommitType, Key: "d", Count: 7},
		{Kind: SignalCommitType, Key: "e", Count: 6},
		{Kind: SignalCommitType, Key: "f", Count: 5}, // 6th — must drop
	}
	got := ProposeCandidates(signals)
	if len(got) != 5 {
		t.Fatalf("cap not enforced, got %d candidates", len(got))
	}
	// The 6th signal ("f") must NOT appear.
	for _, c := range got {
		if c.Title == "f-workflow" {
			t.Errorf("6th candidate leaked past the cap")
		}
	}
}

func TestProposeCandidates_UnknownKindIgnored(t *testing.T) {
	signals := []Signal{
		{Kind: "future-file-touch", Key: "x", Count: 99},
		{Kind: SignalCommitType, Key: "fix", Count: 3},
	}
	got := ProposeCandidates(signals)
	if len(got) != 1 || got[0].Title != "fix-workflow" {
		t.Errorf("unknown signal kind must be ignored, got %v", got)
	}
}

func TestComputeSignals_HandlesLogOneline(t *testing.T) {
	// Realistic git log --oneline shape: "<short-sha> <subject>"
	log := strings.Join([]string{
		"a0cf4fb docs: formal versioning policy",
		"2a29a6b chore: gitignore the local roadmap file",
		"4bfd48a docs: cross-repo nav design",
		"0fcfcae docs: cross-project fingerprint",
		"ef1f084 feat: built, installable, validated",
	}, "\n")
	got := ComputeSignals(splitLines(log))
	// 3 docs (≥ threshold), 1 chore, 1 feat → only docs survives.
	if len(got) != 1 || got[0].Key != "docs" || got[0].Count != 3 {
		t.Errorf("realistic log: got %v, want docs=3", got)
	}
}