package tui import ( "context" "errors" "io" "runtime" "testing" "time" tea "charm.land/bubbletea/v2" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/hotkeys" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/platform" "github.com/stretchr/testify/require" ) func TestProgramExitCancelsAndJoinsReleaseRefresh(t *testing.T) { sandboxConfigHome(t) m := NewModel() m.SetPlatform(PlatformInfo{AppVersion: "test", OSType: runtime.GOOS, Arch: runtime.GOARCH, HWA: "no"}) m.SetInstaller(nil, &platform.Platform{OSType: platform.OSType(runtime.GOOS), Arch: runtime.GOARCH}) entered, stopped := make(chan struct{}), make(chan struct{}) m.SetReleaseInfoLoaderContext(func(ctx context.Context) (*installer.ReleaseInfo, error) { close(entered) <-ctx.Done() close(stopped) return nil, ctx.Err() }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { done <- m.Run(ctx, tea.WithInput(nil), tea.WithOutput(io.Discard), tea.WithoutRenderer()) }() <-entered cancel() select { case err := <-done: require.False(t, errors.Is(err, tea.ErrProgramPanic), "program panic masked cancellation: %v", err) require.True(t, errors.Is(err, context.Canceled), "%v", err) select { case <-stopped: default: t.Fatal("program returned before observation exited") } case <-time.After(2 * time.Second): t.Fatal("program retained cancelled release observation") } } func TestPresetCommitOwnsShutdownUntilReplacementCompletes(t *testing.T) { sandboxConfigHome(t) m := NewModel() m.state = StateHotkeysPreset m.presetApplyInFlight = true m.SetReleaseInfoLoaderContext(func(context.Context) (*installer.ReleaseInfo, error) { return nil, errors.New("offline fixture") }) path := hotkeys.GetInputConfPath() locked, release := make(chan struct{}), make(chan struct{}) lockResult := make(chan error, 1) go func() { lockResult <- fileops.WithLock(path, func() error { close(locked); <-release; return nil }) }() <-locked // Discard the returned tea command: ownership is registered immediately, // including the worker blocked on a cross-process configuration lock. m.runApplyPresetCmd("default") deadline := time.After(2 * time.Second) for { m.operationCommitGuard.mu.Lock() committed := m.operationCommitGuard.committed m.operationCommitGuard.mu.Unlock() if committed { break } select { case <-deadline: close(release) t.Fatal("preset did not reach commit guard") case <-time.After(time.Millisecond): } } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- m.Run(ctx, tea.WithInput(nil), tea.WithOutput(io.Discard), tea.WithoutRenderer()) }() cancel() select { case err := <-done: close(release) t.Fatalf("shutdown abandoned preset commit: %v", err) case <-time.After(100 * time.Millisecond): } close(release) require.NoError(t, <-lockResult) select { case err := <-done: require.True(t, errors.Is(err, context.Canceled), "%v", err) case <-time.After(2 * time.Second): t.Fatal("preset never settled") } require.False(t, m.operationActive) require.False(t, m.presetApplyInFlight) require.FileExists(t, path) }