diff --git a/internal/compact/compactor.go b/internal/compact/compactor.go index 104f5430..bc52b74c 100644 --- a/internal/compact/compactor.go +++ b/internal/compact/compactor.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" ) const ( @@ -24,9 +25,22 @@ type Config struct { // Compactor handles issue compaction using AI summarization. type Compactor struct { - store *sqlite.SQLiteStorage - haiku *HaikuClient - config *Config + store issueStore + summarizer summarizer + config *Config +} + +type issueStore interface { + CheckEligibility(ctx context.Context, issueID string, tier int) (bool, string, error) + GetIssue(ctx context.Context, issueID string) (*types.Issue, error) + UpdateIssue(ctx context.Context, issueID string, updates map[string]interface{}, actor string) error + ApplyCompaction(ctx context.Context, issueID string, tier int, originalSize int, compactedSize int, commitHash string) error + AddComment(ctx context.Context, issueID, actor, comment string) error + MarkIssueDirty(ctx context.Context, issueID string) error +} + +type summarizer interface { + SummarizeTier1(ctx context.Context, issue *types.Issue) (string, error) } // New creates a new Compactor instance with the given configuration. @@ -43,7 +57,7 @@ func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor config.APIKey = apiKey } - var haikuClient *HaikuClient + var haikuClient summarizer var err error if !config.DryRun { haikuClient, err = NewHaikuClient(config.APIKey) @@ -55,15 +69,15 @@ func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor } } } - if haikuClient != nil { - haikuClient.auditEnabled = config.AuditEnabled - haikuClient.auditActor = config.Actor + if hc, ok := haikuClient.(*HaikuClient); ok { + hc.auditEnabled = config.AuditEnabled + hc.auditActor = config.Actor } return &Compactor{ - store: store, - haiku: haikuClient, - config: config, + store: store, + summarizer: haikuClient, + config: config, }, nil } @@ -104,7 +118,10 @@ func (c *Compactor) CompactTier1(ctx context.Context, issueID string) error { return fmt.Errorf("dry-run: would compact %s (original size: %d bytes)", issueID, originalSize) } - summary, err := c.haiku.SummarizeTier1(ctx, issue) + if c.summarizer == nil { + return fmt.Errorf("summarizer not configured") + } + summary, err := c.summarizer.SummarizeTier1(ctx, issue) if err != nil { return fmt.Errorf("failed to summarize with Haiku: %w", err) } @@ -249,7 +266,10 @@ func (c *Compactor) compactSingleWithResult(ctx context.Context, issueID string, result.OriginalSize = len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria) - summary, err := c.haiku.SummarizeTier1(ctx, issue) + if c.summarizer == nil { + return fmt.Errorf("summarizer not configured") + } + summary, err := c.summarizer.SummarizeTier1(ctx, issue) if err != nil { return fmt.Errorf("failed to summarize with Haiku: %w", err) } diff --git a/internal/compact/compactor_unit_test.go b/internal/compact/compactor_unit_test.go new file mode 100644 index 00000000..decc4f34 --- /dev/null +++ b/internal/compact/compactor_unit_test.go @@ -0,0 +1,291 @@ +package compact + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +type stubStore struct { + checkEligibilityFn func(context.Context, string, int) (bool, string, error) + getIssueFn func(context.Context, string) (*types.Issue, error) + updateIssueFn func(context.Context, string, map[string]interface{}, string) error + applyCompactionFn func(context.Context, string, int, int, int, string) error + addCommentFn func(context.Context, string, string, string) error + markDirtyFn func(context.Context, string) error +} + +func (s *stubStore) CheckEligibility(ctx context.Context, issueID string, tier int) (bool, string, error) { + if s.checkEligibilityFn != nil { + return s.checkEligibilityFn(ctx, issueID, tier) + } + return false, "", nil +} + +func (s *stubStore) GetIssue(ctx context.Context, issueID string) (*types.Issue, error) { + if s.getIssueFn != nil { + return s.getIssueFn(ctx, issueID) + } + return nil, fmt.Errorf("GetIssue not stubbed") +} + +func (s *stubStore) UpdateIssue(ctx context.Context, issueID string, updates map[string]interface{}, actor string) error { + if s.updateIssueFn != nil { + return s.updateIssueFn(ctx, issueID, updates, actor) + } + return nil +} + +func (s *stubStore) ApplyCompaction(ctx context.Context, issueID string, tier int, originalSize int, compactedSize int, commitHash string) error { + if s.applyCompactionFn != nil { + return s.applyCompactionFn(ctx, issueID, tier, originalSize, compactedSize, commitHash) + } + return nil +} + +func (s *stubStore) AddComment(ctx context.Context, issueID, actor, comment string) error { + if s.addCommentFn != nil { + return s.addCommentFn(ctx, issueID, actor, comment) + } + return nil +} + +func (s *stubStore) MarkIssueDirty(ctx context.Context, issueID string) error { + if s.markDirtyFn != nil { + return s.markDirtyFn(ctx, issueID) + } + return nil +} + +type stubSummarizer struct { + summary string + err error + calls int +} + +func (s *stubSummarizer) SummarizeTier1(ctx context.Context, issue *types.Issue) (string, error) { + s.calls++ + return s.summary, s.err +} + +func stubIssue() *types.Issue { + return &types.Issue{ + ID: "bd-123", + Title: "Fix login", + Description: strings.Repeat("A", 20), + Design: strings.Repeat("B", 10), + Notes: strings.Repeat("C", 5), + AcceptanceCriteria: "done", + Status: types.StatusClosed, + } +} + +func withGitHash(t *testing.T, hash string) func() { + orig := gitExec + gitExec = func(string, ...string) ([]byte, error) { + return []byte(hash), nil + } + return func() { gitExec = orig } +} + +func TestCompactTier1_Success(t *testing.T) { + cleanup := withGitHash(t, "deadbeef\n") + t.Cleanup(cleanup) + + updateCalled := false + applyCalled := false + markCalled := false + store := &stubStore{ + checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return true, "", nil }, + getIssueFn: func(context.Context, string) (*types.Issue, error) { return stubIssue(), nil }, + updateIssueFn: func(ctx context.Context, id string, updates map[string]interface{}, actor string) error { + updateCalled = true + if updates["description"].(string) != "short" { + t.Fatalf("expected summarized description") + } + if updates["design"].(string) != "" { + t.Fatalf("design should be cleared") + } + return nil + }, + applyCompactionFn: func(ctx context.Context, id string, tier, original, compacted int, hash string) error { + applyCalled = true + if hash != "deadbeef" { + t.Fatalf("unexpected hash %q", hash) + } + return nil + }, + addCommentFn: func(ctx context.Context, id, actor, comment string) error { + if !strings.Contains(comment, "saved") { + t.Fatalf("unexpected comment %q", comment) + } + return nil + }, + markDirtyFn: func(context.Context, string) error { + markCalled = true + return nil + }, + } + summary := &stubSummarizer{summary: "short"} + c := &Compactor{store: store, summarizer: summary, config: &Config{}} + + if err := c.CompactTier1(context.Background(), "bd-123"); err != nil { + t.Fatalf("CompactTier1 unexpected error: %v", err) + } + if summary.calls != 1 { + t.Fatalf("expected summarizer used once, got %d", summary.calls) + } + if !updateCalled || !applyCalled || !markCalled { + t.Fatalf("expected update/apply/mark to be called") + } +} + +func TestCompactTier1_DryRun(t *testing.T) { + store := &stubStore{ + checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return true, "", nil }, + getIssueFn: func(context.Context, string) (*types.Issue, error) { return stubIssue(), nil }, + } + summary := &stubSummarizer{summary: "short"} + c := &Compactor{store: store, summarizer: summary, config: &Config{DryRun: true}} + + err := c.CompactTier1(context.Background(), "bd-123") + if err == nil || !strings.Contains(err.Error(), "dry-run") { + t.Fatalf("expected dry-run error, got %v", err) + } + if summary.calls != 0 { + t.Fatalf("summarizer should not be used in dry run") + } +} + +func TestCompactTier1_Ineligible(t *testing.T) { + store := &stubStore{ + checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return false, "recently compacted", nil }, + } + c := &Compactor{store: store, config: &Config{}} + + err := c.CompactTier1(context.Background(), "bd-123") + if err == nil || !strings.Contains(err.Error(), "recently compacted") { + t.Fatalf("expected ineligible error, got %v", err) + } +} + +func TestCompactTier1_SummaryNotSmaller(t *testing.T) { + commentCalled := false + store := &stubStore{ + checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return true, "", nil }, + getIssueFn: func(context.Context, string) (*types.Issue, error) { return stubIssue(), nil }, + addCommentFn: func(ctx context.Context, id, actor, comment string) error { + commentCalled = true + if !strings.Contains(comment, "Tier 1 compaction skipped") { + t.Fatalf("unexpected comment %q", comment) + } + return nil + }, + } + summary := &stubSummarizer{summary: strings.Repeat("X", 40)} + c := &Compactor{store: store, summarizer: summary, config: &Config{}} + + err := c.CompactTier1(context.Background(), "bd-123") + if err == nil || !strings.Contains(err.Error(), "compaction would increase size") { + t.Fatalf("expected size error, got %v", err) + } + if !commentCalled { + t.Fatalf("expected warning comment to be recorded") + } +} + +func TestCompactTier1_UpdateError(t *testing.T) { + store := &stubStore{ + checkEligibilityFn: func(context.Context, string, int) (bool, string, error) { return true, "", nil }, + getIssueFn: func(context.Context, string) (*types.Issue, error) { return stubIssue(), nil }, + updateIssueFn: func(context.Context, string, map[string]interface{}, string) error { return errors.New("boom") }, + } + summary := &stubSummarizer{summary: "short"} + c := &Compactor{store: store, summarizer: summary, config: &Config{}} + + err := c.CompactTier1(context.Background(), "bd-123") + if err == nil || !strings.Contains(err.Error(), "failed to update issue") { + t.Fatalf("expected update error, got %v", err) + } +} + +func TestCompactTier1Batch_MixedResults(t *testing.T) { + cleanup := withGitHash(t, "cafebabe\n") + t.Cleanup(cleanup) + + var mu sync.Mutex + updated := make(map[string]int) + applied := make(map[string]int) + marked := make(map[string]int) + store := &stubStore{ + checkEligibilityFn: func(ctx context.Context, id string, tier int) (bool, string, error) { + switch id { + case "bd-1": + return true, "", nil + case "bd-2": + return false, "not eligible", nil + default: + return false, "", fmt.Errorf("unexpected id %s", id) + } + }, + getIssueFn: func(ctx context.Context, id string) (*types.Issue, error) { + issue := stubIssue() + issue.ID = id + return issue, nil + }, + updateIssueFn: func(ctx context.Context, id string, updates map[string]interface{}, actor string) error { + mu.Lock() + updated[id]++ + mu.Unlock() + return nil + }, + applyCompactionFn: func(ctx context.Context, id string, tier, original, compacted int, hash string) error { + mu.Lock() + applied[id]++ + mu.Unlock() + return nil + }, + addCommentFn: func(context.Context, string, string, string) error { return nil }, + markDirtyFn: func(ctx context.Context, id string) error { + mu.Lock() + marked[id]++ + mu.Unlock() + return nil + }, + } + summary := &stubSummarizer{summary: "short"} + c := &Compactor{store: store, summarizer: summary, config: &Config{Concurrency: 2}} + + results, err := c.CompactTier1Batch(context.Background(), []string{"bd-1", "bd-2"}) + if err != nil { + t.Fatalf("CompactTier1Batch unexpected error: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + resMap := map[string]*Result{} + for _, r := range results { + resMap[r.IssueID] = r + } + + if res := resMap["bd-1"]; res == nil || res.Err != nil || res.CompactedSize == 0 { + t.Fatalf("expected success result for bd-1, got %+v", res) + } + if res := resMap["bd-2"]; res == nil || res.Err == nil || !strings.Contains(res.Err.Error(), "not eligible") { + t.Fatalf("expected ineligible error for bd-2, got %+v", res) + } + if updated["bd-1"] != 1 || applied["bd-1"] != 1 || marked["bd-1"] != 1 { + t.Fatalf("expected store operations for bd-1 exactly once") + } + if updated["bd-2"] != 0 || applied["bd-2"] != 0 { + t.Fatalf("bd-2 should not be processed") + } + if summary.calls != 1 { + t.Fatalf("summarizer should run once; got %d", summary.calls) + } +} diff --git a/internal/compact/haiku_test.go b/internal/compact/haiku_test.go index 11de2827..87a2b2f0 100644 --- a/internal/compact/haiku_test.go +++ b/internal/compact/haiku_test.go @@ -3,13 +3,21 @@ package compact import ( "context" "errors" + "fmt" "strings" "testing" "time" + "github.com/anthropics/anthropic-sdk-go" "github.com/steveyegge/beads/internal/types" ) +type timeoutErr struct{} + +func (timeoutErr) Error() string { return "timeout" } +func (timeoutErr) Timeout() bool { return true } +func (timeoutErr) Temporary() bool { return true } + func TestNewHaikuClient_RequiresAPIKey(t *testing.T) { t.Setenv("ANTHROPIC_API_KEY", "") @@ -178,6 +186,11 @@ func TestIsRetryable(t *testing.T) { {"context canceled", context.Canceled, false}, {"context deadline exceeded", context.DeadlineExceeded, false}, {"generic error", errors.New("some error"), false}, + {"timeout error", timeoutErr{}, true}, + {"anthropic 429", &anthropic.Error{StatusCode: 429}, true}, + {"anthropic 500", &anthropic.Error{StatusCode: 500}, true}, + {"anthropic 400", &anthropic.Error{StatusCode: 400}, false}, + {"wrapped timeout", fmt.Errorf("wrap: %w", timeoutErr{}), true}, } for _, tt := range tests { @@ -189,3 +202,16 @@ func TestIsRetryable(t *testing.T) { }) } } + +func TestBytesWriterAppends(t *testing.T) { + w := &bytesWriter{} + if _, err := w.Write([]byte("hello")); err != nil { + t.Fatalf("first write failed: %v", err) + } + if _, err := w.Write([]byte(" world")); err != nil { + t.Fatalf("second write failed: %v", err) + } + if got := string(w.buf); got != "hello world" { + t.Fatalf("unexpected buffer content: %q", got) + } +} diff --git a/internal/routing/routing_test.go b/internal/routing/routing_test.go index e000988c..48722d00 100644 --- a/internal/routing/routing_test.go +++ b/internal/routing/routing_test.go @@ -203,6 +203,26 @@ func TestDetectUserRole_ConfigOverrideMaintainer(t *testing.T) { } } +func TestDetectUserRole_ConfigOverrideContributor(t *testing.T) { + orig := gitCommandRunner + stub := &gitStub{t: t, responses: []gitResponse{ + {expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: "contributor\n"}, + }} + gitCommandRunner = stub.run + t.Cleanup(func() { + gitCommandRunner = orig + stub.verify() + }) + + role, err := DetectUserRole("/repo") + if err != nil { + t.Fatalf("DetectUserRole error = %v", err) + } + if role != Contributor { + t.Fatalf("expected %s, got %s", Contributor, role) + } +} + func TestDetectUserRole_PushURLMaintainer(t *testing.T) { orig := gitCommandRunner stub := &gitStub{t: t, responses: []gitResponse{ @@ -224,6 +244,27 @@ func TestDetectUserRole_PushURLMaintainer(t *testing.T) { } } +func TestDetectUserRole_HTTPSCredentialsMaintainer(t *testing.T) { + orig := gitCommandRunner + stub := &gitStub{t: t, responses: []gitResponse{ + {expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: ""}, + {expect: gitCall{"/repo", []string{"remote", "get-url", "--push", "origin"}}, output: "https://token@github.com/owner/repo.git"}, + }} + gitCommandRunner = stub.run + t.Cleanup(func() { + gitCommandRunner = orig + stub.verify() + }) + + role, err := DetectUserRole("/repo") + if err != nil { + t.Fatalf("DetectUserRole error = %v", err) + } + if role != Maintainer { + t.Fatalf("expected %s, got %s", Maintainer, role) + } +} + func TestDetectUserRole_DefaultContributor(t *testing.T) { orig := gitCommandRunner stub := &gitStub{t: t, responses: []gitResponse{ diff --git a/internal/types/types_test.go b/internal/types/types_test.go index 9d48cb2c..87b11cb5 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -414,6 +414,66 @@ func TestIssueTypeIsValid(t *testing.T) { } } +func TestAgentStateIsValid(t *testing.T) { + cases := []struct { + name string + state AgentState + want bool + }{ + {"idle", StateIdle, true}, + {"running", StateRunning, true}, + {"empty", AgentState(""), true}, // empty allowed for non-agent beads + {"invalid", AgentState("dormant"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.state.IsValid(); got != tc.want { + t.Fatalf("AgentState(%q).IsValid() = %v, want %v", tc.state, got, tc.want) + } + }) + } +} + +func TestMolTypeIsValid(t *testing.T) { + cases := []struct { + name string + type_ MolType + want bool + }{ + {"swarm", MolTypeSwarm, true}, + {"patrol", MolTypePatrol, true}, + {"work", MolTypeWork, true}, + {"empty", MolType(""), true}, + {"unknown", MolType("custom"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.type_.IsValid(); got != tc.want { + t.Fatalf("MolType(%q).IsValid() = %v, want %v", tc.type_, got, tc.want) + } + }) + } +} + +func TestIssueCompoundHelpers(t *testing.T) { + issue := &Issue{} + if issue.IsCompound() { + t.Fatalf("issue with no bonded refs should not be compound") + } + if constituents := issue.GetConstituents(); constituents != nil { + t.Fatalf("expected nil constituents for non-compound issue") + } + + bonded := &Issue{BondedFrom: []BondRef{{ProtoID: "proto-1", BondType: BondTypeSequential}}} + if !bonded.IsCompound() { + t.Fatalf("issue with bonded refs should be compound") + } + refs := bonded.GetConstituents() + if len(refs) != 1 || refs[0].ProtoID != "proto-1" { + t.Fatalf("unexpected constituents: %#v", refs) + } +} + func TestDependencyTypeIsValid(t *testing.T) { // IsValid now accepts any non-empty string up to 50 chars (Decision 004) tests := []struct { @@ -431,9 +491,9 @@ func TestDependencyTypeIsValid(t *testing.T) { {DepAuthoredBy, true}, {DepAssignedTo, true}, {DepApprovedBy, true}, - {DependencyType("custom-type"), true}, // Custom types are now valid - {DependencyType("any-string"), true}, // Any non-empty string is valid - {DependencyType(""), false}, // Empty is still invalid + {DependencyType("custom-type"), true}, // Custom types are now valid + {DependencyType("any-string"), true}, // Any non-empty string is valid + {DependencyType(""), false}, // Empty is still invalid {DependencyType("this-is-a-very-long-dependency-type-that-exceeds-fifty-characters"), false}, // Too long } @@ -624,25 +684,25 @@ func TestTreeNodeEmbedding(t *testing.T) { func TestComputeContentHash(t *testing.T) { issue1 := Issue{ - ID: "test-1", - Title: "Test Issue", - Description: "Description", - Status: StatusOpen, - Priority: 2, - IssueType: TypeFeature, - EstimatedMinutes: intPtr(60), + ID: "test-1", + Title: "Test Issue", + Description: "Description", + Status: StatusOpen, + Priority: 2, + IssueType: TypeFeature, + EstimatedMinutes: intPtr(60), } // Same content should produce same hash issue2 := Issue{ - ID: "test-2", // Different ID - Title: "Test Issue", - Description: "Description", - Status: StatusOpen, - Priority: 2, - IssueType: TypeFeature, - EstimatedMinutes: intPtr(60), - CreatedAt: time.Now(), // Different timestamp + ID: "test-2", // Different ID + Title: "Test Issue", + Description: "Description", + Status: StatusOpen, + Priority: 2, + IssueType: TypeFeature, + EstimatedMinutes: intPtr(60), + CreatedAt: time.Now(), // Different timestamp } hash1 := issue1.ComputeContentHash() diff --git a/internal/ui/styles_test.go b/internal/ui/styles_test.go new file mode 100644 index 00000000..4576316a --- /dev/null +++ b/internal/ui/styles_test.go @@ -0,0 +1,154 @@ +package ui + +import ( + "fmt" + "strings" + "testing" +) + +func TestRenderBasicStyles(t *testing.T) { + t.Run("semantic wrappers", func(t *testing.T) { + cases := []struct { + name string + got string + want string + }{ + {"pass", RenderPass("ok"), PassStyle.Render("ok")}, + {"warn", RenderWarn("careful"), WarnStyle.Render("careful")}, + {"fail", RenderFail("boom"), FailStyle.Render("boom")}, + {"muted", RenderMuted("note"), MutedStyle.Render("note")}, + {"accent", RenderAccent("info"), AccentStyle.Render("info")}, + {"category", RenderCategory("mixed Case"), CategoryStyle.Render("MIXED CASE")}, + {"separator", RenderSeparator(), MutedStyle.Render(SeparatorLight)}, + {"pass icon", RenderPassIcon(), PassStyle.Render(IconPass)}, + {"warn icon", RenderWarnIcon(), WarnStyle.Render(IconWarn)}, + {"fail icon", RenderFailIcon(), FailStyle.Render(IconFail)}, + {"skip icon", RenderSkipIcon(), MutedStyle.Render(IconSkip)}, + {"info icon", RenderInfoIcon(), AccentStyle.Render(IconInfo)}, + {"bold", RenderBold("bold"), BoldStyle.Render("bold")}, + {"command", RenderCommand("bd prime"), CommandStyle.Render("bd prime")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Fatalf("%s mismatch: got %q want %q", tc.name, tc.got, tc.want) + } + }) + } + }) +} + +func TestRenderStatusAndPriority(t *testing.T) { + statusCases := []struct { + status string + want string + }{ + {"open", StatusOpenStyle.Render("open")}, + {"in_progress", StatusInProgressStyle.Render("in_progress")}, + {"blocked", StatusBlockedStyle.Render("blocked")}, + {"pinned", StatusPinnedStyle.Render("pinned")}, + {"hooked", StatusHookedStyle.Render("hooked")}, + {"closed", StatusClosedStyle.Render("closed")}, + {"custom", StatusOpenStyle.Render("custom")}, + } + for _, tc := range statusCases { + if got := RenderStatus(tc.status); got != tc.want { + t.Fatalf("status %s mismatch: got %q want %q", tc.status, got, tc.want) + } + } + + priorityCases := []struct { + priority int + want string + }{ + {0, PriorityP0Style.Render("P0")}, + {1, PriorityP1Style.Render("P1")}, + {2, PriorityP2Style.Render("P2")}, + {3, PriorityP3Style.Render("P3")}, + {4, PriorityP4Style.Render("P4")}, + {5, "P5"}, + } + for _, tc := range priorityCases { + if got := RenderPriority(tc.priority); got != tc.want { + t.Fatalf("priority %d mismatch: got %q want %q", tc.priority, got, tc.want) + } + } + + if got := RenderPriorityForStatus(0, "closed"); got != "P0" { + t.Fatalf("closed priority should be plain text, got %q", got) + } + if got := RenderPriorityForStatus(1, "open"); got != RenderPriority(1) { + t.Fatalf("open priority should use styling") + } +} + +func TestRenderTypeVariants(t *testing.T) { + cases := []struct { + issueType string + want string + }{ + {"bug", TypeBugStyle.Render("bug")}, + {"feature", TypeFeatureStyle.Render("feature")}, + {"task", TypeTaskStyle.Render("task")}, + {"epic", TypeEpicStyle.Render("epic")}, + {"chore", TypeChoreStyle.Render("chore")}, + {"agent", TypeAgentStyle.Render("agent")}, + {"role", TypeRoleStyle.Render("role")}, + {"custom", "custom"}, + } + for _, tc := range cases { + if got := RenderType(tc.issueType); got != tc.want { + t.Fatalf("type %s mismatch: got %q want %q", tc.issueType, got, tc.want) + } + } + + if got := RenderTypeForStatus("bug", "closed"); got != "bug" { + t.Fatalf("closed type should be plain, got %q", got) + } + if got := RenderTypeForStatus("bug", "open"); got != RenderType("bug") { + t.Fatalf("open type should be styled") + } +} + +func TestRenderIssueCompact(t *testing.T) { + open := RenderIssueCompact("bd-1", 0, "bug", "in_progress", "ship it") + wantOpen := fmt.Sprintf("%s [%s] [%s] %s - %s", + RenderID("bd-1"), + RenderPriority(0), + RenderType("bug"), + RenderStatus("in_progress"), + "ship it", + ) + if open != wantOpen { + t.Fatalf("open issue line mismatch: got %q want %q", open, wantOpen) + } + + closed := RenderIssueCompact("bd-2", 2, "task", "closed", "done") + raw := fmt.Sprintf("%s [P%d] [%s] %s - %s", "bd-2", 2, "task", "closed", "done") + if closed != StatusClosedStyle.Render(raw) { + t.Fatalf("closed issue line should be dimmed: got %q", closed) + } +} + +func TestRenderClosedUtilities(t *testing.T) { + line := "bd-42 closed" + if got := RenderClosedLine(line); got != StatusClosedStyle.Render(line) { + t.Fatalf("closed line mismatch: got %q", got) + } + + if got := RenderID("bd-5"); got != IDStyle.Render("bd-5") { + t.Fatalf("RenderID mismatch") + } +} + +func TestRenderCommandAndCategoryAreUppercaseSafe(t *testing.T) { + got := RenderCategory(" already upper ") + if !strings.Contains(got, " ALREADY UPPER ") { + t.Fatalf("category should uppercase input, got %q", got) + } + + cmd := RenderCommand("bd prime") + if !strings.Contains(cmd, "bd prime") { + t.Fatalf("command output missing text: %q", cmd) + } +} diff --git a/internal/utils/path_test.go b/internal/utils/path_test.go index 328e439a..df1da201 100644 --- a/internal/utils/path_test.go +++ b/internal/utils/path_test.go @@ -242,3 +242,20 @@ func TestResolveForWrite(t *testing.T) { } }) } + +func TestFindMoleculesJSONLInDir(t *testing.T) { + root := t.TempDir() + molecules := filepath.Join(root, "molecules.jsonl") + if err := os.WriteFile(molecules, []byte("[]"), 0o644); err != nil { + t.Fatalf("failed to create molecules.jsonl: %v", err) + } + + if got := FindMoleculesJSONLInDir(root); got != molecules { + t.Fatalf("expected %q, got %q", molecules, got) + } + + otherDir := t.TempDir() + if got := FindMoleculesJSONLInDir(otherDir); got != "" { + t.Fatalf("expected empty path when file missing, got %q", got) + } +}