返回 DeepSeek-Reasonix
stats_test.go
根目录 / internal / stats / stats_test.go
1 package stats
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/filelock"
16 "reasonix/internal/provider"
17 )
18
19 func flushRecorder(t *testing.T, recorder *Recorder) {
20 t.Helper()
21 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
22 defer cancel()
23 if err := recorder.Flush(ctx); err != nil {
24 t.Fatalf("flush recorder: %v", err)
25 }
26 }
27
28 func TestRecorderWritesDailyFile(t *testing.T) {
29 dir := t.TempDir()
30 inner := &spySink{}
31 r := NewRecorder(inner, dir, "desktop")
32
33 r.Emit(usageEvent("deepseek/deepseek-v4-flash", 100, 50, 10, 20, 30, 150))
34 r.Emit(usageEvent("deepseek/deepseek-v4-pro", 200, 100, 0, 0, 0, 300))
35 r.Emit(turnEvent())
36 flushRecorder(t, r)
37
38 // The daily file must exist with three lines (2 usage + 1 turn marker).
39 files := dailyJSONLFiles(t, dir)
40 if len(files) != 1 {
41 t.Fatalf("want 1 daily file, got %d", len(files))
42 }
43 data, err := os.ReadFile(filepath.Join(dir, files[0].Name()))
44 if err != nil {
45 t.Fatalf("read file: %v", err)
46 }
47 lines := 0
48 for _, b := range data {
49 if b == '\n' {
50 lines++
51 }
52 }
53 if lines != 3 {
54 t.Fatalf("want 3 lines, got %d", lines)
55 }
56 // Forwarding must be untouched.
57 if len(inner.events) != 3 {
58 t.Fatalf("want 3 forwarded events, got %d", len(inner.events))
59 }
60 }
61
62 func TestRecorderCountsMergedProviderRequests(t *testing.T) {
63 dir := t.TempDir()
64 r := NewRecorder(&spySink{}, dir, "desktop")
65 e := usageEvent("deepseek/deepseek-v4-pro", 100, 50, 10, 0, 100, 150)
66 e.Usage.RequestCount = 2
67 r.Emit(e)
68 flushRecorder(t, r)
69
70 day := dayStart(time.Now())
71 got, err := r.writer.Query(SourceFilter{From: day, To: day})
72 if err != nil {
73 t.Fatalf("query: %v", err)
74 }
75 if got.Requests != 2 || len(got.Daily) != 1 || got.Daily[0].Requests != 2 {
76 t.Fatalf("merged requests = total %d daily %+v, want 2", got.Requests, got.Daily)
77 }
78 }
79
80 func TestRecorderCapturesGuardianUsageAndPreservesProtocolAudit(t *testing.T) {
81 dir := t.TempDir()
82 inner := &auditSpySink{}
83 r := NewRecorder(inner, dir, "desktop")
84 r.Emit(event.Event{
85 Kind: event.GuardianAssessment,
86 ModelRef: "deepseek/deepseek-v4-flash",
87 Guardian: event.GuardianResult{Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}},
88 })
89 event.RecordProtocolRecovery(r, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryRecovered})
90 flushRecorder(t, r)
91
92 day := dayStart(time.Now())
93 got, err := r.writer.Query(SourceFilter{From: day, To: day})
94 if err != nil {
95 t.Fatalf("query: %v", err)
96 }
97 if got.Tokens != 15 || got.TopModel != "deepseek/deepseek-v4-flash" {
98 t.Fatalf("guardian usage = %+v", got)
99 }
100 if len(inner.protocol) != 1 || inner.protocol[0].Kind != event.ProtocolRecoveryMissingReasoningRetryRecovered {
101 t.Fatalf("protocol audit was not forwarded: %+v", inner.protocol)
102 }
103 }
104
105 func TestRecorderSkipsZeroUsage(t *testing.T) {
106 dir := t.TempDir()
107 r := NewRecorder(&spySink{}, dir, "desktop")
108 r.Emit(usageEvent("m", 0, 0, 0, 0, 0, 0)) // TotalTokens <= 0 -> skipped
109 r.Emit(turnEvent())
110 flushRecorder(t, r)
111 files := dailyJSONLFiles(t, dir)
112 if len(files) != 1 {
113 t.Fatalf("want 1 file (turn only), got %d", len(files))
114 }
115 }
116
117 func TestRecorderPersistsRequestOnlyFailureWithoutForwardingReceipt(t *testing.T) {
118 dir := t.TempDir()
119 inner := &spySink{}
120 r := NewRecorder(inner, dir, "desktop")
121 r.Emit(event.Event{
122 Kind: event.Usage,
123 ModelRef: "deepseek/deepseek-v4-pro",
124 Usage: &provider.Usage{RequestCount: 3},
125 })
126 flushRecorder(t, r)
127
128 day := dayStart(time.Now())
129 got, err := r.writer.Query(SourceFilter{From: day, To: day})
130 if err != nil {
131 t.Fatalf("query: %v", err)
132 }
133 if got.Requests != 3 || got.Tokens != 0 || got.ActiveDays != 1 {
134 t.Fatalf("request-only totals = %+v, want requests=3 tokens=0 activeDays=1", got)
135 }
136 if len(got.Models) != 0 || len(got.Providers) != 0 {
137 t.Fatalf("request-only failure created token distribution rows: models=%+v providers=%+v", got.Models, got.Providers)
138 }
139 if len(inner.events) != 0 {
140 t.Fatalf("request-only usage forwarded %d zero-token receipts", len(inner.events))
141 }
142 }
143
144 func TestRecorderNeverWaitsForStatsFileLock(t *testing.T) {
145 dir := t.TempDir()
146 release, err := filelock.Acquire(context.Background(), filepath.Join(dir, ".append.lock"))
147 if err != nil {
148 t.Fatalf("hold stats lock: %v", err)
149 }
150 locked := true
151 defer func() {
152 if locked {
153 release()
154 }
155 }()
156
157 inner := &spySink{}
158 recorder := NewRecorder(inner, dir, "desktop")
159 emitted := make(chan struct{})
160 go func() {
161 recorder.Emit(usageEvent("deepseek/model", 10, 4, 0, 0, 10, 14))
162 close(emitted)
163 }()
164
165 select {
166 case <-emitted:
167 case <-time.After(250 * time.Millisecond):
168 t.Fatal("stats file lock blocked event forwarding")
169 }
170 if len(inner.events) != 1 {
171 t.Fatalf("forwarded events = %d, want 1", len(inner.events))
172 }
173
174 release()
175 locked = false
176 flushRecorder(t, recorder)
177 result, err := recorder.writer.Query(SourceFilter{From: dayStart(time.Now()), To: dayStart(time.Now())})
178 if err != nil {
179 t.Fatal(err)
180 }
181 if result.Tokens != 14 {
182 t.Fatalf("tokens after lock release = %d, want 14", result.Tokens)
183 }
184 }
185
186 func TestRecorderDisabledOnEmptyDir(t *testing.T) {
187 r := NewRecorder(&spySink{}, "", "desktop")
188 r.Emit(usageEvent("m", 1, 1, 0, 0, 0, 2))
189 r.Emit(turnEvent())
190 // No panic, nothing written — query on empty dir returns zeros.
191 got, err := r.writer.Query(SourceFilter{From: time.Now().Add(-24 * time.Hour), To: time.Now()})
192 if err != nil {
193 t.Fatalf("query: %v", err)
194 }
195 if got.Tokens != 0 || got.Turns != 0 {
196 t.Fatalf("want zero stats, got %+v", got)
197 }
198 }
199
200 func TestQueryAggregates(t *testing.T) {
201 dir := t.TempDir()
202 w := NewWriter(dir)
203 now := time.Now()
204 day := dayStart(now)
205
206 // Two usage rows + one turn on "today", one usage row yesterday.
207 w.Append(record{Timestamp: day.Add(1 * time.Hour), ModelRef: "deepseek/deepseek-v4-flash", Source: "desktop", Total: 100, Prompt: 60, Completion: 40, CacheHit: 10, CacheMiss: 50})
208 w.Append(record{Timestamp: day.Add(2 * time.Hour), ModelRef: "deepseek/deepseek-v4-pro", Source: "desktop", Total: 200, Prompt: 100, Completion: 100})
209 w.Append(record{Timestamp: day.Add(3 * time.Hour), Source: "desktop", Turn: true})
210 w.Append(record{Timestamp: day.AddDate(0, 0, -1), ModelRef: "zhipu/glm-5.2", Source: "cli", Total: 300})
211
212 got, err := w.Query(SourceFilter{From: day.AddDate(0, 0, -1), To: day})
213 if err != nil {
214 t.Fatalf("query: %v", err)
215 }
216 if got.Tokens != 600 {
217 t.Fatalf("tokens: want 600, got %d", got.Tokens)
218 }
219 if got.Requests != 3 {
220 t.Fatalf("requests: want 3, got %d", got.Requests)
221 }
222 if got.Turns != 1 {
223 t.Fatalf("turns: want 1, got %d", got.Turns)
224 }
225 if got.CacheHit != 10 || got.CacheMiss != 50 {
226 t.Fatalf("cache: want hit=10 miss=50, got hit=%d miss=%d", got.CacheHit, got.CacheMiss)
227 }
228 if got.ActiveDays != 2 {
229 t.Fatalf("active days: want 2, got %d", got.ActiveDays)
230 }
231 if got.TopModel != "zhipu/glm-5.2" {
232 t.Fatalf("top model: want zhipu/glm-5.2 (300 tokens), got %q", got.TopModel)
233 }
234 if len(got.Daily) != 2 {
235 t.Fatalf("daily series: want 2 entries, got %d", len(got.Daily))
236 }
237 // daysInRange walks from -> to, so Daily[0] is yesterday (glm, no cache)
238 // and Daily[1] is today (flash hit=10 miss=50 + pro no cache).
239 if got.Daily[0].CacheHit != 0 || got.Daily[0].CacheMiss != 0 {
240 t.Fatalf("yesterday cache: want 0/0, got hit=%d miss=%d", got.Daily[0].CacheHit, got.Daily[0].CacheMiss)
241 }
242 if got.Daily[1].CacheHit != 10 || got.Daily[1].CacheMiss != 50 {
243 t.Fatalf("today cache: want hit=10 miss=50, got hit=%d miss=%d", got.Daily[1].CacheHit, got.Daily[1].CacheMiss)
244 }
245 if len(got.Models) != 3 {
246 t.Fatalf("models: want 3, got %d", len(got.Models))
247 }
248 // Providers: deepseek (100+200=300), zhipu (300) — tied, so find by name.
249 found := map[string]int64{}
250 for _, p := range got.Providers {
251 found[p.Provider] = p.Tokens
252 }
253 if found["deepseek"] != 300 || found["zhipu"] != 300 || len(found) != 2 {
254 t.Fatalf("providers: want deepseek=300 zhipu=300, got %+v", got.Providers)
255 }
256 // Percent on models sums to ~100 across 3 models: 200/600=33.3, 100/600=16.7, 300/600=50
257 if got.Models[0].Percent <= 0 || got.Models[0].Percent > 100 {
258 t.Fatalf("model percent out of range: %+v", got.Models[0])
259 }
260 }
261
262 func TestQuerySourceFilter(t *testing.T) {
263 dir := t.TempDir()
264 w := NewWriter(dir)
265 now := time.Now()
266 day := dayStart(now)
267
268 w.Append(record{Timestamp: day, ModelRef: "m1", Source: "desktop", Total: 100})
269 w.Append(record{Timestamp: day, ModelRef: "m2", Source: "cli", Total: 50})
270
271 got, err := w.Query(SourceFilter{From: day, To: day, Source: "cli"})
272 if err != nil {
273 t.Fatalf("query: %v", err)
274 }
275 if got.Tokens != 50 {
276 t.Fatalf("cli-filtered tokens: want 50, got %d", got.Tokens)
277 }
278 if len(got.Models) != 1 || got.Models[0].Model != "m2" {
279 t.Fatalf("cli-filtered models: want [m2], got %+v", got.Models)
280 }
281 }
282
283 func TestQueryEmptyRange(t *testing.T) {
284 w := NewWriter(t.TempDir())
285 now := time.Now()
286 got, err := w.Query(SourceFilter{From: now, To: now.Add(-24 * time.Hour)})
287 if err != nil {
288 t.Fatalf("query: %v", err)
289 }
290 if got.Tokens != 0 || got.ActiveDays != 0 || len(got.Daily) != 0 {
291 t.Fatalf("want empty stats, got %+v", got)
292 }
293 }
294
295 func TestQueryDisabledWriterReturnsArrayContract(t *testing.T) {
296 now := time.Now()
297 got, err := NewWriter("").Query(SourceFilter{From: now, To: now})
298 if err != nil {
299 t.Fatalf("query: %v", err)
300 }
301 if got.Daily == nil || got.Models == nil || got.Providers == nil {
302 t.Fatalf("array contract contains nil slices: %+v", got)
303 }
304 b, err := json.Marshal(got)
305 if err != nil {
306 t.Fatalf("marshal: %v", err)
307 }
308 var wire struct {
309 Daily json.RawMessage `json:"daily"`
310 Models json.RawMessage `json:"models"`
311 Providers json.RawMessage `json:"providers"`
312 }
313 if err := json.Unmarshal(b, &wire); err != nil {
314 t.Fatalf("unmarshal: %v", err)
315 }
316 if string(wire.Daily) != "[]" || string(wire.Models) != "[]" || string(wire.Providers) != "[]" {
317 t.Fatalf("empty arrays serialized incorrectly: %s", b)
318 }
319 }
320
321 func TestQueryTopProviderAggregatesAcrossModels(t *testing.T) {
322 dir := t.TempDir()
323 w := NewWriter(dir)
324 day := dayStart(time.Now())
325 for _, rec := range []record{
326 {Timestamp: day, ModelRef: "provider-a/model-1", Total: 60},
327 {Timestamp: day, ModelRef: "provider-a/model-2", Total: 60},
328 {Timestamp: day, ModelRef: "provider-b/model-1", Total: 100},
329 } {
330 if err := w.Append(rec); err != nil {
331 t.Fatalf("append: %v", err)
332 }
333 }
334 got, err := w.Query(SourceFilter{From: day, To: day})
335 if err != nil {
336 t.Fatalf("query: %v", err)
337 }
338 if got.TopModel != "provider-b/model-1" {
339 t.Fatalf("top model = %q, want provider-b/model-1", got.TopModel)
340 }
341 if got.TopProvider != "provider-a" {
342 t.Fatalf("top provider = %q, want provider-a", got.TopProvider)
343 }
344 }
345
346 func TestDecodeRecordsSkipsMalformed(t *testing.T) {
347 // A torn or hand-edited line must not fail the whole day's read: it is
348 // skipped and the surrounding valid records still come through.
349 good := `{"ts":"2026-08-02T10:00:00+08:00","total":100}` + "\n"
350 bad := `{"ts":"2026-08-02T10:00:00+08:00","total":` + "\n" // truncated JSON
351 recs, err := decodeRecords(strings.NewReader(good + bad + bad + good))
352 if err != nil {
353 t.Fatalf("decode: %v", err)
354 }
355 if len(recs) != 2 {
356 t.Fatalf("want 2 valid records, got %d", len(recs))
357 }
358 for _, r := range recs {
359 if r.Total != 100 {
360 t.Fatalf("record total: want 100, got %d", r.Total)
361 }
362 }
363 }
364
365 func TestAppendRepairsTornTrailingRecord(t *testing.T) {
366 dir := t.TempDir()
367 w := NewWriter(dir)
368 now := time.Now()
369 path := filepath.Join(dir, now.Format(dayLayout)+".jsonl")
370 if err := os.WriteFile(path, []byte(`{"ts":"2026-08-02T10:00:00+08:00","total":`), 0o600); err != nil {
371 t.Fatalf("seed torn record: %v", err)
372 }
373 if err := w.Append(record{Timestamp: now, ModelRef: "deepseek/deepseek-v4-flash", Total: 42}); err != nil {
374 t.Fatalf("append after torn record: %v", err)
375 }
376 recs, err := readDaily(dir, now.Format(dayLayout))
377 if err != nil {
378 t.Fatalf("read daily: %v", err)
379 }
380 if len(recs) != 1 || recs[0].Total != 42 || recs[0].ModelRef != "deepseek/deepseek-v4-flash" {
381 t.Fatalf("recovered records = %+v", recs)
382 }
383 }
384
385 func TestConcurrentWritersAppendWholeRecords(t *testing.T) {
386 dir := t.TempDir()
387 now := time.Now()
388 const writers = 8
389 const perWriter = 40
390 var wg sync.WaitGroup
391 for i := 0; i < writers; i++ {
392 wg.Add(1)
393 go func(model int) {
394 defer wg.Done()
395 w := NewWriter(dir)
396 for j := 0; j < perWriter; j++ {
397 if err := w.Append(record{Timestamp: now, ModelRef: fmt.Sprintf("provider/model-%d", model), Total: 1}); err != nil {
398 t.Errorf("append: %v", err)
399 return
400 }
401 }
402 }(i)
403 }
404 wg.Wait()
405
406 recs, err := readDaily(dir, now.Format(dayLayout))
407 if err != nil {
408 t.Fatal(err)
409 }
410 if len(recs) != writers*perWriter {
411 t.Fatalf("records = %d, want %d", len(recs), writers*perWriter)
412 }
413 }
414
415 // TestDailyTokensWireKeys guards the JSON contract the desktop panel reads:
416 // the hand-written frontend types use camelCase (byModel/byProvider), so a
417 // snake_case tag here silently yields undefined fields in DailyTrend and
418 // crashed the panel with "Cannot convert undefined or null to object".
419 func TestDailyTokensWireKeys(t *testing.T) {
420 d := DailyTokens{Day: "2026-08-02", Total: 150, ByModel: map[string]int64{"deepseek/x": 150}, Requests: 2, Turns: 1, CacheHit: 10, CacheMiss: 50}
421 b, err := json.Marshal(d)
422 if err != nil {
423 t.Fatalf("marshal: %v", err)
424 }
425 var keys map[string]any
426 if err := json.Unmarshal(b, &keys); err != nil {
427 t.Fatalf("unmarshal: %v", err)
428 }
429 for _, want := range []string{"day", "total", "byModel", "byProvider", "requests", "turns", "cacheHit", "cacheMiss"} {
430 if _, ok := keys[want]; !ok {
431 t.Fatalf("wire key %q missing from %s", want, b)
432 }
433 }
434 for _, bad := range []string{"by_model", "by_provider", "cache_hit", "cache_miss"} {
435 if _, ok := keys[bad]; ok {
436 t.Fatalf("legacy snake_case key %q still present in %s", bad, b)
437 }
438 }
439 }
440
441 func TestProviderSplit(t *testing.T) {
442 if got := providerOf("deepseek/deepseek-v4-flash"); got != "deepseek" {
443 t.Fatalf("provider: want deepseek, got %q", got)
444 }
445 if got := providerOf("bare-model"); got != "default" {
446 t.Fatalf("bare model: want default, got %q", got)
447 }
448 }
449
450 // --- test helpers ---
451
452 func dailyJSONLFiles(t *testing.T, dir string) []os.DirEntry {
453 t.Helper()
454 entries, err := os.ReadDir(dir)
455 if err != nil {
456 t.Fatalf("read dir: %v", err)
457 }
458 files := make([]os.DirEntry, 0, len(entries))
459 for _, entry := range entries {
460 if strings.HasSuffix(entry.Name(), ".jsonl") {
461 files = append(files, entry)
462 }
463 }
464 return files
465 }
466
467 type spySink struct{ events []event.Event }
468
469 func (s *spySink) Emit(e event.Event) { s.events = append(s.events, e) }
470
471 type auditSpySink struct {
472 events []event.Event
473 protocol []event.ProtocolRecoveryAudit
474 }
475
476 func (s *auditSpySink) Emit(e event.Event) { s.events = append(s.events, e) }
477 func (s *auditSpySink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
478 s.protocol = append(s.protocol, a)
479 }
480
481 func usageEvent(model string, prompt, completion, reasoning, hit, miss, total int) event.Event {
482 return event.Event{
483 Kind: event.Usage,
484 ModelRef: model,
485 Usage: &provider.Usage{
486 PromptTokens: prompt,
487 CompletionTokens: completion,
488 ReasoningTokens: reasoning,
489 CacheHitTokens: hit,
490 CacheMissTokens: miss,
491 TotalTokens: total,
492 },
493 }
494 }
495
496 func turnEvent() event.Event { return event.Event{Kind: event.TurnDone} }
497
497 lines GO