1package client
2
3import (
4 "testing"
5
6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/protocol"
9 "github.com/stretchr/testify/require"
10)
11
12func testOutputView(publication uint64) protocol.ViewContext {
13 return protocol.ViewContext{
14 Publication: publication,
15 Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
16 TabID: "tab-1", FocusedPaneID: "pane-1",
17 }
18}
19
20func TestOutputApplyCapturedViewContext(t *testing.T) {
21 context := testOutputView(1)
22 initial, accepted := (outputApplyState{}).next(protocol.Output{Epoch: 2, New: 1, Full: true, ViewRevision: 3, Context: &context})
23 require.True(t, accepted)
24 require.Equal(t, context, initial.context)
25 context.FocusedPaneID = "changed after acceptance"
26 require.Equal(t, testOutputView(1), initial.context, "accepted metadata must not alias received pointers")
27 for _, scenario := range []string{"delta", "missing context", "stale publication", "invalid context", "different session", "contextual side effect", "plain side effect"} {
28 t.Run(scenario, func(t *testing.T) {
29 context := testOutputView(2)
30 output := protocol.Output{Epoch: 2, Base: 1, New: 2, ViewRevision: 3, Context: &context}
31 switch scenario {
32 case "missing context":
33 output.Context = nil
34 case "different session":
35 context.Route.Target.LifecycleID = domain.SessionLifecycleID{2}
36 case "stale publication":
37 context.Publication = 1
38 case "invalid context":
39 context.FocusedPaneID = ""
40 case "contextual side effect":
41 output.Base, output.New = 0, 0
42 case "plain side effect":
43 output.Base, output.New, output.Epoch = 0, 0, 9
44 output.Context = nil
45 }
46 next, accepted := initial.next(output)
47 switch scenario {
48 case "delta":
49 require.True(t, accepted)
50 require.Equal(t, context, next.context)
51 case "plain side effect":
52 require.True(t, accepted)
53 require.Equal(t, initial, next, "query/cleanup bytes cannot overwrite committed view identity")
54 default:
55 require.False(t, accepted)
56 }
57 })
58 }
59}
60
61func TestOutputApplyMetadataOnlyUpdates(t *testing.T) {
62 initial := outputApplyState{epoch: 2, state: 4, viewRevision: 3, initialized: true, context: testOutputView(7)}
63 for _, test := range []struct {
64 name string
65 initialized bool
66 epoch, state, publication uint64
67 accepted, reset bool
68 }{
69 {"same output new focus", true, 2, 4, 8, true, false},
70 {"before initialization", false, 2, 4, 8, false, true},
71 {"duplicate", true, 2, 4, 7, false, false},
72 {"stale publication", true, 2, 4, 6, false, false},
73 {"stale epoch", true, 1, 4, 8, false, false},
74 {"stale output", true, 2, 3, 8, false, false},
75 {"future epoch", true, 3, 4, 8, false, true},
76 {"future output", true, 2, 5, 8, false, true},
77 } {
78 t.Run(test.name, func(t *testing.T) {
79 state := initial
80 state.initialized = test.initialized
81 view := testOutputView(test.publication)
82 view.FocusedPaneID = "pane-2"
83 next, accepted, reset := state.nextView(protocol.UIViewUpdate{Epoch: test.epoch, State: test.state, Context: view})
84 require.Equal(t, test.accepted, accepted)
85 require.Equal(t, test.reset, reset)
86 if accepted {
87 require.Equal(t, initial.epoch, next.epoch)
88 require.Equal(t, initial.state, next.state)
89 require.Equal(t, initial.viewRevision, next.viewRevision)
90 require.Equal(t, view, next.context)
91 }
92 })
93 }
94}
95
96func TestOutputUIContextUsesOnlyCapturedIdentity(t *testing.T) {
97 state := outputApplyState{epoch: 2, state: 4, viewRevision: 3, initialized: true, context: testOutputView(7)}
98 got := state.uiContext(ports.UIContext{AttachmentHandle: "public", Generation: 5}, ports.UIStatusAttached)
99 require.Equal(t, ports.UIContext{
100 AttachmentHandle: "public", Generation: 5, Route: state.context.Route,
101 TabID: state.context.TabID, FocusedPaneID: state.context.FocusedPaneID,
102 OutputEpoch: 2, OutputState: 4, ViewRevision: 3, ViewPublication: 7, Status: ports.UIStatusAttached,
103 }, got)
104}
105
106// TestOutputUIContextZeroesUnattachedMetadata pins the shape rule for the two
107// unattached presentations: a Picker or Connecting context carries the run's
108// handle and its status, and never the session route, the focus, or the
109// committed output boundary a committed attachment carries.
110func TestOutputUIContextZeroesUnattachedMetadata(t *testing.T) {
111 state := outputApplyState{epoch: 2, state: 4, viewRevision: 3, initialized: true, context: testOutputView(7)}
112 for _, status := range []ports.UIPresentationStatus{ports.UIStatusPicker, ports.UIStatusConnecting} {
113 t.Run(string(status), func(t *testing.T) {
114 got := state.uiContext(ports.UIContext{AttachmentHandle: "public", Generation: 5}, status)
115 require.Equal(t, ports.UIContext{AttachmentHandle: "public", Status: status}, got)
116 })
117 }
118}