Files
beads/cmd/bd/gate_test.go
onyx 3d93166b3f feat(gate): add cross-rig bead gate support (Phase 4)
Add bead gate type for cross-rig bead dependencies:
- await_type=bead with await_id=<rig>:<bead-id> format
- Add `bd gate check` command to evaluate open gates
- Support --type=bead to check only bead gates
- Support --dry-run to preview without closing
- Gate resolves when target bead is closed

Closes bd-w3rh

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 13:11:36 -08:00

232 lines
6.1 KiB
Go

package main
import (
"context"
"database/sql"
"os"
"path/filepath"
"testing"
"time"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/steveyegge/beads/internal/types"
)
func TestShouldCheckGate(t *testing.T) {
tests := []struct {
name string
awaitType string
typeFilter string
want bool
}{
// Empty filter matches all
{"empty filter matches gh:run", "gh:run", "", true},
{"empty filter matches gh:pr", "gh:pr", "", true},
{"empty filter matches timer", "timer", "", true},
{"empty filter matches human", "human", "", true},
{"empty filter matches bead", "bead", "", true},
// "all" filter matches all
{"all filter matches gh:run", "gh:run", "all", true},
{"all filter matches gh:pr", "gh:pr", "all", true},
{"all filter matches timer", "timer", "all", true},
{"all filter matches bead", "bead", "all", true},
// "gh" filter matches all GitHub types
{"gh filter matches gh:run", "gh:run", "gh", true},
{"gh filter matches gh:pr", "gh:pr", "gh", true},
{"gh filter does not match timer", "timer", "gh", false},
{"gh filter does not match human", "human", "gh", false},
{"gh filter does not match bead", "bead", "gh", false},
// Exact type filters
{"gh:run filter matches gh:run", "gh:run", "gh:run", true},
{"gh:run filter does not match gh:pr", "gh:pr", "gh:run", false},
{"gh:pr filter matches gh:pr", "gh:pr", "gh:pr", true},
{"gh:pr filter does not match gh:run", "gh:run", "gh:pr", false},
{"timer filter matches timer", "timer", "timer", true},
{"timer filter does not match gh:run", "gh:run", "timer", false},
{"bead filter matches bead", "bead", "bead", true},
{"bead filter does not match timer", "timer", "bead", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gate := &types.Issue{
AwaitType: tt.awaitType,
}
got := shouldCheckGate(gate, tt.typeFilter)
if got != tt.want {
t.Errorf("shouldCheckGate(%q, %q) = %v, want %v",
tt.awaitType, tt.typeFilter, got, tt.want)
}
})
}
}
func TestCheckBeadGate_InvalidFormat(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
awaitID string
wantErr string
}{
{
name: "empty",
awaitID: "",
wantErr: "invalid await_id format",
},
{
name: "no colon",
awaitID: "gastown-gt-abc",
wantErr: "invalid await_id format",
},
{
name: "missing rig",
awaitID: ":gt-abc",
wantErr: "await_id missing rig name",
},
{
name: "missing bead",
awaitID: "gastown:",
wantErr: "await_id missing rig name or bead ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
satisfied, reason := checkBeadGate(ctx, tt.awaitID)
if satisfied {
t.Errorf("expected not satisfied for %q", tt.awaitID)
}
if reason == "" {
t.Error("expected reason to be set")
}
// Just check the error message contains the expected substring
if tt.wantErr != "" && !gateTestContainsIgnoreCase(reason, tt.wantErr) {
t.Errorf("reason %q does not contain %q", reason, tt.wantErr)
}
})
}
}
func TestCheckBeadGate_RigNotFound(t *testing.T) {
ctx := context.Background()
// Create a temp directory with a minimal beads setup
tmpDir, err := os.MkdirTemp("", "gate_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Change to temp dir
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Try to check a gate for a non-existent rig
satisfied, reason := checkBeadGate(ctx, "nonexistent:some-id")
if satisfied {
t.Error("expected not satisfied for non-existent rig")
}
if reason == "" {
t.Error("expected reason to be set")
}
// The error should mention the rig not being found
if !gateTestContainsIgnoreCase(reason, "not found") && !gateTestContainsIgnoreCase(reason, "could not find") {
t.Errorf("reason should mention not found: %q", reason)
}
}
func TestCheckBeadGate_TargetClosed(t *testing.T) {
// Create a temporary database that simulates a target rig
tmpDir, err := os.MkdirTemp("", "bead_gate_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create a minimal database with a closed issue
dbPath := filepath.Join(tmpDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
// Create minimal schema
_, err = db.Exec(`
CREATE TABLE issues (
id TEXT PRIMARY KEY,
status TEXT,
title TEXT,
created_at TEXT,
updated_at TEXT
)
`)
if err != nil {
t.Fatal(err)
}
// Insert a closed issue
now := time.Now().Format(time.RFC3339)
_, err = db.Exec(`
INSERT INTO issues (id, status, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, "gt-test123", string(types.StatusClosed), "Test Issue", now, now)
if err != nil {
t.Fatal(err)
}
// Insert an open issue
_, err = db.Exec(`
INSERT INTO issues (id, status, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, "gt-open456", string(types.StatusOpen), "Open Issue", now, now)
if err != nil {
t.Fatal(err)
}
db.Close()
// Note: This test can't fully exercise checkBeadGate because it relies on
// routing.ResolveBeadsDirForRig which needs a proper routes.jsonl setup.
// The full integration test would need the town/rig infrastructure.
// For now, we just verify the function signature and basic error handling.
t.Log("Database created with closed issue gt-test123 and open issue gt-open456")
t.Log("Full integration testing requires routes.jsonl setup")
}
// gateTestContainsIgnoreCase checks if haystack contains needle (case-insensitive)
func gateTestContainsIgnoreCase(haystack, needle string) bool {
return gateTestContains(gateTestLowerCase(haystack), gateTestLowerCase(needle))
}
func gateTestContains(s, substr string) bool {
return len(s) >= len(substr) && gateTestFindSubstring(s, substr) >= 0
}
func gateTestLowerCase(s string) string {
b := []byte(s)
for i := range b {
if b[i] >= 'A' && b[i] <= 'Z' {
b[i] += 32
}
}
return string(b)
}
func gateTestFindSubstring(s, substr string) int {
if len(substr) == 0 {
return 0
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}