Files
beads/cmd/bd/state_test.go
Steve Yegge 48dca4ea33 feat: add bd state and bd set-state helper commands (bd-7l67)
Implements convenience commands for the labels-as-state pattern:

- `bd state <id> <dimension>` - Query current state value from labels
- `bd state list <id>` - List all state dimensions on an issue
- `bd set-state <id> <dimension>=<value> --reason "..."` - Atomically:
  1. Create event bead (source of truth)
  2. Remove old dimension label
  3. Add new dimension:value label (cache)

Common dimensions: patrol, mode, health, status
Convention: <dimension>:<value> (e.g., patrol:active, mode:degraded)

Updated docs/CLI_REFERENCE.md with new State section.
Updated docs/LABELS.md to reflect implemented helpers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 15:57:08 -08:00

264 lines
7.4 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
type stateTestHelper struct {
s *sqlite.SQLiteStorage
ctx context.Context
t *testing.T
}
func (h *stateTestHelper) createIssue(title string, issueType types.IssueType, priority int) *types.Issue {
issue := &types.Issue{
Title: title,
Priority: priority,
IssueType: issueType,
Status: types.StatusOpen,
}
if err := h.s.CreateIssue(h.ctx, issue, "test-user"); err != nil {
h.t.Fatalf("Failed to create issue: %v", err)
}
return issue
}
func (h *stateTestHelper) addLabel(issueID, label string) {
if err := h.s.AddLabel(h.ctx, issueID, label, "test-user"); err != nil {
h.t.Fatalf("Failed to add label '%s': %v", label, err)
}
}
func (h *stateTestHelper) removeLabel(issueID, label string) {
if err := h.s.RemoveLabel(h.ctx, issueID, label, "test-user"); err != nil {
h.t.Fatalf("Failed to remove label '%s': %v", label, err)
}
}
func (h *stateTestHelper) getLabels(issueID string) []string {
labels, err := h.s.GetLabels(h.ctx, issueID)
if err != nil {
h.t.Fatalf("Failed to get labels: %v", err)
}
return labels
}
// getStateValue extracts the value for a dimension from labels
func (h *stateTestHelper) getStateValue(issueID, dimension string) string {
labels := h.getLabels(issueID)
prefix := dimension + ":"
for _, label := range labels {
if strings.HasPrefix(label, prefix) {
return strings.TrimPrefix(label, prefix)
}
}
return ""
}
// getStates extracts all dimension:value labels as a map
func (h *stateTestHelper) getStates(issueID string) map[string]string {
labels := h.getLabels(issueID)
states := make(map[string]string)
for _, label := range labels {
if idx := strings.Index(label, ":"); idx > 0 {
dimension := label[:idx]
value := label[idx+1:]
states[dimension] = value
}
}
return states
}
func (h *stateTestHelper) assertStateValue(issueID, dimension, expected string) {
actual := h.getStateValue(issueID, dimension)
if actual != expected {
h.t.Errorf("Expected %s=%s, got %s=%s", dimension, expected, dimension, actual)
}
}
func (h *stateTestHelper) assertNoState(issueID, dimension string) {
value := h.getStateValue(issueID, dimension)
if value != "" {
h.t.Errorf("Expected no %s state, got %s", dimension, value)
}
}
func (h *stateTestHelper) assertStateCount(issueID string, expected int) {
states := h.getStates(issueID)
if len(states) != expected {
h.t.Errorf("Expected %d states, got %d: %v", expected, len(states), states)
}
}
func TestStateQueries(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-state-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
testDB := filepath.Join(tmpDir, "test.db")
s := newTestStore(t, testDB)
defer s.Close()
ctx := context.Background()
h := &stateTestHelper{s: s, ctx: ctx, t: t}
t.Run("query state from label", func(t *testing.T) {
issue := h.createIssue("Role Test", types.TypeTask, 1)
h.addLabel(issue.ID, "patrol:active")
h.assertStateValue(issue.ID, "patrol", "active")
})
t.Run("query multiple states", func(t *testing.T) {
issue := h.createIssue("Multi State Test", types.TypeTask, 1)
h.addLabel(issue.ID, "patrol:active")
h.addLabel(issue.ID, "mode:normal")
h.addLabel(issue.ID, "health:healthy")
h.assertStateValue(issue.ID, "patrol", "active")
h.assertStateValue(issue.ID, "mode", "normal")
h.assertStateValue(issue.ID, "health", "healthy")
h.assertStateCount(issue.ID, 3)
})
t.Run("query missing state returns empty", func(t *testing.T) {
issue := h.createIssue("No State Test", types.TypeTask, 1)
h.assertNoState(issue.ID, "patrol")
})
t.Run("state labels mixed with regular labels", func(t *testing.T) {
issue := h.createIssue("Mixed Labels Test", types.TypeTask, 1)
h.addLabel(issue.ID, "patrol:active")
h.addLabel(issue.ID, "backend") // Not a state label
h.addLabel(issue.ID, "mode:normal")
h.addLabel(issue.ID, "urgent") // Not a state label
h.assertStateValue(issue.ID, "patrol", "active")
h.assertStateValue(issue.ID, "mode", "normal")
h.assertStateCount(issue.ID, 2)
})
t.Run("state with colon in value", func(t *testing.T) {
issue := h.createIssue("Colon Value Test", types.TypeTask, 1)
h.addLabel(issue.ID, "error:code:500")
value := h.getStateValue(issue.ID, "error")
if value != "code:500" {
t.Errorf("Expected 'code:500', got '%s'", value)
}
})
}
func TestStateTransitions(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-state-transition-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
testDB := filepath.Join(tmpDir, "test.db")
s := newTestStore(t, testDB)
defer s.Close()
ctx := context.Background()
h := &stateTestHelper{s: s, ctx: ctx, t: t}
t.Run("change state value", func(t *testing.T) {
issue := h.createIssue("Transition Test", types.TypeTask, 1)
// Initial state
h.addLabel(issue.ID, "patrol:active")
h.assertStateValue(issue.ID, "patrol", "active")
// Transition to muted (remove old, add new)
h.removeLabel(issue.ID, "patrol:active")
h.addLabel(issue.ID, "patrol:muted")
h.assertStateValue(issue.ID, "patrol", "muted")
})
t.Run("prevent duplicate dimension values", func(t *testing.T) {
issue := h.createIssue("Duplicate Prevention Test", types.TypeTask, 1)
// Add initial state
h.addLabel(issue.ID, "patrol:active")
// If we add another value without removing, we'd have both
// This is what the set-state command prevents
h.addLabel(issue.ID, "patrol:muted")
// Now we have both - this is the anti-pattern
labels := h.getLabels(issue.ID)
count := 0
for _, l := range labels {
if strings.HasPrefix(l, "patrol:") {
count++
}
}
if count != 2 {
t.Errorf("Expected 2 patrol labels (anti-pattern), got %d", count)
}
// The getStateValue only returns the first one found
// This demonstrates why proper transitions (remove then add) are needed
})
}
func TestStatePatterns(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-state-patterns-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
testDB := filepath.Join(tmpDir, "test.db")
s := newTestStore(t, testDB)
defer s.Close()
ctx := context.Background()
h := &stateTestHelper{s: s, ctx: ctx, t: t}
t.Run("common operational dimensions", func(t *testing.T) {
issue := h.createIssue("Operations Role", types.TypeTask, 1)
// Set up typical operational state
h.addLabel(issue.ID, "patrol:active")
h.addLabel(issue.ID, "mode:normal")
h.addLabel(issue.ID, "health:healthy")
h.addLabel(issue.ID, "sync:current")
states := h.getStates(issue.ID)
expected := map[string]string{
"patrol": "active",
"mode": "normal",
"health": "healthy",
"sync": "current",
}
for dim, val := range expected {
if states[dim] != val {
t.Errorf("Expected %s=%s, got %s=%s", dim, val, dim, states[dim])
}
}
})
t.Run("degraded mode example", func(t *testing.T) {
issue := h.createIssue("Degraded Role", types.TypeTask, 1)
// Start healthy
h.addLabel(issue.ID, "health:healthy")
h.addLabel(issue.ID, "mode:normal")
// Degrade
h.removeLabel(issue.ID, "mode:normal")
h.addLabel(issue.ID, "mode:degraded")
h.assertStateValue(issue.ID, "mode", "degraded")
h.assertStateValue(issue.ID, "health", "healthy") // Health unchanged
})
}