|
| 1 | +package api |
| 2 | + |
| 3 | +import ( |
| 4 | + "strconv" |
| 5 | + "testing" |
| 6 | + "time" |
| 7 | +) |
| 8 | + |
| 9 | +// TestExtractTS_RFC3339 covers the RFC3339 (seconds-precision) branch. |
| 10 | +func TestExtractTS_RFC3339(t *testing.T) { |
| 11 | + want := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC) |
| 12 | + got := extractTS(map[string]any{ |
| 13 | + "ctm_timestamp": want.Format(time.RFC3339), |
| 14 | + }) |
| 15 | + if !got.Equal(want) { |
| 16 | + t.Errorf("extractTS RFC3339 = %v, want %v", got, want) |
| 17 | + } |
| 18 | +} |
| 19 | + |
| 20 | +// TestExtractTS_RFC3339Nano covers the nano-precision fallback branch. |
| 21 | +func TestExtractTS_RFC3339Nano(t *testing.T) { |
| 22 | + want := time.Date(2026, 4, 21, 12, 0, 0, 123456789, time.UTC) |
| 23 | + got := extractTS(map[string]any{ |
| 24 | + "ctm_timestamp": want.Format(time.RFC3339Nano), |
| 25 | + }) |
| 26 | + if !got.Equal(want) { |
| 27 | + t.Errorf("extractTS RFC3339Nano = %v, want %v", got, want) |
| 28 | + } |
| 29 | +} |
| 30 | + |
| 31 | +// TestExtractTS_Missing covers the "no ctm_timestamp" → zero time branch. |
| 32 | +func TestExtractTS_Missing(t *testing.T) { |
| 33 | + got := extractTS(map[string]any{"other_field": "abc"}) |
| 34 | + if !got.IsZero() { |
| 35 | + t.Errorf("extractTS missing = %v, want zero", got) |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +// TestExtractTS_WrongType covers the type-assertion-failure branch |
| 40 | +// (ctm_timestamp present but not a string). |
| 41 | +func TestExtractTS_WrongType(t *testing.T) { |
| 42 | + got := extractTS(map[string]any{"ctm_timestamp": 12345}) |
| 43 | + if !got.IsZero() { |
| 44 | + t.Errorf("extractTS wrong-type = %v, want zero", got) |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +// TestExtractTS_BadFormat covers the "string but unparseable" branch: |
| 49 | +// neither RFC3339 nor RFC3339Nano accepts → zero time. |
| 50 | +func TestExtractTS_BadFormat(t *testing.T) { |
| 51 | + got := extractTS(map[string]any{"ctm_timestamp": "not-a-timestamp"}) |
| 52 | + if !got.IsZero() { |
| 53 | + t.Errorf("extractTS bad-format = %v, want zero", got) |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +// TestNestedBool covers all branches of nestedBool: missing top key, |
| 58 | +// non-map intermediate, missing leaf, leaf-not-bool, leaf-true. |
| 59 | +func TestNestedBool(t *testing.T) { |
| 60 | + m := map[string]any{ |
| 61 | + "a": map[string]any{ |
| 62 | + "b": map[string]any{ |
| 63 | + "isit": true, |
| 64 | + }, |
| 65 | + "scalar": "x", |
| 66 | + }, |
| 67 | + } |
| 68 | + |
| 69 | + if !nestedBool(m, "a", "b", "isit") { |
| 70 | + t.Errorf("nestedBool(a.b.isit) = false, want true") |
| 71 | + } |
| 72 | + // missing top key |
| 73 | + if nestedBool(m, "missing", "x") { |
| 74 | + t.Errorf("nestedBool(missing.x) = true, want false") |
| 75 | + } |
| 76 | + // intermediate is not a map |
| 77 | + if nestedBool(m, "a", "scalar", "deeper") { |
| 78 | + t.Errorf("nestedBool(a.scalar.deeper) = true, want false") |
| 79 | + } |
| 80 | + // leaf missing |
| 81 | + if nestedBool(m, "a", "b", "missing") { |
| 82 | + t.Errorf("nestedBool(a.b.missing) = true, want false") |
| 83 | + } |
| 84 | + // leaf present but wrong type |
| 85 | + m2 := map[string]any{"flag": "true-as-string"} |
| 86 | + if nestedBool(m2, "flag") { |
| 87 | + t.Errorf("nestedBool wrong-type = true, want false") |
| 88 | + } |
| 89 | + // no path: returns whether root coerces to bool — root is map → false |
| 90 | + if nestedBool(m) { |
| 91 | + t.Errorf("nestedBool empty path on map = true, want false") |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +// TestSummariseHistoryInput_NoToolInput exercises the "tool_input |
| 96 | +// missing or wrong type" early return. |
| 97 | +func TestSummariseHistoryInput_NoToolInput(t *testing.T) { |
| 98 | + if got := summariseHistoryInput(map[string]any{}, "Bash"); got != "" { |
| 99 | + t.Errorf("summariseHistoryInput no input = %q, want \"\"", got) |
| 100 | + } |
| 101 | + if got := summariseHistoryInput(map[string]any{"tool_input": "not-a-map"}, "Bash"); got != "" { |
| 102 | + t.Errorf("summariseHistoryInput wrong-type = %q, want \"\"", got) |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +// TestSummariseHistoryInput_KnownToolPath returns the well-known |
| 107 | +// primary input field via truncateToolInputField. |
| 108 | +func TestSummariseHistoryInput_KnownToolPath(t *testing.T) { |
| 109 | + raw := map[string]any{ |
| 110 | + "tool_input": map[string]any{ |
| 111 | + "command": "echo hello", |
| 112 | + }, |
| 113 | + } |
| 114 | + if got := summariseHistoryInput(raw, "Bash"); got != "echo hello" { |
| 115 | + t.Errorf("summariseHistoryInput Bash = %q, want \"echo hello\"", got) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +// TestSummariseHistoryInput_FallbackJSON exercises the json.Marshal |
| 120 | +// fallback when the tool isn't well-known. |
| 121 | +func TestSummariseHistoryInput_FallbackJSON(t *testing.T) { |
| 122 | + raw := map[string]any{ |
| 123 | + "tool_input": map[string]any{ |
| 124 | + "foo": "bar", |
| 125 | + }, |
| 126 | + } |
| 127 | + got := summariseHistoryInput(raw, "UnknownTool") |
| 128 | + // Marshaled JSON should round-trip back something containing the key. |
| 129 | + if got == "" { |
| 130 | + t.Errorf("summariseHistoryInput fallback = \"\", want non-empty JSON") |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +// TestSummariseHistoryResponse covers each switch arm of the response |
| 135 | +// summariser: missing, string, map.output, map.is_error+error, |
| 136 | +// map.is_error+no-error, map with arbitrary keys, empty map, and the |
| 137 | +// "wrong type" default-fall-through. |
| 138 | +func TestSummariseHistoryResponse(t *testing.T) { |
| 139 | + t.Run("missing key", func(t *testing.T) { |
| 140 | + if got := summariseHistoryResponse(map[string]any{}); got != "" { |
| 141 | + t.Errorf("missing = %q, want \"\"", got) |
| 142 | + } |
| 143 | + }) |
| 144 | + t.Run("string response", func(t *testing.T) { |
| 145 | + raw := map[string]any{"tool_response": "ok"} |
| 146 | + if got := summariseHistoryResponse(raw); got != "ok" { |
| 147 | + t.Errorf("string = %q, want \"ok\"", got) |
| 148 | + } |
| 149 | + }) |
| 150 | + t.Run("string response truncated", func(t *testing.T) { |
| 151 | + long := make([]byte, historyInputMax+50) |
| 152 | + for i := range long { |
| 153 | + long[i] = 'x' |
| 154 | + } |
| 155 | + raw := map[string]any{"tool_response": string(long)} |
| 156 | + got := summariseHistoryResponse(raw) |
| 157 | + if len(got) == 0 || len(got) > historyInputMax { |
| 158 | + t.Errorf("string truncated len=%d, want <= %d and > 0", len(got), historyInputMax) |
| 159 | + } |
| 160 | + }) |
| 161 | + t.Run("map output single line", func(t *testing.T) { |
| 162 | + raw := map[string]any{"tool_response": map[string]any{"output": "hello"}} |
| 163 | + if got := summariseHistoryResponse(raw); got != "hello" { |
| 164 | + t.Errorf("map.output single-line = %q, want \"hello\"", got) |
| 165 | + } |
| 166 | + }) |
| 167 | + t.Run("map output multi-line takes first line", func(t *testing.T) { |
| 168 | + raw := map[string]any{"tool_response": map[string]any{"output": "first\nsecond\nthird"}} |
| 169 | + if got := summariseHistoryResponse(raw); got != "first" { |
| 170 | + t.Errorf("map.output multiline = %q, want \"first\"", got) |
| 171 | + } |
| 172 | + }) |
| 173 | + t.Run("map is_error with message", func(t *testing.T) { |
| 174 | + raw := map[string]any{ |
| 175 | + "tool_response": map[string]any{ |
| 176 | + "is_error": true, |
| 177 | + "error": "boom", |
| 178 | + }, |
| 179 | + } |
| 180 | + if got := summariseHistoryResponse(raw); got != "boom" { |
| 181 | + t.Errorf("map is_error+error = %q, want \"boom\"", got) |
| 182 | + } |
| 183 | + }) |
| 184 | + t.Run("map is_error with no message", func(t *testing.T) { |
| 185 | + raw := map[string]any{ |
| 186 | + "tool_response": map[string]any{ |
| 187 | + "is_error": true, |
| 188 | + }, |
| 189 | + } |
| 190 | + if got := summariseHistoryResponse(raw); got != "error" { |
| 191 | + t.Errorf("map is_error+no-error = %q, want \"error\"", got) |
| 192 | + } |
| 193 | + }) |
| 194 | + t.Run("map empty falls through to keys empty", func(t *testing.T) { |
| 195 | + raw := map[string]any{"tool_response": map[string]any{}} |
| 196 | + if got := summariseHistoryResponse(raw); got != "" { |
| 197 | + t.Errorf("empty map = %q, want \"\"", got) |
| 198 | + } |
| 199 | + }) |
| 200 | + t.Run("map arbitrary keys → bracketed list", func(t *testing.T) { |
| 201 | + raw := map[string]any{ |
| 202 | + "tool_response": map[string]any{ |
| 203 | + "foo": "x", |
| 204 | + "bar": "y", |
| 205 | + }, |
| 206 | + } |
| 207 | + got := summariseHistoryResponse(raw) |
| 208 | + // Map iteration order is random, but the wrapper format is |
| 209 | + // stable: starts with "[" and ends with "]". |
| 210 | + if len(got) < 2 || got[0] != '[' || got[len(got)-1] != ']' { |
| 211 | + t.Errorf("arbitrary keys = %q, want bracketed list", got) |
| 212 | + } |
| 213 | + }) |
| 214 | + t.Run("unsupported response type", func(t *testing.T) { |
| 215 | + raw := map[string]any{"tool_response": 42} |
| 216 | + if got := summariseHistoryResponse(raw); got != "" { |
| 217 | + t.Errorf("unsupported = %q, want \"\"", got) |
| 218 | + } |
| 219 | + }) |
| 220 | +} |
| 221 | + |
| 222 | +// TestTruncateHistory covers the trim-and-truncate helper directly. |
| 223 | +func TestTruncateHistory(t *testing.T) { |
| 224 | + if got := truncateHistory(" hello "); got != "hello" { |
| 225 | + t.Errorf("trim only = %q, want \"hello\"", got) |
| 226 | + } |
| 227 | + short := "abcdef" |
| 228 | + if got := truncateHistory(short); got != "abcdef" { |
| 229 | + t.Errorf("short pass-through = %q, want %q", got, short) |
| 230 | + } |
| 231 | + long := make([]byte, historyInputMax+10) |
| 232 | + for i := range long { |
| 233 | + long[i] = 'x' |
| 234 | + } |
| 235 | + got := truncateHistory(string(long)) |
| 236 | + if len(got) != historyInputMax { |
| 237 | + t.Errorf("truncated len = %d, want %d", len(got), historyInputMax) |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +// TestSplitIDExt and TestIDLessThanExt exercise the cursor-id parser |
| 242 | +// and comparator end-to-end including malformed inputs. |
| 243 | +func TestSplitIDExt(t *testing.T) { |
| 244 | + cases := []struct { |
| 245 | + id string |
| 246 | + wantNS int64 |
| 247 | + wantSeq uint64 |
| 248 | + }{ |
| 249 | + {"1700000000-3", 1700000000, 3}, |
| 250 | + {"42-0", 42, 0}, |
| 251 | + {"", 0, 0}, // no '-' → zeroes |
| 252 | + {"not-a-cursor", 0, 0}, // first segment unparseable, but '-' found |
| 253 | + {"123-notnum", 123, 0}, // seq unparseable |
| 254 | + } |
| 255 | + for _, c := range cases { |
| 256 | + ns, seq := splitIDExt(c.id) |
| 257 | + if ns != c.wantNS || seq != c.wantSeq { |
| 258 | + t.Errorf("splitIDExt(%q) = (%d, %d), want (%d, %d)", |
| 259 | + c.id, ns, seq, c.wantNS, c.wantSeq) |
| 260 | + } |
| 261 | + } |
| 262 | +} |
| 263 | + |
| 264 | +func TestIDLessThanExt(t *testing.T) { |
| 265 | + // older nano → less. |
| 266 | + if !idLessThanExt("100-0", "200-0") { |
| 267 | + t.Error("100-0 < 200-0 should be true") |
| 268 | + } |
| 269 | + // equal nano → seq decides. |
| 270 | + if !idLessThanExt("100-0", "100-1") { |
| 271 | + t.Error("100-0 < 100-1 should be true") |
| 272 | + } |
| 273 | + if idLessThanExt("200-0", "100-9") { |
| 274 | + t.Error("200-0 < 100-9 should be false") |
| 275 | + } |
| 276 | + // equal ids → not less. |
| 277 | + if idLessThanExt("100-1", "100-1") { |
| 278 | + t.Error("equal ids should not be less") |
| 279 | + } |
| 280 | +} |
| 281 | + |
| 282 | +// TestSynthEvent_BadJSON covers synthEvent's "json.Unmarshal failed" |
| 283 | +// branch (returns ok=false). |
| 284 | +func TestSynthEvent_BadJSON(t *testing.T) { |
| 285 | + if _, ok := synthEvent("alpha", []byte("not-json")); ok { |
| 286 | + t.Error("synthEvent should return false on invalid JSON") |
| 287 | + } |
| 288 | +} |
| 289 | + |
| 290 | +// TestSynthEvent_NoToolName covers the "tool_name missing" early return. |
| 291 | +func TestSynthEvent_NoToolName(t *testing.T) { |
| 292 | + line := []byte(`{"foo":"bar"}`) |
| 293 | + if _, ok := synthEvent("alpha", line); ok { |
| 294 | + t.Error("synthEvent should return false when tool_name is missing") |
| 295 | + } |
| 296 | +} |
| 297 | + |
| 298 | +// TestSynthEvent_Happy verifies the synthesised envelope: id is |
| 299 | +// derived from ctm_timestamp, type is tool_call, payload contains the |
| 300 | +// session+tool. |
| 301 | +func TestSynthEvent_Happy(t *testing.T) { |
| 302 | + ts := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC) |
| 303 | + line := []byte(`{ |
| 304 | + "tool_name":"Bash", |
| 305 | + "tool_input":{"command":"echo hi"}, |
| 306 | + "tool_response":{"output":"hi","is_error":false}, |
| 307 | + "ctm_timestamp":"` + ts.Format(time.RFC3339) + `" |
| 308 | + }`) |
| 309 | + ev, ok := synthEvent("alpha", line) |
| 310 | + if !ok { |
| 311 | + t.Fatal("synthEvent returned false on valid line") |
| 312 | + } |
| 313 | + if ev.Session != "alpha" { |
| 314 | + t.Errorf("Session = %q, want alpha", ev.Session) |
| 315 | + } |
| 316 | + if ev.Type != "tool_call" { |
| 317 | + t.Errorf("Type = %q, want tool_call", ev.Type) |
| 318 | + } |
| 319 | + wantID := strconv.FormatInt(ts.UnixNano(), 10) + "-0" |
| 320 | + if ev.ID != wantID { |
| 321 | + t.Errorf("ID = %q, want %q", ev.ID, wantID) |
| 322 | + } |
| 323 | +} |
0 commit comments