test: expand compact and ui coverage
This commit is contained in:
committed by
Steve Yegge
parent
d3b6855aa9
commit
c9fa7af04c
@@ -7,6 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -24,11 +25,24 @@ type Config struct {
|
|||||||
|
|
||||||
// Compactor handles issue compaction using AI summarization.
|
// Compactor handles issue compaction using AI summarization.
|
||||||
type Compactor struct {
|
type Compactor struct {
|
||||||
store *sqlite.SQLiteStorage
|
store issueStore
|
||||||
haiku *HaikuClient
|
summarizer summarizer
|
||||||
config *Config
|
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.
|
// New creates a new Compactor instance with the given configuration.
|
||||||
func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor, error) {
|
func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor, error) {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
@@ -43,7 +57,7 @@ func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor
|
|||||||
config.APIKey = apiKey
|
config.APIKey = apiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
var haikuClient *HaikuClient
|
var haikuClient summarizer
|
||||||
var err error
|
var err error
|
||||||
if !config.DryRun {
|
if !config.DryRun {
|
||||||
haikuClient, err = NewHaikuClient(config.APIKey)
|
haikuClient, err = NewHaikuClient(config.APIKey)
|
||||||
@@ -55,14 +69,14 @@ func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if haikuClient != nil {
|
if hc, ok := haikuClient.(*HaikuClient); ok {
|
||||||
haikuClient.auditEnabled = config.AuditEnabled
|
hc.auditEnabled = config.AuditEnabled
|
||||||
haikuClient.auditActor = config.Actor
|
hc.auditActor = config.Actor
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Compactor{
|
return &Compactor{
|
||||||
store: store,
|
store: store,
|
||||||
haiku: haikuClient,
|
summarizer: haikuClient,
|
||||||
config: config,
|
config: config,
|
||||||
}, nil
|
}, 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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to summarize with Haiku: %w", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to summarize with Haiku: %w", err)
|
return fmt.Errorf("failed to summarize with Haiku: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
291
internal/compact/compactor_unit_test.go
Normal file
291
internal/compact/compactor_unit_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,21 @@ package compact
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"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) {
|
func TestNewHaikuClient_RequiresAPIKey(t *testing.T) {
|
||||||
t.Setenv("ANTHROPIC_API_KEY", "")
|
t.Setenv("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
@@ -178,6 +186,11 @@ func TestIsRetryable(t *testing.T) {
|
|||||||
{"context canceled", context.Canceled, false},
|
{"context canceled", context.Canceled, false},
|
||||||
{"context deadline exceeded", context.DeadlineExceeded, false},
|
{"context deadline exceeded", context.DeadlineExceeded, false},
|
||||||
{"generic error", errors.New("some error"), 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 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestDetectUserRole_PushURLMaintainer(t *testing.T) {
|
||||||
orig := gitCommandRunner
|
orig := gitCommandRunner
|
||||||
stub := &gitStub{t: t, responses: []gitResponse{
|
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) {
|
func TestDetectUserRole_DefaultContributor(t *testing.T) {
|
||||||
orig := gitCommandRunner
|
orig := gitCommandRunner
|
||||||
stub := &gitStub{t: t, responses: []gitResponse{
|
stub := &gitStub{t: t, responses: []gitResponse{
|
||||||
|
|||||||
@@ -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) {
|
func TestDependencyTypeIsValid(t *testing.T) {
|
||||||
// IsValid now accepts any non-empty string up to 50 chars (Decision 004)
|
// IsValid now accepts any non-empty string up to 50 chars (Decision 004)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
154
internal/ui/styles_test.go
Normal file
154
internal/ui/styles_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user