pi-agent: Implement contextual Target actions

This commit is contained in:
proxyctl coordinator
2026-07-17 22:37:38 +08:00
parent 7c0b4481d7
commit 2ca42bd4bd
13 changed files with 793 additions and 120 deletions
+157 -19
View File
@@ -19,8 +19,13 @@ const (
UpdateProfile
DeleteProfile
ActivateProfile
SetTargetDesired
SetTargetDesired // retained for synchronous compatibility; interactive callers use the explicit intents below.
RefreshTargets
EnableTarget
DisableTarget
ApplyProfileToTarget
ReapplyActiveProfile
RestoreOriginal
)
type Intent struct {
@@ -32,6 +37,26 @@ type Intent struct {
Enabled bool
}
// TargetActionKind identifies a Target action independently of display labels.
type TargetActionKind uint8
const (
EnableTargetAction TargetActionKind = iota + 1
DisableTargetAction
ApplyProfileTargetAction
ResolveDriftTargetAction
ReapplyActiveProfileAction
RestoreOriginalAction
)
// TargetAction exposes whether an intent is currently available and, when it
// is not, the reason presentation layers should show instead of ignoring it.
type TargetAction struct {
Kind TargetActionKind
Available bool
DisabledReason string
}
type TargetDetectionPhase uint8
const (
@@ -43,14 +68,16 @@ const (
)
type TargetSnapshot struct {
ID string
Description target.Description
DetectionPhase TargetDetectionPhase
DetectionErr error
Available bool
AvailabilityEvidence string
State target.State
Evidence string
ID string
Description target.Description
DetectionPhase TargetDetectionPhase
DetectionErr error
Available bool
AvailabilityEvidence string
State target.State
Evidence string
PrimaryAction TargetAction
DriftResolutionActions []TargetAction
}
type Snapshot struct {
@@ -154,14 +181,78 @@ func (a *Application) snapshotLocked() Snapshot {
for i, id := range a.targetIDs {
availability := a.availabilities[id]
inspection := a.inspections[id]
phase := a.detectionPhase[id]
primary, resolution := contextualTargetActions(phase, availability.Available, inspection.State, state.ActiveProfile != "")
snapshot.Targets = append(snapshot.Targets, TargetSnapshot{
ID: id, Description: a.targetDescriptions[i], DetectionPhase: a.detectionPhase[id], DetectionErr: a.detectionErr[id],
ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id],
Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence,
PrimaryAction: primary, DriftResolutionActions: resolution,
})
}
return snapshot
}
func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool) (TargetAction, []TargetAction) {
primary := TargetAction{Kind: primaryTargetActionKind(state), Available: true}
if reason := targetActionDisabledReason(phase, available); reason != "" {
primary.Available = false
primary.DisabledReason = reason
}
needsProfile := state == target.Disabled || state == target.External
if primary.Available && needsProfile && !hasActiveProfile {
primary.Available = false
primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first"
}
if state != target.Drifted {
return primary, nil
}
resolution := []TargetAction{
{Kind: ReapplyActiveProfileAction, Available: true},
{Kind: RestoreOriginalAction, Available: true},
}
if !hasActiveProfile {
resolution[0].Available = false
resolution[0].DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile; open Profile management first"
}
if reason := targetActionDisabledReason(phase, available); reason != "" {
for i := range resolution {
resolution[i].Available = false
resolution[i].DisabledReason = reason
}
}
return primary, resolution
}
func primaryTargetActionKind(state target.State) TargetActionKind {
switch state {
case target.Disabled:
return EnableTargetAction
case target.Enabled:
return DisableTargetAction
case target.External:
return ApplyProfileTargetAction
case target.Drifted:
return ResolveDriftTargetAction
default:
return 0
}
}
func targetActionDisabledReason(phase TargetDetectionPhase, available bool) string {
switch phase {
case TargetChecking:
return "Target actions are unavailable while this Target is still being checked"
case TargetDetectionFailed:
return "Target actions are unavailable because this Target check failed; refresh Targets to retry"
default:
if !available {
return "Target actions are unavailable because this Target is unavailable"
}
return ""
}
}
type targetDetectionOutcome struct {
targetID string
detection target.Detection
@@ -261,10 +352,10 @@ func (a *Application) dispatch(intent Intent, publish func(Result)) Result {
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("reload Profile state for mutation: %w", err)}
}
a.replaceState(fresh)
if intent.Kind == SetTargetDesired || intent.Kind == ActivateProfile {
if isTargetMutationIntent(intent.Kind) || intent.Kind == ActivateProfile {
a.refreshInspections()
}
if intent.Kind == SetTargetDesired {
if isTargetMutationIntent(intent.Kind) {
return a.setTargetDesired(intent, publish)
}
if intent.Kind == RefreshTargets {
@@ -580,6 +671,32 @@ func (a *Application) refreshInspections() {
a.replaceInspections(inspections)
}
func isTargetMutationIntent(kind IntentKind) bool {
switch kind {
case SetTargetDesired, EnableTarget, DisableTarget, ApplyProfileToTarget, ReapplyActiveProfile, RestoreOriginal:
return true
default:
return false
}
}
func contextualIntentSemantics(kind IntentKind) (expected target.State, enabled bool, ok bool) {
switch kind {
case EnableTarget:
return target.Disabled, true, true
case DisableTarget:
return target.Enabled, false, true
case ApplyProfileToTarget:
return target.External, true, true
case ReapplyActiveProfile:
return target.Drifted, true, true
case RestoreOriginal:
return target.Drifted, false, true
default:
return "", false, false
}
}
func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Result {
selectedIndex := -1
for i, id := range a.targetIDs {
@@ -592,11 +709,19 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q does not exist", intent.TargetID)}
}
selected := a.targets[selectedIndex]
enabled := intent.Enabled
if expected, contextualEnabled, contextual := contextualIntentSemantics(intent.Kind); contextual {
actual := a.inspection(intent.TargetID).State
if actual != expected {
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q action is no longer available because its state is %s", intent.TargetID, actual)}
}
enabled = contextualEnabled
}
active := a.activeProfile()
if intent.Enabled && active == nil {
if enabled && active == nil {
return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")}
}
if warning := selected.Confirmation(intent.Enabled); warning != "" && !intent.Confirm {
if warning := selected.Confirmation(enabled); warning != "" && !intent.Confirm {
confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning,
ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true}
return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation,
@@ -606,7 +731,7 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: intent.TargetID, Phase: TargetProgressRunning, State: a.inspection(intent.TargetID).State}},
Status: OperationStatus{Summary: "Updating " + a.targetDescriptions[selectedIndex].Name + " Target", TargetID: intent.TargetID}})
}
result := selected.SetDesired(context.Background(), intent.Enabled, active)
result := selected.SetDesired(context.Background(), enabled, active)
inspection := result.Inspection
if result.Err != nil {
inspection = selected.Inspect(context.Background(), active)
@@ -617,10 +742,7 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
outcome, detail = "failure", result.Err.Error()
}
if a.logger != nil {
operation := "disable"
if intent.Enabled {
operation = "enable"
}
operation := targetOperationName(intent.Kind, enabled)
_ = a.logger.Write(LogEntry{Time: time.Now(), Operation: operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)})
}
notice := ""
@@ -637,6 +759,22 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
return Result{Snapshot: a.Snapshot(), Notice: notice, Status: status, Progress: []TargetProgress{progress}, FirstFailedTargetID: map[bool]string{true: intent.TargetID}[result.Err != nil], Err: result.Err}
}
func targetOperationName(kind IntentKind, enabled bool) string {
switch kind {
case ApplyProfileToTarget:
return "apply-Profile"
case ReapplyActiveProfile:
return "reapply-Active-Profile"
case RestoreOriginal:
return "restore-original"
default:
if enabled {
return "enable"
}
return "disable"
}
}
func create(state profile.State, input profile.Profile) (profile.State, string, error) {
p, err := profile.ValidateAndNormalize(input)
if err != nil {
+233
View File
@@ -0,0 +1,233 @@
package app_test
import (
"errors"
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
func TestApplicationSnapshotStructuresContextualTargetActions(t *testing.T) {
states := []struct {
id string
state target.State
action app.TargetActionKind
}{
{id: "disabled", state: target.Disabled, action: app.EnableTargetAction},
{id: "enabled", state: target.Enabled, action: app.DisableTargetAction},
{id: "external", state: target.External, action: app.ApplyProfileTargetAction},
{id: "drifted", state: target.Drifted, action: app.ResolveDriftTargetAction},
}
adapters := make([]target.Target, 0, len(states))
for _, item := range states {
adapters = append(adapters, targetWithID{fakeTarget: &fakeTarget{available: true, state: item.state}, id: item.id})
}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), adapters)
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
snapshot := application.Snapshot()
for i, want := range states {
got := snapshot.Targets[i]
if got.PrimaryAction.Kind != want.action || !got.PrimaryAction.Available || got.PrimaryAction.DisabledReason != "" {
t.Errorf("%s primary action = %#v, want available %v", want.state, got.PrimaryAction, want.action)
}
}
drift := snapshot.Targets[3]
if len(drift.DriftResolutionActions) != 2 || drift.DriftResolutionActions[0].Kind != app.ReapplyActiveProfileAction || drift.DriftResolutionActions[1].Kind != app.RestoreOriginalAction {
t.Fatalf("Drift Resolution actions = %#v", drift.DriftResolutionActions)
}
for _, action := range drift.DriftResolutionActions {
if !action.Available || action.DisabledReason != "" {
t.Errorf("Drift Resolution action = %#v, want available", action)
}
}
}
func TestApplicationDispatchesExplicitContextualTargetIntents(t *testing.T) {
tests := []struct {
name string
state target.State
intent app.IntentKind
enabled bool
}{
{name: "Enable", state: target.Disabled, intent: app.EnableTarget, enabled: true},
{name: "Disable", state: target.Enabled, intent: app.DisableTarget, enabled: false},
{name: "Apply Profile", state: target.External, intent: app.ApplyProfileToTarget, enabled: true},
{name: "Reapply Active Profile", state: target.Drifted, intent: app.ReapplyActiveProfile, enabled: true},
{name: "Restore Original", state: target.Drifted, intent: app.RestoreOriginal, enabled: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.Disabled}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
adapter.state = test.state
adapter.calls = nil
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: test.intent, TargetID: adapter.ID()})
if result.Err != nil || len(adapter.calls) != 1 || adapter.calls[0] != test.enabled {
t.Fatalf("result = %#v, SetDesired calls = %#v", result, adapter.calls)
}
wantState := target.Disabled
if test.enabled {
wantState = target.Enabled
}
if result.Snapshot.Targets[0].State != wantState {
t.Fatalf("final state = %s, want %s", result.Snapshot.Targets[0].State, wantState)
}
})
}
}
func TestApplicationRejectsContextualIntentWhenTargetStateChanged(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.External}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: adapter.ID()})
if result.Err == nil || !strings.Contains(result.Err.Error(), "External") || len(adapter.calls) != 0 {
t.Fatalf("stale Enable intent = %#v, calls = %#v", result, adapter.calls)
}
}
func TestApplicationReapplyPublishesTruthfulDriftWhenAdapterCannotConverge(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.Disabled}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
adapter.state = target.Drifted
adapter.setState = target.Drifted
adapter.calls = nil
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: app.ReapplyActiveProfile, TargetID: adapter.ID()})
if result.Err != nil || result.Snapshot.Targets[0].State != target.Drifted || result.Progress[0].State != target.Drifted {
t.Fatalf("truthful non-converged reapply = %#v", result)
}
}
func TestApplicationRejectsStructuredUnavailableActionWithoutAdapterMutation(t *testing.T) {
adapter := &fakeTarget{available: false, state: target.External}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
adapter.calls = nil
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
if action := application.Snapshot().Targets[0].PrimaryAction; action.Available || !strings.Contains(action.DisabledReason, "unavailable") {
t.Fatalf("unavailable action = %#v", action)
}
result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()})
if result.Err == nil || !strings.Contains(result.Err.Error(), "unavailable") || len(adapter.calls) != 0 {
t.Fatalf("unavailable action result = %#v, calls=%#v", result, adapter.calls)
}
}
func TestApplicationRejectsProfileDependentIntentWithoutActiveProfile(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.External}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()})
if result.Err == nil || !strings.Contains(result.Err.Error(), "no Active Profile") || len(adapter.calls) != 0 {
t.Fatalf("missing Active Profile result = %#v, calls=%#v", result, adapter.calls)
}
}
func TestApplicationReapplyAndRestoreFailuresPublishFreshDriftedState(t *testing.T) {
for _, test := range []struct {
name string
intent app.IntentKind
err string
}{
{name: "reapply", intent: app.ReapplyActiveProfile, err: "write failed"},
{name: "restore ownership loss", intent: app.RestoreOriginal, err: "restoration evidence is insufficient; refusing to overwrite external changes"},
} {
t.Run(test.name, func(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.Drifted, setErr: errors.New(test.err)}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: test.intent, TargetID: adapter.ID()})
if result.Err == nil || !strings.Contains(result.Err.Error(), test.err) || result.Snapshot.Targets[0].State != target.Drifted || result.Snapshot.Targets[0].Evidence != "actual state" {
t.Fatalf("failure = %#v", result)
}
if len(adapter.calls) != 1 || adapter.calls[0] != (test.intent == app.ReapplyActiveProfile) {
t.Fatalf("SetDesired calls = %#v", adapter.calls)
}
})
}
}
func TestApplicationSnapshotDisablesProfileDependentActionsWithoutActiveProfile(t *testing.T) {
adapters := []target.Target{
targetWithID{fakeTarget: &fakeTarget{available: true, state: target.Disabled}, id: "disabled"},
targetWithID{fakeTarget: &fakeTarget{available: true, state: target.External}, id: "external"},
targetWithID{fakeTarget: &fakeTarget{available: true, state: target.Drifted}, id: "drifted"},
}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), adapters)
if err != nil {
t.Fatal(err)
}
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
snapshot := application.Snapshot()
for _, index := range []int{0, 1} {
action := snapshot.Targets[index].PrimaryAction
if action.Available || !strings.Contains(action.DisabledReason, "no Active Profile") {
t.Errorf("profile-dependent primary action = %#v", action)
}
}
drift := snapshot.Targets[2]
if !drift.PrimaryAction.Available {
t.Fatalf("Restore Original should keep Resolve available: %#v", drift.PrimaryAction)
}
if got := drift.DriftResolutionActions[0]; got.Available || !strings.Contains(got.DisabledReason, "no Active Profile") {
t.Fatalf("Reapply action without Active Profile = %#v", got)
}
if got := drift.DriftResolutionActions[1]; !got.Available {
t.Fatalf("Restore Original action without Active Profile = %#v", got)
}
}
+6 -3
View File
@@ -16,6 +16,7 @@ type fakeTarget struct {
available bool
state target.State
setErr error
setState target.State
confirmation string
calls []bool
}
@@ -34,7 +35,9 @@ func (f *fakeTarget) Confirmation(bool) string { return f.confirmation }
func (f *fakeTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult {
f.calls = append(f.calls, enabled)
if f.setErr == nil {
if enabled {
if f.setState != "" {
f.state = f.setState
} else if enabled {
f.state = target.Enabled
} else {
f.state = target.Disabled
@@ -84,11 +87,11 @@ func TestApplicationRequiresTargetConfirmationBeforeMutation(t *testing.T) {
}
createAndActivate(t, application)
warning := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true})
warning := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "docker-daemon"})
if warning.ConfirmationRequest == nil || warning.Err != nil || len(adapter.calls) != 0 || warning.Snapshot.Targets[0].State != target.Disabled {
t.Fatalf("unconfirmed result = %#v, calls=%v", warning, adapter.calls)
}
confirmed := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true, Confirm: true})
confirmed := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "docker-daemon", Confirm: true})
if confirmed.Err != nil || len(adapter.calls) != 1 || confirmed.Snapshot.Targets[0].State != target.Enabled {
t.Fatalf("confirmed result = %#v, calls=%v", confirmed, adapter.calls)
}
+135
View File
@@ -0,0 +1,135 @@
package tui
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/target"
)
func completeTargetSnapshot(state target.State, primary app.TargetAction) app.Snapshot {
return app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{
ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: state, PrimaryAction: primary,
}}}
}
func TestCompleteModelRendersAndDispatchesContextualPrimaryActions(t *testing.T) {
tests := []struct {
name string
state target.State
action app.TargetActionKind
intent app.IntentKind
label string
}{
{name: "Disabled", state: target.Disabled, action: app.EnableTargetAction, intent: app.EnableTarget, label: "Enable"},
{name: "Enabled", state: target.Enabled, action: app.DisableTargetAction, intent: app.DisableTarget, label: "Disable"},
{name: "External", state: target.External, action: app.ApplyProfileTargetAction, intent: app.ApplyProfileToTarget, label: "Apply Profile"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
initial := completeTargetSnapshot(test.state, app.TargetAction{Kind: test.action, Available: true})
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
view := model.View()
if !strings.Contains(view, "Action: "+test.label) || !strings.Contains(view, "Space: "+test.label) {
t.Fatalf("contextual action not rendered:\n%s", view)
}
if test.state == target.External && (!strings.Contains(view, "proxyctl will begin managing this configuration") || !strings.Contains(view, "while preserving restorable state")) {
t.Fatalf("Apply Profile ownership explanation missing:\n%s", view)
}
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != test.intent {
t.Fatalf("intent = %#v, command=%v", module.intents, command != nil)
}
})
}
}
func TestCompleteModelOffersBothExplicitDriftResolutionIntents(t *testing.T) {
initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true})
initial.Targets[0].DriftResolutionActions = []app.TargetAction{
{Kind: app.ReapplyActiveProfileAction, Available: true},
{Kind: app.RestoreOriginalAction, Available: true},
}
for _, test := range []struct {
key string
intent app.IntentKind
}{
{key: "r", intent: app.ReapplyActiveProfile},
{key: "o", intent: app.RestoreOriginal},
} {
t.Run(test.key, func(t *testing.T) {
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, " ")
view := model.View()
for _, want := range []string{"Drift Resolution", "Reapply Active Profile", "Restore Original"} {
if !strings.Contains(view, want) {
t.Fatalf("missing %q:\n%s", want, view)
}
}
updated, command := model.Update(keyMessage(test.key))
model = updated.(Model)
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != test.intent {
t.Fatalf("resolution intent = %#v, command=%v", module.intents, command != nil)
}
})
}
}
func TestCompleteModelUsesStructuredDisabledReasonWithoutInferringFromState(t *testing.T) {
initial := completeTargetSnapshot(target.Disabled, app.TargetAction{
Kind: app.EnableTargetAction, DisabledReason: "No Active Profile is selected; create or select one before enabling",
})
module := &targetModule{snapshot: initial}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
view := model.View()
if !strings.Contains(view, "No Active Profile is selected") || !strings.Contains(view, "Space: Enable disabled") {
t.Fatalf("structured disabled reason missing:\n%s", view)
}
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command != nil || len(module.intents) != 0 || !strings.Contains(model.View(), "No Active Profile is selected") {
t.Fatalf("disabled action mutated: intents=%#v\n%s", module.intents, model.View())
}
}
func TestCompleteModelRestoreOwnershipLossFailsTruthfully(t *testing.T) {
initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true})
initial.Targets[0].DriftResolutionActions = []app.TargetAction{
{Kind: app.ReapplyActiveProfileAction, Available: true},
{Kind: app.RestoreOriginalAction, Available: true},
}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("restoration evidence is insufficient; external changes were not overwritten")}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, " ")
updated, command := model.Update(keyMessage("o"))
model = updated.(Model)
if command == nil {
t.Fatal("Restore Original did not start")
}
updated, _ = model.Update(command())
model = updated.(Model)
if len(module.intents) != 1 || module.intents[0].Kind != app.RestoreOriginal || !strings.Contains(model.View(), "Current error") || !strings.Contains(model.View(), "Drifted") {
t.Fatalf("ownership-loss result: intents=%#v\n%s", module.intents, model.View())
}
}
func TestCompleteModelCancelsDriftResolutionWithoutMutation(t *testing.T) {
initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true})
initial.Targets[0].DriftResolutionActions = []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}, {Kind: app.RestoreOriginalAction, Available: true}}
module := &targetModule{snapshot: initial}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, " ")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command != nil || len(module.intents) != 0 || strings.Contains(model.View(), "r: Reapply Active Profile") {
t.Fatalf("safe-default cancellation mutated: intents=%#v\n%s", module.intents, model.View())
}
}
+19
View File
@@ -5,6 +5,25 @@ import (
"proxyctl/internal/target"
)
func targetActionLabel(kind app.TargetActionKind) string {
switch kind {
case app.EnableTargetAction:
return "Enable"
case app.DisableTargetAction:
return "Disable"
case app.ApplyProfileTargetAction:
return "Apply Profile"
case app.ResolveDriftTargetAction:
return "Resolve"
case app.ReapplyActiveProfileAction:
return "Reapply Active Profile"
case app.RestoreOriginalAction:
return "Restore Original"
default:
return "Unavailable"
}
}
type dashboardPane uint8
const (
+12
View File
@@ -142,6 +142,18 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
var details strings.Builder
details.WriteString(p.theme.section.Render(item.Description.Name))
fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence)
if item.PrimaryAction.Kind != 0 {
fmt.Fprintf(&details, "\nAction: %s", targetActionLabel(item.PrimaryAction.Kind))
if !item.PrimaryAction.Available && item.PrimaryAction.DisabledReason != "" {
fmt.Fprintf(&details, "\nAction unavailable: %s", item.PrimaryAction.DisabledReason)
}
switch item.PrimaryAction.Kind {
case app.ApplyProfileTargetAction:
details.WriteString("\nApply Profile means proxyctl will begin managing this configuration while preserving restorable state.")
case app.ResolveDriftTargetAction:
details.WriteString("\nResolve opens Drift Resolution without choosing a recovery direction for you.")
}
}
if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil {
fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr)
}
+4 -4
View File
@@ -51,11 +51,11 @@ func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, "down")
model = resize(model, 80, 24)
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") {
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") {
t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view)
}
model = resize(model, 120, 36)
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") {
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") {
t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view)
}
@@ -199,7 +199,7 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
"State: ! Drifted",
"Docker daemon selected evidence.",
"Status: Refresh completed; selected Target remains actionable.",
"Space: toggle",
"Space: Resolve",
"p: manage Proxy Profiles",
)
@@ -209,7 +209,7 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
"> ! Drifted Docker daemon",
"State: ! Drifted",
"Docker daemon selected evidence.",
"Space: toggle",
"Space: Resolve",
)
}
+117 -51
View File
@@ -10,7 +10,6 @@ import (
"proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
type page int
@@ -31,22 +30,23 @@ type formState struct {
}
type Model struct {
application app.InteractiveModule
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirm bool
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
application app.InteractiveModule
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
driftResolutionTargetID string
form formState
confirm bool
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
}
// probeDone carries one finished Protocol Probe back to the form. address is
@@ -237,27 +237,54 @@ type mutationAvailability struct {
compactReason string
}
// targetMutationAvailability is the sole policy used by both input handling
// and rendering, so the displayed disabled reason cannot drift from behavior.
func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, activeProfile string, operationBlocked bool) mutationAvailability {
// targetMutationAvailability consumes the Application's structured action
// policy. It adds only Model-owned serialization state and never derives an
// action from Target state or display text.
func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, operationBlocked bool) mutationAvailability {
switch {
case operationBlocked:
return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"}
case !selectedOK:
return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"}
case selected.DetectionPhase == app.TargetChecking:
return mutationAvailability{reason: "Target mutation is disabled while this Target is still being checked", compactReason: "checking Target"}
case selected.DetectionPhase == app.TargetDetectionFailed:
return mutationAvailability{reason: "Target mutation is disabled because this Target check failed; refresh Targets to retry", compactReason: "Target check failed"}
case !selected.Available:
return mutationAvailability{reason: "Target mutation is disabled because this Target is unavailable", compactReason: "Target unavailable"}
case (selected.State == target.Disabled || selected.State == target.External) && activeProfile == "":
return mutationAvailability{reason: "Target mutation is disabled because there is no Active Profile; open Profile management first", compactReason: "no Active Profile"}
case !selected.PrimaryAction.Available:
reason := selected.PrimaryAction.DisabledReason
if reason == "" {
reason = "This Target action is unavailable"
}
return mutationAvailability{reason: reason, compactReason: "unavailable"}
default:
return mutationAvailability{available: true}
}
}
func driftResolutionAction(selected app.TargetSnapshot, kind app.TargetActionKind) (app.TargetAction, bool) {
for _, action := range selected.DriftResolutionActions {
if action.Kind == kind {
return action, true
}
}
return app.TargetAction{Kind: kind}, false
}
func targetIntent(kind app.TargetActionKind, targetID string) (app.Intent, bool) {
intent := app.Intent{TargetID: targetID}
switch kind {
case app.EnableTargetAction:
intent.Kind = app.EnableTarget
case app.DisableTargetAction:
intent.Kind = app.DisableTarget
case app.ApplyProfileTargetAction:
intent.Kind = app.ApplyProfileToTarget
case app.ReapplyActiveProfileAction:
intent.Kind = app.ReapplyActiveProfile
case app.RestoreOriginalAction:
intent.Kind = app.RestoreOriginal
default:
return app.Intent{}, false
}
return intent, true
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.operation.confirmation != nil {
switch key.String() {
@@ -273,6 +300,37 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, nil
}
if m.driftResolutionTargetID != "" {
switch key.String() {
case "enter", "esc", "n":
m.driftResolutionTargetID = ""
return m, nil
case "r", "o":
kind := app.ReapplyActiveProfileAction
if key.String() == "o" {
kind = app.RestoreOriginalAction
}
selected, ok := newTargetCatalog(m.snapshot, true, m.driftResolutionTargetID).selected(m.driftResolutionTargetID)
if !ok {
m.driftResolutionTargetID = ""
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Drift Resolution is unavailable because the Target is no longer present"}
return m, nil
}
action, ok := driftResolutionAction(selected, kind)
if !ok || !action.Available {
reason := action.DisabledReason
if reason == "" {
reason = "This Drift Resolution action is unavailable"
}
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: reason}
return m, nil
}
intent, _ := targetIntent(kind, selected.ID)
m.driftResolutionTargetID = ""
return m, m.startOperation(intent)
}
return m, nil
}
if key.String() == "e" && m.operation.currentError != nil {
m.operation.errorExpanded = !m.operation.errorExpanded
m.operation.errorOffset = 0
@@ -356,13 +414,20 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case " ":
selected, ok := catalog.selected(m.selectedTargetID)
availability := targetMutationAvailability(selected, ok, m.snapshot.ActiveProfile, m.operation.active || m.operation.quitRequested)
availability := targetMutationAvailability(selected, ok, m.operation.active || m.operation.quitRequested)
if !availability.available {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason}
break
}
desired := selected.State == target.Disabled || selected.State == target.External
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
if selected.PrimaryAction.Kind == app.ResolveDriftTargetAction {
m.driftResolutionTargetID = selected.ID
break
}
intent, ok := targetIntent(selected.PrimaryAction.Kind, selected.ID)
if !ok {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "This Target action is unavailable"}
break
}
return m, m.startOperation(intent)
}
@@ -553,26 +618,27 @@ func (m *Model) selectName(name string) {
func (m Model) View() string {
return m.presentation.Render(presentationState{
snapshot: m.snapshot,
page: m.page,
width: m.width,
height: m.height,
selected: m.selected,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
form: m.form,
confirm: m.confirm,
confirmation: m.operation.confirmation,
status: m.status,
operationStatus: m.operation.status,
currentError: m.operation.currentError,
errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationActive: m.operation.active,
operationProgress: m.operation.progress,
snapshot: m.snapshot,
page: m.page,
width: m.width,
height: m.height,
selected: m.selected,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
driftResolutionTargetID: m.driftResolutionTargetID,
form: m.form,
confirm: m.confirm,
confirmation: m.operation.confirmation,
status: m.status,
operationStatus: m.operation.status,
currentError: m.operation.currentError,
errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationActive: m.operation.active,
operationProgress: m.operation.progress,
})
}
+36 -2
View File
@@ -11,6 +11,7 @@ import (
"proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
type recordingModule struct {
@@ -18,10 +19,43 @@ type recordingModule struct {
intents []app.Intent
}
func (m *recordingModule) Snapshot() app.Snapshot { return m.snapshot }
func withTestTargetActions(snapshot app.Snapshot) app.Snapshot {
for i := range snapshot.Targets {
item := &snapshot.Targets[i]
if item.PrimaryAction.Kind != 0 {
continue
}
switch item.State {
case target.Disabled:
item.PrimaryAction.Kind = app.EnableTargetAction
case target.Enabled:
item.PrimaryAction.Kind = app.DisableTargetAction
case target.External:
item.PrimaryAction.Kind = app.ApplyProfileTargetAction
case target.Drifted:
item.PrimaryAction.Kind = app.ResolveDriftTargetAction
item.DriftResolutionActions = []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: snapshot.ActiveProfile != ""}, {Kind: app.RestoreOriginalAction, Available: true}}
}
item.PrimaryAction.Available = item.Available && item.DetectionPhase == app.TargetReady
if item.PrimaryAction.Available && (item.State == target.Disabled || item.State == target.External) && snapshot.ActiveProfile == "" {
item.PrimaryAction.Available = false
item.PrimaryAction.DisabledReason = "Target mutation is disabled because there is no Active Profile; open Profile management first"
}
if item.DetectionPhase == app.TargetChecking {
item.PrimaryAction.DisabledReason = "Target mutation is disabled while this Target is still being checked"
} else if item.DetectionPhase == app.TargetDetectionFailed {
item.PrimaryAction.DisabledReason = "Target mutation is disabled because this Target check failed; refresh Targets to retry"
} else if !item.Available {
item.PrimaryAction.DisabledReason = "Target mutation is disabled because this Target is unavailable"
}
}
return snapshot
}
func (m *recordingModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) }
func (m *recordingModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent)
return app.Result{Snapshot: m.snapshot, Notice: "done"}
return app.Result{Snapshot: withTestTargetActions(m.snapshot), Notice: "done"}
}
func (m *recordingModule) StartOperation(intent app.Intent) app.OperationStream {
result := m.Dispatch(intent)
+8 -4
View File
@@ -18,7 +18,7 @@ type interactiveScriptModule struct {
intents []app.Intent
}
func (m *interactiveScriptModule) Snapshot() app.Snapshot { return m.snapshot }
func (m *interactiveScriptModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) }
func (m *interactiveScriptModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent)
return app.Result{Snapshot: m.snapshot}
@@ -27,18 +27,18 @@ func (m *interactiveScriptModule) StartOperation(intent app.Intent) app.Operatio
m.intents = append(m.intents, intent)
stream := m.streams[0]
m.streams = m.streams[1:]
return app.OperationStream{Snapshot: m.snapshot, Updates: stream}
return app.OperationStream{Snapshot: withTestTargetActions(m.snapshot), Updates: stream}
}
func operationSnapshot() app.Snapshot {
return app.Snapshot{
return withTestTargetActions(app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{{Name: "office"}, {Name: "remote"}},
Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell details"}, Available: true, State: target.Disabled},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT details"}, Available: true, State: target.Enabled},
},
}
})
}
func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) {
@@ -194,6 +194,8 @@ func TestCompleteModelExplainsDisabledTargetMutations(t *testing.T) {
module := &interactiveScriptModule{snapshot: checking}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model.snapshot.Targets[0].DetectionPhase = app.TargetChecking
model.snapshot.Targets[0].PrimaryAction.Available = false
model.snapshot.Targets[0].PrimaryAction.DisabledReason = "Target mutation is disabled while this Target is still being checked"
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
@@ -202,6 +204,8 @@ func TestCompleteModelExplainsDisabledTargetMutations(t *testing.T) {
}
model.snapshot.Targets[0].DetectionPhase = app.TargetReady
model.snapshot.Targets[0].PrimaryAction.Available = false
model.snapshot.Targets[0].PrimaryAction.DisabledReason = "Target mutation is disabled because there is no Active Profile; open Profile management first"
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command != nil || !strings.Contains(model.View(), "disabled because there is no Active Profile") {
+32 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
@@ -41,6 +42,13 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
operationProgress: model.operationProgress,
},
}
if model.driftResolutionTargetID != "" && model.confirmation == nil {
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok {
page.dashboard = nil
page.primary, page.context = p.driftResolutionView(selected)
page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/Esc: Cancel"
}
}
if model.confirmation != nil {
var details strings.Builder
details.WriteString(p.theme.section.Render(model.confirmation.Title))
@@ -57,6 +65,26 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
return page
}
func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) (string, string) {
var choices strings.Builder
fmt.Fprintf(&choices, "%s\nChoose how to resolve %s without an automatic recovery direction.", p.theme.section.Render("Drift Resolution — "+selected.Description.Name), selected.Description.Name)
for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} {
action, ok := driftResolutionAction(selected, kind)
label := targetActionLabel(kind)
if !ok || !action.Available {
reason := action.DisabledReason
if reason == "" {
reason = "unavailable"
}
fmt.Fprintf(&choices, "\n\n%s — unavailable: %s", label, reason)
continue
}
fmt.Fprintf(&choices, "\n\n%s", label)
}
context := "Reapply Active Profile writes the Active Profile again. Restore Original asks the Target Adapter to use its existing ownership and restoration safeguards; insufficient evidence fails safely without overwriting external changes."
return choices.String(), context
}
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
if model.exitDeferred {
return "Exit pending; browsing remains safe while the current mutation completes"
@@ -74,11 +102,12 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t
}
actions := "↑/k ↓/j: select Tab: focus details " + visibility
selected, ok := catalog.selected(model.selectedTargetID)
availability := targetMutationAvailability(selected, ok, model.snapshot.ActiveProfile, model.operationActive || model.exitDeferred)
availability := targetMutationAvailability(selected, ok, model.operationActive || model.exitDeferred)
label := targetActionLabel(selected.PrimaryAction.Kind)
if availability.available {
actions = "Space: toggle " + actions
actions = "Space: " + label + " " + actions
} else {
actions = "Space: disabled (" + availability.compactReason + ") " + actions
actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions
}
footer := actions + "\np: manage Proxy Profiles r: refresh ?: help q: quit"
if model.operationActive {
+21 -20
View File
@@ -53,26 +53,27 @@ func defaultModelOptions() modelOptions {
}
type presentationState struct {
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirm bool
confirmation *app.Confirmation
status string
operationStatus app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
operationActive bool
operationProgress map[string]app.TargetProgressPhase
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
driftResolutionTargetID string
form formState
confirm bool
confirmation *app.Confirmation
status string
operationStatus app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
operationActive bool
operationProgress map[string]app.TargetProgressPhase
}
// terminalPresentation is the terminal-presentation Module. Application
+13 -14
View File
@@ -18,9 +18,10 @@ type targetModule struct {
intents []app.Intent
}
func (m *targetModule) Snapshot() app.Snapshot { return m.snapshot }
func (m *targetModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) }
func (m *targetModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent)
m.result.Snapshot = withTestTargetActions(m.result.Snapshot)
m.snapshot = m.result.Snapshot
return m.result
}
@@ -49,7 +50,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches"}}}, Notice: "Shell Target is now Enabled"}
model := NewModel(module)
view := model.View()
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") {
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Action: Apply Profile") {
t.Fatalf("Target panel:\n%s", view)
}
@@ -61,7 +62,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
message := command()
updated, _ = model.Update(message)
model = updated.(Model)
if len(module.intents) != 1 || module.intents[0].Kind != app.SetTargetDesired || !module.intents[0].Enabled || !strings.Contains(model.View(), "Enabled") {
if len(module.intents) != 1 || module.intents[0].Kind != app.ApplyProfileToTarget || !strings.Contains(model.View(), "Enabled") {
t.Fatalf("completed toggle = %#v\n%s", module.intents, model.View())
}
}
@@ -109,7 +110,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
}}}
model := NewModel(module)
view := model.View()
if strings.Contains(view, "APT") || !strings.Contains(view, "summary") || !strings.Contains(view, "integration installed") {
if strings.Contains(view, "APT") || !strings.Contains(view, "Action: Enable") || !strings.Contains(view, "integration installed") {
t.Fatalf("main Target panel:\n%s", view)
}
model = updateKey(model, "?")
@@ -346,17 +347,15 @@ func TestTargetPanelRendersEveryTargetState(t *testing.T) {
}
}
func TestDriftedTargetWithoutActiveProfileTogglesTowardDisabled(t *testing.T) {
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}}}
func TestDriftedTargetWithoutActiveProfileRequiresExplicitResolutionDirection(t *testing.T) {
initial := withTestTargetActions(app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}})
initial.Targets[0].DriftResolutionActions[0].DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile"
module := &targetModule{snapshot: initial}
model := NewModel(module)
_, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
if command == nil {
t.Fatal("Drifted Target did not start disable")
}
_ = command()
if len(module.intents) != 1 || module.intents[0].Enabled {
t.Fatalf("Drifted Target intent = %#v", module.intents)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command != nil || len(module.intents) != 0 || !strings.Contains(model.View(), "Drift Resolution") || !strings.Contains(model.View(), "Reapply Active Profile is unavailable") {
t.Fatalf("Drifted Target chose a direction implicitly: intents=%#v\n%s", module.intents, model.View())
}
}