Implements configurable per-field merge strategies (hq-ew1mbr.11):
- Add FieldStrategy type with strategies: newest, max, union, manual
- Add conflict.fields config section for per-field overrides
- compaction_level defaults to "max" (highest value wins)
- estimated_minutes defaults to "manual" (flags for user resolution)
- labels defaults to "union" (set merge)
Manual conflicts are displayed during sync with resolution options:
bd sync --ours / --theirs, or bd resolve <id> <field> <value>
Config example:
conflict:
strategy: newest
fields:
compaction_level: max
estimated_minutes: manual
labels: union
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
339 lines
7.1 KiB
Go
339 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/beads"
|
|
)
|
|
|
|
func TestTruncateText(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
want: "(empty)",
|
|
},
|
|
{
|
|
name: "short string",
|
|
input: "hello",
|
|
want: "hello",
|
|
},
|
|
{
|
|
name: "newlines replaced",
|
|
input: "line1\nline2\r\nline3",
|
|
want: "line1 line2 line3",
|
|
},
|
|
{
|
|
name: "truncated at fixed max",
|
|
input: strings.Repeat("a", truncateTextMaxLen+10),
|
|
want: strings.Repeat("a", truncateTextMaxLen-3) + "...",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := truncateText(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("truncateText(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValueOrNone(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"", "(none)"},
|
|
{"value", "value"},
|
|
{" ", " "},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := valueOrNone(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("valueOrNone(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInteractiveConflictDisplay(t *testing.T) {
|
|
now := time.Now()
|
|
earlier := now.Add(-1 * time.Hour)
|
|
|
|
// Test that displayConflictDiff doesn't panic for various inputs
|
|
tests := []struct {
|
|
name string
|
|
conflict InteractiveConflict
|
|
}{
|
|
{
|
|
name: "both exist with differences",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-1",
|
|
Local: &beads.Issue{
|
|
ID: "test-1",
|
|
Title: "Local title",
|
|
Status: beads.StatusOpen,
|
|
Priority: 1,
|
|
UpdatedAt: now,
|
|
},
|
|
Remote: &beads.Issue{
|
|
ID: "test-1",
|
|
Title: "Remote title",
|
|
Status: beads.StatusInProgress,
|
|
Priority: 2,
|
|
UpdatedAt: earlier,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "local deleted",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-2",
|
|
Local: nil,
|
|
Remote: &beads.Issue{
|
|
ID: "test-2",
|
|
Title: "Remote only",
|
|
Status: beads.StatusOpen,
|
|
UpdatedAt: now,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "remote deleted",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-3",
|
|
Local: &beads.Issue{
|
|
ID: "test-3",
|
|
Title: "Local only",
|
|
Status: beads.StatusOpen,
|
|
UpdatedAt: now,
|
|
},
|
|
Remote: nil,
|
|
},
|
|
},
|
|
{
|
|
name: "same timestamps",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-4",
|
|
Local: &beads.Issue{
|
|
ID: "test-4",
|
|
Title: "Same time local",
|
|
UpdatedAt: now,
|
|
},
|
|
Remote: &beads.Issue{
|
|
ID: "test-4",
|
|
Title: "Same time remote",
|
|
UpdatedAt: now,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "with labels",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-5",
|
|
Local: &beads.Issue{
|
|
ID: "test-5",
|
|
Title: "Local",
|
|
Labels: []string{"bug", "urgent"},
|
|
UpdatedAt: now,
|
|
},
|
|
Remote: &beads.Issue{
|
|
ID: "test-5",
|
|
Title: "Remote",
|
|
Labels: []string{"feature", "low-priority"},
|
|
UpdatedAt: earlier,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "both nil (edge case)",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-6",
|
|
Local: nil,
|
|
Remote: nil,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Just make sure it doesn't panic
|
|
displayConflictDiff(tt.conflict)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShowDetailedDiff(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
tests := []struct {
|
|
name string
|
|
conflict InteractiveConflict
|
|
}{
|
|
{
|
|
name: "both exist",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-1",
|
|
Local: &beads.Issue{
|
|
ID: "test-1",
|
|
Title: "Local",
|
|
UpdatedAt: now,
|
|
},
|
|
Remote: &beads.Issue{
|
|
ID: "test-1",
|
|
Title: "Remote",
|
|
UpdatedAt: now,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "local nil",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-2",
|
|
Local: nil,
|
|
Remote: &beads.Issue{
|
|
ID: "test-2",
|
|
Title: "Remote",
|
|
UpdatedAt: now,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "remote nil",
|
|
conflict: InteractiveConflict{
|
|
IssueID: "test-3",
|
|
Local: &beads.Issue{
|
|
ID: "test-3",
|
|
Title: "Local",
|
|
UpdatedAt: now,
|
|
},
|
|
Remote: nil,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Just make sure it doesn't panic
|
|
showDetailedDiff(tt.conflict)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrintResolutionHelp(t *testing.T) {
|
|
// Test all combinations of hasLocal/hasRemote
|
|
tests := []struct {
|
|
hasLocal bool
|
|
hasRemote bool
|
|
}{
|
|
{true, true},
|
|
{true, false},
|
|
{false, true},
|
|
{false, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
// Just make sure it doesn't panic
|
|
printResolutionHelp(tt.hasLocal, tt.hasRemote)
|
|
}
|
|
}
|
|
|
|
func TestDisplayIssueSummary(t *testing.T) {
|
|
issue := &beads.Issue{
|
|
ID: "test-1",
|
|
Title: "Test issue",
|
|
Status: beads.StatusOpen,
|
|
Priority: 2,
|
|
Assignee: "alice",
|
|
}
|
|
|
|
// Just make sure it doesn't panic
|
|
displayIssueSummary(issue, " ")
|
|
displayIssueSummary(nil, " ")
|
|
}
|
|
|
|
func TestInteractiveResolutionMerge(t *testing.T) {
|
|
// Test that mergeFieldLevel is called correctly in resolution
|
|
now := time.Now()
|
|
earlier := now.Add(-1 * time.Hour)
|
|
|
|
local := &beads.Issue{
|
|
ID: "test-1",
|
|
Title: "Local title",
|
|
Status: beads.StatusOpen,
|
|
Priority: 1,
|
|
Labels: []string{"bug"},
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
remote := &beads.Issue{
|
|
ID: "test-1",
|
|
Title: "Remote title",
|
|
Status: beads.StatusInProgress,
|
|
Priority: 2,
|
|
Labels: []string{"feature"},
|
|
UpdatedAt: earlier,
|
|
}
|
|
|
|
// mergeFieldLevel should pick local values (newer) for scalars
|
|
// and union for labels
|
|
merged, _ := mergeFieldLevel(nil, local, remote)
|
|
|
|
if merged.Title != "Local title" {
|
|
t.Errorf("Expected title 'Local title', got %q", merged.Title)
|
|
}
|
|
if merged.Status != beads.StatusOpen {
|
|
t.Errorf("Expected status 'open', got %q", merged.Status)
|
|
}
|
|
if merged.Priority != 1 {
|
|
t.Errorf("Expected priority 1, got %d", merged.Priority)
|
|
}
|
|
// Labels should be merged (union)
|
|
if len(merged.Labels) != 2 {
|
|
t.Errorf("Expected 2 labels, got %d", len(merged.Labels))
|
|
}
|
|
labelsStr := strings.Join(merged.Labels, ",")
|
|
if !strings.Contains(labelsStr, "bug") || !strings.Contains(labelsStr, "feature") {
|
|
t.Errorf("Expected labels to contain 'bug' and 'feature', got %v", merged.Labels)
|
|
}
|
|
}
|
|
|
|
func TestInteractiveResolutionChoices(t *testing.T) {
|
|
// Test InteractiveResolution struct values
|
|
tests := []struct {
|
|
name string
|
|
choice string
|
|
issue *beads.Issue
|
|
}{
|
|
{"local", "local", &beads.Issue{ID: "test"}},
|
|
{"remote", "remote", &beads.Issue{ID: "test"}},
|
|
{"merged", "merged", &beads.Issue{ID: "test"}},
|
|
{"skip", "skip", nil},
|
|
{"quit", "quit", nil},
|
|
{"accept-all", "accept-all", nil},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
res := InteractiveResolution{Choice: tt.choice, Issue: tt.issue}
|
|
if res.Choice != tt.choice {
|
|
t.Errorf("Expected choice %q, got %q", tt.choice, res.Choice)
|
|
}
|
|
if tt.issue == nil && res.Issue != nil {
|
|
t.Errorf("Expected nil issue, got %v", res.Issue)
|
|
}
|
|
if tt.issue != nil && res.Issue == nil {
|
|
t.Errorf("Expected non-nil issue, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|