fix(mol): persist template label in bondProtoProto + add comprehensive tests

Code review found that bondProtoProto was setting Labels on the compound
issue, but CreateIssue doesn't persist labels (they're stored separately.
Fixed by calling AddLabel after creating the compound.

Also added comprehensive tests for:
- isProto() - template label detection
- operandType() - proto vs molecule string
- minPriority() - priority comparison
- bondProtoProto() - compound proto creation with deps and labels
- bondProtoMol() - spawn proto and attach to molecule
- bondMolMol() - join two molecules with sequential/parallel bonds

Tests verify dependency types (blocks vs parent-child) based on bond type.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)
This commit is contained in:
Steve Yegge
2025-12-21 11:18:27 -08:00
parent 6580178226
commit 213a61c2be
2 changed files with 306 additions and 1 deletions

View File

@@ -786,7 +786,6 @@ func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *type
Status: types.StatusOpen,
Priority: minPriority(protoA.Priority, protoB.Priority),
IssueType: types.TypeEpic,
Labels: []string{MoleculeLabel}, // Mark as proto
BondedFrom: []types.BondRef{
{ProtoID: protoA.ID, BondType: bondType, BondPoint: ""},
{ProtoID: protoB.ID, BondType: bondType, BondPoint: ""},
@@ -797,6 +796,11 @@ func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *type
}
compoundID = compound.ID
// Add template label (labels are stored separately, not in issue table)
if err := tx.AddLabel(ctx, compoundID, MoleculeLabel, actorName); err != nil {
return fmt.Errorf("adding template label: %w", err)
}
// Add parent-child dependencies from compound to both proto roots
depA := &types.Dependency{
IssueID: protoA.ID,

View File

@@ -1,9 +1,11 @@
package main
import (
"context"
"strings"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
@@ -157,3 +159,302 @@ func TestCollectSubgraphText(t *testing.T) {
}
}
}
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")
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)
}
}