package validator import ( "bufio" "encoding/json" "os" "path/filepath" "sync" "testing" ) func TestSessionLogger_AppendsSchemaTaggedRows(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sessions.jsonl") logger := NewSessionLogger(path) if logger == nil { t.Fatal("expected non-nil logger for a non-empty path") } rec := SessionRecord{ SessionID: "trace-1", Kind: "fill", Model: "qwen3.5:latest", Provider: "ollama", Prompt: "produce a fill artifact", Iterations: 1, FinalVerdict: "accepted", } logger.Append(rec) rows := readJSONL(t, path) if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } if rows[0]["schema"] != SessionRecordSchema { t.Errorf("schema autopopulation: %v", rows[0]["schema"]) } if rows[0]["session_id"] != "trace-1" { t.Errorf("session_id round-trip: %v", rows[0]["session_id"]) } if rows[0]["timestamp"] == "" || rows[0]["timestamp"] == nil { t.Errorf("timestamp autopopulation: %v", rows[0]["timestamp"]) } if rows[0]["daemon"] != "validatord" { t.Errorf("daemon autopopulation: %v", rows[0]["daemon"]) } } func TestSessionLogger_NilIsNoOp(t *testing.T) { var logger *SessionLogger // nil logger.Append(SessionRecord{SessionID: "trace-1"}) // No panic, no error — the test passing IS the assertion. } func TestSessionLogger_EmptyPathReturnsNil(t *testing.T) { if NewSessionLogger("") != nil { t.Error("empty path should disable logging (nil logger)") } } // TestSessionLogger_ConcurrentAppends locks the per-row atomicity: N // goroutines each appending must produce N well-formed JSONL rows // with no torn writes. Mutex lives at the logger level, not OS-level, // so this also documents that concurrent logger instances would NOT // be safe (one logger per daemon — that's fine). func TestSessionLogger_ConcurrentAppends(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sessions.jsonl") logger := NewSessionLogger(path) const N = 64 var wg sync.WaitGroup for i := 0; i < N; i++ { wg.Add(1) go func(i int) { defer wg.Done() logger.Append(SessionRecord{ SessionID: "trace-" + string(rune('a'+i%26)), Kind: "fill", FinalVerdict: "accepted", Iterations: i, }) }(i) } wg.Wait() rows := readJSONL(t, path) if len(rows) != N { t.Fatalf("expected %d rows after concurrent appends, got %d", N, len(rows)) } // Every row must be parseable JSON — a torn write would throw // a decode error, which readJSONL t.Fatal's on. } func TestBuildSessionRecord_SuccessPathCapturesArtifact(t *testing.T) { req := IterateRequest{ TraceID: "trace-x", Kind: "fill", Prompt: "produce fill", Provider: "ollama", Model: "qwen3.5:latest", MaxIterations: 3, } resp := &IterateResponse{ Artifact: map[string]any{"fills": []any{ map[string]any{"candidate_id": "W-1"}, }}, Validation: Report{}, Iterations: 1, History: []IterateAttempt{{ Iteration: 0, Raw: `{"fills":[{"candidate_id":"W-1"}]}`, Status: AttemptStatus{Kind: "accepted"}, SpanID: "span-0", }}, TraceID: "trace-x", } roster := func(_ map[string]any) *bool { t := true return &t } rec := BuildSessionRecord(req, resp, nil, nil, roster, 1234) if rec.FinalVerdict != "accepted" || rec.Iterations != 1 { t.Errorf("verdict/iterations: %+v", rec) } if rec.GroundedInRoster == nil || !*rec.GroundedInRoster { t.Errorf("grounded_in_roster: expected true, got %v", rec.GroundedInRoster) } if rec.Artifact == nil { t.Errorf("artifact missing on success path") } if len(rec.Attempts) != 1 || rec.Attempts[0].SpanID != "span-0" { t.Errorf("attempts shape: %+v", rec.Attempts) } if rec.SessionID != "trace-x" { t.Errorf("session_id: %s", rec.SessionID) } if rec.DurationMs != 1234 { t.Errorf("duration: %d", rec.DurationMs) } } func TestBuildSessionRecord_FailurePathOmitsArtifact(t *testing.T) { req := IterateRequest{ TraceID: "trace-y", Kind: "fill", Provider: "ollama", Model: "qwen", MaxIterations: 3, } fail := &IterateFailure{ Error: "max iterations reached (3)", Iterations: 3, History: []IterateAttempt{ {Iteration: 0, Status: AttemptStatus{Kind: "no_json"}}, {Iteration: 1, Status: AttemptStatus{Kind: "validation_failed", Error: "phantom W-X"}}, {Iteration: 2, Status: AttemptStatus{Kind: "validation_failed", Error: "phantom W-Y"}}, }, TraceID: "trace-y", } rec := BuildSessionRecord(req, nil, fail, nil, nil, 8765) if rec.FinalVerdict != "max_iter_exhausted" { t.Errorf("final_verdict: %s", rec.FinalVerdict) } if rec.Artifact != nil { t.Errorf("artifact must be omitted on failure path: %v", rec.Artifact) } if rec.GroundedInRoster != nil { t.Errorf("grounded_in_roster should be nil on failure: %v", *rec.GroundedInRoster) } if len(rec.Attempts) != 3 || rec.Attempts[2].VerdictKind != "validation_failed" { t.Errorf("attempts: %+v", rec.Attempts) } } func TestBuildSessionRecord_InfraErrorEmitsRow(t *testing.T) { // An infrastructure failure (e.g. chat hop crashed) must STILL // produce a session row — otherwise debugging gets harder // (no log → operator can't see it failed at all). req := IterateRequest{TraceID: "trace-z", Kind: "fill"} rec := BuildSessionRecord(req, nil, nil, errExample{"chat refused"}, nil, 50) if rec.FinalVerdict != "infra_error" { t.Errorf("verdict: %s", rec.FinalVerdict) } if len(rec.Attempts) != 1 || rec.Attempts[0].VerdictKind != "infra_error" { t.Errorf("attempts: %+v", rec.Attempts) } if rec.Attempts[0].Error == "" { t.Errorf("error must be carried") } } // ─── Helpers ─────────────────────────────────────────────────── type errExample struct{ msg string } func (e errExample) Error() string { return e.msg } func readJSONL(t *testing.T, path string) []map[string]any { t.Helper() f, err := os.Open(path) if err != nil { t.Fatalf("open: %v", err) } defer f.Close() var out []map[string]any sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, 1<<16), 1<<24) for sc.Scan() { line := sc.Bytes() if len(line) == 0 { continue } var row map[string]any if err := json.Unmarshal(line, &row); err != nil { t.Fatalf("parse line %q: %v", line, err) } out = append(out, row) } if err := sc.Err(); err != nil { t.Fatalf("scan: %v", err) } return out }