package tui import ( "context" "errors" "strings" "testing" tea "charm.land/bubbletea/v2" "gitgud.io/mike/mpv-manager/pkg/version" ) func startManagerUpdateForTest(t *testing.T, applyErr error) *Model { t.Helper() m := NewModel() m.state = StateUpdateInstaller m.versionCheckResult = &version.VersionCheckResult{ CurrentVersion: "1.2.0", LatestVersion: "1.3.0", UpdateAvailable: true, URL: "https://example.invalid/mpv-manager", BLAKE3: strings.Repeat("a", 64), } originalSelection := m.versionCheckResult resolveCalls := 0 m.managerExecutable = func() (string, error) { resolveCalls++ return "/tmp/mpv-manager", nil } selectionWasImmutable := false m.prepareManagerUpdate = func(_ context.Context, path string, selected *version.VersionCheckResult, callback version.SelfUpdateProgressCallback) (*version.PreparedSelfUpdate, error) { if path != "/tmp/mpv-manager" { t.Errorf("unexpected executable path %q", path) } selectionWasImmutable = selected != originalSelection && selected.LatestVersion == "1.3.0" callback(version.SelfUpdateProgress{Phase: version.SelfUpdateDownloading, Written: 50, Total: 100, Message: "Downloading update"}) callback(version.SelfUpdateProgress{Phase: version.SelfUpdateVerifying, Message: "Verifying update"}) if applyErr == nil { callback(version.SelfUpdateProgress{Phase: version.SelfUpdateReadyToRestart, Message: "Update ready"}) return &version.PreparedSelfUpdate{TransactionID: "test-transaction"}, nil } return nil, applyErr } updated, cmd := m.Update(keyRunes("u")) m = updated.(*Model) if cmd == nil { t.Fatal("manager update did not return channel setup command") } for i := 0; i < 50 && !m.installComplete; i++ { msg := cmd() updated, cmd = m.Update(msg) m = updated.(*Model) if cmd == nil && !m.installComplete { t.Fatalf("manager update stream stopped before completion after %T", msg) } } if !m.installComplete { t.Fatal("manager update never reached its completion message") } if resolveCalls != 1 { t.Errorf("expected executable path to be resolved once, got %d", resolveCalls) } if !selectionWasImmutable { t.Error("manager update did not use a copied immutable release selection") } return m } func TestManagerUpdateCompletionRestartsAndQuits(t *testing.T) { m := startManagerUpdateForTest(t, nil) if !m.managerUpdateReady || m.installOperationType != OpTypeManagerUpdate { t.Fatalf("unexpected prepared manager state: ready=%v operation=%v", m.managerUpdateReady, m.installOperationType) } launchedMode := "" m.launchManagerUpdate = func(prepared *version.PreparedSelfUpdate, mode string) error { if prepared != m.preparedManagerUpdate { t.Error("launcher did not receive the prepared transaction") } launchedMode = mode return nil } updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = updated.(*Model) if launchedMode != "tui" { t.Fatalf("expected helper to relaunch TUI, got mode %q", launchedMode) } if cmd == nil { t.Fatal("successful restart must quit the old TUI") } if _, ok := cmd().(tea.QuitMsg); !ok { t.Fatalf("expected tea.QuitMsg after restart, got %T", cmd()) } if m.currentCommand != "Restarting MPV Manager" { t.Fatalf("expected restarting state, got %q", m.currentCommand) } } func TestManagerUpdateEscapeCancelsPreparedTransaction(t *testing.T) { m := startManagerUpdateForTest(t, nil) updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) m = updated.(*Model) if cmd != nil { t.Fatalf("expected no command for ignored Escape, got %T", cmd()) } if m.state != StateMainMenu || m.managerUpdateReady { t.Fatal("Escape did not cancel the not-yet-applied update transaction") } } func TestManagerUpdateRestartFailureCanRetry(t *testing.T) { m := startManagerUpdateForTest(t, nil) m.launchManagerUpdate = func(*version.PreparedSelfUpdate, string) error { return errors.New("launcher unavailable") } updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = updated.(*Model) if cmd != nil { t.Fatal("failed restart must keep the TUI open") } if m.installError == nil || !strings.Contains(m.installError.Error(), "launcher unavailable") { t.Fatalf("expected restart error, got %v", m.installError) } if !m.managerUpdateReady { t.Fatal("a launcher failure must not erase the prepared update state") } m.launchManagerUpdate = func(*version.PreparedSelfUpdate, string) error { return nil } _, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd == nil { t.Fatal("retrying a successful restart must quit the old TUI") } } func TestManagerUpdateFailureReturnsToMenu(t *testing.T) { m := startManagerUpdateForTest(t, errors.New("verification failed")) if m.managerUpdateReady { t.Fatal("failed update was marked ready") } updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = updated.(*Model) if cmd != nil { t.Fatalf("failed update should return to menu without quitting, got %T", cmd()) } if m.state != StateMainMenu || m.installOperationType != OpTypeNone { t.Fatalf("failed update did not reset to main menu: state=%v operation=%v", m.state, m.installOperationType) } } func TestOrdinaryClientUpdateDoesNotQuitOrRequestManagerRestart(t *testing.T) { m := NewModel() m.state = StateInstalling m.installComplete = true m.installSuccess = true m.installOperationType = OpTypeUpdate m.currentCommand = "Updating MPV" view := m.installingView() if strings.Contains(view, "Installer Updated") || strings.Contains(view, "restart MPV Manager") { t.Fatalf("ordinary app update was rendered as a manager update:\n%s", view) } updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = updated.(*Model) if cmd != nil { t.Fatalf("ordinary app update should not quit, got %T", cmd()) } if m.state != StateMainMenu { t.Fatalf("ordinary app update did not return to main menu: %v", m.state) } } func TestManagerUpdateStreamPreservesStructuredProgress(t *testing.T) { progressChan := make(chan downloadProgressMsg, 1) want := downloadProgressMsg{ written: 25, total: 100, phase: version.SelfUpdateVerifying, message: "Verifying update", } progressChan <- want got, ok := startStreaming(nil, nil, progressChan, nil)().(downloadProgressMsg) if !ok { t.Fatalf("expected downloadProgressMsg, got %T", got) } if got != want { t.Fatalf("structured progress changed in stream: got %#v, want %#v", got, want) } }