feat: unify interactive operation coordination

This commit is contained in:
proxyctl coordinator
2026-07-17 22:23:31 +08:00
parent 177bf34354
commit 7c0b4481d7
17 changed files with 985 additions and 1013 deletions
+32 -218
View File
@@ -2,7 +2,6 @@ package tui
import (
"errors"
"reflect"
"strings"
"testing"
@@ -26,26 +25,20 @@ func (m *targetModule) Dispatch(intent app.Intent) app.Result {
return m.result
}
func (m *targetModule) StartOperation(intent app.Intent) app.OperationStream {
initial := m.Snapshot()
result := m.Dispatch(intent)
updates := make(chan app.OperationUpdate, 1)
phase := app.OperationSucceeded
status := app.OperationStatus{Kind: app.StatusSuccess, Summary: result.Notice, TargetID: intent.TargetID}
if result.ConfirmationRequest != nil || result.Confirmation != "" {
phase = app.OperationAwaitingConfirmation
confirmation := result.ConfirmationRequest
if confirmation == nil {
confirmation = &app.Confirmation{Title: "Confirm Target change", Summary: result.Confirmation, ConfirmLabel: "Continue", CancelLabel: "Cancel"}
}
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: app.OperationStatus{Kind: app.StatusConfirmation, Summary: confirmation.Title}, Confirmation: confirmation}
} else {
if result.Err != nil {
phase = app.OperationFailed
status = app.OperationStatus{Kind: app.StatusError, Summary: "Operation failed", Detail: result.Err.Error(), TargetID: intent.TargetID}
}
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: status, Err: result.Err}
status := app.OperationStatus{Summary: result.Notice, TargetID: intent.TargetID}
switch {
case result.ConfirmationRequest != nil:
updates <- app.ConfirmationOperationUpdate(result.Snapshot, *result.ConfirmationRequest)
case result.Err != nil:
updates <- app.FailedOperationUpdate(result.Snapshot, app.OperationStatus{Summary: "Operation failed", TargetID: intent.TargetID}, nil, intent.TargetID, result.Err)
default:
updates <- app.SucceededOperationUpdate(result.Snapshot, status, nil)
}
close(updates)
return app.OperationStream{Snapshot: m.snapshot, Updates: updates}
return app.OperationStream{Snapshot: initial, Updates: updates}
}
func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing.T) {
@@ -75,7 +68,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T) {
initial := app.Snapshot{ActiveProfile: "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 and may disrupt running work."}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, ConfirmationRequest: &app.Confirmation{Title: "Confirm Docker restart", Summary: "Docker will restart immediately and may disrupt running work.", ConfirmLabel: "Continue", CancelLabel: "Cancel"}}}
model := NewModel(module)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
@@ -155,7 +148,7 @@ func TestBusyActivationBlocksPageEntryAndEveryNewOperation(t *testing.T) {
Profiles: []profile.Profile{{Name: "new"}},
Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}},
}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: make(chan app.Result)}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: make(chan app.OperationUpdate)}
model := updateKey(NewModel(module), "p")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
@@ -172,8 +165,8 @@ func TestBusyActivationBlocksPageEntryAndEveryNewOperation(t *testing.T) {
}
updated, quit := model.Update(keyMessage("q"))
model = updated.(Model)
if quit != nil || !model.quitRequested || len(module.intents) != 1 {
t.Fatalf("busy activation did not defer quit: command=%v requested=%v intents=%#v", quit != nil, model.quitRequested, module.intents)
if quit != nil || !model.operation.quitRequested || len(module.intents) != 1 {
t.Fatalf("busy activation did not defer quit: command=%v requested=%v intents=%#v", quit != nil, model.operation.quitRequested, module.intents)
}
// Even if a Profiles-page activation key is delivered while the transition
@@ -208,154 +201,11 @@ func TestBusyRefreshBlocksTogglePageEntryRefreshAndActivation(t *testing.T) {
}
updated, quit := model.Update(keyMessage("q"))
model = updated.(Model)
if quit != nil || !model.quitRequested || len(module.intents) != 1 {
if quit != nil || !model.operation.quitRequested || len(module.intents) != 1 {
t.Fatalf("busy refresh did not defer quit: command=%v intents=%#v", quit != nil, module.intents)
}
}
func TestPendingActivationRemainsBusyUntilConfirmationIsResolved(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, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
updated, _ = model.Update(command())
model = updated.(Model)
if !model.busy() || model.pendingActivation.Kind != app.ActivateProfile || model.profileConfirm == nil {
t.Fatalf("pending activation was not guarded: %#v", model)
}
model.page = mainPage
for _, key := range []string{"p", "?", "r", " "} {
updated, blocked := model.Update(keyMessage(key))
model = updated.(Model)
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
t.Fatalf("pending activation accepted %q: page=%v command=%v intents=%#v", key, model.page, blocked != nil, module.intents)
}
}
model.page = profilesPage
updated, _ = model.Update(keyMessage("n"))
model = updated.(Model)
if model.busy() || model.profileConfirm != nil {
t.Fatal("cancelling confirmation did not clear pending activation")
}
}
func TestProfileConfirmationQuitKeysCancelWithoutLeavingBusyState(t *testing.T) {
for _, cancelKey := range []tea.KeyMsg{keyMessage("q"), {Type: tea.KeyEsc}} {
t.Run(cancelKey.String(), func(t *testing.T) {
initial := app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}
updates := make(chan app.Result, 2)
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition"}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
updated, command = model.Update(command())
model = updated.(Model)
if command != nil || model.profileConfirm == nil || !model.busy() {
t.Fatal("activation confirmation was not presented")
}
updated, command = model.Update(cancelKey)
model = updated.(Model)
if command != nil || model.profileConfirm != nil || model.busy() || model.quitRequested {
t.Fatalf("confirmation cancel %q left the model blocked: %#v", cancelKey.String(), model)
}
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition again"}
model = updateKey(model, "p")
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
if command == nil {
t.Fatal("confirmation cancellation prevented a later activation")
}
})
}
}
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},
@@ -411,14 +261,16 @@ func TestTargetPanelNavigationKeysAndQuitCombinations(t *testing.T) {
}
}
}
for _, quit := range []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune{'q'}}, {Type: tea.KeyEsc}} {
_, command := NewModel(module).Update(quit)
if command == nil {
t.Fatalf("quit key %q returned no command", quit.String())
}
if _, ok := command().(tea.QuitMsg); !ok {
t.Fatalf("quit key %q command did not return tea.QuitMsg", quit.String())
}
_, command := NewModel(module).Update(keyMessage("q"))
if command == nil {
t.Fatal("dashboard q returned no command")
}
if _, ok := command().(tea.QuitMsg); !ok {
t.Fatal("dashboard q did not return tea.QuitMsg")
}
_, command = NewModel(module).Update(tea.KeyMsg{Type: tea.KeyEsc})
if command != nil {
t.Fatal("dashboard Escape must remain cancel/back rather than exit")
}
}
@@ -440,59 +292,21 @@ func TestQAndEscapeReturnFromNestedPages(t *testing.T) {
type progressiveTargetModule struct {
targetModule
updates chan app.Result
updates chan app.OperationUpdate
}
func (m *progressiveTargetModule) DispatchProgress(intent app.Intent) <-chan app.Result {
m.intents = append(m.intents, intent)
return m.updates
}
func (m *progressiveTargetModule) StartOperation(intent app.Intent) app.OperationStream {
m.intents = append(m.intents, intent)
updates := make(chan app.OperationUpdate, cap(m.updates)+1)
go func() {
defer close(updates)
for result := range m.updates {
phase := app.OperationRunning
status := app.OperationStatus{Kind: app.StatusProgress, Summary: result.Notice}
var confirmation *app.Confirmation
if result.Confirmation != "" {
phase = app.OperationAwaitingConfirmation
confirmation = &app.Confirmation{Title: "Confirm Active Profile change", Summary: result.Confirmation, ConfirmLabel: "Continue", CancelLabel: "Cancel"}
status = app.OperationStatus{Kind: app.StatusConfirmation, Summary: confirmation.Title}
} else if result.Complete {
phase = app.OperationSucceeded
status.Kind = app.StatusSuccess
if result.Err != nil {
phase = app.OperationFailed
status = app.OperationStatus{Kind: app.StatusError, Summary: "Active Profile change failed", Detail: result.Err.Error()}
}
}
progress := append([]app.TargetProgress(nil), result.Progress...)
for i := range progress {
if progress[i].Applying {
progress[i].Phase = app.TargetProgressRunning
} else if progress[i].Err != nil {
progress[i].Phase = app.TargetProgressFailed
} else {
progress[i].Phase = app.TargetProgressSucceeded
}
}
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: status, Progress: progress, Confirmation: confirmation, Err: result.Err}
if phase == app.OperationAwaitingConfirmation {
return
}
}
}()
return app.OperationStream{Snapshot: m.snapshot, Updates: updates}
return app.OperationStream{Snapshot: m.snapshot, Updates: m.updates}
}
func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) {
initial := app.Snapshot{ActiveProfile: "old", Profiles: []profile.Profile{{Name: "new"}}, Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
updates := make(chan app.Result, 3)
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: initial.Targets}, Progress: []app.TargetProgress{{TargetID: "shell", Applying: true}}}
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}, Progress: []app.TargetProgress{{TargetID: "shell", State: target.Enabled}}, Notice: "Shell Target update completed"}
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}, Complete: true, Notice: "Activated Profile new"}
updates := make(chan app.OperationUpdate, 3)
updates <- app.RunningOperationUpdate(app.Snapshot{ActiveProfile: "new", Targets: initial.Targets}, app.OperationStatus{Summary: "Updating Shell Target", TargetID: "shell"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
completedSnapshot := app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
updates <- app.RunningOperationUpdate(completedSnapshot, app.OperationStatus{Summary: "Shell Target update completed", TargetID: "shell"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded, State: target.Enabled}})
updates <- app.SucceededOperationUpdate(completedSnapshot, app.OperationStatus{Summary: "Activated Profile new"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded, State: target.Enabled}})
close(updates)
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p")