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