返回 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
218 done := make(chan struct{})
219 go func() {
220 app.emitProjectTreeChanged()
221 close(done)
222 }()
223 select {
224 case <-done:
225 case <-time.After(500 * time.Millisecond):
226 t.Fatal("project tree event blocked behind runtime EventsEmit")
227 }
228
229 close(release)
230 select {
231 case <-allDelivered:
232 return
233 case <-time.After(500 * time.Millisecond):
234 t.Fatalf("runtime emit calls = %d (runtime=%d legacy=%d), want two broadcasts on both contracts", calls.Load(), runtimeCalls.Load(), legacyCalls.Load())
235 }
236 }
237
238 func TestAsyncRuntimeEmitterDrainsBacklogInOrder(t *testing.T) {
239 const backlog = 256
240
241 entered := make(chan struct{})
242 release := make(chan struct{})
243 delivered := make(chan string, backlog)
244 var calls atomic.Int32
245
246 emitter := &asyncRuntimeEmitter{}
247 emitter.emit = func(_ context.Context, _ string, payload ...any) {
248 if len(payload) != 1 {
249 t.Errorf("payload count = %d, want 1", len(payload))
250 return
251 }
252 value, ok := payload[0].(string)
253 if !ok {
254 t.Errorf("payload type = %T, want string", payload[0])
255 return
256 }
257 delivered <- value
258 if calls.Add(1) == 1 {
259 close(entered)
260 <-release
261 }
262 }
263
264 ctx := context.Background()
265 for i := range backlog {
266 emitter.Emit(ctx, "agent:event", strconv.Itoa(i))
267 }
268
269 select {
270 case <-entered:
271 case <-time.After(500 * time.Millisecond):
272 t.Fatal("first runtime emit did not start")
273 }
274 close(release)
275
276 for i := range backlog {
277 select {
278 case got := <-delivered:
279 if want := strconv.Itoa(i); got != want {
280 t.Fatalf("delivered[%d] = %q, want %q", i, got, want)
281 }
282 case <-time.After(500 * time.Millisecond):
283 t.Fatalf("timed out waiting for delivered event %d", i)
284 }
285 }
286 }
287
287 lines GO