返回 DeepSeek-Reasonix
tab_event_sink_test.go
根目录 / desktop / tab_event_sink_test.go
1 package main
2
3 import (
4 "context"
5 "strconv"
6 "sync/atomic"
7 "testing"
8 "time"
9
10 "reasonix/internal/event"
11 )
12
13 type closeTrackingSink struct {
14 closed atomic.Bool
15 }
16
17 func (s *closeTrackingSink) Emit(event.Event) {}
18
19 func (s *closeTrackingSink) Close() {
20 s.closed.Store(true)
21 }
22
23 type blockingCloseTrackingSink struct {
24 closeTrackingSink
25 entered chan struct{}
26 release chan struct{}
27 }
28
29 func (s *blockingCloseTrackingSink) Emit(event.Event) {
30 close(s.entered)
31 <-s.release
32 }
33
34 func TestTabEventSinkSetBotSinkClosesPreviousSink(t *testing.T) {
35 sink := &tabEventSink{}
36 first := &closeTrackingSink{}
37 second := &closeTrackingSink{}
38
39 sink.SetBotSink(first)
40 if first.closed.Load() {
41 t.Fatal("newly attached sink was closed")
42 }
43
44 sink.SetBotSink(second)
45 if !first.closed.Load() {
46 t.Fatal("previous sink was not closed when replaced")
47 }
48 if second.closed.Load() {
49 t.Fatal("replacement sink was closed too early")
50 }
51
52 sink.SetBotSink(nil)
53 if !second.closed.Load() {
54 t.Fatal("second sink was not closed when cleared")
55 }
56 }
57
58 func TestTabEventSinkOldTurnDoneDoesNotClearReplacement(t *testing.T) {
59 sink := &tabEventSink{}
60 old := &blockingCloseTrackingSink{
61 entered: make(chan struct{}),
62 release: make(chan struct{}),
63 }
64 replacement := &closeTrackingSink{}
65
66 if !sink.tryBeginTurn() {
67 t.Fatal("failed to reserve initial turn")
68 }
69 sink.SetBotSink(old)
70 done := make(chan struct{})
71 go func() {
72 sink.Emit(event.Event{Kind: event.TurnDone})
73 close(done)
74 }()
75 select {
76 case <-old.entered:
77 case <-time.After(500 * time.Millisecond):
78 t.Fatal("old forwarder did not receive TurnDone")
79 }
80
81 sink.SetBotSink(replacement)
82 if sink.tryBeginTurn() {
83 t.Fatal("new turn admitted before old TurnDone completed")
84 }
85 close(old.release)
86 select {
87 case <-done:
88 case <-time.After(500 * time.Millisecond):
89 t.Fatal("TurnDone did not finish")
90 }
91
92 if replacement.closed.Load() {
93 t.Fatal("old TurnDone cleared the replacement forwarder")
94 }
95 got, _ := sink.botSinkSnapshot()
96 if got != replacement {
97 t.Fatalf("attached forwarder = %T, want replacement", got)
98 }
99 if !sink.tryBeginTurn() {
100 t.Fatal("next turn was not admitted after TurnDone completed")
101 }
102 sink.cancelTurnStart()
103 sink.SetBotSink(nil)
104 }
105
106 func TestTabEventSinkDoesNotBlockOnRuntimeEventsEmit(t *testing.T) {
107 entered := make(chan struct{})
108 release := make(chan struct{})
109 delivered := make(chan string, 2)
110 var calls atomic.Int32
111
112 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
113 sink.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
114 if name != eventChannel {
115 t.Errorf("event name = %q, want %q", name, eventChannel)
116 }
117 if len(payload) != 1 {
118 t.Errorf("payload count = %d, want 1", len(payload))
119 return
120 }
121 wire, ok := payload[0].(wireEventTab)
122 if !ok {
123 t.Errorf("payload type = %T, want wireEventTab", payload[0])
124 return
125 }
126 delivered <- wire.Text
127 if calls.Add(1) == 1 {
128 close(entered)
129 <-release
130 }
131 }
132
133 wrapped := event.Sync(sink)
134 wrapped.Emit(event.Event{Kind: event.Text, Text: "one"})
135
136 select {
137 case <-entered:
138 case <-time.After(500 * time.Millisecond):
139 t.Fatal("first runtime emit did not start")
140 }
141
142 done := make(chan struct{})
143 go func() {
144 wrapped.Emit(event.Event{Kind: event.Text, Text: "two"})
145 close(done)
146 }()
147 select {
148 case <-done:
149 case <-time.After(500 * time.Millisecond):
150 t.Fatal("second event blocked behind runtime EventsEmit")
151 }
152
153 close(release)
154 if got := <-delivered; got != "one" {
155 t.Fatalf("first delivered event = %q, want one", got)
156 }
157 select {
158 case got := <-delivered:
159 if got != "two" {
160 t.Fatalf("second delivered event = %q, want two", got)
161 }
162 case <-time.After(500 * time.Millisecond):
163 t.Fatal("second queued event was not delivered")
164 }
165 }
166
167 func TestEmitProjectTreeChangedDoesNotBlockOnRuntimeEventsEmit(t *testing.T) {
168 entered := make(chan struct{})
169 release := make(chan struct{})
170 allDelivered := make(chan struct{}, 1)
171 var calls atomic.Int32
172 var runtimeCalls atomic.Int32
173 var legacyCalls atomic.Int32
174
175 app := &App{ctx: context.Background()}
176 app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
177 switch name {
178 case "project-tree:runtime-changed":
179 runtimeCalls.Add(1)
180 if len(payload) != 1 {
181 t.Errorf("runtime payload count = %d, want 1", len(payload))
182 } else if event, ok := payload[0].(ProjectTreeRuntimeSnapshot); !ok || event.Topics == nil || event.Revision == 0 {
183 t.Errorf("runtime payload = %#v, want a versioned snapshot with [] topics", payload[0])
184 }
185 case "runtime-state:changed":
186 if len(payload) != 1 {
187 t.Errorf("unified runtime payload count = %d", len(payload))
188 } else if snapshot, ok := payload[0].(RuntimeStateProjection); !ok || snapshot.Sessions == nil || snapshot.Revision == 0 {
189 t.Errorf("invalid unified runtime snapshot: %#v", payload[0])
190 }
191 case "project-tree:changed":
192 legacyCalls.Add(1)
193 if len(payload) != 0 {
194 t.Errorf("legacy payload count = %d, want 0", len(payload))
195 }
196 default:
197 t.Errorf("event name = %q, want project-tree:runtime-changed or project-tree:changed", name)
198 }
199 if runtimeCalls.Load() >= 2 && legacyCalls.Load() >= 2 {
200 select {
201 case allDelivered <- struct{}{}:
202 default:
203 }
204 }
205 if calls.Add(1) == 1 {
206 close(entered)
207 <-release
208 }
209 }
210
211 app.emitProjectTreeChanged()
212 select {
213 case <-entered:
214 case <-time.After(500 * time.Millisecond):
215 t.Fatal("first project tree runtime emit did not start")
216 }
217 // Unchanged snapshots are intentionally deduplicated. A real binding
218 // change must still publish without waiting for the blocked transport.
219 app.mu.Lock()
220 app.tabs = map[string]*WorkspaceTab{"second": {ID: "second", Scope: "global"}}
221 app.mu.Unlock()
222
223 done := make(chan struct{})
224 go func() {
225 app.emitProjectTreeChanged()
226 close(done)
227 }()
228 select {
229 case <-done:
230 case <-time.After(500 * time.Millisecond):
231 t.Fatal("project tree event blocked behind runtime EventsEmit")
232 }
233
234 close(release)
235 select {
236 case <-allDelivered:
237 return
238 case <-time.After(500 * time.Millisecond):
239 t.Fatalf("runtime emit calls = %d (runtime=%d legacy=%d), want two broadcasts on both contracts", calls.Load(), runtimeCalls.Load(), legacyCalls.Load())
240 }
241 }
242
243 func TestAsyncRuntimeEmitterDrainsBacklogInOrder(t *testing.T) {
244 const backlog = 256
245
246 entered := make(chan struct{})
247 release := make(chan struct{})
248 delivered := make(chan string, backlog)
249 var calls atomic.Int32
250
251 emitter := &asyncRuntimeEmitter{}
252 emitter.emit = func(_ context.Context, _ string, payload ...any) {
253 if len(payload) != 1 {
254 t.Errorf("payload count = %d, want 1", len(payload))
255 return
256 }
257 value, ok := payload[0].(string)
258 if !ok {
259 t.Errorf("payload type = %T, want string", payload[0])
260 return
261 }
262 delivered <- value
263 if calls.Add(1) == 1 {
264 close(entered)
265 <-release
266 }
267 }
268
269 ctx := context.Background()
270 for i := range backlog {
271 emitter.Emit(ctx, "agent:event", strconv.Itoa(i))
272 }
273
274 select {
275 case <-entered:
276 case <-time.After(500 * time.Millisecond):
277 t.Fatal("first runtime emit did not start")
278 }
279 close(release)
280
281 for i := range backlog {
282 select {
283 case got := <-delivered:
284 if want := strconv.Itoa(i); got != want {
285 t.Fatalf("delivered[%d] = %q, want %q", i, got, want)
286 }
287 case <-time.After(500 * time.Millisecond):
288 t.Fatalf("timed out waiting for delivered event %d", i)
289 }
290 }
291 }
292
292 lines GO