| 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 |