1package daemon
2
3import (
4 "testing"
5 "time"
6
7 "github.com/stretchr/testify/require"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"
11)
12
13// TestPaletteInventoryNamespaceLifecycle pins the overlay interaction
14// namespace: open bumps the generation and resets admit state, close drops
15// relayed rows, and a reopen starts a new namespace.
16func TestPaletteInventoryNamespaceLifecycle(t *testing.T) {
17 rt := &overlayRuntime{}
18 first := openPaletteInventory(rt)
19 require.Equal(t, uint64(1), first)
20 rt.paletteInventoryPublication = 3
21 rt.paletteInventoryGroups = []protocol.NavigationInventorySourceGroup{{SourceKey: "local"}}
22
23 interaction, ok := takePaletteInventoryClose(rt)
24 require.True(t, ok)
25 require.Equal(t, first, interaction)
26
27 closePaletteInventory(rt)
28 if _, ok := takePaletteInventoryClose(rt); ok {
29 t.Fatal("closed palette must not offer a close-demand")
30 }
31 require.Empty(t, rt.paletteInventoryGroups)
32
33 second := openPaletteInventory(rt)
34 require.Equal(t, first+1, second)
35 require.Zero(t, rt.paletteInventoryPublication)
36}
37
38// TestAdmitPaletteInventoryPublication pins admission: newer generations
39// store, older and duplicate generations discard, stale interactions and
40// closed palettes drop, malformed messages never store.
41func TestAdmitPaletteInventoryPublication(t *testing.T) {
42 groups := func() []protocol.NavigationInventorySourceGroup {
43 return []protocol.NavigationInventorySourceGroup{{
44 SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
45 Entries: []protocol.NavigationInventoryEntry{
46 {SourceKey: "local", EntryKey: "aaa/one", Name: "one"},
47 },
48 }}
49 }
50 publication := func(interaction, generation uint64) protocol.NavigationInventoryPublication {
51 return protocol.NavigationInventoryPublication{InteractionGeneration: interaction, PublicationGeneration: generation, Groups: groups()}
52 }
53
54 rt := &overlayRuntime{}
55 interaction := openPaletteInventory(rt)
56
57 require.True(t, admitPaletteInventoryPublication(rt, publication(interaction, 1)))
58 require.Len(t, rt.paletteInventoryGroups, 1)
59 require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 1)), "duplicate generation must discard")
60 require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 0)), "older generation must discard")
61 require.True(t, admitPaletteInventoryPublication(rt, publication(interaction, 2)))
62 require.Equal(t, uint64(2), rt.paletteInventoryPublication)
63 require.False(t, admitPaletteInventoryPublication(rt, publication(interaction+7, 3)), "stale interaction must drop")
64
65 closePaletteInventory(rt)
66 require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 3)), "closed palette must drop")
67
68 other := &overlayRuntime{}
69 openPaletteInventory(other)
70 malformed := publication(other.paletteInventoryInteraction, 1)
71 malformed.Groups = nil
72 malformed.PublicationGeneration = 0
73 require.False(t, admitPaletteInventoryPublication(other, malformed))
74 require.Empty(t, other.paletteInventoryGroups)
75}
76
77// TestPaletteInventoryDemandAndSelectionFlow drives the serving-daemon side
78// end to end through a capturing transport: capability-gated open demand,
79// publication admission with imported-row projection, Enter resolving
80// through opaque keys with bound cause, Selection before close-demand wire
81// order, and overlay teardown that preserves the frozen selection.
82func TestPaletteInventoryDemandAndSelectionFlow(t *testing.T) {
83 p, release := newBlockingPTY(t)
84 defer release()
85 d, current, ac, sends := newManualSessionWithPTYs(t, p)
86 ac.navigationCapabilities = protocol.NavigationCapabilityInventory
87 effect := beginRecentRoutePaletteEffect(t, d, current, ac)
88 // No automation action ID: ordinary keyboard input carries cause zero,
89 // which must still select.
90
91 interaction := d.enterPalette(current, ac)
92 d.sendPaletteInventoryDemand(ac, effect, true, interaction)
93 demandFrame := awaitFrame(t, sends, "NavigationInventoryDemand")
94 demand := decodeServerMessage(t, demandFrame).(protocol.NavigationInventoryDemand)
95 require.True(t, demand.Open)
96 require.Equal(t, interaction, demand.InteractionGeneration)
97
98 published := protocol.NavigationInventoryPublication{
99 InteractionGeneration: interaction, PublicationGeneration: 1,
100 Groups: []protocol.NavigationInventorySourceGroup{{
101 SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
102 Entries: []protocol.NavigationInventoryEntry{
103 {SourceKey: "local", EntryKey: "aaa/zzqimported", Name: "zzqimported", DisplayOrigin: "local", State: "up"},
104 },
105 }},
106 }
107 ac.overlays.paletteMu.Lock()
108 require.True(t, admitPaletteInventoryPublication(ac.overlays, published))
109 ac.overlays.paletteMu.Unlock()
110 d.refreshPalette(ac)
111
112 d.handlePaletteInput(ac, []byte("zzqimported\r"), effect)
113
114 selectionFrame := awaitFrame(t, sends, "NavigationInventorySelection")
115 selection := decodeServerMessage(t, selectionFrame).(protocol.NavigationInventorySelection)
116 require.Equal(t, uint64(0), selection.CauseActionID, "keyboard input carries no automation cause")
117 require.Equal(t, interaction, selection.InteractionGeneration)
118 require.Equal(t, uint64(1), selection.PublicationGeneration)
119 require.Equal(t, "local", selection.SourceKey)
120 require.Equal(t, "aaa/zzqimported", selection.EntryKey)
121
122 closeFrame := awaitFrame(t, sends, "NavigationInventoryDemand")
123 closeDemand, ok := decodeServerMessage(t, closeFrame).(protocol.NavigationInventoryDemand)
124 require.True(t, ok)
125 require.False(t, closeDemand.Open)
126 require.Equal(t, interaction, closeDemand.InteractionGeneration)
127 // Input handling sends controls synchronously; no second close may be queued.
128 for len(sends) > 0 {
129 frame := <-sends
130 require.NotEqual(t, "NavigationInventoryDemand", envelopeMessageName(t, frame.Payload), "selection emits exactly one close")
131 }
132
133 require.False(t, ac.overlays.paletteActive(), "select closes the overlay")
134 ac.overlays.paletteMu.Lock()
135 frozen := ac.overlays.paletteInventorySelected
136 ac.overlays.paletteMu.Unlock()
137 require.NotNil(t, frozen, "the pending selection survives the close so a late failure correlates")
138 require.Equal(t, interaction, frozen.InteractionGeneration)
139 require.Equal(t, "aaa/zzqimported", frozen.EntryKey)
140}
141
142// TestPaletteInventoryRemoteEnterNeverAttaches pins read-only remote
143// vision: Enter on a remote-origin row reports feedback, keeps the palette
144// open, and emits no Selection frame.
145func TestPaletteInventoryRemoteEnterNeverAttaches(t *testing.T) {
146 p, release := newBlockingPTY(t)
147 defer release()
148 d, current, ac, sends := newManualSessionWithPTYs(t, p)
149 ac.navigationCapabilities = protocol.NavigationCapabilityInventory
150 effect := beginRecentRoutePaletteEffect(t, d, current, ac)
151 effect.uiActionID = 123
152
153 interaction := d.enterPalette(current, ac)
154 published := protocol.NavigationInventoryPublication{
155 InteractionGeneration: interaction, PublicationGeneration: 1,
156 Groups: []protocol.NavigationInventorySourceGroup{
157 {
158 SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
159 Entries: []protocol.NavigationInventoryEntry{
160 {SourceKey: "local", EntryKey: "aaa/zzqlocal", Name: "zzqlocal", DisplayOrigin: "local", State: "up"},
161 },
162 },
163 {
164 SourceKey: "arch", Status: protocol.NavigationInventorySourceOK,
165 Entries: []protocol.NavigationInventoryEntry{
166 {SourceKey: "arch", EntryKey: "aaa/zzqremote", Name: "zzqremote", DisplayOrigin: "arch", State: "down", Reason: "unreachable"},
167 },
168 },
169 },
170 }
171 ac.overlays.paletteMu.Lock()
172 require.True(t, admitPaletteInventoryPublication(ac.overlays, published))
173 ac.overlays.paletteMu.Unlock()
174 d.refreshPalette(ac)
175
176 d.handlePaletteInput(ac, []byte("zzqremote\r"), effect)
177
178 ac.overlays.paletteMu.Lock()
179 feedback := ac.overlays.paletteFeedback
180 ac.overlays.paletteMu.Unlock()
181 require.Equal(t, "remote sessions are visible but cannot be attached", feedback)
182 require.True(t, ac.overlays.paletteActive(), "refused remote selection keeps the palette open")
183 select {
184 case frame := <-sends:
185 require.NotEqual(t, "NavigationInventorySelection", envelopeMessageName(t, frame.Payload), "remote Enter must not emit a selection")
186 case <-time.After(200 * time.Millisecond):
187 }
188}
189
190// TestPaletteInventoryFailureCorrelatesToPendingSelection pins durable
191// failure routing: a failure matching the frozen selection records a
192// notice even with the palette closed, shows contextual feedback while
193// the interaction is open, and drops when nothing matches.
194func TestPaletteInventoryFailureCorrelatesToPendingSelection(t *testing.T) {
195 p, release := newBlockingPTY(t)
196 defer release()
197 d, current, ac, _ := newManualSessionWithPTYs(t, p)
198
199 interaction := d.enterPalette(current, ac)
200 selection := protocol.NavigationInventorySelection{
201 InteractionGeneration: interaction, PublicationGeneration: 1,
202 SourceKey: "local", EntryKey: "aaa/zzqimported",
203 }
204 ac.overlays.paletteMu.Lock()
205 freezePaletteInventorySelection(ac.overlays, selection)
206 ac.overlays.paletteMu.Unlock()
207 d.closeExecutedPalette(ac, nil, 1, "")
208
209 matched := protocol.NavigationInventoryFailure{
210 InteractionGeneration: interaction, SourceKey: "local", EntryKey: "aaa/zzqimported",
211 Code: protocol.NavigationInventorySourceGone,
212 }
213 d.handleAttachmentClientMessage(captureAttachmentCapability(current, ac, ac.transport()), matched)
214 require.True(t, hasInventoryNotice(t, ac), "matched failure must record a durable notice")
215
216 stale := protocol.NavigationInventoryFailure{
217 InteractionGeneration: interaction + 1, SourceKey: "local", EntryKey: "aaa/zzqimported",
218 Code: protocol.NavigationInventorySourceGone,
219 }
220 before := countInventoryNotices(t, ac)
221 d.handleAttachmentClientMessage(captureAttachmentCapability(current, ac, ac.transport()), stale)
222 require.Equal(t, before, countInventoryNotices(t, ac), "unmatched failure must drop")
223}
224
225func hasInventoryNotice(t *testing.T, ac *attachedClient) bool {
226 t.Helper()
227 return countInventoryNotices(t, ac) != 0
228}
229
230func countInventoryNotices(t *testing.T, ac *attachedClient) int {
231 t.Helper()
232 toasts, _ := visibleToasts(ac)
233 count := 0
234 for _, toast := range toasts {
235 if toast.Code == domain.NoticeNavigationInventory {
236 count++
237 }
238 }
239 return count
240}
241
242// TestPaletteInventoryEscapeSendsCloseDemand pins the plain-cancel path: a
243// bare Escape cancels the unselected interaction and still stops polling.
244func TestPaletteInventoryEscapeSendsCloseDemand(t *testing.T) {
245 p, release := newBlockingPTY(t)
246 defer release()
247 d, current, ac, sends := newManualSessionWithPTYs(t, p)
248 ac.navigationCapabilities = protocol.NavigationCapabilityInventory
249 effect := beginRecentRoutePaletteEffect(t, d, current, ac)
250
251 interaction := d.enterPalette(current, ac)
252 d.sendPaletteInventoryDemand(ac, effect, true, interaction)
253 awaitFrame(t, sends, "NavigationInventoryDemand")
254
255 d.handlePaletteInput(ac, []byte{0x1b}, effect)
256 closeFrame := awaitFrame(t, sends, "NavigationInventoryDemand")
257 closeDemand, ok := decodeServerMessage(t, closeFrame).(protocol.NavigationInventoryDemand)
258 require.True(t, ok)
259 require.False(t, closeDemand.Open)
260 require.Equal(t, interaction, closeDemand.InteractionGeneration)
261 require.False(t, ac.overlays.paletteActive())
262}
263
264// TestPaletteInventoryDemandRequiresCapability pins the serving-side gate:
265// without the advertised capability bit no demand crosses, even with an
266// admitted effect, and the overlay namespace still opens natively.
267func TestPaletteInventoryDemandRequiresCapability(t *testing.T) {
268 p, release := newBlockingPTY(t)
269 defer release()
270 d, current, ac, sends := newManualSessionWithPTYs(t, p)
271 effect := beginRecentRoutePaletteEffect(t, d, current, ac)
272
273 interaction := d.enterPalette(current, ac)
274 d.sendPaletteInventoryDemand(ac, effect, true, interaction)
275 deadline := time.After(time.Second)
276 for {
277 select {
278 case frame := <-sends:
279 if envelopeMessageName(t, frame.Payload) == "NavigationInventoryDemand" {
280 t.Fatalf("demand without capability must not send")
281 }
282 case <-deadline:
283 require.True(t, ac.overlays.paletteActive())
284 return
285 }
286 }
287}