From e540738e5df1d1a5f1273c5c63d8152497dc4198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Fri, 3 Apr 2026 23:26:46 +0200 Subject: [PATCH] Strengthen TUI edge-case coverage --- cmd/cloudstic/cmd_tui_test.go | 125 ++++++++++++++++++++++++++++++++++ cmd/cloudstic/tui_runtime.go | 46 ++++++++----- internal/tui/shell.go | 6 +- internal/tui/shell_test.go | 86 +++++++++++++++++++++++ 4 files changed, 244 insertions(+), 19 deletions(-) diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index 1d95c3c..1b6ed75 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -10,6 +10,7 @@ import ( "reflect" "strings" "testing" + "time" cloudstic "github.com/cloudstic/cli" "github.com/cloudstic/cli/internal/tui" @@ -713,6 +714,74 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { } } +func TestTUISession_HandleActionRunRefreshFailureRestoresRawMode(t *testing.T) { + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { + _ = readEnd.Close() + _ = writeEnd.Close() + }() + + oldBuild := tuiBuildDashboard + oldAction := tuiRunProfileAction + oldMakeRaw := tuiMakeRaw + oldRestore := tuiRestoreTerminal + oldEnterAlt := tuiEnterAltScreen + oldLeaveAlt := tuiLeaveAltScreen + t.Cleanup(func() { + tuiBuildDashboard = oldBuild + tuiRunProfileAction = oldAction + tuiMakeRaw = oldMakeRaw + tuiRestoreTerminal = oldRestore + tuiEnterAltScreen = oldEnterAlt + tuiLeaveAltScreen = oldLeaveAlt + }) + + var madeRaw, restored int + state := &xterm.State{} + tuiMakeRaw = func(int) (*xterm.State, error) { madeRaw++; return state, nil } + tuiRestoreTerminal = func(int, *xterm.State) error { restored++; return nil } + tuiEnterAltScreen = func(io.Writer) error { return nil } + tuiLeaveAltScreen = func(io.Writer) error { return nil } + + tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { + return tui.Dashboard{}, errors.New("boom") + } + tuiRunProfileAction = func(_ context.Context, _ *runner, _ string, _ tui.ProfileCard, log *tuiActionState) error { + log.Printf("backup complete") + return nil + } + + s := newTUISession(&runner{out: io.Discard, stdoutFile: os.Stdout, stdin: readEnd}, "profiles.yaml", tui.Dashboard{ + SelectedProfile: "docs", + Profiles: []tui.ProfileCard{ + { + Name: "docs", + Source: "local:/docs", + StoreRef: "remote", + Enabled: true, + Status: tui.ProfileStatusReady, + Actions: []tui.ProfileAction{ + {Kind: tui.ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}, + }, + }, + }, + }) + s.rawState = state + + if _, err := s.handleAction(context.Background(), tuiActionRun); err == nil { + t.Fatalf("expected refresh failure") + } + if madeRaw != 1 || restored != 1 { + t.Fatalf("unexpected raw lifecycle counts: make=%d restore=%d", madeRaw, restored) + } + if s.rawState != state { + t.Fatalf("raw state not restored after refresh failure") + } +} + func TestTUISession_HandleActionCreateRefreshesDashboard(t *testing.T) { stubTUITestHooks(t) @@ -838,6 +907,62 @@ func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) { } } +func TestRunTUIActionIntoDashboard_RedrawsUsingCurrentWidthDuringLongAction(t *testing.T) { + stubTUITestHooks(t) + + oldWidth := tuiGetTerminalSize + oldAction := tuiRunProfileAction + t.Cleanup(func() { + tuiGetTerminalSize = oldWidth + tuiRunProfileAction = oldAction + }) + + var widthCalls int + tuiGetTerminalSize = func(int) (int, int, error) { + widthCalls++ + if widthCalls == 1 { + return 120, 40, nil + } + return 72, 40, nil + } + tuiRunProfileAction = func(_ context.Context, _ *runner, _ string, _ tui.ProfileCard, log *tuiActionState) error { + phase := log.Reporter().StartPhase("Uploading", 4, false) + time.Sleep(120 * time.Millisecond) + phase.Increment(2) + time.Sleep(120 * time.Millisecond) + phase.Increment(2) + phase.Done() + return nil + } + + var out strings.Builder + dashboard := tui.Dashboard{ + SelectedProfile: "docs", + Profiles: []tui.ProfileCard{ + { + Name: "docs", + Source: "local:/docs", + StoreRef: "remote", + Enabled: true, + Status: tui.ProfileStatusReady, + Actions: []tui.ProfileAction{ + {Kind: tui.ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}, + }, + }, + }, + } + result := runTUIActionIntoDashboard(context.Background(), &runner{out: &out, stdoutFile: os.Stdout}, "profiles.yaml", dashboard) + if widthCalls < 2 { + t.Fatalf("expected multiple width polls during long action, got %d", widthCalls) + } + if result.Activity.Status != tui.ActivityStatusSuccess { + t.Fatalf("unexpected activity status: %+v", result.Activity) + } + if !strings.Contains(out.String(), "Progress") { + t.Fatalf("expected live renders with progress, got:\n%s", out.String()) + } +} + func TestCaptureTUIRunnerOutput_RestoresRunnerState(t *testing.T) { var out strings.Builder var errOut strings.Builder diff --git a/cmd/cloudstic/tui_runtime.go b/cmd/cloudstic/tui_runtime.go index e2c9058..3a0f38c 100644 --- a/cmd/cloudstic/tui_runtime.go +++ b/cmd/cloudstic/tui_runtime.go @@ -279,26 +279,24 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e case tuiActionDown: s.dashboard = moveTUISelection(s.dashboard, 1) case tuiActionRun: - if err := s.suspendRaw(); err != nil { - return -1, fmt.Errorf("failed to configure terminal: %v", err) - } - s.dashboard = runTUIActionIntoDashboard(ctx, s.r, s.profilesFile, s.dashboard) - if err := s.refresh(ctx); err != nil { - return -1, fmt.Errorf("failed to refresh TUI dashboard: %v", err) - } - if err := s.resumeRaw(); err != nil { - return -1, fmt.Errorf("failed to configure terminal: %v", err) + if err := s.runSuspended(ctx, func(ctx context.Context) error { + s.dashboard = runTUIActionIntoDashboard(ctx, s.r, s.profilesFile, s.dashboard) + if err := s.refresh(ctx); err != nil { + return fmt.Errorf("failed to refresh TUI dashboard: %v", err) + } + return nil + }); err != nil { + return -1, err } case tuiActionCheck: - if err := s.suspendRaw(); err != nil { - return -1, fmt.Errorf("failed to configure terminal: %v", err) - } - s.dashboard = runTUICheckIntoDashboard(ctx, s.r, s.profilesFile, s.dashboard) - if err := s.refresh(ctx); err != nil { - return -1, fmt.Errorf("failed to refresh TUI dashboard: %v", err) - } - if err := s.resumeRaw(); err != nil { - return -1, fmt.Errorf("failed to configure terminal: %v", err) + if err := s.runSuspended(ctx, func(ctx context.Context) error { + s.dashboard = runTUICheckIntoDashboard(ctx, s.r, s.profilesFile, s.dashboard) + if err := s.refresh(ctx); err != nil { + return fmt.Errorf("failed to refresh TUI dashboard: %v", err) + } + return nil + }); err != nil { + return -1, err } case tuiActionCreate: if err := s.runProfileModal(ctx, "", false); err != nil { @@ -328,6 +326,18 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e return -1, s.render() } +func (s *tuiSession) runSuspended(ctx context.Context, fn func(context.Context) error) (err error) { + if err := s.suspendRaw(); err != nil { + return fmt.Errorf("failed to configure terminal: %v", err) + } + defer func() { + if resumeErr := s.resumeRaw(); err == nil && resumeErr != nil { + err = fmt.Errorf("failed to configure terminal: %v", resumeErr) + } + }() + return fn(ctx) +} + func (s *tuiSession) refresh(ctx context.Context) error { selected := s.dashboard.SelectedProfile activity := s.dashboard.Activity diff --git a/internal/tui/shell.go b/internal/tui/shell.go index ef32947..5a055c6 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -103,7 +103,11 @@ func dashboardLinesWidth(d Dashboard, width int) []string { )...) lines = append(lines, boxLinesExact("Activity", renderActivityPanel(d.Activity), panelWidth(width))...) - lines = append(lines, "", fmt.Sprintf("%sUse ↑/↓ to select a profile. Press b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.%s", ui.Dim, ui.Reset)) + footer := fmt.Sprintf("%sUse ↑/↓ to select a profile. Press b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.%s", ui.Dim, ui.Reset) + if width > 0 { + footer = truncateVisible(footer, width) + } + lines = append(lines, "", footer) return lines } diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go index 0c5694b..70c6c89 100644 --- a/internal/tui/shell_test.go +++ b/internal/tui/shell_test.go @@ -132,3 +132,89 @@ func TestRenderDashboardWithModal(t *testing.T) { t.Fatalf("did not expect example when source field is not active:\n%s", got) } } + +func TestDashboardLinesWidth_TruncatesForNarrowTerminals(t *testing.T) { + d := Dashboard{ + ProfileCount: 1, + StoreCount: 1, + SelectedProfile: "google-test", + Activity: ActivityPanel{ + Status: ActivityStatusSuccess, + Action: "Run backup (profile google-test)", + Summary: "completed successfully", + Lines: []string{"Snapshot c9a98d85cd65e691c427554664c612c4014ff25572644e4ce4a158ecd593a773 saved"}, + }, + Profiles: []ProfileCard{ + { + Name: "google-test", + Source: "gdrive-changes:/Very Long Shared Drive Name/Extremely Long Folder Name", + StoreRef: "default-store", + AuthRef: "google-google-test", + Enabled: true, + Status: ProfileStatusReady, + StoreHealth: StoreHealthReady, + BackupState: BackupFreshnessRecent, + LastBackup: "2026-04-03 14:53", + LastRef: "snapshot/c9a98d85cd65e691c427554664c612c4014ff25572644e4ce4a158ecd593a773", + Actions: []ProfileAction{ + {Kind: ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}, + {Kind: ActionKindCheck, Key: "c", Label: "Press c to run repository check", Enabled: true}, + }, + }, + }, + } + + lines := dashboardLinesWidth(d, 72) + for _, line := range lines { + if got := visibleLen(line); got > 72 { + t.Fatalf("line width=%d exceeds terminal width: %q", got, line) + } + } + got := strings.Join(lines, "\n") + if !strings.Contains(got, "…") { + t.Fatalf("expected truncated content in narrow layout:\n%s", got) + } +} + +func TestLayoutDashboardWidth_TracksProfileRowsAndActionRect(t *testing.T) { + d := Dashboard{ + ProfileCount: 2, + StoreCount: 1, + SelectedProfile: "photos", + Profiles: []ProfileCard{ + {Name: "docs", Enabled: true, Status: ProfileStatusReady}, + { + Name: "photos", + Enabled: true, + Status: ProfileStatusReady, + Actions: []ProfileAction{{Kind: ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}}, + StoreRef: "remote", + Source: "local:/photos", + }, + }, + } + + layout := LayoutDashboardWidth(d, 100) + if len(layout.ProfileRows) != 2 { + t.Fatalf("profile rows=%d want 2", len(layout.ProfileRows)) + } + foundDocs := false + foundPhotos := false + for _, name := range layout.ProfileRows { + if name == "docs" { + foundDocs = true + } + if name == "photos" { + foundPhotos = true + } + } + if !foundDocs || !foundPhotos { + t.Fatalf("unexpected profile row mapping: %+v", layout.ProfileRows) + } + if layout.ActionRect.W <= 0 || layout.ActionRect.H != 1 { + t.Fatalf("unexpected action rect: %+v", layout.ActionRect) + } + if layout.ActionRect.X <= 0 || layout.ActionRect.Y <= 0 { + t.Fatalf("unexpected action rect origin: %+v", layout.ActionRect) + } +}