1package client
2
3import (
4 "fmt"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/protocol/catalogue"
13)
14
15// TestPickerCommitResolvesTheCommittedRow drives the whole picker commit path
16// through the real controller and catalogue: a keypress records the commit
17// decision with its row key, TakeOp hands the key over, and ResolveCommit
18// revalidates it against the latest publication into the exact broker stream
19// request and tab. A publication between commit and resolution must never
20// retarget the commit.
21func TestPickerCommitResolvesTheCommittedRow(t *testing.T) {
22 now := time.Unix(1000, 0)
23 // tabbed publishes session "work" at revision with tabs t1, t2, ... named
24 // in order. A session with tabs is a header; only its tab rows commit.
25 tabbed := func(revision ports.BrokerRevision, names ...string) ports.BrokerSnapshot {
26 tabs := make([]catalogue.RemoteCatalogTab, 0, len(names))
27 for i, name := range names {
28 tabs = append(tabs, pickerTestTab(fmt.Sprintf("t%d", i+1), name, "", false))
29 }
30 return ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: []ports.BrokerDaemonObservation{
31 pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", tabs...)),
32 }}
33 }
34 tests := []struct {
35 name string
36 // row is the label of the tab row the cursor commits.
37 row string
38 republished *ports.BrokerSnapshot
39 wantTab domain.TabStableID
40 wantErr pickerCatalogueErrorCode
41 }{
42 {name: "first tab commits its tab", row: "shell", wantTab: "t1"},
43 {name: "second tab commits its tab", row: "build", wantTab: "t2"},
44 {name: "unrelated republication keeps the commit", row: "build", republished: ptr(tabbed(2, "shell", "build", "logs")), wantTab: "t2"},
45 {name: "tab closed after commit is refused", row: "build", republished: ptr(tabbed(2, "shell")), wantErr: pickerCatalogueGone},
46 }
47 for _, tt := range tests {
48 t.Run(tt.name, func(t *testing.T) {
49 controller, _ := pickerTestController(t)
50 controller.ApplySnapshot(tabbed(1, "shell", "build"))
51 key := pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.row)
52 for i := 0; i < 16; i++ {
53 if current, ok := controller.CursorKey(); ok && current == key {
54 break
55 }
56 require.True(t, controller.ConsumeTerminalRead([]byte("j")))
57 }
58 require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
59 op, committed := controller.TakeOp()
60 require.True(t, op.commit)
61 require.Equal(t, key, committed, "the commit captures the row under the cursor")
62
63 if tt.republished != nil {
64 controller.ApplySnapshot(*tt.republished)
65 }
66 request, tab, err := controller.ResolveCommit(committed, pickerTestBase())
67
68 if tt.wantErr != 0 {
69 require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
70 return
71 }
72 require.NoError(t, err)
73 require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
74 require.Equal(t, "work", request.Target.SessionName)
75 require.Equal(t, pickerTestBase().Stream, request.Stream)
76 require.Equal(t, tt.wantTab, tab.preferred)
77 })
78 }
79}
80
81// TestPickerCatalogueResolveTargetKinds covers every selection kind a commit
82// key can carry: destinations resolve to their admission, while a CLI-only
83// attach-by-name identity is refused as a picker commit.
84func TestPickerCatalogueResolveTargetKinds(t *testing.T) {
85 observation := pickerTestLocalObservation(time.Unix(1000, 0), pickerTestSession("alpha", 1, catalogue_Up))
86 tests := []struct {
87 name string
88 ref pickerSelectionRef
89 wantAdmission ports.BrokerStreamAdmission
90 wantErr pickerCatalogueErrorCode
91 }{
92 {name: "exact session", ref: pickerSelectionRef{kind: pickerSelectionExact, lifecycle: pickerTestLifecycle(1), name: "alpha"}, wantAdmission: ports.BrokerAdmissionExact},
93 {name: "create named", ref: pickerSelectionRef{kind: pickerSelectionCreateNamed, createName: "fresh"}, wantAdmission: ports.BrokerAdmissionCreateNamed},
94 {name: "create named with invalid name", ref: pickerSelectionRef{kind: pickerSelectionCreateNamed, createName: "bad name/"}, wantErr: pickerCatalogueInvalidName},
95 {name: "create ephemeral", ref: pickerSelectionRef{kind: pickerSelectionCreateEphemeral}, wantAdmission: ports.BrokerAdmissionCreateEphemeral},
96 {name: "attach named is not a picker destination", ref: pickerSelectionRef{kind: pickerSelectionAttachNamed, name: "alpha"}, wantErr: pickerCatalogueUnavailable},
97 {name: "unknown key", wantErr: pickerCatalogueUnknown},
98 }
99 for _, tt := range tests {
100 t.Run(tt.name, func(t *testing.T) {
101 catalogue, _ := pickerTestCatalogue(t)
102 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation}}))
103 key := "missing"
104 if tt.ref.kind != 0 {
105 key = "injected"
106 tt.ref.epoch = 3
107 tt.ref.local = true
108 catalogue.mu.Lock()
109 catalogue.refs[key] = tt.ref
110 catalogue.mu.Unlock()
111 }
112
113 request, _, err := catalogue.ResolveTarget(key, pickerTestBase())
114
115 if tt.wantErr != 0 {
116 require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
117 return
118 }
119 require.NoError(t, err)
120 require.Equal(t, tt.wantAdmission, request.Admission)
121 })
122 }
123}
124
125func ptr[T any](v T) *T { return &v }