Files
beads/cmd/bd/sync_manual_test.go
T
jane 80cd1f35c0 fix(sync): address code review issues in manual conflict resolution
Fixes from code review:
- Fix duplicate check in merge logic (use else clause)
- Handle io.EOF gracefully (treat as quit)
- Add quit (q) option to abort resolution early
- Add accept-all (a) option to auto-merge remaining conflicts
- Fix skipped conflicts to keep local version (not auto-merge)
- Handle json.MarshalIndent errors properly
- Fix truncateText to use rune count for UTF-8 safety
- Update help text with new options
- Add UTF-8 and emoji test cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:44:24 -08:00

380 lines
7.8 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
maxLen int
want string
}{
{
name: "empty string",
input: "",
maxLen: 10,
want: "(empty)",
},
{
name: "short string",
input: "hello",
maxLen: 10,
want: "hello",
},
{
name: "exact length",
input: "0123456789",
maxLen: 10,
want: "0123456789",
},
{
name: "truncated",
input: "this is a very long string",
maxLen: 15,
want: "this is a ve...",
},
{
name: "newlines replaced",
input: "line1\nline2\nline3",
maxLen: 30,
want: "line1 line2 line3",
},
{
name: "very short max",
input: "hello world",
maxLen: 3,
want: "...",
},
{
name: "UTF-8 characters preserved",
input: "Hello 世界!This is a test",
maxLen: 12,
want: "Hello 世界!...",
},
{
name: "UTF-8 exact length",
input: "日本語テスト",
maxLen: 6,
want: "日本語テスト",
},
{
name: "UTF-8 truncate",
input: "日本語テストです",
maxLen: 6,
want: "日本語...",
},
{
name: "emoji handling",
input: "Hello 🌍🌎🌏 World",
maxLen: 12,
want: "Hello 🌍🌎🌏...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateText(tt.input, tt.maxLen)
if got != tt.want {
t.Errorf("truncateText(%q, %d) = %q, want %q", tt.input, tt.maxLen, 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")
}
})
}
}