22
33from __future__ import annotations
44
5+ import csv
56import json
67import statistics
78from dataclasses import dataclass , field
89from pathlib import Path
9- from typing import TYPE_CHECKING
10+ from typing import TYPE_CHECKING , Any
1011from xml .etree import ElementTree as ET
1112
1213from agentunit .reporting .html import render_html_report
1920 from agentunit .core .trace import TraceLog
2021
2122
23+ # -------------------------
24+ # Data Models
25+ # -------------------------
26+
27+
2228@dataclass (slots = True )
2329class ScenarioRun :
2430 scenario_name : str
@@ -68,6 +74,29 @@ def to_dict(self) -> dict[str, object]:
6874 }
6975
7076
77+ # -------------------------
78+ # Helpers
79+ # -------------------------
80+
81+
82+ def _flatten_metrics (metrics : dict [str , Any ], prefix : str = "metric" ) -> dict [str , Any ]:
83+ flat : dict [str , Any ] = {}
84+
85+ for key , value in metrics .items ():
86+ if isinstance (value , dict ):
87+ for inner_key , inner_value in value .items ():
88+ flat [f"{ prefix } _{ key } _{ inner_key } " ] = inner_value
89+ else :
90+ flat [f"{ prefix } _{ key } " ] = value
91+
92+ return flat
93+
94+
95+ # -------------------------
96+ # Suite Result
97+ # -------------------------
98+
99+
71100@dataclass (slots = True )
72101class SuiteResult :
73102 scenarios : list [ScenarioResult ]
@@ -84,7 +113,10 @@ def to_dict(self) -> dict[str, object]:
84113 def to_json (self , path : str | Path ) -> Path :
85114 target = Path (path )
86115 target .parent .mkdir (parents = True , exist_ok = True )
87- target .write_text (json .dumps (self .to_dict (), indent = 2 ), encoding = "utf-8" )
116+ target .write_text (
117+ json .dumps (self .to_dict (), indent = 2 ),
118+ encoding = "utf-8" ,
119+ )
88120 return target
89121
90122 def to_markdown (self , path : str | Path ) -> Path :
@@ -99,7 +131,8 @@ def to_markdown(self, path: str | Path) -> Path:
99131 def to_junit (self , path : str | Path ) -> Path :
100132 target = Path (path )
101133 target .parent .mkdir (parents = True , exist_ok = True )
102- testsuites = ET .Element (
134+
135+ testsuite = ET .Element (
103136 "testsuite" ,
104137 attrib = {
105138 "name" : "agentunit" ,
@@ -108,10 +141,11 @@ def to_junit(self, path: str | Path) -> Path:
108141 "time" : f"{ (self .finished_at - self .started_at ).total_seconds ():.4f} " ,
109142 },
110143 )
144+
111145 for scenario in self .scenarios :
112146 for run in scenario .runs :
113147 testcase = ET .SubElement (
114- testsuites ,
148+ testsuite ,
115149 "testcase" ,
116150 attrib = {
117151 "classname" : scenario .name ,
@@ -126,7 +160,8 @@ def to_junit(self, path: str | Path) -> Path:
126160 attrib = {"message" : run .error or "Scenario failed" },
127161 )
128162 failure .text = json .dumps (run .metrics )
129- tree = ET .ElementTree (testsuites )
163+
164+ tree = ET .ElementTree (testsuite )
130165 tree .write (target , encoding = "utf-8" , xml_declaration = True )
131166 return target
132167
@@ -140,22 +175,78 @@ def to_html(self, path: str | Path) -> Path:
140175 target .write_text (html , encoding = "utf-8" )
141176 return target
142177
178+ def to_csv (self , path : str | Path ) -> Path :
179+ """
180+ Export suite results to CSV.
181+ One row per scenario run.
182+ """
183+ target = Path (path )
184+ target .parent .mkdir (parents = True , exist_ok = True )
185+
186+ rows : list [dict [str , Any ]] = []
187+
188+ for scenario in self .scenarios :
189+ for run in scenario .runs :
190+ row : dict [str , Any ] = {
191+ "scenario_name" : scenario .name ,
192+ "case_id" : run .case_id ,
193+ "success" : run .success ,
194+ "duration_ms" : run .duration_ms ,
195+ "error" : run .error ,
196+ }
197+
198+ if run .metrics :
199+ row .update (_flatten_metrics (run .metrics ))
200+
201+ rows .append (row )
202+
203+ if not rows :
204+ return target
205+
206+ fieldnames = sorted ({key for row in rows for key in row })
207+
208+ with target .open ("w" , newline = "" , encoding = "utf-8" ) as f :
209+ writer = csv .DictWriter (f , fieldnames = fieldnames )
210+ writer .writeheader ()
211+ writer .writerows (rows )
212+
213+ return target
214+
215+
216+ # -------------------------
217+ # Utilities
218+ # -------------------------
219+
143220
144221def merge_results (results : Iterable [SuiteResult ]) -> SuiteResult :
145222 results = list (results )
146223 scenarios : dict [str , ScenarioResult ] = {}
224+
147225 for result in results :
148226 for scenario in result .scenarios :
149227 existing = scenarios .setdefault (scenario .name , ScenarioResult (name = scenario .name ))
150228 for run in scenario .runs :
151229 existing .add_run (run )
230+
152231 started = min (result .started_at for result in results )
153232 finished = max (result .finished_at for result in results )
154- return SuiteResult (scenarios = list (scenarios .values ()), started_at = started , finished_at = finished )
233+
234+ return SuiteResult (
235+ scenarios = list (scenarios .values ()),
236+ started_at = started ,
237+ finished_at = finished ,
238+ )
155239
156240
157- def _render_markdown_scenario (scenario : ScenarioResult ) -> list [str ]:
158- lines = [f"## { scenario .name } " , f"Success rate: { scenario .success_rate :.2%} " , "" ]
241+ def _render_markdown_scenario (
242+ scenario : ScenarioResult ,
243+ ) -> list [str ]:
244+ lines = [
245+ f"## { scenario .name } " ,
246+ f"Success rate: { scenario .success_rate :.2%} " ,
247+ "" ,
248+ ]
249+
159250 for run in scenario .runs :
160251 lines .append (f"- **{ run .case_id } **: { '✅' if run .success else '❌' } " )
161252 metrics_repr = ", " .join (
@@ -165,5 +256,6 @@ def _render_markdown_scenario(scenario: ScenarioResult) -> list[str]:
165256 lines .append (f" - Metrics: { metrics_repr } " )
166257 if run .error :
167258 lines .append (f" - Error: { run .error } " )
259+
168260 lines .append ("" )
169261 return lines
0 commit comments