返回 DeepSeek-Reasonix
event_test.go
根目录 / internal / event / event_test.go
1 package event
2
3 import (
4 "sync"
5 "testing"
6
7 "reasonix/internal/evidence"
8 "reasonix/internal/provider"
9 )
10
11 // Kind constants
12
13 func TestKindConstants(t *testing.T) {
14 // Verify the iota sequence is stable and sequential for the original
15 // core kinds. New kinds are appended before KindCount.
16 kinds := []Kind{
17 TurnStarted, Reasoning, Text, Message, ToolDispatch, ToolResult,
18 Usage, Notice, Phase, ApprovalRequest, AskRequest, TurnDone,
19 }
20 for i, k := range kinds {
21 if int(k) != i {
22 t.Errorf("Kind %d: got %d", i, int(k))
23 }
24 }
25 if TurnPhase >= KindCount || CompletionSummary >= KindCount {
26 t.Fatal("new kinds must sit before KindCount")
27 }
28 if TurnPhaseName(TurnPhaseWorking) != "working" || TurnPhaseName(TurnPhaseReviewing) != "reviewing" {
29 t.Fatal("turn phase names drifted")
30 }
31 }
32
33 // Level constants
34
35 func TestLevelConstants(t *testing.T) {
36 if LevelInfo != 0 {
37 t.Errorf("LevelInfo = %d, want 0", LevelInfo)
38 }
39 if LevelWarn != 1 {
40 t.Errorf("LevelWarn = %d, want 1", LevelWarn)
41 }
42 }
43
44 func TestNoticeAudienceConstants(t *testing.T) {
45 if NoticeAudienceDefault != "" {
46 t.Errorf("NoticeAudienceDefault = %q, want empty for backward-compatible delivery", NoticeAudienceDefault)
47 }
48 if NoticeAudienceOperator != "operator" {
49 t.Errorf("NoticeAudienceOperator = %q, want operator", NoticeAudienceOperator)
50 }
51 }
52
53 // FuncSink
54
55 func TestFuncSinkEmit(t *testing.T) {
56 var received Event
57 fs := FuncSink(func(e Event) { received = e })
58 e := Event{Kind: Text, Text: "hello"}
59 fs.Emit(e)
60 if received.Kind != Text || received.Text != "hello" {
61 t.Errorf("FuncSink did not forward event: got %+v", received)
62 }
63 }
64
65 func TestFuncSinkNilEmitIsNoop(t *testing.T) {
66 var fs FuncSink
67
68 fs.Emit(Event{Kind: Text, Text: "hello"})
69 }
70
71 type typedNilSink struct{}
72
73 func (*typedNilSink) Emit(Event) {}
74
75 func TestSyncTreatsTypedNilSinkAsDiscard(t *testing.T) {
76 var base *typedNilSink
77
78 Sync(base).Emit(Event{Kind: Text, Text: "hello"})
79 }
80
81 type readinessAuditRecorder struct {
82 events []evidence.ReadinessAudit
83 recovery []ProtocolRecoveryAudit
84 workspace []WorkspaceMutation
85 turns int
86 }
87
88 func (r *readinessAuditRecorder) Emit(Event) {}
89
90 func (r *readinessAuditRecorder) RecordReadinessAudit(a evidence.ReadinessAudit) {
91 r.events = append(r.events, a)
92 }
93
94 func (r *readinessAuditRecorder) RecordProtocolRecovery(a ProtocolRecoveryAudit) {
95 r.recovery = append(r.recovery, a)
96 }
97
98 func (r *readinessAuditRecorder) RecordTurnCompletion() { r.turns++ }
99
100 func (r *readinessAuditRecorder) RecordWorkspaceMutation(m WorkspaceMutation) {
101 r.workspace = append(r.workspace, m)
102 }
103
104 func TestSyncForwardsTurnCompletion(t *testing.T) {
105 rec := &readinessAuditRecorder{}
106 RecordTurnCompletion(Sync(rec))
107 if rec.turns != 1 {
108 t.Fatalf("turn completions = %d, want 1", rec.turns)
109 }
110 }
111
112 func TestSyncForwardsWorkspaceMutationWithoutUIEvent(t *testing.T) {
113 rec := &readinessAuditRecorder{}
114 sink := Sync(rec)
115 RecordWorkspaceMutation(sink, WorkspaceMutation{ToolName: "write_file", Paths: []string{"a.go"}, Content: true})
116 if len(rec.workspace) != 1 || rec.workspace[0].ToolName != "write_file" || len(rec.workspace[0].Paths) != 1 {
117 t.Fatalf("workspace mutation not forwarded through Sync: %+v", rec.workspace)
118 }
119 }
120
121 func TestSyncForwardsReadinessAuditReceipts(t *testing.T) {
122 rec := &readinessAuditRecorder{}
123 sink := Sync(rec)
124
125 RecordReadinessAudit(sink, evidence.ReadinessAudit{
126 Result: evidence.ReadinessBlocked,
127 MissingProjectChecks: 1,
128 CommandMismatchMissing: 1,
129 })
130
131 if len(rec.events) != 1 {
132 t.Fatalf("readiness audit events = %d, want 1", len(rec.events))
133 }
134 if rec.events[0].Result != evidence.ReadinessBlocked || rec.events[0].MissingProjectChecks != 1 {
135 t.Fatalf("readiness audit not forwarded through Sync: %+v", rec.events[0])
136 }
137 }
138
139 func TestSyncForwardsProtocolRecoveryWithoutEmittingUIEvent(t *testing.T) {
140 rec := &readinessAuditRecorder{}
141 sink := Sync(rec)
142
143 RecordProtocolRecovery(sink, ProtocolRecoveryAudit{Kind: ProtocolRecoveryMissingReasoningRetryReplaced})
144
145 if len(rec.recovery) != 1 || rec.recovery[0].Kind != ProtocolRecoveryMissingReasoningRetryReplaced {
146 t.Fatalf("protocol recovery not forwarded through Sync: %+v", rec.recovery)
147 }
148 }
149
150 // Discard
151
152 func TestDiscardSink(t *testing.T) {
153 // Discard should accept any event without panic.
154 Discard.Emit(Event{Kind: TurnStarted})
155 Discard.Emit(Event{Kind: Text, Text: "discarded"})
156 Discard.Emit(Event{Kind: TurnDone})
157 }
158
159 // Event struct field access
160
161 func TestEventFields(t *testing.T) {
162 usage := &provider.Usage{PromptTokens: 100, CompletionTokens: 50}
163 pricing := &provider.Pricing{Input: 2.0, Output: 10.0, Currency: "$"}
164
165 e := Event{
166 Kind: Usage,
167 Usage: usage,
168 Pricing: pricing,
169 SessionHit: 80,
170 SessionMiss: 20,
171 }
172 if e.Kind != Usage {
173 t.Errorf("Kind = %d, want %d", e.Kind, Usage)
174 }
175 if e.Usage.PromptTokens != 100 {
176 t.Errorf("PromptTokens = %d, want 100", e.Usage.PromptTokens)
177 }
178 if e.Pricing.Currency != "$" {
179 t.Errorf("Currency = %q, want $", e.Pricing.Currency)
180 }
181 if e.SessionHit != 80 || e.SessionMiss != 20 {
182 t.Errorf("SessionHit=%d, SessionMiss=%d", e.SessionHit, e.SessionMiss)
183 }
184 }
185
186 // Tool struct
187
188 func TestToolStruct(t *testing.T) {
189 tool := Tool{
190 ID: "call-1",
191 Name: "bash",
192 Args: `{"command":"echo hi"}`,
193 ReadOnly: false,
194 Partial: true,
195 ParentID: "parent-1",
196 }
197 if tool.ID != "call-1" || tool.Name != "bash" {
198 t.Errorf("unexpected tool: %+v", tool)
199 }
200 if !tool.Partial {
201 t.Error("Partial should be true")
202 }
203 if tool.ParentID != "parent-1" {
204 t.Errorf("ParentID = %q", tool.ParentID)
205 }
206
207 result := Tool{
208 ID: "call-1",
209 Name: "bash",
210 Output: "hi\n",
211 Err: "",
212 Truncated: false,
213 }
214 if result.Output != "hi\n" {
215 t.Errorf("Output = %q", result.Output)
216 }
217 }
218
219 // Approval struct
220
221 func TestApprovalStruct(t *testing.T) {
222 a := Approval{ID: "42", Tool: "bash", Subject: "rm -rf /"}
223 if a.ID != "42" || a.Tool != "bash" || a.Subject != "rm -rf /" {
224 t.Errorf("unexpected approval: %+v", a)
225 }
226 }
227
228 // Ask / AskQuestion / AskOption / AskAnswer
229
230 func TestAskStructs(t *testing.T) {
231 q := AskQuestion{
232 ID: "q1",
233 Header: "Confirm",
234 Prompt: "Are you sure?",
235 Options: []AskOption{
236 {Label: "Yes", Description: "Proceed"},
237 {Label: "No", Description: "Cancel"},
238 },
239 Multi: false,
240 }
241 ask := Ask{
242 ID: "ask-1",
243 Questions: []AskQuestion{q},
244 }
245 if len(ask.Questions) != 1 {
246 t.Fatalf("questions count = %d", len(ask.Questions))
247 }
248 if ask.Questions[0].Options[0].Label != "Yes" {
249 t.Errorf("first option = %q", ask.Questions[0].Options[0].Label)
250 }
251
252 ans := AskAnswer{QuestionID: "q1", Selected: []string{"Yes"}}
253 if len(ans.Selected) != 1 || ans.Selected[0] != "Yes" {
254 t.Errorf("answer = %+v", ans)
255 }
256 }
257
258 // Multiple Emit via channel-backed sink
259
260 func TestChannelBackedSink(t *testing.T) {
261 ch := make(chan Event, 8)
262 sink := FuncSink(func(e Event) { ch <- e })
263
264 events := []Event{
265 {Kind: TurnStarted},
266 {Kind: Text, Text: "hello"},
267 {Kind: ToolDispatch, Tool: Tool{Name: "bash"}},
268 {Kind: ToolResult, Tool: Tool{Output: "ok"}},
269 {Kind: Usage, Usage: &provider.Usage{TotalTokens: 42}},
270 {Kind: Notice, Level: LevelWarn, Text: "heads up", Detail: "diagnostics"},
271 {Kind: TurnDone},
272 }
273 for _, e := range events {
274 sink.Emit(e)
275 }
276
277 for i, want := range events {
278 got := <-ch
279 if got.Kind != want.Kind {
280 t.Errorf("event %d: Kind = %d, want %d", i, got.Kind, want.Kind)
281 }
282 if got.Detail != want.Detail {
283 t.Errorf("event %d: Detail = %q, want %q", i, got.Detail, want.Detail)
284 }
285 }
286 }
287
288 // FuncSink forwards every concurrent Emit exactly once
289
290 // FuncSink.Emit forwards to the wrapped func with no synchronization of its own,
291 // so a concurrency-safe callback is the caller's responsibility (here a
292 // mutex-guarded counter). This verifies that N concurrent Emits produce exactly
293 // N forwarded calls, and under `go test -race` that the forwarding itself is
294 // race-free.
295 func TestFuncSinkForwardsEachConcurrentEmit(t *testing.T) {
296 var mu sync.Mutex
297 var count int
298 sink := FuncSink(func(e Event) {
299 mu.Lock()
300 count++
301 mu.Unlock()
302 })
303 var wg sync.WaitGroup
304 for range 100 {
305 wg.Go(func() {
306 sink.Emit(Event{Kind: Text})
307 })
308 }
309 wg.Wait()
310 mu.Lock()
311 defer mu.Unlock()
312 if count != 100 {
313 t.Errorf("count = %d, want 100", count)
314 }
315 }
316
316 lines GO