diff --git a/internal/ui/wizard/flows/model_test.go b/internal/ui/wizard/flows/model_test.go new file mode 100644 index 0000000..7f96486 --- /dev/null +++ b/internal/ui/wizard/flows/model_test.go @@ -0,0 +1,413 @@ +package flows + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/raphi011/wt/internal/ui/wizard/framework" + "github.com/raphi011/wt/internal/ui/wizard/steps" +) + +// keyMsgFlows creates a tea.KeyPressMsg from a string key. +// Mirrors the keyMsg helper in the steps package tests. +func keyMsgFlows(key string) tea.KeyPressMsg { + switch key { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "left": + return tea.KeyPressMsg{Code: tea.KeyLeft} + case "right": + return tea.KeyPressMsg{Code: tea.KeyRight} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + case "ctrl+c": + return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} + case "space": + return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} + case "backspace": + return tea.KeyPressMsg{Code: tea.KeyBackspace} + default: + if len(key) == 1 { + r := rune(key[0]) + return tea.KeyPressMsg{Code: r, Text: key} + } + return tea.KeyPressMsg{} + } +} + +// newCdModel creates a cdListModel with the given worktrees for testing. +func newCdModel(worktrees []CdWorktreeInfo) *cdListModel { + options := make([]framework.Option, len(worktrees)) + for i, wt := range worktrees { + options[i] = framework.Option{ + Label: wt.RepoName + ":" + wt.Branch, + Value: i, + } + } + selectStep := steps.NewFilterableList("worktree", "Worktree", "", options) + return &cdListModel{ + step: selectStep, + worktrees: worktrees, + selectedAt: -1, + } +} + +// --- cdListModel tests --- + +func TestCdListModel_Init(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "my-repo", Branch: "main", Path: "/tmp/main"}, + } + m := newCdModel(worktrees) + // Init should return a command (or nil) without panicking + _ = m.Init() +} + +func TestCdListModel_View_NotDone(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "my-repo", Branch: "main", Path: "/tmp/main"}, + {RepoName: "my-repo", Branch: "feature-a", Path: "/tmp/feature-a"}, + } + m := newCdModel(worktrees) + + view := m.View() + // View should return a non-empty string containing list content + if view.Content == "" { + t.Error("View() should return non-empty content when not done/cancelled") + } +} + +func TestCdListModel_View_Done(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "my-repo", Branch: "main", Path: "/tmp/main"}, + } + m := newCdModel(worktrees) + m.done = true + + view := m.View() + if view.Content != "" { + t.Errorf("View() should return empty content when done, got %q", view.Content) + } +} + +func TestCdListModel_View_Cancelled(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "my-repo", Branch: "main", Path: "/tmp/main"}, + } + m := newCdModel(worktrees) + m.cancelled = true + + view := m.View() + if view.Content != "" { + t.Errorf("View() should return empty content when cancelled, got %q", view.Content) + } +} + +func TestCdListModel_Update_NonKeyMsg(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "my-repo", Branch: "main", Path: "/tmp/main"}, + } + m := newCdModel(worktrees) + + // Non-key messages should be ignored + model, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if cmd != nil { + t.Error("expected nil cmd for non-key message") + } + updated := model.(*cdListModel) + if updated.done { + t.Error("model should not be done after non-key message") + } + if updated.cancelled { + t.Error("model should not be cancelled after non-key message") + } +} + +func TestCdListModel_Update_CtrlC(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "my-repo", Branch: "main", Path: "/tmp/main"}, + } + m := newCdModel(worktrees) + + model, _ := m.Update(keyMsgFlows("ctrl+c")) + updated := model.(*cdListModel) + if !updated.cancelled { + t.Error("ctrl+c should set cancelled=true") + } +} + +func TestCdListModel_Update_Esc_CancelsWhenNoFilter(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "my-repo", Branch: "main", Path: "/tmp/main"}, + } + m := newCdModel(worktrees) + + // No filter set, esc should cancel + model, _ := m.Update(keyMsgFlows("esc")) + updated := model.(*cdListModel) + if !updated.cancelled { + t.Error("esc should cancel when no filter is set") + } +} + +func TestCdListModel_Update_Esc_ClearsFilter(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "my-repo", Branch: "main", Path: "/tmp/main"}, + {RepoName: "my-repo", Branch: "feature-a", Path: "/tmp/feature-a"}, + } + m := newCdModel(worktrees) + + // Type something to set a filter + m.Update(keyMsgFlows("m")) + if !m.step.HasClearableInput() { + t.Skip("filter not set after typing; skipping esc-clears-filter test") + } + + // Esc should clear filter, not cancel + model, _ := m.Update(keyMsgFlows("esc")) + updated := model.(*cdListModel) + if updated.cancelled { + t.Error("esc should clear filter rather than cancel when filter is set") + } + if updated.step.HasClearableInput() { + t.Error("filter should be cleared after esc") + } +} + +func TestCdListModel_Update_Enter_SelectsItem(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "repo1", Branch: "main", Path: "/tmp/main", LastAccess: time.Now()}, + {RepoName: "repo1", Branch: "feature", Path: "/tmp/feature", LastAccess: time.Now()}, + } + m := newCdModel(worktrees) + + // Press enter to select the first item + model, _ := m.Update(keyMsgFlows("enter")) + updated := model.(*cdListModel) + + if !updated.done { + t.Error("pressing enter should set done=true") + } + if updated.cancelled { + t.Error("pressing enter should not cancel") + } + if updated.selectedAt != 0 { + t.Errorf("selectedAt = %d, want 0", updated.selectedAt) + } +} + +func TestCdListModel_Update_Down_ThenEnter(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "repo1", Branch: "main", Path: "/tmp/main"}, + {RepoName: "repo1", Branch: "feature", Path: "/tmp/feature"}, + } + m := newCdModel(worktrees) + + // Navigate down, then select + m.Update(keyMsgFlows("down")) + model, _ := m.Update(keyMsgFlows("enter")) + updated := model.(*cdListModel) + + if !updated.done { + t.Error("should be done after enter") + } + if updated.selectedAt != 1 { + t.Errorf("selectedAt = %d, want 1", updated.selectedAt) + } +} + +// --- addHookStep tests --- + +func TestAddHookStep_NilHooks_AddsEmptyStep(t *testing.T) { + t.Parallel() + w := framework.NewWizard("Test") + // addHookStep always adds the step — callers guard with `if hasHooks` + addHookStep(w, nil) + if w.StepCount() != 1 { + t.Errorf("StepCount() = %d, want 1 (empty hooks step is still added)", w.StepCount()) + } + step := w.GetStep("hooks") + if step == nil { + t.Fatal("hooks step should be present even with nil hooks") + } +} + +func TestAddHookStep_WithHooks(t *testing.T) { + t.Parallel() + w := framework.NewWizard("Test") + hooks := []HookInfo{ + {Name: "build", Description: "Run build", IsDefault: false}, + {Name: "test", Description: "Run tests", IsDefault: true}, + } + addHookStep(w, hooks) + if w.StepCount() != 1 { + t.Errorf("StepCount() = %d, want 1 after adding hooks step", w.StepCount()) + } + step := w.GetStep("hooks") + if step == nil { + t.Fatal("hooks step should be present") + } + if step.ID() != "hooks" { + t.Errorf("step.ID() = %q, want %q", step.ID(), "hooks") + } +} + +func TestAddHookStep_PreSelectsDefaultHooks(t *testing.T) { + t.Parallel() + w := framework.NewWizard("Test") + hooks := []HookInfo{ + {Name: "build", Description: "Run build", IsDefault: false}, + {Name: "test", Description: "Run tests", IsDefault: true}, + {Name: "lint", Description: "Lint code", IsDefault: true}, + } + addHookStep(w, hooks) + + step := w.GetStep("hooks") + if step == nil { + t.Fatal("hooks step should be present") + } + fl, ok := step.(*steps.FilterableListStep) + if !ok { + t.Fatalf("hooks step should be *FilterableListStep, got %T", step) + } + + // "test" and "lint" are default, so 2 pre-selected + if fl.SelectedCount() != 2 { + t.Errorf("SelectedCount() = %d, want 2 (default hooks pre-selected)", fl.SelectedCount()) + } +} + +func TestAddHookStep_NoDefaultHooks_NonePreSelected(t *testing.T) { + t.Parallel() + w := framework.NewWizard("Test") + hooks := []HookInfo{ + {Name: "deploy", Description: "Deploy", IsDefault: false}, + {Name: "notify", Description: "Notify", IsDefault: false}, + } + addHookStep(w, hooks) + + step := w.GetStep("hooks") + fl, ok := step.(*steps.FilterableListStep) + if !ok { + t.Fatalf("hooks step should be *FilterableListStep, got %T", step) + } + if fl.SelectedCount() != 0 { + t.Errorf("SelectedCount() = %d, want 0 when no defaults", fl.SelectedCount()) + } +} + +func TestAddHookStep_LabelFormat_WithDescription(t *testing.T) { + t.Parallel() + w := framework.NewWizard("Test") + hooks := []HookInfo{ + {Name: "build", Description: "Run the build script", IsDefault: false}, + } + addHookStep(w, hooks) + + step := w.GetStep("hooks") + fl, ok := step.(*steps.FilterableListStep) + if !ok { + t.Fatalf("hooks step should be *FilterableListStep, got %T", step) + } + view := fl.View() + if !strings.Contains(view, "build - Run the build script") { + t.Errorf("View should contain 'build - Run the build script', got: %s", view) + } +} + +func TestAddHookStep_LabelFormat_WithoutDescription(t *testing.T) { + t.Parallel() + w := framework.NewWizard("Test") + hooks := []HookInfo{ + {Name: "deploy", Description: "", IsDefault: false}, + } + addHookStep(w, hooks) + + step := w.GetStep("hooks") + fl, ok := step.(*steps.FilterableListStep) + if !ok { + t.Fatalf("hooks step should be *FilterableListStep, got %T", step) + } + view := fl.View() + // Should show just the name, without a " - " separator + if !strings.Contains(view, "deploy") { + t.Errorf("View should contain 'deploy', got: %s", view) + } + if strings.Contains(view, "deploy -") { + t.Errorf("View should not contain 'deploy -' when no description, got: %s", view) + } +} + +// --- buildBranchOptions additional tests --- + +func TestBuildBranchOptions_SingleNormal(t *testing.T) { + t.Parallel() + branches := []BranchInfo{ + {Name: "develop", InWorktree: false}, + } + opts := buildBranchOptions(branches) + if len(opts) != 1 { + t.Fatalf("expected 1 option, got %d", len(opts)) + } + if opts[0].Label != "develop" { + t.Errorf("Label = %q, want develop", opts[0].Label) + } + if opts[0].Value != "develop" { + t.Errorf("Value = %v, want develop", opts[0].Value) + } +} + +// --- cdListModel View content tests --- + +func TestCdListModel_View_ContainsBranch(t *testing.T) { + t.Parallel() + worktrees := []CdWorktreeInfo{ + {RepoName: "repo1", Branch: "main", Path: "/tmp/main"}, + } + m := newCdModel(worktrees) + view := m.View() + if !strings.Contains(view.Content, "repo1:main") { + t.Errorf("View should contain worktree label 'repo1:main', got: %s", view.Content) + } +} + +// --- PruneOptionValue tests --- + +func TestPruneOptionValue_Fields(t *testing.T) { + t.Parallel() + v := pruneOptionValue{ + ID: 7, + IsPrunable: true, + IsStale: false, + Reason: "● Merged", + } + if v.ID != 7 { + t.Errorf("ID = %d, want 7", v.ID) + } + if !v.IsPrunable { + t.Error("IsPrunable should be true") + } + if v.IsStale { + t.Error("IsStale should be false") + } + if v.Reason != "● Merged" { + t.Errorf("Reason = %q, want '● Merged'", v.Reason) + } +} diff --git a/internal/ui/wizard/framework/styles_test.go b/internal/ui/wizard/framework/styles_test.go new file mode 100644 index 0000000..5a67cbe --- /dev/null +++ b/internal/ui/wizard/framework/styles_test.go @@ -0,0 +1,141 @@ +package framework + +import ( + "testing" +) + +// TestStyles verifies all style functions return non-zero lipgloss styles +// (i.e. they don't panic and return a usable style that renders to a non-empty string). +func TestStyles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + style func() string + }{ + { + name: "BorderStyle", + style: func() string { + return BorderStyle().Render("content") + }, + }, + { + name: "TitleStyle", + style: func() string { + return TitleStyle().Render("Title") + }, + }, + { + name: "StepActiveStyle", + style: func() string { + return StepActiveStyle().Render("Step 1") + }, + }, + { + name: "StepCompletedStyle", + style: func() string { + return StepCompletedStyle().Render("Step 1") + }, + }, + { + name: "StepCheckStyle", + style: func() string { + return StepCheckStyle().Render("✓") + }, + }, + { + name: "StepInactiveStyle", + style: func() string { + return StepInactiveStyle().Render("Step 1") + }, + }, + { + name: "StepArrowStyle", + style: func() string { + return StepArrowStyle().Render("→") + }, + }, + { + name: "OptionSelectedStyle", + style: func() string { + return OptionSelectedStyle().Render("Option") + }, + }, + { + name: "OptionNormalStyle", + style: func() string { + return OptionNormalStyle().Render("Option") + }, + }, + { + name: "OptionDisabledStyle", + style: func() string { + return OptionDisabledStyle().Render("Disabled") + }, + }, + { + name: "HelpStyle", + style: func() string { + return HelpStyle().Render("help text") + }, + }, + { + name: "InfoStyle", + style: func() string { + return InfoStyle().Render("info text") + }, + }, + { + name: "FilterStyle", + style: func() string { + return FilterStyle().Render("filter") + }, + }, + { + name: "FilterLabelStyle", + style: func() string { + return FilterLabelStyle().Render("Filter:") + }, + }, + { + name: "SummaryLabelStyle", + style: func() string { + return SummaryLabelStyle().Render("Label:") + }, + }, + { + name: "SummaryValueStyle", + style: func() string { + return SummaryValueStyle().Render("value") + }, + }, + { + name: "OptionDescriptionStyle", + style: func() string { + return OptionDescriptionStyle().Render("description") + }, + }, + { + name: "MatchHighlightStyle", + style: func() string { + return MatchHighlightStyle().Render("match") + }, + }, + { + name: "ErrorStyle", + style: func() string { + return ErrorStyle().Render("error message") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.style() + if got == "" { + t.Errorf("%s: rendered empty string", tt.name) + } + }) + } +} diff --git a/internal/ui/wizard/framework/wizard_test.go b/internal/ui/wizard/framework/wizard_test.go index cfc1b3a..a9af31e 100644 --- a/internal/ui/wizard/framework/wizard_test.go +++ b/internal/ui/wizard/framework/wizard_test.go @@ -632,3 +632,520 @@ func TestWizard_WindowSizeMsg(t *testing.T) { t.Errorf("Window size = %dx%d, want 100x50", w.width, w.height) } } + +func TestWizard_View(t *testing.T) { + t.Parallel() + + t.Run("View renders title and step content", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("My Wizard").AddStep(step1) + w.Init() + + view := w.View().Content + if view == "" { + t.Error("View() should return non-empty string") + } + // Should contain the wizard title + if !contains(view, "My Wizard") { + t.Errorf("View() = %q, should contain title 'My Wizard'", view) + } + }) + + t.Run("View returns empty when done", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1) + w.Init() + w.done = true + + view := w.View().Content + if view != "" { + t.Errorf("View() when done = %q, want empty string", view) + } + }) + + t.Run("View renders step tabs with multiple steps", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + step2 := newMockStep("step2", "Step 2") + + w := NewWizard("Test").AddStep(step1).AddStep(step2) + w.Init() + + view := w.View().Content + if view == "" { + t.Error("View() should return non-empty string") + } + // Should show step tabs with step titles + if !contains(view, "Step 1") { + t.Errorf("View() should contain step title, got %q", view) + } + }) + + t.Run("View renders summary when on summary step", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1").setValue(StepValue{ + Key: "step1", + Label: "my value", + Raw: "my value", + }) + + w := NewWizard("Test").AddStep(step1) + w.Init() + // Advance to summary + w = updateWizard(t, w, "enter") + + view := w.View().Content + if view == "" { + t.Error("View() should return non-empty string") + } + }) + + t.Run("View renders info line when set and non-empty", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test"). + AddStep(step1). + WithInfoLine(func(w *Wizard) string { + return "Some info text" + }) + w.Init() + + view := w.View().Content + if !contains(view, "Some info text") { + t.Errorf("View() should contain info line, got %q", view) + } + }) + + t.Run("View skips step tabs for single step with skipSummary", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "My Step") + + w := NewWizard("Test"). + AddStep(step1). + WithSkipSummary(true) + w.Init() + + view := w.View().Content + // With single step and skipSummary, no tabs rendered, but step content is shown + if view == "" { + t.Error("View() should return non-empty string") + } + }) + + t.Run("View includes help text from step", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1) + w.Init() + + view := w.View().Content + if !contains(view, "mock help") { + t.Errorf("View() should contain step help text, got %q", view) + } + }) +} + +func TestWizard_RenderStepTabs(t *testing.T) { + t.Parallel() + + t.Run("tabs show checkmarks for confirmed steps", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + step2 := newMockStep("step2", "Step 2") + + w := NewWizard("Test").AddStep(step1).AddStep(step2) + w.Init() + + // Advance step1 (confirms it) + w = updateWizard(t, w, "enter") + + tabs := w.renderStepTabs() + if tabs == "" { + t.Error("renderStepTabs() should return non-empty string") + } + }) + + t.Run("tabs skip skipped steps", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + step2 := newMockStep("step2", "Step 2") + step3 := newMockStep("step3", "Step 3") + + w := NewWizard("Test"). + AddStep(step1). + AddStep(step2). + AddStep(step3). + SkipWhen("step2", func(w *Wizard) bool { return true }) + w.Init() + + tabs := w.renderStepTabs() + if tabs == "" { + t.Error("renderStepTabs() should return non-empty string") + } + // step2 is skipped, so should not appear in tabs + if contains(tabs, "Step 2") { + t.Errorf("renderStepTabs() should not contain skipped step, got %q", tabs) + } + }) + + t.Run("tabs include summary tab when not skipSummary", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1).WithSkipSummary(false) + w.Init() + + tabs := w.renderStepTabs() + if !contains(tabs, "Summary") { + t.Errorf("renderStepTabs() should include Summary tab, got %q", tabs) + } + }) + + t.Run("tabs do not include summary tab when skipSummary", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1).WithSkipSummary(true) + w.Init() + + tabs := w.renderStepTabs() + if contains(tabs, "Summary") { + t.Errorf("renderStepTabs() should not include Summary tab with skipSummary, got %q", tabs) + } + }) + + t.Run("active step on summary highlighted correctly", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1) + w.Init() + // Advance to summary + w = updateWizard(t, w, "enter") + + tabs := w.renderStepTabs() + if !contains(tabs, "Summary") { + t.Errorf("renderStepTabs() should include Summary, got %q", tabs) + } + }) + + t.Run("confirmed and active step shows checkmark", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + step2 := newMockStep("step2", "Step 2") + + w := NewWizard("Test").AddStep(step1).AddStep(step2) + w.Init() + + // Advance to step2 (confirms step1), then go back to step1 + w = updateWizard(t, w, "enter") // now on step2 + w = updateWizard(t, w, "left") // back to step1 (confirmed) + + tabs := w.renderStepTabs() + if tabs == "" { + t.Error("renderStepTabs() should return non-empty string") + } + // step1 should show checkmark since it's confirmed and active + if !contains(tabs, "✓") { + t.Errorf("renderStepTabs() should contain checkmark for confirmed+active step, got %q", tabs) + } + }) +} + +func TestWizard_RenderSummary(t *testing.T) { + t.Parallel() + + t.Run("summary shows step values", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Branch").setValue(StepValue{ + Key: "step1", + Label: "feature-x", + Raw: "feature-x", + }) + + w := NewWizard("Test").AddStep(step1) + w.Init() + + summary := w.renderSummary() + if !contains(summary, "Branch") { + t.Errorf("renderSummary() should contain step title, got %q", summary) + } + if !contains(summary, "feature-x") { + t.Errorf("renderSummary() should contain step value, got %q", summary) + } + }) + + t.Run("summary skips steps with empty label", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1").setValue(StepValue{ + Key: "step1", + Label: "", // empty label, should be skipped + Raw: nil, + }) + step2 := newMockStep("step2", "Step 2").setValue(StepValue{ + Key: "step2", + Label: "val2", + Raw: "val2", + }) + + w := NewWizard("Test").AddStep(step1).AddStep(step2) + w.Init() + + summary := w.renderSummary() + if contains(summary, "Step 1") { + t.Errorf("renderSummary() should skip step with empty label, got %q", summary) + } + if !contains(summary, "val2") { + t.Errorf("renderSummary() should contain step2 value, got %q", summary) + } + }) + + t.Run("summary skips skipped steps", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1").setValue(StepValue{ + Key: "step1", + Label: "value1", + Raw: "value1", + }) + step2 := newMockStep("step2", "Skipped Step").setValue(StepValue{ + Key: "step2", + Label: "skipped-value", + Raw: "skipped-value", + }) + + w := NewWizard("Test"). + AddStep(step1). + AddStep(step2). + SkipWhen("step2", func(w *Wizard) bool { return true }) + w.Init() + + summary := w.renderSummary() + if contains(summary, "skipped-value") { + t.Errorf("renderSummary() should skip skipped steps, got %q", summary) + } + }) + + t.Run("summary uses WithSummary title", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test"). + AddStep(step1). + WithSummary("Confirm Action") + w.Init() + + summary := w.renderSummary() + if !contains(summary, "Confirm Action") { + t.Errorf("renderSummary() should contain custom title, got %q", summary) + } + }) +} + +func TestWizard_GetStrings_AllBranches(t *testing.T) { + t.Parallel() + + t.Run("GetStrings with []any slice", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("items", "Items").setValue(StepValue{ + Key: "items", + Label: "a, b", + Raw: []any{"a", "b", "c"}, + }) + + w := NewWizard("Test").AddStep(step1) + w.Init() + + strs := w.GetStrings("items") + if len(strs) != 3 { + t.Errorf("GetStrings with []any = %v, want 3 items", strs) + } + if strs[0] != "a" || strs[1] != "b" || strs[2] != "c" { + t.Errorf("GetStrings = %v, want [a, b, c]", strs) + } + }) + + t.Run("GetStrings with []any containing non-string skips them", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("items", "Items").setValue(StepValue{ + Key: "items", + Label: "mixed", + Raw: []any{"a", 42, "b"}, + }) + + w := NewWizard("Test").AddStep(step1) + w.Init() + + strs := w.GetStrings("items") + if len(strs) != 2 { + t.Errorf("GetStrings with mixed []any = %v, want 2 strings", strs) + } + }) + + t.Run("GetStrings returns nil for non-slice raw", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("items", "Items").setValue(StepValue{ + Key: "items", + Label: "single", + Raw: "single-string", + }) + + w := NewWizard("Test").AddStep(step1) + w.Init() + + strs := w.GetStrings("items") + if strs != nil { + t.Errorf("GetStrings with non-slice raw = %v, want nil", strs) + } + }) + + t.Run("GetStrings returns nil for unknown step", func(t *testing.T) { + t.Parallel() + w := NewWizard("Test") + + strs := w.GetStrings("unknown") + if strs != nil { + t.Errorf("GetStrings unknown step = %v, want nil", strs) + } + }) + + t.Run("GetBool returns false for non-bool raw", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("confirm", "Confirm").setValue(StepValue{ + Key: "confirm", + Label: "no", + Raw: "not-a-bool", + }) + + w := NewWizard("Test").AddStep(step1) + w.Init() + + b := w.GetBool("confirm") + if b { + t.Error("GetBool with non-bool raw should return false") + } + }) + + t.Run("GetString falls back to label when raw is not string", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("items", "Items").setValue(StepValue{ + Key: "items", + Label: "display label", + Raw: 42, // non-string raw + }) + + w := NewWizard("Test").AddStep(step1) + w.Init() + + s := w.GetString("items") + if s != "display label" { + t.Errorf("GetString with non-string raw = %q, want 'display label'", s) + } + }) +} + +func TestWizard_SetCurrentStep(t *testing.T) { + t.Parallel() + + t.Run("SetCurrentStep changes active step", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + step2 := newMockStep("step2", "Step 2") + + w := NewWizard("Test").AddStep(step1).AddStep(step2) + w.Init() + + w.SetCurrentStep("step2") + + if w.CurrentStepID() != "step2" { + t.Errorf("CurrentStepID = %s, want step2", w.CurrentStepID()) + } + }) + + t.Run("SetCurrentStep is no-op for unknown ID", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1) + w.Init() + + w.SetCurrentStep("unknown") + + if w.CurrentStepID() != "step1" { + t.Errorf("CurrentStepID after unknown SetCurrentStep = %s, want step1", w.CurrentStepID()) + } + }) +} + +func TestWizard_AdvanceWithStepAdvanceResult(t *testing.T) { + t.Parallel() + + t.Run("StepAdvance on last step goes to summary", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1) + w.Init() + + // right arrow triggers StepAdvance + w = updateWizard(t, w, "right") + + if w.CurrentStepID() != "summary" { + t.Errorf("CurrentStepID = %s, want summary", w.CurrentStepID()) + } + }) + + t.Run("StepAdvance on last step with skipSummary completes wizard", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1).WithSkipSummary(true) + w.Init() + + // right arrow triggers StepAdvance + w = updateWizard(t, w, "right") + + if !w.done { + t.Error("Wizard should be done after StepAdvance on last step with skipSummary") + } + }) +} + +func TestWizard_UnknownMsg(t *testing.T) { + t.Parallel() + + t.Run("unknown message is ignored gracefully", func(t *testing.T) { + t.Parallel() + step1 := newMockStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1) + w.Init() + + // Send an unknown message type (not KeyPressMsg or WindowSizeMsg) + m, _ := w.Update("unknown message") + w = m.(*Wizard) + + // Should still be on step1 + if w.CurrentStepID() != "step1" { + t.Errorf("CurrentStepID = %s, want step1", w.CurrentStepID()) + } + }) +} + +// contains is a helper to check if a string contains a substring. +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }()) +} diff --git a/internal/ui/wizard/steps/filterable_list_test.go b/internal/ui/wizard/steps/filterable_list_test.go index fe5776e..d802802 100644 --- a/internal/ui/wizard/steps/filterable_list_test.go +++ b/internal/ui/wizard/steps/filterable_list_test.go @@ -504,3 +504,536 @@ func TestFilterableListStep_RuneFilter(t *testing.T) { } }) } + +func TestFilterableListStep_View(t *testing.T) { + options := []framework.Option{ + {Label: "apple", Value: "apple"}, + {Label: "banana", Value: "banana"}, + {Label: "cherry", Value: "cherry"}, + } + + t.Run("View returns non-empty string", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select an option", options) + + view := step.View() + if view == "" { + t.Error("View() should return non-empty string") + } + }) + + t.Run("View includes prompt", func(t *testing.T) { + step := NewFilterableList("test", "Test", "My Prompt", options) + + view := step.View() + if !containsStr(view, "My Prompt") { + t.Errorf("View() should contain prompt, got %q", view) + } + }) + + t.Run("View shows prompt with selected count in multi-select mode", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Choose items", options). + WithMultiSelect() + + updateStep(t, step, keyMsg("space")) + + view := step.View() + if !containsStr(view, "(1 selected)") { + t.Errorf("View() in multi-select should show count, got %q", view) + } + }) + + t.Run("View shows empty message when no items match", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + // Type something that matches nothing + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + + view := step.View() + if !containsStr(view, "No matching items") { + t.Errorf("View() should show empty message, got %q", view) + } + }) + + t.Run("View shows custom empty message", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithEmptyMessage("Nothing found here") + + // Type something that matches nothing + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + + view := step.View() + if !containsStr(view, "Nothing found here") { + t.Errorf("View() should show custom empty message, got %q", view) + } + }) + + t.Run("View shows checkboxes in multi-select mode", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithMultiSelect() + + view := step.View() + if !containsStr(view, "[ ]") { + t.Errorf("View() in multi-select should show unchecked checkboxes, got %q", view) + } + }) + + t.Run("View shows checked checkbox for selected items", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithMultiSelect() + + // Select first item + updateStep(t, step, keyMsg("space")) + + view := step.View() + if !containsStr(view, "[✓]") { + t.Errorf("View() should show checked checkbox for selected item, got %q", view) + } + }) + + t.Run("View shows disabled options with description", func(t *testing.T) { + disabledOptions := []framework.Option{ + {Label: "Active", Value: "active"}, + {Label: "Disabled", Value: "disabled", Disabled: true, Description: "not available"}, + } + step := NewFilterableList("test", "Test", "Select", disabledOptions) + + view := step.View() + if !containsStr(view, "not available") { + t.Errorf("View() should show disabled option description, got %q", view) + } + }) + + t.Run("View shows filter focused with textinput view", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + // Type to focus filter + updateStep(t, step, keyMsg("a")) + + // Now filter is focused + view := step.View() + if view == "" { + t.Error("View() should return non-empty string when filter focused") + } + }) + + t.Run("View shows option descriptions via default renderer", func(t *testing.T) { + optionsWithDesc := []framework.Option{ + {Label: "Option A", Value: "a", Description: "A great option"}, + {Label: "Option B", Value: "b"}, + } + step := NewFilterableList("test", "Test", "Select", optionsWithDesc) + + view := step.View() + if !containsStr(view, "A great option") { + t.Errorf("View() should show option description, got %q", view) + } + }) + + t.Run("View uses custom description renderer", func(t *testing.T) { + optionsWithDesc := []framework.Option{ + {Label: "Option A", Value: "a", Description: "original desc"}, + } + step := NewFilterableList("test", "Test", "Select", optionsWithDesc). + WithDescriptionRenderer(func(opt framework.Option, isSelected bool) string { + return "custom: " + opt.Description + }) + + view := step.View() + if !containsStr(view, "custom: original desc") { + t.Errorf("View() should use custom description renderer, got %q", view) + } + }) +} + +func TestFilterableListStep_Help(t *testing.T) { + options := []framework.Option{ + {Label: "opt", Value: "opt"}, + } + + t.Run("Help returns single-select help", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + help := step.Help() + if help == "" { + t.Error("Help() should return non-empty string") + } + // Single-select help should not mention space toggle + if containsStr(help, "space toggle") { + t.Errorf("Single-select help should not mention space, got %q", help) + } + }) + + t.Run("Help returns multi-select help", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithMultiSelect() + help := step.Help() + if !containsStr(help, "space toggle") { + t.Errorf("Multi-select help should mention space toggle, got %q", help) + } + }) +} + +func TestFilterableListStep_GetSelectedOption(t *testing.T) { + options := []framework.Option{ + {Label: "apple", Value: "apple"}, + {Label: "banana", Value: "banana"}, + } + + t.Run("GetSelectedOption returns empty when nothing selected", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + opt := step.GetSelectedOption() + if opt.Label != "" { + t.Errorf("GetSelectedOption() = %v, want empty Option", opt) + } + }) + + t.Run("GetSelectedOption returns selected option", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + updateStep(t, step, keyMsg("enter")) // select first (apple) + + opt := step.GetSelectedOption() + if opt.Label != "apple" { + t.Errorf("GetSelectedOption() = %v, want apple", opt) + } + }) + + t.Run("GetSelectedOption returns empty when create is selected", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithCreateFromFilter(func(f string) string { return "+ Create " + f }) + + // Type something new + updateStep(t, step, keyMsg("x")) + updateStep(t, step, keyMsg("y")) + updateStep(t, step, keyMsg("z")) + // Select create option (cursor should be at 0 which is create) + updateStep(t, step, keyMsg("enter")) + + opt := step.GetSelectedOption() + if opt.Label != "" { + t.Errorf("GetSelectedOption() for create selection = %v, want empty", opt) + } + }) +} + +func TestFilterableListStep_GetSelectedValue(t *testing.T) { + options := []framework.Option{ + {Label: "apple", Value: "apple-val"}, + {Label: "banana", Value: "banana-val"}, + } + + t.Run("GetSelectedValue returns nil when nothing selected", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + val := step.GetSelectedValue() + if val != nil { + t.Errorf("GetSelectedValue() = %v, want nil", val) + } + }) + + t.Run("GetSelectedValue returns option value after selection", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + updateStep(t, step, keyMsg("enter")) // select first (apple) + + val := step.GetSelectedValue() + if val != "apple-val" { + t.Errorf("GetSelectedValue() = %v, want apple-val", val) + } + }) + + t.Run("GetSelectedValue returns filter for create selection", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithCreateFromFilter(func(f string) string { return "+ Create " + f }) + + // Type something new + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("z")) + // Enter selects the create option + updateStep(t, step, keyMsg("enter")) + + val := step.GetSelectedValue() + if val != "zzz" { + t.Errorf("GetSelectedValue() for create = %v, want 'zzz'", val) + } + }) +} + +func TestFilterableListStep_WithValueLabel(t *testing.T) { + options := []framework.Option{ + {Label: "apple", Value: "apple"}, + } + + t.Run("WithValueLabel customizes value label", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithValueLabel(func(value string, isNew bool, opt framework.Option) string { + return "custom:" + value + }) + + updateStep(t, step, keyMsg("enter")) + + value := step.Value() + if value.Label != "custom:apple" { + t.Errorf("Value.Label = %q, want 'custom:apple'", value.Label) + } + }) + + t.Run("WithValueLabel for create option", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithCreateFromFilter(func(f string) string { return "+ Create " + f }). + WithValueLabel(func(value string, isNew bool, opt framework.Option) string { + if isNew { + return "new:" + value + } + return value + }) + + // Type something new + updateStep(t, step, keyMsg("x")) + updateStep(t, step, keyMsg("y")) + updateStep(t, step, keyMsg("z")) + updateStep(t, step, keyMsg("enter")) + + value := step.Value() + if value.Label != "new:xyz" { + t.Errorf("Value.Label for create = %q, want 'new:xyz'", value.Label) + } + }) +} + +func TestFilterableListStep_NavigationWithFilter(t *testing.T) { + options := []framework.Option{ + {Label: "apple", Value: "apple"}, + {Label: "banana", Value: "banana"}, + {Label: "cherry", Value: "cherry"}, + } + + t.Run("pgup navigates to first when filter focused", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + // Move to end first + updateStep(t, step, keyMsg("end")) + + updateStep(t, step, keyMsg("pgup")) + if step.GetCursor() != 0 { + t.Errorf("Cursor after pgup = %d, want 0", step.GetCursor()) + } + }) + + t.Run("pgdown navigates to last", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + updateStep(t, step, keyMsg("pgdown")) + if step.GetCursor() != 2 { + t.Errorf("Cursor after pgdown = %d, want 2", step.GetCursor()) + } + }) + + t.Run("up at top of list moves to filter", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + // At cursor 0, up should focus filter + updateStep(t, step, keyMsg("up")) + + if !step.filterInput.Focused() { + t.Error("filterInput should be focused after up at top of list") + } + }) + + t.Run("down from filter moves focus to list", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + // First up to focus filter + updateStep(t, step, keyMsg("up")) + if !step.filterInput.Focused() { + t.Fatal("filterInput should be focused") + } + + // Now down should move focus back to list + updateStep(t, step, keyMsg("down")) + if step.filterInput.Focused() { + t.Error("filterInput should lose focus after down") + } + }) + + t.Run("backspace from list with filter focuses filter", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + // Type to set filter + updateStep(t, step, keyMsg("a")) + // Now down to move to list + updateStep(t, step, keyMsg("down")) + // Now list is focused, backspace should focus filter + updateStep(t, step, keyMsg("backspace")) + + if !step.filterInput.Focused() { + t.Error("filterInput should be focused after backspace from list") + } + }) + + t.Run("up in filter input is no-op at top", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + // Focus filter + updateStep(t, step, keyMsg("up")) + if !step.filterInput.Focused() { + t.Fatal("filterInput should be focused") + } + + // Up while filter is focused - should be no-op + cursorBefore := step.GetCursor() + updateStep(t, step, keyMsg("up")) + if step.GetCursor() != cursorBefore { + t.Errorf("Cursor changed after up in filter, was %d now %d", cursorBefore, step.GetCursor()) + } + }) +} + +func TestFilterableListStep_MultiSelect_MaxConstraint(t *testing.T) { + options := []framework.Option{ + {Label: "Option A", Value: "a"}, + {Label: "Option B", Value: "b"}, + {Label: "Option C", Value: "c"}, + } + + t.Run("cannot select more than max", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithMultiSelect(). + SetMinMax(0, 2) + + // Select first + updateStep(t, step, keyMsg("space")) + // Select second + updateStep(t, step, keyMsg("down")) + updateStep(t, step, keyMsg("space")) + // Try to select third - should be blocked by max + updateStep(t, step, keyMsg("down")) + updateStep(t, step, keyMsg("space")) + + if step.SelectedCount() > 2 { + t.Errorf("SelectedCount = %d, max is 2", step.SelectedCount()) + } + }) + + t.Run("can advance with zero selections when minSelect is 0", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithMultiSelect(). + SetMinMax(0, 3) + + // No selections, but min is 0 - should be able to advance + _, result := updateStep(t, step, keyMsg("enter")) + if result != framework.StepSubmitIfReady { + t.Errorf("Result = %v, want StepSubmitIfReady when min is 0", result) + } + }) +} + +func TestFilterableListStep_MultiSelectWithCreate(t *testing.T) { + options := []framework.Option{ + {Label: "main", Value: "main"}, + } + + t.Run("space on create option is no-op in multi-select", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithMultiSelect(). + WithCreateFromFilter(func(f string) string { return "+ Create " + f }) + + // Type to show create option + updateStep(t, step, keyMsg("n")) + updateStep(t, step, keyMsg("e")) + updateStep(t, step, keyMsg("w")) + + // Cursor should be at 0 (create option) + if step.GetCursor() != 0 { + t.Fatalf("Cursor should be 0 for create option, got %d", step.GetCursor()) + } + + // Space on create option should be no-op + beforeCount := step.SelectedCount() + updateStep(t, step, keyMsg("space")) + if step.SelectedCount() != beforeCount { + t.Errorf("SelectedCount changed after space on create option: %d -> %d", + beforeCount, step.SelectedCount()) + } + }) +} + +func TestFilterableListStep_ScrollView(t *testing.T) { + // Create many options to trigger scroll + var manyOptions []framework.Option + for i := 0; i < 15; i++ { + label := string(rune('a'+i)) + "-option" + manyOptions = append(manyOptions, framework.Option{Label: label, Value: label}) + } + + t.Run("scroll view shows more above indicator", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", manyOptions) + + // Navigate to last item to trigger scroll + updateStep(t, step, keyMsg("end")) + + view := step.View() + if !containsStr(view, "↑ more above") { + t.Errorf("View() should show scroll indicator, got %q", view) + } + }) + + t.Run("scroll view shows more below indicator", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", manyOptions) + + view := step.View() + if !containsStr(view, "↓ more below") { + t.Errorf("View() should show more below indicator, got %q", view) + } + }) +} + +func TestFilterableListStep_HighlightMatches(t *testing.T) { + options := []framework.Option{ + {Label: "feature-branch", Value: "feature-branch"}, + {Label: "fix-bug", Value: "fix-bug"}, + } + + t.Run("fuzzy match highlights are shown in view", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + // Type "fb" which should fuzzy match "feature-branch" + updateStep(t, step, keyMsg("f")) + updateStep(t, step, keyMsg("b")) + + view := step.View() + // Just verify View doesn't panic and returns something + if view == "" { + t.Error("View() should return non-empty string with fuzzy highlights") + } + }) +} + +func TestFilterableListStep_String(t *testing.T) { + options := []framework.Option{ + {Label: "opt", Value: "opt"}, + } + + step := NewFilterableList("myid", "Test", "Select", options) + s := step.String() + if !containsStr(s, "myid") { + t.Errorf("String() = %q, should contain id", s) + } +} + +// containsStr checks if s contains sub. +func containsStr(s, sub string) bool { + if len(sub) == 0 { + return true + } + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/ui/wizard/steps/single_select_test.go b/internal/ui/wizard/steps/single_select_test.go index 59a92c8..6fbbd72 100644 --- a/internal/ui/wizard/steps/single_select_test.go +++ b/internal/ui/wizard/steps/single_select_test.go @@ -431,3 +431,216 @@ func TestSingleSelectStep_FormatValue(t *testing.T) { t.Errorf("FormatValue() with custom labels = %s, want Custom Label", formatted) } } + +func TestSingleSelectStep_View(t *testing.T) { + options := []framework.Option{ + {Label: "Option 1", Value: "opt1"}, + {Label: "Option 2", Value: "opt2", Description: "A description"}, + {Label: "Disabled", Value: "dis", Disabled: true, Description: "Not available"}, + } + + t.Run("View returns non-empty string", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Pick one:", options) + view := step.View() + if view == "" { + t.Error("View() should return non-empty string") + } + }) + + t.Run("View shows prompt", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "My Prompt:", options) + view := step.View() + if !containsStr(view, "My Prompt:") { + t.Errorf("View() should contain prompt, got %q", view) + } + }) + + t.Run("View shows cursor on selected option", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", options) + view := step.View() + if !containsStr(view, "> ") { + t.Errorf("View() should show cursor, got %q", view) + } + }) + + t.Run("View shows descriptions", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", options) + view := step.View() + if !containsStr(view, "A description") { + t.Errorf("View() should show option description, got %q", view) + } + }) + + t.Run("View shows disabled option with reason", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", options) + view := step.View() + if !containsStr(view, "Not available") { + t.Errorf("View() should show disabled reason, got %q", view) + } + }) +} + +func TestSingleSelectStep_RenderWithScroll(t *testing.T) { + // Create many options to trigger scroll + var manyOptions []framework.Option + for i := 0; i < 15; i++ { + manyOptions = append(manyOptions, framework.Option{ + Label: string(rune('a'+i)) + "-option", + Value: string(rune('a' + i)), + }) + } + + t.Run("RenderWithScroll returns non-empty", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", manyOptions) + view := step.RenderWithScroll(5) + if view == "" { + t.Error("RenderWithScroll() should return non-empty string") + } + }) + + t.Run("RenderWithScroll shows more below indicator", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", manyOptions) + view := step.RenderWithScroll(5) + if !containsStr(view, "↓ more below") { + t.Errorf("RenderWithScroll() should show more below, got %q", view) + } + }) + + t.Run("RenderWithScroll shows more above when scrolled", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", manyOptions) + // Navigate to end + updateStep(t, step, keyMsg("end")) + view := step.RenderWithScroll(5) + if !containsStr(view, "↑ more above") { + t.Errorf("RenderWithScroll() should show more above when scrolled, got %q", view) + } + }) + + t.Run("RenderWithScroll shows no options message for empty list", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", nil) + view := step.RenderWithScroll(5) + if !containsStr(view, "No options available") { + t.Errorf("RenderWithScroll() should show empty message, got %q", view) + } + }) +} + +func TestSingleSelectStep_EnableAllOptions(t *testing.T) { + options := []framework.Option{ + {Label: "Option 1", Value: "opt1"}, + {Label: "Option 2", Value: "opt2"}, + } + + t.Run("EnableAllOptions re-enables disabled options", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", options) + step.DisableOption(0, "Disabled for testing") + step.DisableOption(1, "Also disabled") + + step.EnableAllOptions() + + // Both should now be enabled and selectable + opt0, _ := step.GetOption(0) + opt1, _ := step.GetOption(1) + + if opt0.Disabled { + t.Error("Option 0 should be enabled after EnableAllOptions") + } + if opt1.Disabled { + t.Error("Option 1 should be enabled after EnableAllOptions") + } + }) +} + +func TestSingleSelectStep_Value_EmptyWhenNotSelected(t *testing.T) { + options := []framework.Option{ + {Label: "Option 1", Value: "opt1"}, + } + + step := NewSingleSelect("test", "Test", "Select:", options) + + // Before selection + value := step.Value() + if value.Label != "" { + t.Errorf("Value.Label before selection = %q, want empty", value.Label) + } + if value.Key != "test" { + t.Errorf("Value.Key before selection = %q, want 'test'", value.Key) + } +} + +func TestSingleSelectStep_SetOptions_ClearsOutOfBoundsSelection(t *testing.T) { + options := []framework.Option{ + {Label: "Option 1", Value: "opt1"}, + {Label: "Option 2", Value: "opt2"}, + {Label: "Option 3", Value: "opt3"}, + } + + step := NewSingleSelect("test", "Test", "Select:", options) + step.SetCursor(2) + updateStep(t, step, keyMsg("enter")) + + if step.GetSelectedIndex() != 2 { + t.Fatalf("Selected index = %d, want 2", step.GetSelectedIndex()) + } + + // Set fewer options - previous selection index is now out of bounds + fewerOptions := []framework.Option{ + {Label: "New 1", Value: "new1"}, + } + step.SetOptions(fewerOptions) + + // Selection should be cleared + if step.GetSelectedIndex() != -1 { + t.Errorf("Selected index after SetOptions with fewer options = %d, want -1", step.GetSelectedIndex()) + } +} + +func TestSingleSelectStep_String(t *testing.T) { + options := []framework.Option{ + {Label: "opt", Value: "opt"}, + } + + step := NewSingleSelect("myid", "Test", "Select:", options) + s := step.String() + if !containsStr(s, "myid") { + t.Errorf("String() = %q, should contain id", s) + } +} + +func TestSingleSelectStep_GetOption_OutOfBounds(t *testing.T) { + options := []framework.Option{ + {Label: "opt1", Value: "v1"}, + } + step := NewSingleSelect("test", "Test", "Select:", options) + + _, ok := step.GetOption(-1) + if ok { + t.Error("GetOption(-1) should return false") + } + + _, ok = step.GetOption(99) + if ok { + t.Error("GetOption(99) should return false") + } + + opt, ok := step.GetOption(0) + if !ok { + t.Error("GetOption(0) should return true") + } + if opt.Label != "opt1" { + t.Errorf("GetOption(0).Label = %q, want opt1", opt.Label) + } +} + +func TestSingleSelectStep_FormatValue_WhenNotSelected(t *testing.T) { + options := []framework.Option{ + {Label: "opt1", Value: "v1"}, + } + step := NewSingleSelect("test", "Test", "Select:", options) + + // Not selected + formatted := step.FormatValue(nil) + if formatted != "" { + t.Errorf("FormatValue() when not selected = %q, want empty", formatted) + } +} diff --git a/internal/ui/wizard/steps/text_input_test.go b/internal/ui/wizard/steps/text_input_test.go index 339e38c..5103d02 100644 --- a/internal/ui/wizard/steps/text_input_test.go +++ b/internal/ui/wizard/steps/text_input_test.go @@ -4,6 +4,8 @@ import ( "errors" "testing" + tea "charm.land/bubbletea/v2" + "github.com/raphi011/wt/internal/ui/wizard/framework" ) @@ -283,3 +285,66 @@ func TestTextInputStep_Configuration(t *testing.T) { t.Error("Configuration methods should not break step") } } + +func TestTextInputStep_View(t *testing.T) { + t.Run("View returns non-empty string", func(t *testing.T) { + step := NewTextInput("test", "Test", "Enter something:", "") + view := step.View() + if view == "" { + t.Error("View() should return non-empty string") + } + }) + + t.Run("View shows prompt", func(t *testing.T) { + step := NewTextInput("test", "Test", "My Prompt:", "") + view := step.View() + if !containsStr(view, "My Prompt:") { + t.Errorf("View() should contain prompt, got %q", view) + } + }) + + t.Run("View shows validation error", func(t *testing.T) { + step := NewTextInput("test", "Test", "Enter:", "") + // Trigger validation error by submitting empty value + updateStep(t, step, keyMsg("enter")) + + view := step.View() + if !containsStr(view, "cannot be empty") { + t.Errorf("View() should contain validation error, got %q", view) + } + }) + + t.Run("View clears error when typing after error", func(t *testing.T) { + step := NewTextInput("test", "Test", "Enter:", "") + step.Init() + // Trigger validation error + updateStep(t, step, keyMsg("enter")) + + // Now type something to clear the error + updateStep(t, step, keyMsg("a")) + + view := step.View() + if containsStr(view, "cannot be empty") { + t.Errorf("View() should not show error after typing, got %q", view) + } + }) +} + +func TestTextInputStep_WithCursor(t *testing.T) { + step := NewTextInput("test", "Test", "Enter:", "") + + // WithCursor should configure cursor without panicking + step = step.WithCursor(tea.CursorBlock, false) + + if step.ID() != "test" { + t.Error("WithCursor should not break step") + } +} + +func TestTextInputStep_String(t *testing.T) { + step := NewTextInput("myid", "Test", "Enter:", "") + s := step.String() + if !containsStr(s, "myid") { + t.Errorf("String() = %q, should contain id", s) + } +}