fix: serialize target panel operations

This commit is contained in:
proxyctl coordinator
2026-07-17 09:33:13 +08:00
parent fb456f35e4
commit e9718a6d08
2 changed files with 224 additions and 12 deletions
+174
View File
@@ -126,6 +126,180 @@ func TestRefreshKeyRunsAsynchronouslyAndRendersRefreshingThenCompleted(t *testin
}
}
func TestBusyActivationBlocksPageEntryAndEveryNewOperation(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}},
}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: make(chan app.Result)}
model := updateKey(NewModel(module), "p")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile {
t.Fatalf("activation did not start exactly once: command=%v intents=%#v", command != nil, module.intents)
}
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("busy activation accepted %q: page=%v command=%v intents=%#v", key, model.page, blocked != nil, module.intents)
}
}
for _, key := range []tea.KeyMsg{keyMessage("q"), {Type: tea.KeyEsc}} {
_, quit := model.Update(key)
if quit == nil {
t.Fatalf("busy activation changed quit behavior for %q", key.String())
}
if _, ok := quit().(tea.QuitMsg); !ok || len(module.intents) != 1 {
t.Fatalf("busy activation quit %q triggered an operation: intents=%#v", key.String(), module.intents)
}
}
// Even if a Profiles-page activation key is delivered while the transition
// is active, the single-operation guard must reject a second activation.
model.page = profilesPage
updated, blocked := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if blocked != nil || len(module.intents) != 1 {
t.Fatalf("busy activation started a second activation: command=%v intents=%#v", blocked != nil, module.intents)
}
}
func TestBusyRefreshBlocksTogglePageEntryRefreshAndActivation(t *testing.T) {
initial := app.Snapshot{
Profiles: []profile.Profile{{Name: "office"}},
Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}},
}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial}}
model := NewModel(module)
updated, refreshCommand := model.Update(keyMessage("r"))
model = updated.(Model)
if refreshCommand == nil {
t.Fatal("refresh did not start")
}
for _, key := range []string{" ", "p", "?", "r"} {
updated, blocked := model.Update(keyMessage(key))
model = updated.(Model)
if blocked != nil || model.page != mainPage || len(module.intents) != 0 {
t.Fatalf("busy refresh accepted %q: page=%v command=%v intents=%#v", key, model.page, blocked != nil, module.intents)
}
}
for _, key := range []tea.KeyMsg{keyMessage("q"), {Type: tea.KeyEsc}} {
_, quit := model.Update(key)
if quit == nil {
t.Fatalf("busy refresh changed quit behavior for %q", key.String())
}
if _, ok := quit().(tea.QuitMsg); !ok || len(module.intents) != 0 {
t.Fatalf("busy refresh quit %q triggered an operation: intents=%#v", key.String(), module.intents)
}
}
model.page = profilesPage
updated, blocked := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
if blocked != nil || len(module.intents) != 0 {
t.Fatalf("busy refresh started activation: command=%v intents=%#v", blocked != 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 TestSingleOperationGuardRejectsMismatchedCompletionAndPreventsOverlap(t *testing.T) {
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial}}
model := NewModel(module)
updated, refreshCommand := model.Update(keyMessage("r"))
model = updated.(Model)
updated, command := model.Update(targetOperationDone{result: app.Result{Snapshot: app.Snapshot{}}})
model = updated.(Model)
if command != nil || !model.refreshing || !model.busy() {
t.Fatal("completion from a non-current operation cleared refresh state")
}
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command != nil {
t.Fatal("Target operation overlapped the in-flight refresh")
}
refreshCompletion := refreshCommand()
updated, command = model.Update(refreshCompletion)
model = updated.(Model)
if command != nil || model.busy() {
t.Fatal("current refresh completion did not clear busy state")
}
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
if command == nil {
t.Fatal("new operation was not allowed after the prior completion was consumed")
}
}
func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) {
initial := app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "one", Description: target.Description{Name: "One"}, Available: true},
{ID: "two", Description: target.Description{Name: "Two"}, Available: true},
{ID: "three", Description: target.Description{Name: "Three"}, Available: true},
{ID: "four", Description: target.Description{Name: "Four"}, Available: true},
}}
refreshed := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "one", Description: target.Description{Name: "One"}, Available: true}}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: refreshed}}
model := NewModel(module)
model.selected = 3
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
updated, _ = model.Update(command())
model = updated.(Model)
if model.selected != 0 || !strings.Contains(model.View(), "> [ ] One") {
t.Fatalf("selection after multi-Target reduction = %d\n%s", model.selected, model.View())
}
module.result = app.Result{Snapshot: app.Snapshot{}}
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
updated, _ = model.Update(command())
model = updated.(Model)
if model.selected != 0 {
t.Fatalf("selection with no Available Targets = %d, want 0", model.selected)
}
}
func keyMessage(key string) tea.KeyMsg {
if key == " " {
return tea.KeyMsg{Type: tea.KeySpace}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
}
func TestTargetPanelNavigationKeysAndQuitCombinations(t *testing.T) {
module := &targetModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "first", Description: target.Description{Name: "First", Summary: "first summary"}, Available: true},