package main import ( "errors" "path/filepath" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) func TestCommitPreviewLinesWrapsToAtMostTwoLines(t *testing.T) { lines := commitPreviewLines("Add the initial terminal interface with a detailed commit editor and safer apply workflow", 24, 2) if len(lines) == 0 || len(lines) > 2 { t.Fatalf("expected 1-2 preview lines, got %d: %#v", len(lines), lines) } for _, line := range lines { if lipgloss.Width(line) > 24 { t.Fatalf("preview line exceeds width: %q", line) } } } func TestDirtyCommitCount(t *testing.T) { commits := []CommitRecord{ {Hash: "one", ShortHash: "one", AuthorName: "A", AuthorEmail: "a@example.com", AuthorDate: "2024-01-01T10:00:00Z", CommitterName: "A", CommitterEmail: "a@example.com", CommitterDate: "2024-01-01T10:00:00Z", Message: "first"}, {Hash: "two", ShortHash: "two", AuthorName: "B", AuthorEmail: "b@example.com", AuthorDate: "2024-01-02T10:00:00Z", CommitterName: "B", CommitterEmail: "b@example.com", CommitterDate: "2024-01-02T10:00:00Z", Message: "second"}, } original := mapByHash(commits) drafts := cloneMapByHash(commits) edited := drafts["two"] edited.Message = "edited second" drafts["two"] = edited if got := dirtyCommitCount(commits, original, drafts); got != 1 { t.Fatalf("expected one dirty commit, got %d", got) } } func TestStageAuthorReplacementMatchesPartialIdentityCaseInsensitively(t *testing.T) { commits := []CommitRecord{ {Hash: "one", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz", CommitterName: "Release Bot", CommitterEmail: "bot@example.com"}, {Hash: "two", AuthorName: "Someone Else", AuthorEmail: "FOO@EXAMPLE.COM", CommitterName: "Other Bot", CommitterEmail: "other@example.com"}, {Hash: "three", AuthorName: "Unaffected", AuthorEmail: "unaffected@example.com", CommitterName: "Keep Me", CommitterEmail: "keep@example.com"}, } drafts := cloneMapByHash(commits) matched, err := stageAuthorReplacement(commits, drafts, "fOo", "New Author", "new@example.com", false) if err != nil { t.Fatal(err) } if matched != 2 { t.Fatalf("expected two matching commits, got %d", matched) } if got := drafts["one"]; got.AuthorName != "New Author" || got.AuthorEmail != "new@example.com" { t.Fatalf("expected first author identity to be replaced, got %#v", got) } else if got.CommitterName != "Release Bot" || got.CommitterEmail != "bot@example.com" { t.Fatalf("expected first committer identity to be preserved, got %#v", got) } if got := drafts["two"]; got.AuthorName != "New Author" || got.AuthorEmail != "new@example.com" { t.Fatalf("expected second author identity to be replaced, got %#v", got) } else if got.CommitterName != "Other Bot" || got.CommitterEmail != "other@example.com" { t.Fatalf("expected second committer identity to be preserved, got %#v", got) } if got := drafts["three"]; got.AuthorName != "Unaffected" || got.AuthorEmail != "unaffected@example.com" { t.Fatalf("expected non-matching author identity to be preserved, got %#v", got) } } func TestStageAuthorReplacementExactModeRequiresWholeFieldMatch(t *testing.T) { commits := []CommitRecord{ {Hash: "partial", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz"}, {Hash: "exact", AuthorName: "FOO", AuthorEmail: "someone@example.com"}, } drafts := cloneMapByHash(commits) matched, err := stageAuthorReplacement(commits, drafts, "foo", "New Author", "new@example.com", true) if err != nil { t.Fatal(err) } if matched != 1 { t.Fatalf("expected one exact match, got %d", matched) } if got := drafts["partial"]; got.AuthorName != "Foo Bar" || got.AuthorEmail != "foo@bar.baz" { t.Fatalf("expected partial match to remain unchanged in exact mode, got %#v", got) } if got := drafts["exact"]; got.AuthorName != "New Author" || got.AuthorEmail != "new@example.com" { t.Fatalf("expected exact match to be replaced, got %#v", got) } } func TestStageAuthorReplacementPartialModeUsesUnicodeCaseFolding(t *testing.T) { commits := []CommitRecord{ {Hash: "greek", AuthorName: "Σωκράτης", AuthorEmail: "socrates@example.com"}, } drafts := cloneMapByHash(commits) matched, err := stageAuthorReplacement(commits, drafts, "ΣΩΚΡΆΤΗΣ", "Socrates", "new@example.com", false) if err != nil { t.Fatal(err) } if matched != 1 { t.Fatalf("expected Unicode case variants to match, got %d matches", matched) } if got := drafts["greek"]; got.AuthorName != "Socrates" || got.AuthorEmail != "new@example.com" { t.Fatalf("expected Unicode author identity to be replaced, got %#v", got) } } func TestStageAuthorReplacementRejectsBlankInputsWithoutChangingDrafts(t *testing.T) { commits := []CommitRecord{{Hash: "one", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz"}} tests := []struct { name string query string replacementName string replacementEmail string }{ {name: "search", query: " ", replacementName: "New Author", replacementEmail: "new@example.com"}, {name: "author name", query: "Foo", replacementName: " ", replacementEmail: "new@example.com"}, {name: "author email", query: "Foo", replacementName: "New Author", replacementEmail: " "}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { drafts := cloneMapByHash(commits) matched, err := stageAuthorReplacement(commits, drafts, tt.query, tt.replacementName, tt.replacementEmail, false) if err == nil { t.Fatal("expected blank input to be rejected") } if matched != 0 { t.Fatalf("expected no matches on validation failure, got %d", matched) } if got := drafts["one"]; got.AuthorName != "Foo Bar" || got.AuthorEmail != "foo@bar.baz" { t.Fatalf("expected drafts to remain unchanged on validation failure, got %#v", got) } }) } } func TestSelectionAfterFilterPreservesSelectedCommit(t *testing.T) { commits := []CommitRecord{ {Hash: "one", ShortHash: "one", AuthorName: "A", Message: "alpha"}, {Hash: "two", ShortHash: "two", AuthorName: "B", Message: "beta"}, {Hash: "three", ShortHash: "three", AuthorName: "C", Message: "gamma"}, } drafts := cloneMapByHash(commits) filtered, index, hash := selectionAfterFilter(commits, drafts, "two", "") if len(filtered) != 3 || index != 1 || hash != "two" { t.Fatalf("expected selected commit to be preserved, got len=%d index=%d hash=%q", len(filtered), index, hash) } filtered, index, hash = selectionAfterFilter(commits, drafts, "two", "gamma") if len(filtered) != 1 || index != 0 || hash != "three" { t.Fatalf("expected selection to move to first filtered commit, got len=%d index=%d hash=%q", len(filtered), index, hash) } } func TestNextFormFocusTraversal(t *testing.T) { next, done := nextFormFocus(formAuthorName, false) if done || next != formAuthorEmail { t.Fatalf("expected tab to advance to author email, got next=%d done=%v", next, done) } next, done = nextFormFocus(formMessage, false) if !done || next != formMessage { t.Fatalf("expected tab from final field to return to list, got next=%d done=%v", next, done) } next, done = nextFormFocus(formAuthorName, true) if !done || next != formAuthorName { t.Fatalf("expected shift-tab from first field to return to list, got next=%d done=%v", next, done) } } func TestInitialRepositoryPath(t *testing.T) { wd := t.TempDir() got, err := initialRepositoryPath(nil, func() (string, error) { return wd, nil }) if err != nil { t.Fatal(err) } if got != filepath.Clean(wd) { t.Fatalf("expected cwd path %q, got %q", filepath.Clean(wd), got) } explicit := filepath.Join(wd, "repo") got, err = initialRepositoryPath([]string{explicit}, func() (string, error) { return "", errors.New("should not be called") }) if err != nil { t.Fatal(err) } if got != filepath.Clean(explicit) { t.Fatalf("expected explicit path %q, got %q", filepath.Clean(explicit), got) } if _, err := initialRepositoryPath([]string{"one", "two"}, func() (string, error) { return wd, nil }); err == nil { t.Fatal("expected usage error for too many arguments") } } func TestRepositoryLoadErrorOpensPathEntryState(t *testing.T) { path := t.TempDir() model := newTUIModel(NewApp(), path) model.handleRepoLoaded(repoLoadedMsg{path: path, err: errors.New("not a git worktree")}) if model.repoLoaded { t.Fatal("expected repository to remain unloaded") } if model.overlay != overlayPath { t.Fatalf("expected path overlay, got %v", model.overlay) } if model.pathInput.Value() != path { t.Fatalf("expected path input to keep attempted path %q, got %q", path, model.pathInput.Value()) } } func TestBulkAuthorKeyOpensReplacementDialogInPartialMode(t *testing.T) { model := newTUIModel(NewApp(), "/tmp/repo") model.width = 100 model.height = 30 model.handleRepoLoaded(repoLoadedMsg{ repo: RepositoryState{Path: "/tmp/repo", Clean: true, CurrentBranch: "main"}, commits: []CommitRecord{ {Hash: "one", ShortHash: "one", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz", Message: "first"}, }, }) updated, _ := model.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}}) view := updated.(tuiModel).View() if !strings.Contains(view, "Bulk replace author") { t.Fatalf("expected bulk author dialog after pressing b, got:\n%s", view) } if !strings.Contains(view, "Match mode: Partial") { t.Fatalf("expected partial match mode by default, got:\n%s", view) } } func TestBulkAuthorDialogStagesExactAuthorReplacement(t *testing.T) { model := newTUIModel(NewApp(), "/tmp/repo") model.handleRepoLoaded(repoLoadedMsg{ repo: RepositoryState{Path: "/tmp/repo", Clean: true, CurrentBranch: "main"}, commits: []CommitRecord{ {Hash: "partial", ShortHash: "partial", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz", Message: "partial"}, {Hash: "exact", ShortHash: "exact", AuthorName: "FOO", AuthorEmail: "someone@example.com", Message: "exact"}, }, }) send := func(msg tea.KeyMsg) { t.Helper() updated, _ := model.handleKey(msg) model = updated.(tuiModel) } send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}}) send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("foo")}) send(tea.KeyMsg{Type: tea.KeyTab}) send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("New Author")}) send(tea.KeyMsg{Type: tea.KeyTab}) send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("new@example.com")}) send(tea.KeyMsg{Type: tea.KeyCtrlT}) send(tea.KeyMsg{Type: tea.KeyEnter}) if got := model.draftByHash["partial"]; got.AuthorName != "Foo Bar" || got.AuthorEmail != "foo@bar.baz" { t.Fatalf("expected partial-only match to remain unchanged in exact mode, got %#v", got) } if got := model.draftByHash["exact"]; got.AuthorName != "New Author" || got.AuthorEmail != "new@example.com" { t.Fatalf("expected exact match to be staged through the dialog, got %#v", got) } if model.overlay != overlayNone { t.Fatalf("expected successful replacement to close the dialog, got overlay %v", model.overlay) } } func TestViewFitsTerminalDimensions(t *testing.T) { model := newTUIModel(NewApp(), "/tmp/repo") model.width = 80 model.height = 24 initialView := model.View() if height := lipgloss.Height(initialView); height > model.height { t.Fatalf("expected initial view height <= %d, got %d", model.height, height) } if width := maxRenderedLineWidth(initialView); width > model.width-1 { t.Fatalf("expected initial max line width <= %d, got %d", model.width-1, width) } model.handleRepoLoaded(repoLoadedMsg{ repo: RepositoryState{ Path: "/tmp/repo", Clean: true, CurrentBranch: "main", }, commits: []CommitRecord{ {Hash: "1111111111111111111111111111111111111111", ShortHash: "1111111", AuthorName: "Ada Lovelace", AuthorEmail: "ada@example.com", AuthorDate: "2024-01-01T10:00:00Z", CommitterName: "Ada Lovelace", CommitterEmail: "ada@example.com", CommitterDate: "2024-01-01T10:00:00Z", Message: "Add a compact terminal interface that should not wrap every line"}, {Hash: "2222222222222222222222222222222222222222", ShortHash: "2222222", AuthorName: "Grace Hopper", AuthorEmail: "grace@example.com", AuthorDate: "2024-01-02T10:00:00Z", CommitterName: "Grace Hopper", CommitterEmail: "grace@example.com", CommitterDate: "2024-01-02T10:00:00Z", Message: "Fix rendering dimensions"}, }, }) view := model.View() if height := lipgloss.Height(view); height > model.height { t.Fatalf("expected view height <= %d, got %d", model.height, height) } if width := maxRenderedLineWidth(view); width > model.width-1 { t.Fatalf("expected max line width <= %d, got %d", model.width-1, width) } } func TestDatePickerOpensAndSavesFromDateField(t *testing.T) { model := newTUIModel(NewApp(), "/tmp/repo") commit := CommitRecord{ Hash: "one", ShortHash: "one", AuthorName: "A", AuthorEmail: "a@example.com", AuthorDate: "2024-01-02T10:00:00+00:00", CommitterName: "A", CommitterEmail: "a@example.com", CommitterDate: "2024-01-02T10:00:00+00:00", Message: "subject", } model.handleRepoLoaded(repoLoadedMsg{repo: RepositoryState{Path: "/tmp/repo"}, commits: []CommitRecord{commit}}) model.focus = focusForm model.focusFormField(formAuthorDate) updated, _ := model.updateForm(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(tuiModel) if model.overlay != overlayDatePicker { t.Fatalf("expected date picker overlay, got %v", model.overlay) } model.datePickerTime = time.Date(2025, time.March, 4, 12, 35, 45, 0, time.FixedZone("test", 90*60)) updated, _ = model.updateDatePicker(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(tuiModel) if got := model.draftByHash["one"].AuthorDate; got != "2025-03-04T12:35:45+01:30" { t.Fatalf("expected selected date to be saved, got %q", got) } } func TestPlannedRewriteCountIncludesCoAuthorCleanup(t *testing.T) { commits := []CommitRecord{ {Hash: "one", Message: "subject\n\nCo-authored-by: A "}, {Hash: "two", Message: "another subject"}, } original := mapByHash(commits) drafts := cloneMapByHash(commits) if got := plannedRewriteCount(commits, original, drafts, false); got != 0 { t.Fatalf("expected no planned rewrites with cleanup disabled, got %d", got) } if got := plannedRewriteCount(commits, original, drafts, true); got != 1 { t.Fatalf("expected one planned cleanup rewrite, got %d", got) } } func TestCalendarShiftsClampToValidDays(t *testing.T) { jan31 := time.Date(2024, time.January, 31, 9, 15, 30, 0, time.UTC) if got := shiftCalendarMonth(jan31, 1); got != time.Date(2024, time.February, 29, 9, 15, 30, 0, time.UTC) { t.Fatalf("expected leap-year February to clamp to the 29th, got %s", got) } leapDay := time.Date(2024, time.February, 29, 9, 15, 30, 0, time.UTC) if got := shiftCalendarYear(leapDay, 1); got != time.Date(2025, time.February, 28, 9, 15, 30, 0, time.UTC) { t.Fatalf("expected non-leap year to clamp to 28 February, got %s", got) } } func maxRenderedLineWidth(value string) int { width := 0 for _, line := range strings.Split(value, "\n") { if lineWidth := lipgloss.Width(line); lineWidth > width { width = lineWidth } } return width } func TestStageDateSpreadOrdersTimestampsWithinWindow(t *testing.T) { // newest first, as the TUI holds them commits := []CommitRecord{{Hash: "c"}, {Hash: "b"}, {Hash: "a"}} drafts := cloneMapByHash(commits) // Reverse-ordered random draws would break ordering without the sort. draws := []int64{2, 3000, 0, 100, 1, 50} randN := func(n int64) int64 { v := draws[0] % n; draws = draws[1:]; return v } count, err := stageDateSpread(commits, drafts, "2024-03-01", "2024-03-03", "08:00", "12:00", randN) if err != nil || count != 3 { t.Fatalf("expected 3 staged commits, got %d, %v", count, err) } var prev time.Time for _, hash := range []string{"a", "b", "c"} { d := drafts[hash] if d.AuthorDate != d.CommitterDate { t.Fatalf("%s: author and committer dates differ: %s vs %s", hash, d.AuthorDate, d.CommitterDate) } ts, err := parseGitDate(d.AuthorDate) if err != nil { t.Fatal(err) } if ts.Before(prev) { t.Fatalf("%s: %s is before previous %s", hash, ts, prev) } if ts.Hour() < 8 || ts.Hour() >= 12 || ts.Day() < 1 || ts.Day() > 3 { t.Fatalf("%s: %s outside range", hash, ts) } prev = ts } if _, err := stageDateSpread(commits, drafts, "2024-03-01", "2024-03-03", "12:00", "08:00", randN); err == nil { t.Fatal("expected inverted window to be rejected") } }