Skip to content

Commit 1e68493

Browse files
committed
tests: add replay harness report generator and unit tests
Implement DiffReport generation with run metadata, per-case results, and aggregate summary statistics including false-positive rate calculation. Supports JSON serialization and file output via write_report. 21 unit tests cover: ReportMetadata defaults, CaseResult fields, ReportSummary, false-positive rate computation (edge cases for no-passing, all-pass-no-diffs, mixed, failing-only), generate_report with empty/all-pass/all-fail/mixed scenarios, report_to_dict JSON round-trip, and write_report file creation. Updates #89 RELEASE NOTES: NONE
1 parent f699f28 commit 1e68493

2 files changed

Lines changed: 370 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#
2+
# Tencent is pleased to support the open source community by making trpc-agent-python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# trpc-agent-python is licensed under the Apache License Version 2.0.
7+
#
8+
"""Diff report generation for replay consistency testing.
9+
10+
Produces a ``session_memory_summary_diff_report.json`` that documents
11+
every discrepancy across backends with field-level precision.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
import time
18+
import uuid
19+
from pathlib import Path
20+
from typing import Any
21+
from typing import Optional
22+
23+
from pydantic import BaseModel
24+
from pydantic import Field
25+
26+
from ._comparator import DiffEntry
27+
28+
29+
class ReportMetadata(BaseModel):
30+
"""Run-level metadata for the diff report."""
31+
32+
run_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
33+
"""Unique identifier for this report run."""
34+
35+
timestamp: str = Field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
36+
"""ISO-8601 timestamp of report generation."""
37+
38+
backends: list[str] = Field(default_factory=list)
39+
"""Backend labels that were tested (e.g. ``["in_memory", "sql"]``)."""
40+
41+
42+
class CaseResult(BaseModel):
43+
"""Per-case summary in the report."""
44+
45+
case_id: str
46+
"""Identifier of the replay case."""
47+
48+
description: str = ""
49+
"""Human-readable description of the case."""
50+
51+
status: str = "pass"
52+
"""Overall status: ``"pass"``, ``"fail"``, or ``"error"``."""
53+
54+
diffs: list[DiffEntry] = Field(default_factory=list)
55+
"""All diffs found for this case."""
56+
57+
58+
class ReportSummary(BaseModel):
59+
"""Aggregate statistics for the report."""
60+
61+
total: int = 0
62+
"""Total number of cases executed."""
63+
64+
passed: int = 0
65+
"""Number of cases that passed."""
66+
67+
failed: int = 0
68+
"""Number of cases that failed."""
69+
70+
error: int = 0
71+
"""Number of cases that errored during execution."""
72+
73+
false_positive_rate: float = 0.0
74+
"""False-positive rate (fraction of passing cases that had allowed diffs)."""
75+
76+
77+
class DiffReport(BaseModel):
78+
"""Top-level diff report model."""
79+
80+
metadata: ReportMetadata = Field(default_factory=ReportMetadata)
81+
"""Run metadata."""
82+
83+
results: list[CaseResult] = Field(default_factory=list)
84+
"""Per-case results."""
85+
86+
summary: ReportSummary = Field(default_factory=ReportSummary)
87+
"""Aggregate statistics."""
88+
89+
90+
def _compute_false_positive_rate(results: list[CaseResult]) -> float:
91+
"""Compute false-positive rate for passing cases."""
92+
passing = [r for r in results if r.status == "pass"]
93+
if not passing:
94+
return 0.0
95+
false_positive_count = sum(1 for r in passing if r.diffs)
96+
return false_positive_count / len(passing)
97+
98+
99+
def generate_report(
100+
results: list[CaseResult],
101+
backends: Optional[list[str]] = None,
102+
) -> DiffReport:
103+
"""Generate a ``DiffReport`` from a list of ``CaseResult`` items.
104+
105+
Args:
106+
results: Per-case comparison results.
107+
backends: List of backend labels tested.
108+
109+
Returns:
110+
A fully populated ``DiffReport``.
111+
"""
112+
if backends is None:
113+
backends = []
114+
115+
metadata = ReportMetadata(backends=backends)
116+
117+
passed = sum(1 for r in results if r.status == "pass")
118+
failed = sum(1 for r in results if r.status == "fail")
119+
error = sum(1 for r in results if r.status == "error")
120+
fps_rate = _compute_false_positive_rate(results)
121+
122+
report_summary = ReportSummary(
123+
total=len(results),
124+
passed=passed,
125+
failed=failed,
126+
error=error,
127+
false_positive_rate=fps_rate,
128+
)
129+
130+
return DiffReport(metadata=metadata, results=results, summary=report_summary)
131+
132+
133+
def write_report(report: DiffReport, path: Path) -> None:
134+
"""Serialize *report* to a JSON file at *path*.
135+
136+
Args:
137+
report: The report to write.
138+
path: Destination file path.
139+
"""
140+
with open(path, "w", encoding="utf-8") as fh:
141+
fh.write(report.model_dump_json(indent=2))
142+
143+
144+
def report_to_dict(report: DiffReport) -> dict[str, Any]:
145+
"""Convert a ``DiffReport`` to a plain dictionary.
146+
147+
Useful for programmatic inspection in tests.
148+
"""
149+
return json.loads(report.model_dump_json())
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#
2+
# Tencent is pleased to support the open source community by making trpc-agent-python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# trpc-agent-python is licensed under the Apache License Version 2.0.
7+
#
8+
"""Unit tests for the replay harness report generator."""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
import tempfile
14+
from pathlib import Path
15+
16+
from tests.sessions.replay_harness._comparator import DiffEntry
17+
from tests.sessions.replay_harness._report import (
18+
CaseResult,
19+
DiffReport,
20+
ReportMetadata,
21+
ReportSummary,
22+
_compute_false_positive_rate,
23+
generate_report,
24+
report_to_dict,
25+
write_report,
26+
)
27+
28+
29+
BP = ("in_memory", "sql")
30+
31+
32+
def _case(case_id, status="pass", diffs=None):
33+
return CaseResult(
34+
case_id=case_id,
35+
description=f"Case {case_id}",
36+
status=status,
37+
diffs=diffs or [],
38+
)
39+
40+
41+
def _diff(field_path="events[0].text", value_a="a", value_b="b", allowed=False):
42+
return DiffEntry(
43+
backend_pair=BP,
44+
category="events",
45+
session_id="s-1",
46+
event_index=0,
47+
field_path=field_path,
48+
value_a=value_a,
49+
value_b=value_b,
50+
allowed=allowed,
51+
)
52+
53+
54+
# ── ReportMetadata ─────────────────────────────────────────────────────
55+
56+
57+
class TestReportMetadata:
58+
59+
def test_default_run_id_is_non_empty(self):
60+
m = ReportMetadata()
61+
assert m.run_id
62+
63+
def test_default_timestamp_is_non_empty(self):
64+
m = ReportMetadata()
65+
assert m.timestamp
66+
assert "T" in m.timestamp
67+
68+
def test_backends_recorded(self):
69+
m = ReportMetadata(backends=["in_memory", "sql"])
70+
assert m.backends == ["in_memory", "sql"]
71+
72+
73+
# ── CaseResult ─────────────────────────────────────────────────────────
74+
75+
76+
class TestCaseResult:
77+
78+
def test_default_status_is_pass(self):
79+
r = CaseResult(case_id="01")
80+
assert r.status == "pass"
81+
82+
def test_explicit_status(self):
83+
r = CaseResult(case_id="01", status="fail")
84+
assert r.status == "fail"
85+
86+
87+
# ── ReportSummary ──────────────────────────────────────────────────────
88+
89+
90+
class TestReportSummary:
91+
92+
def test_defaults_are_zero(self):
93+
s = ReportSummary()
94+
assert s.total == 0
95+
assert s.passed == 0
96+
assert s.failed == 0
97+
98+
def test_passed_failed_tracked(self):
99+
s = ReportSummary(total=10, passed=8, failed=2)
100+
assert s.total == 10
101+
assert s.passed == 8
102+
assert s.failed == 2
103+
104+
105+
# ── _compute_false_positive_rate ───────────────────────────────────────
106+
107+
108+
class TestFalsePositiveRate:
109+
110+
def test_zero_when_no_passing(self):
111+
results = [_case("01", status="fail")]
112+
assert _compute_false_positive_rate(results) == 0.0
113+
114+
def test_zero_when_all_pass_no_diffs(self):
115+
results = [_case("01", status="pass"), _case("02", status="pass")]
116+
assert _compute_false_positive_rate(results) == 0.0
117+
118+
def test_half_when_half_have_allowed_diffs(self):
119+
results = [
120+
_case("01", status="pass"),
121+
_case("02", status="pass", diffs=[_diff(allowed=True)]),
122+
]
123+
assert _compute_false_positive_rate(results) == 0.5
124+
125+
def test_ignores_failing_cases(self):
126+
results = [
127+
_case("01", status="pass", diffs=[_diff(allowed=True)]),
128+
_case("02", status="fail", diffs=[_diff()]),
129+
]
130+
assert _compute_false_positive_rate(results) == 1.0
131+
132+
133+
# ── generate_report ────────────────────────────────────────────────────
134+
135+
136+
class TestGenerateReport:
137+
138+
def test_empty_results(self):
139+
report = generate_report([])
140+
assert isinstance(report, DiffReport)
141+
assert report.summary.total == 0
142+
143+
def test_all_pass(self):
144+
results = [_case("01"), _case("02")]
145+
report = generate_report(results, backends=["in_memory", "sql"])
146+
assert report.summary.total == 2
147+
assert report.summary.passed == 2
148+
assert report.summary.failed == 0
149+
150+
def test_all_fail(self):
151+
results = [_case("01", status="fail"), _case("02", status="fail")]
152+
report = generate_report(results)
153+
assert report.summary.passed == 0
154+
assert report.summary.failed == 2
155+
156+
def test_mixed_statuses(self):
157+
results = [
158+
_case("01", status="pass"),
159+
_case("02", status="fail"),
160+
_case("03", status="error"),
161+
]
162+
report = generate_report(results)
163+
assert report.summary.passed == 1
164+
assert report.summary.failed == 1
165+
assert report.summary.error == 1
166+
167+
def test_metadata_present(self):
168+
report = generate_report([], backends=["in_memory", "sql"])
169+
assert report.metadata.run_id
170+
assert report.metadata.timestamp
171+
assert report.metadata.backends == ["in_memory", "sql"]
172+
173+
def test_summary_counts_accurate(self):
174+
results = [_case("01"), _case("02", status="fail")]
175+
report = generate_report(results)
176+
assert report.summary.total == 2
177+
assert report.summary.passed == 1
178+
assert report.summary.failed == 1
179+
180+
def test_diff_entries_in_results(self):
181+
results = [_case("01", diffs=[_diff()])]
182+
report = generate_report(results)
183+
assert len(report.results[0].diffs) == 1
184+
assert report.results[0].diffs[0].field_path == "events[0].text"
185+
186+
187+
# ── report_to_dict ─────────────────────────────────────────────────────
188+
189+
190+
class TestReportSerialization:
191+
192+
def test_json_serializable(self):
193+
report = generate_report([_case("01")], backends=["in_memory"])
194+
d = report_to_dict(report)
195+
assert isinstance(d, dict)
196+
assert d["metadata"]["run_id"] == report.metadata.run_id
197+
assert d["summary"]["total"] == 1
198+
199+
def test_round_trip(self):
200+
report = generate_report([_case("01", diffs=[_diff()])], backends=["in_memory", "sql"])
201+
d = report_to_dict(report)
202+
assert d["results"][0]["diffs"][0]["field_path"] == "events[0].text"
203+
204+
205+
# ── write_report ───────────────────────────────────────────────────────
206+
207+
208+
class TestWriteReport:
209+
210+
def test_write_report_creates_file(self):
211+
report = generate_report([_case("01")], backends=["in_memory"])
212+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
213+
tmp_path = Path(tmp.name)
214+
try:
215+
write_report(report, tmp_path)
216+
assert tmp_path.exists()
217+
with open(tmp_path, "r", encoding="utf-8") as fh:
218+
data = json.load(fh)
219+
assert data["metadata"]["run_id"] == report.metadata.run_id
220+
finally:
221+
tmp_path.unlink(missing_ok=True)

0 commit comments

Comments
 (0)