Files
beads/cmd/bd/mol_test.go
Peter Chanthamynavong fc8437ed08 fix(mol): substitute title/desc vars in root molecule bead
When pouring a formula with `title` and `desc` variables defined,
the root molecule's title and description now use {{title}} and
{{desc}} placeholders that get substituted during pour.

Previously, the root was always assigned the formula name and static
description, ignoring these common variables. Child beads correctly
substituted variables, but the root did not.

Fixes #852
2026-01-02 16:00:54 -08:00

2834 lines
80 KiB
Go

package main
import (
"context"
"strings"
"testing"
"github.com/steveyegge/beads/internal/formula"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
func TestParseDistillVar(t *testing.T) {
tests := []struct {
name string
varFlag string
searchableText string
wantFind string
wantVar string
wantErr bool
}{
{
name: "spawn-style: variable=value",
varFlag: "branch=feature-auth",
searchableText: "Implement feature-auth login flow",
wantFind: "feature-auth",
wantVar: "branch",
wantErr: false,
},
{
name: "substitution-style: value=variable",
varFlag: "feature-auth=branch",
searchableText: "Implement feature-auth login flow",
wantFind: "feature-auth",
wantVar: "branch",
wantErr: false,
},
{
name: "spawn-style with version number",
varFlag: "version=1.2.3",
searchableText: "Release version 1.2.3 to production",
wantFind: "1.2.3",
wantVar: "version",
wantErr: false,
},
{
name: "both found - prefers spawn-style",
varFlag: "api=api",
searchableText: "The api endpoint uses api keys",
wantFind: "api",
wantVar: "api",
wantErr: false,
},
{
name: "neither found - error",
varFlag: "foo=bar",
searchableText: "Nothing matches here",
wantFind: "",
wantVar: "",
wantErr: true,
},
{
name: "empty left side - error",
varFlag: "=value",
searchableText: "Some text with value",
wantFind: "",
wantVar: "",
wantErr: true,
},
{
name: "empty right side - error",
varFlag: "value=",
searchableText: "Some text with value",
wantFind: "",
wantVar: "",
wantErr: true,
},
{
name: "no equals sign - error",
varFlag: "noequals",
searchableText: "Some text",
wantFind: "",
wantVar: "",
wantErr: true,
},
{
name: "value with equals sign",
varFlag: "env=KEY=VALUE",
searchableText: "Set KEY=VALUE in config",
wantFind: "KEY=VALUE",
wantVar: "env",
wantErr: false,
},
{
name: "partial match in longer word - finds it",
varFlag: "name=auth",
searchableText: "authentication module",
wantFind: "auth",
wantVar: "name",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotFind, gotVar, err := parseDistillVar(tt.varFlag, tt.searchableText)
if tt.wantErr {
if err == nil {
t.Errorf("parseDistillVar() expected error, got none")
}
return
}
if err != nil {
t.Errorf("parseDistillVar() unexpected error: %v", err)
return
}
if gotFind != tt.wantFind {
t.Errorf("parseDistillVar() find = %q, want %q", gotFind, tt.wantFind)
}
if gotVar != tt.wantVar {
t.Errorf("parseDistillVar() var = %q, want %q", gotVar, tt.wantVar)
}
})
}
}
func TestCollectSubgraphText(t *testing.T) {
// Create a simple subgraph for testing
subgraph := &MoleculeSubgraph{
Issues: []*types.Issue{
{
Title: "Epic: Feature Auth",
Description: "Implement authentication",
Design: "Use OAuth2",
},
{
Title: "Add login endpoint",
Notes: "See RFC 6749",
},
},
}
text := collectSubgraphText(subgraph)
// Verify all fields are included
expected := []string{
"Epic: Feature Auth",
"Implement authentication",
"Use OAuth2",
"Add login endpoint",
"See RFC 6749",
}
for _, exp := range expected {
if !strings.Contains(text, exp) {
t.Errorf("collectSubgraphText() missing %q", exp)
}
}
}
func TestIsProto(t *testing.T) {
tests := []struct {
name string
labels []string
want bool
}{
{"with template label", []string{"template", "other"}, true},
{"template only", []string{"template"}, true},
{"no template label", []string{"bug", "feature"}, false},
{"empty labels", []string{}, false},
{"nil labels", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
issue := &types.Issue{Labels: tt.labels}
got := isProto(issue)
if got != tt.want {
t.Errorf("isProto() = %v, want %v", got, tt.want)
}
})
}
}
func TestOperandType(t *testing.T) {
if got := operandType(true); got != "proto" {
t.Errorf("operandType(true) = %q, want %q", got, "proto")
}
if got := operandType(false); got != "molecule" {
t.Errorf("operandType(false) = %q, want %q", got, "molecule")
}
}
func TestMinPriority(t *testing.T) {
tests := []struct {
a, b, want int
}{
{1, 2, 1},
{2, 1, 1},
{0, 3, 0},
{3, 3, 3},
}
for _, tt := range tests {
got := minPriority(tt.a, tt.b)
if got != tt.want {
t.Errorf("minPriority(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
func TestBondProtoProto(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create two protos
protoA := &types.Issue{
Title: "Proto A",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
protoB := &types.Issue{
Title: "Proto B",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := store.CreateIssue(ctx, protoA, "test"); err != nil {
t.Fatalf("Failed to create protoA: %v", err)
}
if err := store.CreateIssue(ctx, protoB, "test"); err != nil {
t.Fatalf("Failed to create protoB: %v", err)
}
// Test sequential bond
result, err := bondProtoProto(ctx, store, protoA, protoB, types.BondTypeSequential, "", "test")
if err != nil {
t.Fatalf("bondProtoProto failed: %v", err)
}
if result.ResultType != "compound_proto" {
t.Errorf("ResultType = %q, want %q", result.ResultType, "compound_proto")
}
if result.BondType != types.BondTypeSequential {
t.Errorf("BondType = %q, want %q", result.BondType, types.BondTypeSequential)
}
// Verify compound was created
compound, err := store.GetIssue(ctx, result.ResultID)
if err != nil {
t.Fatalf("Failed to get compound: %v", err)
}
if !isProto(compound) {
t.Errorf("Compound should be a proto (have template label), got labels: %v", compound.Labels)
}
if compound.Priority != 1 {
t.Errorf("Compound priority = %d, want %d (min of 1,2)", compound.Priority, 1)
}
// Verify dependencies exist (protoA depends on compound via parent-child)
deps, err := store.GetDependenciesWithMetadata(ctx, protoA.ID)
if err != nil {
t.Fatalf("Failed to get deps for protoA: %v", err)
}
foundParentChild := false
for _, dep := range deps {
if dep.ID == compound.ID && dep.DependencyType == types.DepParentChild {
foundParentChild = true
}
}
if !foundParentChild {
t.Error("Expected parent-child dependency from protoA to compound")
}
}
func TestBondProtoMol(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a proto with a child issue
proto := &types.Issue{
Title: "Proto: {{name}}",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := store.CreateIssue(ctx, proto, "test"); err != nil {
t.Fatalf("Failed to create proto: %v", err)
}
protoChild := &types.Issue{
Title: "Step 1 for {{name}}",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
Labels: []string{MoleculeLabel},
}
if err := store.CreateIssue(ctx, protoChild, "test"); err != nil {
t.Fatalf("Failed to create proto child: %v", err)
}
// Add parent-child dependency
if err := store.AddDependency(ctx, &types.Dependency{
IssueID: protoChild.ID,
DependsOnID: proto.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
// Create a molecule (existing epic)
mol := &types.Issue{
Title: "Existing Work",
Status: types.StatusInProgress,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, mol, "test"); err != nil {
t.Fatalf("Failed to create molecule: %v", err)
}
// Bond proto to molecule
vars := map[string]string{"name": "auth-feature"}
result, err := bondProtoMol(ctx, store, proto, mol, types.BondTypeSequential, vars, "", "test", false, false)
if err != nil {
t.Fatalf("bondProtoMol failed: %v", err)
}
if result.ResultType != "compound_molecule" {
t.Errorf("ResultType = %q, want %q", result.ResultType, "compound_molecule")
}
if result.Spawned != 2 {
t.Errorf("Spawned = %d, want 2", result.Spawned)
}
if result.ResultID != mol.ID {
t.Errorf("ResultID = %q, want %q (original molecule)", result.ResultID, mol.ID)
}
}
func TestBondMolMol(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create two molecules
molA := &types.Issue{
Title: "Molecule A",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
molB := &types.Issue{
Title: "Molecule B",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, molA, "test"); err != nil {
t.Fatalf("Failed to create molA: %v", err)
}
if err := store.CreateIssue(ctx, molB, "test"); err != nil {
t.Fatalf("Failed to create molB: %v", err)
}
// Test sequential bond
result, err := bondMolMol(ctx, store, molA, molB, types.BondTypeSequential, "test")
if err != nil {
t.Fatalf("bondMolMol failed: %v", err)
}
if result.ResultType != "compound_molecule" {
t.Errorf("ResultType = %q, want %q", result.ResultType, "compound_molecule")
}
if result.ResultID != molA.ID {
t.Errorf("ResultID = %q, want %q", result.ResultID, molA.ID)
}
// Verify dependency: B blocks on A
deps, err := store.GetDependenciesWithMetadata(ctx, molB.ID)
if err != nil {
t.Fatalf("Failed to get deps for molB: %v", err)
}
foundBlocks := false
for _, dep := range deps {
if dep.ID == molA.ID && dep.DependencyType == types.DepBlocks {
foundBlocks = true
}
}
if !foundBlocks {
t.Error("Expected blocks dependency from molB to molA for sequential bond")
}
// Test parallel bond (create new molecules)
molC := &types.Issue{
Title: "Molecule C",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
molD := &types.Issue{
Title: "Molecule D",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, molC, "test"); err != nil {
t.Fatalf("Failed to create molC: %v", err)
}
if err := store.CreateIssue(ctx, molD, "test"); err != nil {
t.Fatalf("Failed to create molD: %v", err)
}
result2, err := bondMolMol(ctx, store, molC, molD, types.BondTypeParallel, "test")
if err != nil {
t.Fatalf("bondMolMol parallel failed: %v", err)
}
// Verify parent-child dependency for parallel
deps2, err := store.GetDependenciesWithMetadata(ctx, molD.ID)
if err != nil {
t.Fatalf("Failed to get deps for molD: %v", err)
}
foundParentChild := false
for _, dep := range deps2 {
if dep.ID == molC.ID && dep.DependencyType == types.DepParentChild {
foundParentChild = true
}
}
if !foundParentChild {
t.Errorf("Expected parent-child dependency for parallel bond, result: %+v", result2)
}
}
func TestSquashMolecule(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a molecule (root issue)
root := &types.Issue{
Title: "Test Molecule",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := s.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("Failed to create root: %v", err)
}
// Create ephemeral children
child1 := &types.Issue{
Title: "Step 1: Design",
Description: "Design the architecture",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
Ephemeral: true,
CloseReason: "Completed design",
}
child2 := &types.Issue{
Title: "Step 2: Implement",
Description: "Build the feature",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
Ephemeral: true,
CloseReason: "Code merged",
}
if err := s.CreateIssue(ctx, child1, "test"); err != nil {
t.Fatalf("Failed to create child1: %v", err)
}
if err := s.CreateIssue(ctx, child2, "test"); err != nil {
t.Fatalf("Failed to create child2: %v", err)
}
// Add parent-child dependencies
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: child1.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add child1 dependency: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: child2.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add child2 dependency: %v", err)
}
// Test squash with keep-children
children := []*types.Issue{child1, child2}
result, err := squashMolecule(ctx, s, root, children, true, "", "test")
if err != nil {
t.Fatalf("squashMolecule failed: %v", err)
}
if result.SquashedCount != 2 {
t.Errorf("SquashedCount = %d, want 2", result.SquashedCount)
}
if result.DeletedCount != 0 {
t.Errorf("DeletedCount = %d, want 0 (keep-children)", result.DeletedCount)
}
if !result.KeptChildren {
t.Error("KeptChildren should be true")
}
// Verify digest was created
digest, err := s.GetIssue(ctx, result.DigestID)
if err != nil {
t.Fatalf("Failed to get digest: %v", err)
}
if digest.Ephemeral {
t.Error("Digest should NOT be ephemeral")
}
if digest.Status != types.StatusClosed {
t.Errorf("Digest status = %v, want closed", digest.Status)
}
if !strings.Contains(digest.Description, "Step 1: Design") {
t.Error("Digest should contain child titles")
}
if !strings.Contains(digest.Description, "Completed design") {
t.Error("Digest should contain close reasons")
}
// Children should still exist
c1, err := s.GetIssue(ctx, child1.ID)
if err != nil || c1 == nil {
t.Error("Child1 should still exist with keep-children")
}
}
func TestSquashMoleculeWithDelete(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a molecule with ephemeral children
root := &types.Issue{
Title: "Delete Test Molecule",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := s.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("Failed to create root: %v", err)
}
child := &types.Issue{
Title: "Wisp Step",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
Ephemeral: true,
}
if err := s.CreateIssue(ctx, child, "test"); err != nil {
t.Fatalf("Failed to create child: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: child.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
// Squash with delete (keepChildren=false)
result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, false, "", "test")
if err != nil {
t.Fatalf("squashMolecule failed: %v", err)
}
if result.DeletedCount != 1 {
t.Errorf("DeletedCount = %d, want 1", result.DeletedCount)
}
// Child should be deleted
c, err := s.GetIssue(ctx, child.ID)
if err == nil && c != nil {
t.Error("Child should have been deleted")
}
// Digest should exist
digest, err := s.GetIssue(ctx, result.DigestID)
if err != nil || digest == nil {
t.Error("Digest should exist after squash")
}
}
func TestGenerateDigest(t *testing.T) {
root := &types.Issue{
Title: "Test Molecule",
}
children := []*types.Issue{
{
Title: "Step 1",
Description: "First step description",
Status: types.StatusClosed,
CloseReason: "Done",
},
{
Title: "Step 2",
Description: "Second step description that is longer",
Status: types.StatusInProgress,
},
}
digest := generateDigest(root, children)
// Verify structure
if !strings.Contains(digest, "## Molecule Execution Summary") {
t.Error("Digest should have summary header")
}
if !strings.Contains(digest, "Test Molecule") {
t.Error("Digest should contain molecule title")
}
if !strings.Contains(digest, "**Steps**: 2") {
t.Error("Digest should show step count")
}
if !strings.Contains(digest, "**Completed**: 1/2") {
t.Error("Digest should show completion stats")
}
if !strings.Contains(digest, "**In Progress**: 1") {
t.Error("Digest should show in-progress count")
}
if !strings.Contains(digest, "Step 1") {
t.Error("Digest should list step titles")
}
if !strings.Contains(digest, "*Outcome: Done*") {
t.Error("Digest should include close reasons")
}
}
// TestSquashMoleculeWithAgentSummary verifies that agent-provided summaries are used
func TestSquashMoleculeWithAgentSummary(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a molecule with ephemeral child
root := &types.Issue{
Title: "Agent Summary Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := s.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("Failed to create root: %v", err)
}
child := &types.Issue{
Title: "Wisp Step",
Description: "This should NOT appear in digest",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
Ephemeral: true,
CloseReason: "Done",
}
if err := s.CreateIssue(ctx, child, "test"); err != nil {
t.Fatalf("Failed to create child: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: child.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
// Squash with agent-provided summary
agentSummary := "## AI-Generated Summary\n\nThe agent completed the task successfully."
result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, true, agentSummary, "test")
if err != nil {
t.Fatalf("squashMolecule failed: %v", err)
}
// Verify digest uses agent summary, not auto-generated content
digest, err := s.GetIssue(ctx, result.DigestID)
if err != nil {
t.Fatalf("Failed to get digest: %v", err)
}
if digest.Description != agentSummary {
t.Errorf("Digest should use agent summary.\nGot: %s\nWant: %s", digest.Description, agentSummary)
}
// Verify auto-generated content is NOT present
if strings.Contains(digest.Description, "Wisp Step") {
t.Error("Digest should NOT contain auto-generated content when agent summary provided")
}
}
// =============================================================================
// Spawn --attach Tests (bd-f7p1)
// =============================================================================
// TestSpawnWithBasicAttach tests spawning a proto with one --attach flag
func TestSpawnWithBasicAttach(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create primary proto with a child
primaryProto := &types.Issue{
Title: "Primary: {{feature}}",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, primaryProto, "test"); err != nil {
t.Fatalf("Failed to create primary proto: %v", err)
}
primaryChild := &types.Issue{
Title: "Step 1 for {{feature}}",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := s.CreateIssue(ctx, primaryChild, "test"); err != nil {
t.Fatalf("Failed to create primary child: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: primaryChild.ID,
DependsOnID: primaryProto.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add primary child dependency: %v", err)
}
// Create attachment proto with a child
attachProto := &types.Issue{
Title: "Attachment: {{feature}} docs",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, attachProto, "test"); err != nil {
t.Fatalf("Failed to create attach proto: %v", err)
}
attachChild := &types.Issue{
Title: "Write docs for {{feature}}",
Status: types.StatusOpen,
Priority: 3,
IssueType: types.TypeTask,
}
if err := s.CreateIssue(ctx, attachChild, "test"); err != nil {
t.Fatalf("Failed to create attach child: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: attachChild.ID,
DependsOnID: attachProto.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add attach child dependency: %v", err)
}
// Spawn primary proto
primarySubgraph, err := loadTemplateSubgraph(ctx, s, primaryProto.ID)
if err != nil {
t.Fatalf("Failed to load primary subgraph: %v", err)
}
vars := map[string]string{"feature": "auth"}
spawnResult, err := spawnMolecule(ctx, s, primarySubgraph, vars, "", "test", true, "")
if err != nil {
t.Fatalf("Failed to spawn primary: %v", err)
}
if spawnResult.Created != 2 {
t.Errorf("Spawn created = %d, want 2", spawnResult.Created)
}
// Get the spawned molecule
spawnedMol, err := s.GetIssue(ctx, spawnResult.NewEpicID)
if err != nil {
t.Fatalf("Failed to get spawned molecule: %v", err)
}
// Attach the second proto (simulating --attach flag behavior)
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test", false, false)
if err != nil {
t.Fatalf("Failed to bond attachment: %v", err)
}
if bondResult.Spawned != 2 {
t.Errorf("Bond spawned = %d, want 2", bondResult.Spawned)
}
if bondResult.ResultType != "compound_molecule" {
t.Errorf("ResultType = %q, want %q", bondResult.ResultType, "compound_molecule")
}
// Verify the spawned attachment root has dependency on the primary molecule
attachedRootID := bondResult.IDMapping[attachProto.ID]
deps, err := s.GetDependenciesWithMetadata(ctx, attachedRootID)
if err != nil {
t.Fatalf("Failed to get deps: %v", err)
}
foundBlocks := false
for _, dep := range deps {
if dep.ID == spawnedMol.ID && dep.DependencyType == types.DepBlocks {
foundBlocks = true
}
}
if !foundBlocks {
t.Error("Expected blocks dependency from attached proto to spawned molecule for sequential bond")
}
// Verify variable substitution worked in attached issues
attachedRoot, err := s.GetIssue(ctx, attachedRootID)
if err != nil {
t.Fatalf("Failed to get attached root: %v", err)
}
if !strings.Contains(attachedRoot.Title, "auth") {
t.Errorf("Attached root title %q should contain 'auth' from variable substitution", attachedRoot.Title)
}
}
// TestSpawnWithMultipleAttachments tests spawning with --attach A --attach B
func TestSpawnWithMultipleAttachments(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create primary proto
primaryProto := &types.Issue{
Title: "Primary Feature",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, primaryProto, "test"); err != nil {
t.Fatalf("Failed to create primary proto: %v", err)
}
// Create first attachment proto
attachA := &types.Issue{
Title: "Attachment A: Testing",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, attachA, "test"); err != nil {
t.Fatalf("Failed to create attachA: %v", err)
}
// Create second attachment proto
attachB := &types.Issue{
Title: "Attachment B: Documentation",
Status: types.StatusOpen,
Priority: 3,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, attachB, "test"); err != nil {
t.Fatalf("Failed to create attachB: %v", err)
}
// Spawn primary
primarySubgraph, err := loadTemplateSubgraph(ctx, s, primaryProto.ID)
if err != nil {
t.Fatalf("Failed to load primary subgraph: %v", err)
}
spawnResult, err := spawnMolecule(ctx, s, primarySubgraph, nil, "", "test", true, "")
if err != nil {
t.Fatalf("Failed to spawn primary: %v", err)
}
spawnedMol, err := s.GetIssue(ctx, spawnResult.NewEpicID)
if err != nil {
t.Fatalf("Failed to get spawned molecule: %v", err)
}
// Attach both protos (simulating --attach A --attach B)
bondResultA, err := bondProtoMol(ctx, s, attachA, spawnedMol, types.BondTypeSequential, nil, "", "test", false, false)
if err != nil {
t.Fatalf("Failed to bond attachA: %v", err)
}
bondResultB, err := bondProtoMol(ctx, s, attachB, spawnedMol, types.BondTypeSequential, nil, "", "test", false, false)
if err != nil {
t.Fatalf("Failed to bond attachB: %v", err)
}
// Both should have spawned their protos
if bondResultA.Spawned != 1 {
t.Errorf("bondResultA.Spawned = %d, want 1", bondResultA.Spawned)
}
if bondResultB.Spawned != 1 {
t.Errorf("bondResultB.Spawned = %d, want 1", bondResultB.Spawned)
}
// Both should depend on the primary molecule
attachedAID := bondResultA.IDMapping[attachA.ID]
attachedBID := bondResultB.IDMapping[attachB.ID]
depsA, err := s.GetDependenciesWithMetadata(ctx, attachedAID)
if err != nil {
t.Fatalf("Failed to get deps for A: %v", err)
}
depsB, err := s.GetDependenciesWithMetadata(ctx, attachedBID)
if err != nil {
t.Fatalf("Failed to get deps for B: %v", err)
}
foundABlocks := false
for _, dep := range depsA {
if dep.ID == spawnedMol.ID && dep.DependencyType == types.DepBlocks {
foundABlocks = true
}
}
foundBBlocks := false
for _, dep := range depsB {
if dep.ID == spawnedMol.ID && dep.DependencyType == types.DepBlocks {
foundBBlocks = true
}
}
if !foundABlocks {
t.Error("Expected A to block on spawned molecule")
}
if !foundBBlocks {
t.Error("Expected B to block on spawned molecule")
}
}
// TestSpawnAttachTypes verifies sequential vs parallel bonding behavior
func TestSpawnAttachTypes(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create primary proto
primaryProto := &types.Issue{
Title: "Primary",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, primaryProto, "test"); err != nil {
t.Fatalf("Failed to create primary: %v", err)
}
// Create attachment proto
attachProto := &types.Issue{
Title: "Attachment",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, attachProto, "test"); err != nil {
t.Fatalf("Failed to create attachment: %v", err)
}
tests := []struct {
name string
bondType string
expectType types.DependencyType
}{
{"sequential uses blocks", types.BondTypeSequential, types.DepBlocks},
{"parallel uses parent-child", types.BondTypeParallel, types.DepParentChild},
{"conditional uses conditional-blocks", types.BondTypeConditional, types.DepConditionalBlocks},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Spawn fresh primary for each test
primarySubgraph, err := loadTemplateSubgraph(ctx, s, primaryProto.ID)
if err != nil {
t.Fatalf("Failed to load primary subgraph: %v", err)
}
spawnResult, err := spawnMolecule(ctx, s, primarySubgraph, nil, "", "test", true, "")
if err != nil {
t.Fatalf("Failed to spawn primary: %v", err)
}
spawnedMol, err := s.GetIssue(ctx, spawnResult.NewEpicID)
if err != nil {
t.Fatalf("Failed to get spawned molecule: %v", err)
}
// Bond with specified type
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, tt.bondType, nil, "", "test", false, false)
if err != nil {
t.Fatalf("Failed to bond: %v", err)
}
// Check dependency type
attachedID := bondResult.IDMapping[attachProto.ID]
deps, err := s.GetDependenciesWithMetadata(ctx, attachedID)
if err != nil {
t.Fatalf("Failed to get deps: %v", err)
}
foundExpected := false
for _, dep := range deps {
if dep.ID == spawnedMol.ID && dep.DependencyType == tt.expectType {
foundExpected = true
}
}
if !foundExpected {
t.Errorf("Expected %s dependency from attached to spawned molecule", tt.expectType)
}
})
}
}
// TestSpawnAttachNonProtoError tests that attaching a non-proto fails validation
func TestSpawnAttachNonProtoError(t *testing.T) {
// The isProto function is tested separately in TestIsProto
// This test verifies the validation logic that would be used in runMolSpawn
// Create a non-proto issue (no template label)
issue := &types.Issue{
Title: "Not a proto",
Status: types.StatusOpen,
Labels: []string{"bug"}, // Not MoleculeLabel
}
if isProto(issue) {
t.Error("isProto should return false for issue without template label")
}
// Issue with template label should pass
protoIssue := &types.Issue{
Title: "A proto",
Status: types.StatusOpen,
Labels: []string{MoleculeLabel},
}
if !isProto(protoIssue) {
t.Error("isProto should return true for issue with template label")
}
}
// TestSpawnVariableAggregation tests that variables from primary + attachments are combined
func TestSpawnVariableAggregation(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create primary proto with one variable
primaryProto := &types.Issue{
Title: "Feature: {{feature_name}}",
Description: "Implement the {{feature_name}} feature",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, primaryProto, "test"); err != nil {
t.Fatalf("Failed to create primary: %v", err)
}
// Create attachment proto with a different variable
attachProto := &types.Issue{
Title: "Docs for {{doc_version}}",
Description: "Document version {{doc_version}}",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, attachProto, "test"); err != nil {
t.Fatalf("Failed to create attachment: %v", err)
}
// Load subgraphs and extract variables
primarySubgraph, err := loadTemplateSubgraph(ctx, s, primaryProto.ID)
if err != nil {
t.Fatalf("Failed to load primary subgraph: %v", err)
}
attachSubgraph, err := loadTemplateSubgraph(ctx, s, attachProto.ID)
if err != nil {
t.Fatalf("Failed to load attach subgraph: %v", err)
}
// Aggregate variables (simulating runMolSpawn logic)
requiredVars := extractAllVariables(primarySubgraph)
attachVars := extractAllVariables(attachSubgraph)
for _, v := range attachVars {
found := false
for _, rv := range requiredVars {
if rv == v {
found = true
break
}
}
if !found {
requiredVars = append(requiredVars, v)
}
}
// Should have both variables
if len(requiredVars) != 2 {
t.Errorf("Expected 2 required vars, got %d: %v", len(requiredVars), requiredVars)
}
hasFeatureName := false
hasDocVersion := false
for _, v := range requiredVars {
if v == "feature_name" {
hasFeatureName = true
}
if v == "doc_version" {
hasDocVersion = true
}
}
if !hasFeatureName {
t.Error("Missing feature_name variable from primary proto")
}
if !hasDocVersion {
t.Error("Missing doc_version variable from attachment proto")
}
// Provide both variables and verify substitution
vars := map[string]string{
"feature_name": "authentication",
"doc_version": "2.0",
}
// Spawn primary with variables
spawnResult, err := spawnMolecule(ctx, s, primarySubgraph, vars, "", "test", true, "")
if err != nil {
t.Fatalf("Failed to spawn primary: %v", err)
}
// Verify primary variable was substituted
spawnedPrimary, err := s.GetIssue(ctx, spawnResult.NewEpicID)
if err != nil {
t.Fatalf("Failed to get spawned primary: %v", err)
}
if !strings.Contains(spawnedPrimary.Title, "authentication") {
t.Errorf("Primary title %q should contain 'authentication'", spawnedPrimary.Title)
}
// Bond attachment with same variables
spawnedMol, _ := s.GetIssue(ctx, spawnResult.NewEpicID)
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test", false, false)
if err != nil {
t.Fatalf("Failed to bond: %v", err)
}
// Verify attachment variable was substituted
attachedID := bondResult.IDMapping[attachProto.ID]
attachedIssue, err := s.GetIssue(ctx, attachedID)
if err != nil {
t.Fatalf("Failed to get attached issue: %v", err)
}
if !strings.Contains(attachedIssue.Title, "2.0") {
t.Errorf("Attached title %q should contain '2.0'", attachedIssue.Title)
}
}
// TestSpawnAttachDryRunOutput tests that dry-run includes attachment info
// This is a lighter test since dry-run is mainly a CLI output concern
func TestSpawnAttachDryRunOutput(t *testing.T) {
// The dry-run logic in runMolSpawn outputs attachment info when len(attachments) > 0
// We verify the data structures that would be used in dry-run
type attachmentInfo struct {
id string
title string
subgraph *MoleculeSubgraph
}
// Simulate the attachment info collection
attachments := []attachmentInfo{
{id: "test-1", title: "Attachment 1", subgraph: &MoleculeSubgraph{
Issues: []*types.Issue{{Title: "Issue A"}, {Title: "Issue B"}},
}},
{id: "test-2", title: "Attachment 2", subgraph: &MoleculeSubgraph{
Issues: []*types.Issue{{Title: "Issue C"}},
}},
}
// Verify attachment count calculation (used in dry-run output)
totalAttachmentIssues := 0
for _, attach := range attachments {
totalAttachmentIssues += len(attach.subgraph.Issues)
}
if totalAttachmentIssues != 3 {
t.Errorf("Expected 3 total attachment issues, got %d", totalAttachmentIssues)
}
// Verify bond type would be included (sequential is default)
attachType := types.BondTypeSequential
if attachType != "sequential" {
t.Errorf("Expected default attach type 'sequential', got %q", attachType)
}
}
// TestWispFilteringFromExport verifies that wisp issues are filtered
// from JSONL export (bd-687g). Wisp issues should only exist in SQLite,
// not in issues.jsonl, to prevent "zombie" resurrection after mol squash.
func TestWispFilteringFromExport(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a mix of wisp and non-wisp issues
normalIssue := &types.Issue{
Title: "Normal Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
Ephemeral: false,
}
wispIssue := &types.Issue{
Title: "Wisp Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
Ephemeral: true,
}
if err := s.CreateIssue(ctx, normalIssue, "test"); err != nil {
t.Fatalf("Failed to create normal issue: %v", err)
}
if err := s.CreateIssue(ctx, wispIssue, "test"); err != nil {
t.Fatalf("Failed to create wisp issue: %v", err)
}
// Get all issues from DB - should include both
allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to search issues: %v", err)
}
if len(allIssues) != 2 {
t.Fatalf("Expected 2 issues in DB, got %d", len(allIssues))
}
// Filter wisp issues (simulating export behavior)
exportableIssues := make([]*types.Issue, 0)
for _, issue := range allIssues {
if !issue.Ephemeral {
exportableIssues = append(exportableIssues, issue)
}
}
// Should only have the non-wisp issue
if len(exportableIssues) != 1 {
t.Errorf("Expected 1 exportable issue, got %d", len(exportableIssues))
}
if exportableIssues[0].ID != normalIssue.ID {
t.Errorf("Expected normal issue %s, got %s", normalIssue.ID, exportableIssues[0].ID)
}
}
// =============================================================================
// Mol Current Tests (bd-nurq)
// =============================================================================
// TestGetMoleculeProgress tests loading a molecule and computing progress
func TestGetMoleculeProgress(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a molecule (epic with template label)
root := &types.Issue{
Title: "Test Molecule",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{BeadsTemplateLabel},
Assignee: "test-agent",
}
if err := s.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("Failed to create root: %v", err)
}
// Create steps with different statuses
step1 := &types.Issue{
Title: "Step 1: Done",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
}
step2 := &types.Issue{
Title: "Step 2: Current",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeTask,
}
step3 := &types.Issue{
Title: "Step 3: Pending",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
for _, step := range []*types.Issue{step1, step2, step3} {
if err := s.CreateIssue(ctx, step, "test"); err != nil {
t.Fatalf("Failed to create step: %v", err)
}
// Add parent-child dependency
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: step.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
}
// Add blocking dependency: step3 blocks on step2
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: step3.ID,
DependsOnID: step2.ID,
Type: types.DepBlocks,
}, "test"); err != nil {
t.Fatalf("Failed to add blocking dependency: %v", err)
}
// Get progress
progress, err := getMoleculeProgress(ctx, s, root.ID)
if err != nil {
t.Fatalf("getMoleculeProgress failed: %v", err)
}
// Verify progress
if progress.MoleculeID != root.ID {
t.Errorf("MoleculeID = %q, want %q", progress.MoleculeID, root.ID)
}
if progress.MoleculeTitle != root.Title {
t.Errorf("MoleculeTitle = %q, want %q", progress.MoleculeTitle, root.Title)
}
if progress.Assignee != "test-agent" {
t.Errorf("Assignee = %q, want %q", progress.Assignee, "test-agent")
}
if progress.Total != 3 {
t.Errorf("Total = %d, want 3", progress.Total)
}
if progress.Completed != 1 {
t.Errorf("Completed = %d, want 1", progress.Completed)
}
if progress.CurrentStep == nil {
t.Error("CurrentStep should not be nil")
} else if progress.CurrentStep.ID != step2.ID {
t.Errorf("CurrentStep.ID = %q, want %q", progress.CurrentStep.ID, step2.ID)
}
if len(progress.Steps) != 3 {
t.Errorf("Steps count = %d, want 3", len(progress.Steps))
}
}
// TestFindParentMolecule tests walking up parent-child chain
func TestFindParentMolecule(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create molecule root (epic with template label)
root := &types.Issue{
Title: "Molecule Root",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{BeadsTemplateLabel},
}
if err := s.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("Failed to create root: %v", err)
}
// Create child step
child := &types.Issue{
Title: "Child Step",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := s.CreateIssue(ctx, child, "test"); err != nil {
t.Fatalf("Failed to create child: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: child.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add parent-child: %v", err)
}
// Create grandchild
grandchild := &types.Issue{
Title: "Grandchild Step",
Status: types.StatusOpen,
Priority: 3,
IssueType: types.TypeTask,
}
if err := s.CreateIssue(ctx, grandchild, "test"); err != nil {
t.Fatalf("Failed to create grandchild: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: grandchild.ID,
DependsOnID: child.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add grandchild parent-child: %v", err)
}
// Find parent molecule from grandchild
moleculeID := findParentMolecule(ctx, s, grandchild.ID)
if moleculeID != root.ID {
t.Errorf("findParentMolecule(grandchild) = %q, want %q", moleculeID, root.ID)
}
// Find parent molecule from child
moleculeID = findParentMolecule(ctx, s, child.ID)
if moleculeID != root.ID {
t.Errorf("findParentMolecule(child) = %q, want %q", moleculeID, root.ID)
}
// Find parent molecule from root
moleculeID = findParentMolecule(ctx, s, root.ID)
if moleculeID != root.ID {
t.Errorf("findParentMolecule(root) = %q, want %q", moleculeID, root.ID)
}
// Create orphan issue (not part of any molecule)
orphan := &types.Issue{
Title: "Orphan Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := s.CreateIssue(ctx, orphan, "test"); err != nil {
t.Fatalf("Failed to create orphan: %v", err)
}
// Should return empty for orphan
moleculeID = findParentMolecule(ctx, s, orphan.ID)
if moleculeID != "" {
t.Errorf("findParentMolecule(orphan) = %q, want empty", moleculeID)
}
}
// TestAdvanceToNextStep tests auto-advancing to next step
func TestAdvanceToNextStep(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create molecule with sequential steps
root := &types.Issue{
Title: "Advance Test Molecule",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{BeadsTemplateLabel},
}
if err := s.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("Failed to create root: %v", err)
}
step1 := &types.Issue{
Title: "Step 1",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
}
step2 := &types.Issue{
Title: "Step 2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
for _, step := range []*types.Issue{step1, step2} {
if err := s.CreateIssue(ctx, step, "test"); err != nil {
t.Fatalf("Failed to create step: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: step.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
}
// step2 blocks on step1
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: step2.ID,
DependsOnID: step1.ID,
Type: types.DepBlocks,
}, "test"); err != nil {
t.Fatalf("Failed to add blocking dependency: %v", err)
}
// Advance from step1 (just closed) without auto-claim
result, err := AdvanceToNextStep(ctx, s, step1.ID, false, "test")
if err != nil {
t.Fatalf("AdvanceToNextStep failed: %v", err)
}
if result == nil {
t.Fatal("result should not be nil")
}
if result.MoleculeID != root.ID {
t.Errorf("MoleculeID = %q, want %q", result.MoleculeID, root.ID)
}
if result.NextStep == nil {
t.Error("NextStep should not be nil")
} else if result.NextStep.ID != step2.ID {
t.Errorf("NextStep.ID = %q, want %q", result.NextStep.ID, step2.ID)
}
if result.AutoAdvanced {
t.Error("AutoAdvanced should be false when not requested")
}
// Verify step2 is still open
step2Updated, _ := s.GetIssue(ctx, step2.ID)
if step2Updated.Status != types.StatusOpen {
t.Errorf("Step2 status = %v, want open (no auto-claim)", step2Updated.Status)
}
// Now test with auto-claim
result, err = AdvanceToNextStep(ctx, s, step1.ID, true, "test")
if err != nil {
t.Fatalf("AdvanceToNextStep with auto-claim failed: %v", err)
}
if !result.AutoAdvanced {
t.Error("AutoAdvanced should be true when requested")
}
// Verify step2 is now in_progress
step2Updated, _ = s.GetIssue(ctx, step2.ID)
if step2Updated.Status != types.StatusInProgress {
t.Errorf("Step2 status = %v, want in_progress (auto-claim)", step2Updated.Status)
}
}
// TestAdvanceToNextStepMoleculeComplete tests behavior when molecule is complete
func TestAdvanceToNextStepMoleculeComplete(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create molecule with single step
root := &types.Issue{
Title: "Complete Test Molecule",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Labels: []string{BeadsTemplateLabel},
}
if err := s.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("Failed to create root: %v", err)
}
step1 := &types.Issue{
Title: "Only Step",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
}
if err := s.CreateIssue(ctx, step1, "test"); err != nil {
t.Fatalf("Failed to create step: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: step1.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
// Advance from the only step (molecule should be complete)
result, err := AdvanceToNextStep(ctx, s, step1.ID, false, "test")
if err != nil {
t.Fatalf("AdvanceToNextStep failed: %v", err)
}
if result == nil {
t.Fatal("result should not be nil")
}
if !result.MolComplete {
t.Error("MolComplete should be true when all steps are done")
}
if result.NextStep != nil {
t.Error("NextStep should be nil when molecule is complete")
}
}
// TestAdvanceToNextStepOrphanIssue tests behavior for non-molecule issues
func TestAdvanceToNextStepOrphanIssue(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create standalone issue (not part of molecule)
orphan := &types.Issue{
Title: "Standalone Issue",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
}
if err := s.CreateIssue(ctx, orphan, "test"); err != nil {
t.Fatalf("Failed to create orphan: %v", err)
}
// Advance should return nil (not part of molecule)
result, err := AdvanceToNextStep(ctx, s, orphan.ID, false, "test")
if err != nil {
t.Fatalf("AdvanceToNextStep failed: %v", err)
}
if result != nil {
t.Error("result should be nil for orphan issue")
}
}
// =============================================================================
// Dynamic Bonding Tests (bd-xo1o.1)
// =============================================================================
// TestGenerateBondedID tests the custom ID generation for dynamic bonding
func TestGenerateBondedID(t *testing.T) {
tests := []struct {
name string
oldID string
rootID string
opts CloneOptions
wantID string
wantErr bool
errMatch string
}{
{
name: "root issue with simple childRef",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm-ace",
},
wantID: "patrol-x7k.arm-ace",
},
{
name: "root issue with variable substitution",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm-{{polecat_name}}",
Vars: map[string]string{"polecat_name": "ace"},
},
wantID: "patrol-x7k.arm-ace",
},
{
name: "child issue with relative ID",
oldID: "mol-arm.capture",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm-ace",
},
wantID: "patrol-x7k.arm-ace.capture",
},
{
name: "nested child issue",
oldID: "mol-arm.capture.sub",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm-ace",
},
wantID: "patrol-x7k.arm-ace.capture.sub",
},
{
name: "no parent ID returns empty (not a bonded operation)",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{},
wantID: "",
},
{
name: "empty childRef after substitution is error",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "{{missing_var}}",
},
wantErr: true,
errMatch: "invalid childRef",
},
{
name: "childRef with special chars is error",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm/ace",
},
wantErr: true,
errMatch: "invalid childRef",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotID, err := generateBondedID(tt.oldID, tt.rootID, tt.opts)
if tt.wantErr {
if err == nil {
t.Errorf("generateBondedID() expected error containing %q, got nil", tt.errMatch)
} else if !strings.Contains(err.Error(), tt.errMatch) {
t.Errorf("generateBondedID() error = %q, want error containing %q", err.Error(), tt.errMatch)
}
return
}
if err != nil {
t.Errorf("generateBondedID() unexpected error: %v", err)
return
}
if gotID != tt.wantID {
t.Errorf("generateBondedID() = %q, want %q", gotID, tt.wantID)
}
})
}
}
// TestGetRelativeID tests extracting relative portion from child IDs
func TestGetRelativeID(t *testing.T) {
tests := []struct {
name string
oldID string
rootID string
want string
}{
{
name: "same ID returns empty",
oldID: "mol-arm",
rootID: "mol-arm",
want: "",
},
{
name: "child with single step",
oldID: "mol-arm.capture",
rootID: "mol-arm",
want: "capture",
},
{
name: "child with nested steps",
oldID: "mol-arm.capture.sub.deep",
rootID: "mol-arm",
want: "capture.sub.deep",
},
{
name: "unrelated IDs returns empty",
oldID: "other-123",
rootID: "mol-arm",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getRelativeID(tt.oldID, tt.rootID)
if got != tt.want {
t.Errorf("getRelativeID() = %q, want %q", got, tt.want)
}
})
}
}
// TestBondProtoMolWithRef tests dynamic bonding with custom child references
func TestBondProtoMolWithRef(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "patrol"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a proto with child steps (mol-polecat-arm template)
protoRoot := &types.Issue{
Title: "Polecat Arm: {{polecat_name}}",
IssueType: types.TypeEpic,
Status: types.StatusOpen,
Priority: 2,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, protoRoot, "test"); err != nil {
t.Fatalf("Failed to create proto root: %v", err)
}
// Add proto steps
protoCapture := &types.Issue{
Title: "Capture {{polecat_name}}",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
}
if err := s.CreateIssue(ctx, protoCapture, "test"); err != nil {
t.Fatalf("Failed to create proto capture: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: protoCapture.ID,
DependsOnID: protoRoot.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add proto dependency: %v", err)
}
// Create target molecule (patrol-xxx)
patrol := &types.Issue{
Title: "Witness Patrol",
IssueType: types.TypeEpic,
Status: types.StatusInProgress,
Priority: 1,
}
if err := s.CreateIssue(ctx, patrol, "test"); err != nil {
t.Fatalf("Failed to create patrol: %v", err)
}
// Bond proto to patrol with custom child ref
vars := map[string]string{"polecat_name": "ace"}
childRef := "arm-{{polecat_name}}"
result, err := bondProtoMol(ctx, s, protoRoot, patrol, types.BondTypeSequential, vars, childRef, "test", false, false)
if err != nil {
t.Fatalf("bondProtoMol failed: %v", err)
}
// Verify spawned count
if result.Spawned != 2 {
t.Errorf("Spawned = %d, want 2", result.Spawned)
}
// Verify root ID follows pattern: patrol.arm-ace
expectedRootID := patrol.ID + ".arm-ace"
if result.IDMapping[protoRoot.ID] != expectedRootID {
t.Errorf("Root ID = %q, want %q", result.IDMapping[protoRoot.ID], expectedRootID)
}
// Verify child ID follows pattern: patrol.arm-ace.relative
// The child's ID should be patrol.arm-ace.capture (but relative part depends on proto structure)
childID := result.IDMapping[protoCapture.ID]
if !strings.HasPrefix(childID, expectedRootID+".") {
t.Errorf("Child ID %q should start with %q", childID, expectedRootID+".")
}
// Verify the spawned issues exist and have correct titles
spawnedRoot, err := s.GetIssue(ctx, expectedRootID)
if err != nil {
t.Fatalf("Failed to get spawned root: %v", err)
}
if !strings.Contains(spawnedRoot.Title, "ace") {
t.Errorf("Spawned root title %q should contain 'ace'", spawnedRoot.Title)
}
}
// TestBondProtoMolMultipleArms tests bonding multiple arms to the same parent
func TestBondProtoMolMultipleArms(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "patrol"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create simple proto
proto := &types.Issue{
Title: "Arm: {{name}}",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, proto, "test"); err != nil {
t.Fatalf("Failed to create proto: %v", err)
}
// Create parent patrol
patrol := &types.Issue{
Title: "Patrol",
IssueType: types.TypeEpic,
Status: types.StatusOpen,
Priority: 1,
}
if err := s.CreateIssue(ctx, patrol, "test"); err != nil {
t.Fatalf("Failed to create patrol: %v", err)
}
// Bond arm-ace
varsAce := map[string]string{"name": "ace"}
resultAce, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsAce, "arm-{{name}}", "test", false, false)
if err != nil {
t.Fatalf("bondProtoMol (ace) failed: %v", err)
}
// Bond arm-nux
varsNux := map[string]string{"name": "nux"}
resultNux, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsNux, "arm-{{name}}", "test", false, false)
if err != nil {
t.Fatalf("bondProtoMol (nux) failed: %v", err)
}
// Verify IDs are correct and distinct
aceID := resultAce.IDMapping[proto.ID]
nuxID := resultNux.IDMapping[proto.ID]
expectedAceID := patrol.ID + ".arm-ace"
expectedNuxID := patrol.ID + ".arm-nux"
if aceID != expectedAceID {
t.Errorf("Ace ID = %q, want %q", aceID, expectedAceID)
}
if nuxID != expectedNuxID {
t.Errorf("Nux ID = %q, want %q", nuxID, expectedNuxID)
}
// Verify both exist
aceIssue, err := s.GetIssue(ctx, aceID)
if err != nil || aceIssue == nil {
t.Errorf("Ace issue not found: %v", err)
}
nuxIssue, err := s.GetIssue(ctx, nuxID)
if err != nil || nuxIssue == nil {
t.Errorf("Nux issue not found: %v", err)
}
}
// =============================================================================
// Parallel Detection Tests (bd-xo1o.4)
// =============================================================================
// TestAnalyzeMoleculeParallelNoBlocking tests parallel detection with no blocking deps
func TestAnalyzeMoleculeParallelNoBlocking(t *testing.T) {
// Create a simple molecule with parallel children (no blocking deps between them)
root := &types.Issue{
ID: "mol-test",
Title: "Test Molecule",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
child1 := &types.Issue{
ID: "mol-test.step1",
Title: "Step 1",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
child2 := &types.Issue{
ID: "mol-test.step2",
Title: "Step 2",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, child1, child2},
IssueMap: map[string]*types.Issue{
root.ID: root,
child1.ID: child1,
child2.ID: child2,
},
Dependencies: []*types.Dependency{
{IssueID: child1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: child2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
},
}
analysis := analyzeMoleculeParallel(subgraph)
// All 3 should be ready (root + 2 children with no blocking deps)
if analysis.ReadySteps != 3 {
t.Errorf("ReadySteps = %d, want 3", analysis.ReadySteps)
}
// Children should be in the same parallel group
step1Info := analysis.Steps[child1.ID]
step2Info := analysis.Steps[child2.ID]
if step1Info.ParallelGroup == "" {
t.Error("Step1 should be in a parallel group")
}
if step1Info.ParallelGroup != step2Info.ParallelGroup {
t.Errorf("Step1 and Step2 should be in same parallel group: %s vs %s",
step1Info.ParallelGroup, step2Info.ParallelGroup)
}
// Check can_parallel
found := false
for _, id := range step1Info.CanParallel {
if id == child2.ID {
found = true
break
}
}
if !found {
t.Errorf("Step1.CanParallel should contain Step2.ID")
}
}
// TestAnalyzeMoleculeParallelWithBlocking tests parallel detection with blocking deps
func TestAnalyzeMoleculeParallelWithBlocking(t *testing.T) {
// Create a sequential molecule: step1 blocks step2
root := &types.Issue{
ID: "mol-seq",
Title: "Sequential Molecule",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
step1 := &types.Issue{
ID: "mol-seq.step1",
Title: "Step 1",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
step2 := &types.Issue{
ID: "mol-seq.step2",
Title: "Step 2 (blocked by Step 1)",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, step1, step2},
IssueMap: map[string]*types.Issue{
root.ID: root,
step1.ID: step1,
step2.ID: step2,
},
Dependencies: []*types.Dependency{
{IssueID: step1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: step2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: step2.ID, DependsOnID: step1.ID, Type: types.DepBlocks}, // step2 blocked by step1
},
}
analysis := analyzeMoleculeParallel(subgraph)
// Only root and step1 should be ready (step2 is blocked)
if analysis.ReadySteps != 2 {
t.Errorf("ReadySteps = %d, want 2 (step2 blocked)", analysis.ReadySteps)
}
step1Info := analysis.Steps[step1.ID]
step2Info := analysis.Steps[step2.ID]
if !step1Info.IsReady {
t.Error("Step1 should be ready")
}
if step2Info.IsReady {
t.Error("Step2 should NOT be ready (blocked by step1)")
}
if len(step2Info.BlockedBy) != 1 || step2Info.BlockedBy[0] != step1.ID {
t.Errorf("Step2.BlockedBy = %v, want [%s]", step2Info.BlockedBy, step1.ID)
}
// Step1 and Step2 should NOT be in the same parallel group
if step1Info.ParallelGroup != "" && step1Info.ParallelGroup == step2Info.ParallelGroup {
t.Error("Blocking steps should NOT be in the same parallel group")
}
}
// TestAnalyzeMoleculeParallelCompletedBlockers tests that completed steps don't block
func TestAnalyzeMoleculeParallelCompletedBlockers(t *testing.T) {
// Create molecule where step1 is completed, so step2 should be ready
root := &types.Issue{
ID: "mol-done",
Title: "Molecule with completed step",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
step1 := &types.Issue{
ID: "mol-done.step1",
Title: "Step 1 (completed)",
Status: types.StatusClosed, // Completed!
IssueType: types.TypeTask,
}
step2 := &types.Issue{
ID: "mol-done.step2",
Title: "Step 2 (depends on step1)",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, step1, step2},
IssueMap: map[string]*types.Issue{
root.ID: root,
step1.ID: step1,
step2.ID: step2,
},
Dependencies: []*types.Dependency{
{IssueID: step1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: step2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: step2.ID, DependsOnID: step1.ID, Type: types.DepBlocks},
},
}
analysis := analyzeMoleculeParallel(subgraph)
step2Info := analysis.Steps[step2.ID]
// Step2 should be ready since step1 is closed
if !step2Info.IsReady {
t.Error("Step2 should be ready (step1 is completed)")
}
if len(step2Info.BlockedBy) != 0 {
t.Errorf("Step2.BlockedBy = %v, want empty (step1 completed)", step2Info.BlockedBy)
}
}
// TestAnalyzeMoleculeParallelMultipleArms tests parallel detection across bonded arms
func TestAnalyzeMoleculeParallelMultipleArms(t *testing.T) {
// Create molecule with two arms that can run in parallel
root := &types.Issue{
ID: "patrol",
Title: "Patrol",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
armAce := &types.Issue{
ID: "patrol.arm-ace",
Title: "Arm: ace",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
armNux := &types.Issue{
ID: "patrol.arm-nux",
Title: "Arm: nux",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, armAce, armNux},
IssueMap: map[string]*types.Issue{
root.ID: root,
armAce.ID: armAce,
armNux.ID: armNux,
},
Dependencies: []*types.Dependency{
{IssueID: armAce.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: armNux.ID, DependsOnID: root.ID, Type: types.DepParentChild},
// No blocking deps between arms
},
}
analysis := analyzeMoleculeParallel(subgraph)
// All 3 should be ready
if analysis.ReadySteps != 3 {
t.Errorf("ReadySteps = %d, want 3", analysis.ReadySteps)
}
// Arms should be in the same parallel group
aceInfo := analysis.Steps[armAce.ID]
nuxInfo := analysis.Steps[armNux.ID]
if aceInfo.ParallelGroup == "" {
t.Error("arm-ace should be in a parallel group")
}
if aceInfo.ParallelGroup != nuxInfo.ParallelGroup {
t.Errorf("Arms should be in same parallel group: %s vs %s",
aceInfo.ParallelGroup, nuxInfo.ParallelGroup)
}
// Should have at least one parallel group with both arms
foundGroup := false
for _, members := range analysis.ParallelGroups {
hasAce := false
hasNux := false
for _, id := range members {
if id == armAce.ID {
hasAce = true
}
if id == armNux.ID {
hasNux = true
}
}
if hasAce && hasNux {
foundGroup = true
break
}
}
if !foundGroup {
t.Error("Should have a parallel group containing both arms")
}
}
// TestCalculateBlockingDepths tests the depth calculation
func TestCalculateBlockingDepths(t *testing.T) {
// Create chain: root -> step1 -> step2 -> step3
root := &types.Issue{ID: "root", Status: types.StatusOpen}
step1 := &types.Issue{ID: "step1", Status: types.StatusOpen}
step2 := &types.Issue{ID: "step2", Status: types.StatusOpen}
step3 := &types.Issue{ID: "step3", Status: types.StatusOpen}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, step1, step2, step3},
IssueMap: map[string]*types.Issue{"root": root, "step1": step1, "step2": step2, "step3": step3},
}
blockedBy := map[string]map[string]bool{
"root": {},
"step1": {"root": true},
"step2": {"step1": true},
"step3": {"step2": true},
}
depths := calculateBlockingDepths(subgraph, blockedBy)
if depths["root"] != 0 {
t.Errorf("root depth = %d, want 0", depths["root"])
}
if depths["step1"] != 1 {
t.Errorf("step1 depth = %d, want 1", depths["step1"])
}
if depths["step2"] != 2 {
t.Errorf("step2 depth = %d, want 2", depths["step2"])
}
if depths["step3"] != 3 {
t.Errorf("step3 depth = %d, want 3", depths["step3"])
}
}
// TestSpawnMoleculeEphemeralFlag verifies that spawnMolecule with ephemeral=true
// creates issues with the Ephemeral flag set (bd-phin)
func TestSpawnMoleculeEphemeralFlag(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a template with a child (IDs will be auto-generated)
root := &types.Issue{
Title: "Template Epic",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel}, // Required for loadTemplateSubgraph
}
child := &types.Issue{
Title: "Template Task",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := s.CreateIssue(ctx, root, "test"); err != nil {
t.Fatalf("Failed to create template root: %v", err)
}
if err := s.CreateIssue(ctx, child, "test"); err != nil {
t.Fatalf("Failed to create template child: %v", err)
}
// Add parent-child dependency
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: child.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add parent-child dependency: %v", err)
}
// Load subgraph
subgraph, err := loadTemplateSubgraph(ctx, s, root.ID)
if err != nil {
t.Fatalf("Failed to load subgraph: %v", err)
}
// Spawn with ephemeral=true
result, err := spawnMolecule(ctx, s, subgraph, nil, "", "test", true, "wisp")
if err != nil {
t.Fatalf("spawnMolecule failed: %v", err)
}
// Verify all spawned issues have Ephemeral=true
for oldID, newID := range result.IDMapping {
spawned, err := s.GetIssue(ctx, newID)
if err != nil {
t.Fatalf("Failed to get spawned issue %s: %v", newID, err)
}
if !spawned.Ephemeral {
t.Errorf("Spawned issue %s (from %s) should have Ephemeral=true, got false", newID, oldID)
}
}
// Verify spawned issues have the correct prefix
for _, newID := range result.IDMapping {
if !strings.HasPrefix(newID, "test-wisp-") {
t.Errorf("Spawned issue ID %s should have prefix 'test-wisp-'", newID)
}
}
}
// TestSpawnMoleculeFromFormulaEphemeral verifies that spawning from a cooked formula
// with ephemeral=true creates issues with the Ephemeral flag set (bd-phin)
func TestSpawnMoleculeFromFormulaEphemeral(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a minimal in-memory subgraph (simulating cookFormulaToSubgraph output)
root := &types.Issue{
ID: "test-formula",
Title: "Test Formula",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
IsTemplate: true,
}
step := &types.Issue{
ID: "test-formula.step1",
Title: "Step 1",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
IsTemplate: true,
}
subgraph := &TemplateSubgraph{
Root: root,
Issues: []*types.Issue{root, step},
Dependencies: []*types.Dependency{
{
IssueID: step.ID,
DependsOnID: root.ID,
Type: types.DepParentChild,
},
},
IssueMap: map[string]*types.Issue{
root.ID: root,
step.ID: step,
},
}
// Spawn with ephemeral=true (simulating bd mol wisp <formula>)
result, err := spawnMolecule(ctx, s, subgraph, nil, "", "test", true, "wisp")
if err != nil {
t.Fatalf("spawnMolecule failed: %v", err)
}
// Verify all spawned issues have Ephemeral=true
for oldID, newID := range result.IDMapping {
spawned, err := s.GetIssue(ctx, newID)
if err != nil {
t.Fatalf("Failed to get spawned issue %s: %v", newID, err)
}
if !spawned.Ephemeral {
t.Errorf("Spawned issue %s (from %s) should have Ephemeral=true, got false", newID, oldID)
}
t.Logf("Issue %s: Ephemeral=%v", newID, spawned.Ephemeral)
}
// Verify they have the correct prefix
for _, newID := range result.IDMapping {
if !strings.HasPrefix(newID, "test-wisp-") {
t.Errorf("Spawned issue ID %s should have prefix 'test-wisp-'", newID)
}
}
// Verify ephemeral issues are excluded from ready work
readyWork, err := s.GetReadyWork(ctx, types.WorkFilter{})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
for _, issue := range readyWork {
for _, spawnedID := range result.IDMapping {
if issue.ID == spawnedID {
t.Errorf("Ephemeral issue %s should not appear in ready work", spawnedID)
}
}
}
}
// TestCompoundMoleculeVisualization tests the compound molecule display in mol show
func TestCompoundMoleculeVisualization(t *testing.T) {
// Test IsCompound() and GetConstituents()
tests := []struct {
name string
bondedFrom []types.BondRef
isCompound bool
expectedCount int
}{
{
name: "not a compound - no BondedFrom",
bondedFrom: nil,
isCompound: false,
expectedCount: 0,
},
{
name: "not a compound - empty BondedFrom",
bondedFrom: []types.BondRef{},
isCompound: false,
expectedCount: 0,
},
{
name: "compound with one constituent",
bondedFrom: []types.BondRef{
{SourceID: "proto-a", BondType: types.BondTypeSequential},
},
isCompound: true,
expectedCount: 1,
},
{
name: "compound with two constituents - sequential bond",
bondedFrom: []types.BondRef{
{SourceID: "proto-a", BondType: types.BondTypeSequential},
{SourceID: "proto-b", BondType: types.BondTypeSequential},
},
isCompound: true,
expectedCount: 2,
},
{
name: "compound with parallel bond",
bondedFrom: []types.BondRef{
{SourceID: "proto-a", BondType: types.BondTypeParallel},
{SourceID: "proto-b", BondType: types.BondTypeParallel},
},
isCompound: true,
expectedCount: 2,
},
{
name: "compound with bond point",
bondedFrom: []types.BondRef{
{SourceID: "proto-a", BondType: types.BondTypeSequential, BondPoint: "step-2"},
},
isCompound: true,
expectedCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
issue := &types.Issue{
ID: "test-compound",
Title: "Test Compound Molecule",
BondedFrom: tt.bondedFrom,
}
if got := issue.IsCompound(); got != tt.isCompound {
t.Errorf("IsCompound() = %v, want %v", got, tt.isCompound)
}
constituents := issue.GetConstituents()
if len(constituents) != tt.expectedCount {
t.Errorf("GetConstituents() returned %d items, want %d", len(constituents), tt.expectedCount)
}
})
}
}
// TestFormatBondType tests the formatBondType helper function
func TestFormatBondType(t *testing.T) {
tests := []struct {
bondType string
expected string
}{
{types.BondTypeSequential, "sequential"},
{types.BondTypeParallel, "parallel"},
{types.BondTypeConditional, "on-failure"},
{types.BondTypeRoot, "root"},
{"", "default"},
{"custom-type", "custom-type"},
}
for _, tt := range tests {
t.Run(tt.bondType, func(t *testing.T) {
if got := formatBondType(tt.bondType); got != tt.expected {
t.Errorf("formatBondType(%q) = %q, want %q", tt.bondType, got, tt.expected)
}
})
}
}
// TestPourRootTitleDescSubstitution verifies that the root molecule's title and description
// are substituted with {{title}} and {{desc}} variables when pouring a formula.
// This is a tracer bullet test for GitHub issue #852:
// https://github.com/steveyegge/beads/issues/852
func TestPourRootTitleDescSubstitution(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a formula that has title and desc variables
f := &formula.Formula{
Formula: "mol-task",
Description: "Standard task workflow for 2-8 hour work...",
Version: 1,
Type: formula.TypeWorkflow,
Vars: map[string]*formula.VarDef{
"title": {
Description: "Task title",
Required: true,
},
"desc": {
Description: "Task description",
Required: false,
Default: "No description provided",
},
},
Steps: []*formula.Step{
{ID: "plan", Title: "Plan: {{title}}", Type: "task"},
{ID: "implement", Title: "Implement: {{title}}", Type: "task", DependsOn: []string{"plan"}},
{ID: "verify", Title: "Verify: {{title}}", Type: "task", DependsOn: []string{"implement"}},
{ID: "review", Title: "Review: {{title}}", Type: "task", DependsOn: []string{"verify"}},
},
}
// Cook the formula to a subgraph (in-memory, no DB)
subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars)
if err != nil {
t.Fatalf("Failed to cook formula: %v", err)
}
// Spawn with title and desc variables
vars := map[string]string{
"title": "My Task",
"desc": "My description",
}
result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol")
if err != nil {
t.Fatalf("spawnMolecule failed: %v", err)
}
// Get the spawned root issue
spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID)
if err != nil {
t.Fatalf("Failed to get spawned root: %v", err)
}
// BUG: The root title should contain "My Task" but currently contains "mol-task"
// because cookFormulaToSubgraph sets root.Title = f.Formula instead of using
// a template that includes {{title}}.
if !strings.Contains(spawnedRoot.Title, "My Task") {
t.Errorf("Root title should contain 'My Task' from variable substitution, got: %q", spawnedRoot.Title)
}
// BUG: The root description should contain "My description" but currently
// contains the formula's static description.
if !strings.Contains(spawnedRoot.Description, "My description") {
t.Errorf("Root description should contain 'My description' from variable substitution, got: %q", spawnedRoot.Description)
}
// Verify child beads DO have correct substitution (this should pass)
for oldID, newID := range result.IDMapping {
if oldID == f.Formula {
continue // Skip root
}
spawned, err := s.GetIssue(ctx, newID)
if err != nil {
t.Fatalf("Failed to get spawned issue %s: %v", newID, err)
}
if !strings.Contains(spawned.Title, "My Task") {
t.Errorf("Child issue %s (from %s) title should contain 'My Task', got: %q", newID, oldID, spawned.Title)
}
}
}
// TestPourRootTitleOnly verifies edge case: only title var defined, no desc.
// Root should use {{title}} for title, but keep formula description.
func TestPourRootTitleOnly(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Formula with only title var (no desc)
f := &formula.Formula{
Formula: "mol-simple",
Description: "Static description that should be preserved",
Version: 1,
Type: formula.TypeWorkflow,
Vars: map[string]*formula.VarDef{
"title": {Description: "Task title", Required: true},
},
Steps: []*formula.Step{
{ID: "work", Title: "Do: {{title}}", Type: "task"},
},
}
subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars)
if err != nil {
t.Fatalf("Failed to cook formula: %v", err)
}
vars := map[string]string{"title": "Custom Title"}
result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol")
if err != nil {
t.Fatalf("spawnMolecule failed: %v", err)
}
spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID)
if err != nil {
t.Fatalf("Failed to get spawned root: %v", err)
}
// Title should be substituted
if !strings.Contains(spawnedRoot.Title, "Custom Title") {
t.Errorf("Root title should contain 'Custom Title', got: %q", spawnedRoot.Title)
}
// Description should be the static formula description (no desc var)
if spawnedRoot.Description != "Static description that should be preserved" {
t.Errorf("Root description should be static formula desc, got: %q", spawnedRoot.Description)
}
}
// TestPourRootNoVars verifies backward compatibility: no title/desc vars defined.
// Root should use formula name and formula description (original behavior).
func TestPourRootNoVars(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Formula with no title/desc vars (uses different var names)
f := &formula.Formula{
Formula: "mol-release",
Description: "Release workflow for version bumps",
Version: 1,
Type: formula.TypeWorkflow,
Vars: map[string]*formula.VarDef{
"version": {Description: "Version number", Required: true},
},
Steps: []*formula.Step{
{ID: "bump", Title: "Bump to {{version}}", Type: "task"},
{ID: "tag", Title: "Tag {{version}}", Type: "task", DependsOn: []string{"bump"}},
},
}
subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars)
if err != nil {
t.Fatalf("Failed to cook formula: %v", err)
}
vars := map[string]string{"version": "1.2.3"}
result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol")
if err != nil {
t.Fatalf("spawnMolecule failed: %v", err)
}
spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID)
if err != nil {
t.Fatalf("Failed to get spawned root: %v", err)
}
// Title should be formula name (no title var defined)
if spawnedRoot.Title != "mol-release" {
t.Errorf("Root title should be formula name 'mol-release', got: %q", spawnedRoot.Title)
}
// Description should be formula description (no desc var defined)
if spawnedRoot.Description != "Release workflow for version bumps" {
t.Errorf("Root description should be formula desc, got: %q", spawnedRoot.Description)
}
}