fix: guard terminal deferred quit state
This commit is contained in:
@@ -77,7 +77,7 @@ func (m Model) Init() tea.Cmd { return nil }
|
||||
// operation start checks it, so a completion can only belong to the one current
|
||||
// operation rather than racing another operation for shared model state.
|
||||
func (m Model) busy() bool {
|
||||
return m.operationInFlight() || m.pendingActivation.Kind != 0
|
||||
return m.quitRequested || m.operationInFlight() || m.pendingActivation.Kind != 0
|
||||
}
|
||||
|
||||
func (m Model) operationInFlight() bool {
|
||||
@@ -85,6 +85,13 @@ func (m Model) operationInFlight() bool {
|
||||
}
|
||||
|
||||
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// A deferred tea.Quit is queued only after the in-flight completion has
|
||||
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
|
||||
// terminal so intervening input cannot cancel a confirmation or start a
|
||||
// second mutation.
|
||||
if m.quitRequested && !m.operationInFlight() {
|
||||
return m, nil
|
||||
}
|
||||
if key, ok := message.(tea.KeyMsg); ok && m.operationInFlight() && isQuitKey(key) {
|
||||
m.quitRequested = true
|
||||
return m, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -288,8 +289,8 @@ func TestSingleOperationGuardRejectsMismatchedCompletionAndPreventsOverlap(t *te
|
||||
refreshCompletion := refreshCommand()
|
||||
updated, command = model.Update(refreshCompletion)
|
||||
model = updated.(Model)
|
||||
if command == nil || model.busy() || model.snapshot.ActiveProfile != "fresh" {
|
||||
t.Fatal("current refresh completion did not apply fresh state and trigger deferred quit")
|
||||
if command == nil || !model.busy() || model.snapshot.ActiveProfile != "fresh" {
|
||||
t.Fatal("current refresh completion did not apply fresh state and enter terminal deferred-quit state")
|
||||
}
|
||||
if _, ok := command().(tea.QuitMsg); !ok {
|
||||
t.Fatal("current completion did not return tea.Quit")
|
||||
@@ -321,8 +322,8 @@ func TestBusyQuitWaitsForLongRunningTargetMutationCompletion(t *testing.T) {
|
||||
}
|
||||
updated, quit = model.Update(completion)
|
||||
model = updated.(Model)
|
||||
if quit == nil || model.busy() || model.snapshot.Targets[0].State != target.Enabled {
|
||||
t.Fatalf("mutation completion was not consumed before deferred quit: busy=%v snapshot=%#v", model.busy(), model.snapshot)
|
||||
if quit == nil || !model.busy() || model.snapshot.Targets[0].State != target.Enabled {
|
||||
t.Fatalf("mutation completion was not consumed before terminal deferred quit: busy=%v snapshot=%#v", model.busy(), model.snapshot)
|
||||
}
|
||||
if _, ok := quit().(tea.QuitMsg); !ok {
|
||||
t.Fatal("completion did not return tea.Quit")
|
||||
@@ -331,6 +332,87 @@ func TestBusyQuitWaitsForLongRunningTargetMutationCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredQuitIsTerminalAfterTargetConfirmationCompletion(t *testing.T) {
|
||||
initial := app.Snapshot{
|
||||
ActiveProfile: "office",
|
||||
Profiles: []profile.Profile{{Name: "office"}},
|
||||
Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Disabled}},
|
||||
}
|
||||
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Confirmation: "Docker will restart immediately."}}
|
||||
model := NewModel(module)
|
||||
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
updated, command := model.Update(keyMessage("q"))
|
||||
model = updated.(Model)
|
||||
if command != nil || !model.quitRequested {
|
||||
t.Fatal("quit was not deferred during Target operation")
|
||||
}
|
||||
updated, queuedQuit := model.Update(operation())
|
||||
model = updated.(Model)
|
||||
if queuedQuit == nil || model.targetConfirm == nil || len(module.intents) != 1 {
|
||||
t.Fatalf("confirmation completion did not queue quit: command=%v confirmation=%v intents=%#v", queuedQuit != nil, model.targetConfirm != nil, module.intents)
|
||||
}
|
||||
|
||||
baselineSnapshot := model.snapshot
|
||||
baselineStatus := model.status
|
||||
baselinePage := model.page
|
||||
baselineSelected := model.selected
|
||||
baselineConfirmation := *model.targetConfirm
|
||||
for _, message := range []tea.Msg{
|
||||
keyMessage("y"), keyMessage("n"), tea.KeyMsg{Type: tea.KeyEsc}, keyMessage(" "),
|
||||
keyMessage("r"), keyMessage("p"), keyMessage("?"), keyMessage("j"), queuedQuit(),
|
||||
} {
|
||||
updated, command = model.Update(message)
|
||||
model = updated.(Model)
|
||||
if command != nil {
|
||||
t.Fatalf("terminal deferred-quit model returned a command for %T", message)
|
||||
}
|
||||
if !model.quitRequested || !reflect.DeepEqual(model.snapshot, baselineSnapshot) || model.status != baselineStatus || model.page != baselinePage || model.selected != baselineSelected || model.targetConfirm == nil || !reflect.DeepEqual(*model.targetConfirm, baselineConfirmation) || len(module.intents) != 1 {
|
||||
t.Fatalf("terminal deferred-quit model changed for %T: model=%#v intents=%#v", message, model, module.intents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredQuitIsTerminalAfterProfileConfirmationCompletion(t *testing.T) {
|
||||
initial := app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}
|
||||
updates := make(chan app.Result, 1)
|
||||
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition"}
|
||||
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
|
||||
model := updateKey(NewModel(module), "p")
|
||||
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
updated, command := model.Update(keyMessage("q"))
|
||||
model = updated.(Model)
|
||||
if command != nil || !model.quitRequested {
|
||||
t.Fatal("quit was not deferred during Profile activation")
|
||||
}
|
||||
updated, queuedQuit := model.Update(operation())
|
||||
model = updated.(Model)
|
||||
if queuedQuit == nil || model.profileConfirm == nil || len(module.intents) != 1 {
|
||||
t.Fatalf("Profile confirmation completion did not queue quit: command=%v confirmation=%v intents=%#v", queuedQuit != nil, model.profileConfirm != nil, module.intents)
|
||||
}
|
||||
|
||||
baselineSnapshot := model.snapshot
|
||||
baselineStatus := model.status
|
||||
baselinePage := model.page
|
||||
baselineSelected := model.selected
|
||||
baselinePending := model.pendingActivation
|
||||
baselineConfirmation := *model.profileConfirm
|
||||
for _, message := range []tea.Msg{
|
||||
keyMessage("y"), keyMessage("n"), tea.KeyMsg{Type: tea.KeyEsc}, tea.KeyMsg{Type: tea.KeyEnter},
|
||||
keyMessage("d"), keyMessage("e"), keyMessage("a"), keyMessage("r"), queuedQuit(),
|
||||
} {
|
||||
updated, command = model.Update(message)
|
||||
model = updated.(Model)
|
||||
if command != nil {
|
||||
t.Fatalf("terminal deferred-quit Profile model returned a command for %T", message)
|
||||
}
|
||||
if !model.quitRequested || !reflect.DeepEqual(model.snapshot, baselineSnapshot) || model.status != baselineStatus || model.page != baselinePage || model.selected != baselineSelected || !reflect.DeepEqual(model.pendingActivation, baselinePending) || model.profileConfirm == nil || !reflect.DeepEqual(*model.profileConfirm, baselineConfirmation) || len(module.intents) != 1 {
|
||||
t.Fatalf("terminal deferred-quit Profile model changed for %T: model=%#v intents=%#v", message, model, module.intents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) {
|
||||
initial := app.Snapshot{Targets: []app.TargetSnapshot{
|
||||
{ID: "one", Description: target.Description{Name: "One"}, Available: true},
|
||||
|
||||
Reference in New Issue
Block a user