44
55"""Generate reports."""
66
7- from collections .abc import Callable
7+ from collections import defaultdict
8+ from collections .abc import Callable , Collection , Iterable
9+ from datetime import datetime
810from pathlib import Path
9- from typing import Protocol , TypeAlias
11+ from typing import Any , Protocol , TypeAlias
12+
13+ from tabulate import tabulate
1014
1115from dvsim .logging import log
16+ from dvsim .report .data import IPMeta
1217from dvsim .sim .data import SimFlowResults , SimResultsSummary
1318from dvsim .templates .render import render_static , render_template
19+ from dvsim .utils import TS_FORMAT_LONG
1420
1521__all__ = (
1622 "HtmlReportRenderer" ,
2228 "write_report" ,
2329)
2430
31+
32+ def _plural (item : str , n : int | Collection [Any ], suffix : str = "s" ) -> str :
33+ if not isinstance (n , int ):
34+ n = len (n )
35+ return item if n == 1 else item + suffix
36+
37+
38+ def _indent_by_levels (lines : Iterable [tuple [int , str ]], indent_spaces : int = 4 ) -> str :
39+ """Format per-line indentation of (0-indexed level, msg) log messages."""
40+ return "\n " .join (" " * lvl * indent_spaces + msg for lvl , msg in lines )
41+
42+
2543# Report rendering returns mappings of relative report paths to (string) contents.
2644ReportArtifacts : TypeAlias = dict [str , str ]
2745
@@ -131,6 +149,9 @@ class MarkdownReportRenderer:
131149
132150 format_name = "markdown"
133151
152+ MAX_TESTS_PER_BUCKET = 5
153+ MAX_RESEEDS_PER_BUCKETED_TEST = 2
154+
134155 def render (self , summary : SimResultsSummary , outdir : Path | None = None ) -> ReportArtifacts :
135156 """Render a Markdown report of the sim flow results."""
136157 if outdir is not None :
@@ -150,8 +171,195 @@ def render(self, summary: SimResultsSummary, outdir: Path | None = None) -> Repo
150171
151172 def render_block (self , results : SimFlowResults ) -> ReportArtifacts :
152173 """Render a Markdown report of the sim flow results for a given block/flow."""
153- _results = results
154- return {"report.md" : "TODO: Markdown block report" }
174+ # Generate block result metadata information
175+ report_md = self .render_metadata (results .block , results .timestamp , results .build_seed )
176+ testplan_ref = (results .testplan_ref or "" ).strip ()
177+ if len (results .stages ) > 0 and testplan_ref :
178+ report_md += f"\n ### [Testplan]({ testplan_ref } )"
179+ report_md += f"\n ### Simulator: { results .tool .name .upper ()} "
180+
181+ # Record a summary of the simulation results, coverage, and failure buckets, if applicable.
182+ result_summary = self .render_block_results (results )
183+ if result_summary :
184+ report_md += "\n \n " + result_summary
185+
186+ return {"report.md" : report_md }
187+
188+ def render_metadata (
189+ self ,
190+ scope : IPMeta ,
191+ timestamp : datetime ,
192+ seed : int | None ,
193+ title : str = "Simulation Results" ,
194+ ) -> str :
195+ """Generate a Markdown string summary of the result metadata.
196+
197+ Args:
198+ scope: The scope (block/top) to generate metadata from.
199+ timestamp: The timestamp metadata info to include.
200+ seed: The build seed, if one was used in this run.
201+ title: The title to use as a suffix (to "NAME %s"). Defaults to "Simulation Results".
202+
203+ """
204+ name = scope .variant_name (sep = "/" )
205+ report_md = f"## { name .upper ()} { title } "
206+ report_md += f"\n ### { timestamp .strftime (TS_FORMAT_LONG )} "
207+
208+ revision = (scope .revision_info or "" ).strip ()
209+ if not revision :
210+ revision = f"Github Revision: [`{ scope .commit_short } `]({ scope .url } )"
211+ report_md += f"\n ### { revision } "
212+ report_md += f"\n ### Branch: { scope .branch } "
213+
214+ if seed is not None :
215+ report_md += f"\n ### Build randomization enabled with --build-seed { seed } "
216+
217+ return report_md
218+
219+ def render_block_results (self , results : SimFlowResults ) -> str :
220+ """Generate a Markdown string covering the results, coverage and failure buckets."""
221+ report_md = self .render_result_table (results ) if results .total else "No results to display."
222+
223+ # TODO: need to optionally generate a progress table if `--map-full-testplan` was set.
224+ # This can be passed through and set when instantiating the markdown renderer, but
225+ # right now we don't record the correct information for testplan progress in the sim
226+ # results, so we leave this incomplete for now.
227+
228+ if results .coverage :
229+ coverage_table = self .render_coverage_table (results )
230+ if coverage_table :
231+ report_md += "\n \n " + coverage_table
232+
233+ if results .failed_jobs .buckets :
234+ bucket_summary = self .render_bucket_summary (results )
235+ if bucket_summary :
236+ report_md += "\n \n " + bucket_summary
237+
238+ return report_md
239+
240+ def render_result_table (self , results : SimFlowResults ) -> str :
241+ """Generate a Markdown string containing a table of the testplan results."""
242+ column_info = [
243+ ("Stage" , "center" ),
244+ ("Name" , "center" ),
245+ ("Tests" , "left" ),
246+ ("Max Job Runtime" , "center" ),
247+ ("Simulated Time" , "center" ),
248+ ("Passing" , "center" ),
249+ ("Total" , "center" ),
250+ ("Pass Rate" , "center" ),
251+ ]
252+ table = []
253+ hidden_names = ("n.a." , "unmapped" )
254+
255+ for stage_key , stage in results .stages .items ():
256+ # Coalesce result information to default values if necessary
257+ stage_name = "" if stage_key .lower () in hidden_names else stage_key
258+
259+ for tp_key , tp in stage .testpoints .items ():
260+ tp_name = "" if tp_key .lower () in hidden_names else tp_key
261+ for test_name , result in tp .tests .items ():
262+ job_runtime = "" if result .max_time is None else f"{ result .max_time :.3f} s"
263+ sim_time = "" if result .sim_time is None else f"{ result .sim_time :.3f} us"
264+ pass_rate = "-- %" if result .total == 0 else f"{ result .percent :.2f} %"
265+
266+ row = [
267+ stage_name ,
268+ tp_name ,
269+ test_name ,
270+ job_runtime ,
271+ sim_time ,
272+ result .passed ,
273+ result .total ,
274+ pass_rate ,
275+ ]
276+ table .append (row )
277+
278+ pass_rate = "-- %" if stage .total == 0 else f"{ stage .percent :.2f} %"
279+ # TODO: note the calculated stage totals are currently not correct.
280+ table .append (
281+ [stage_name , None , "**TOTAL**" , None , None , stage .passed , stage .total , pass_rate ]
282+ )
283+
284+ # TODO: note the calculated overall totals are currently not correct.
285+ pass_rate = "-- %" if results .total == 0 else f"{ results .percent :.2f} %"
286+ table .append (
287+ [None , None , "**TOTAL**" , None , None , results .passed , results .total , pass_rate ]
288+ )
289+
290+ if not table :
291+ return ""
292+
293+ return "### Test Results\n \n " + tabulate (
294+ table ,
295+ headers = [c [0 ] for c in column_info ],
296+ tablefmt = "pipe" ,
297+ colalign = [c [1 ] for c in column_info ],
298+ )
299+
300+ def render_coverage_table (self , results : SimFlowResults ) -> str :
301+ """Generate a Markdown string containing a table of the coverage results."""
302+ if results .coverage is None :
303+ return ""
304+
305+ cov_results = {
306+ k .upper ().replace ("_" , "/" ): f"{ v :.2f} %"
307+ for k , v in results .coverage .flattened ().items ()
308+ if v is not None
309+ }
310+ if not cov_results and not results .cov_report_page :
311+ return ""
312+
313+ report_md = "## Coverage Results"
314+ if results .cov_report_page :
315+ report_md += f"\n ### [Coverage Dashboard]({ results .cov_report_page } )"
316+ if cov_results :
317+ colalign = ("center" ,) * len (cov_results )
318+ report_md += "\n \n " + tabulate (
319+ [cov_results ], headers = "keys" , tablefmt = "pipe" , colalign = colalign
320+ )
321+
322+ return report_md
323+
324+ def render_bucket_summary (self , results : SimFlowResults ) -> str :
325+ """Generate a Markdown string with a summary of the buckets (failures/killed)."""
326+ lines = [(0 , "## Failure Buckets" )]
327+
328+ for bucket , tests in sorted (
329+ results .failed_jobs .buckets .items (),
330+ key = lambda kv : len (kv [1 ]),
331+ reverse = True ,
332+ ):
333+ lines .append ((0 , f"* `{ bucket } ` has { len (tests )} { _plural ('failure' , tests )} :" ))
334+
335+ grouped_tests = defaultdict (list )
336+ for job in tests :
337+ grouped_tests [job .name ].append (job )
338+
339+ displayed = list (grouped_tests .items ())[: self .MAX_TESTS_PER_BUCKET ]
340+ for name , reseeds in displayed :
341+ lines .append (
342+ (1 , f"* Test { name } has { len (reseeds )} { _plural ('failure' , reseeds )} ." )
343+ )
344+
345+ for failure in reseeds [: self .MAX_RESEEDS_PER_BUCKETED_TEST ]:
346+ lines .append ((2 , f"* { failure .qual_name } \\ " ))
347+ line_context = "Log" if failure .line is None else f"Line { failure .line } , in log"
348+ lines .append ((2 , f" { line_context } { failure .log_path } " ))
349+ if failure .log_context :
350+ lines .append ((0 , "" ))
351+ lines .extend ((4 , line .rstrip ()) for line in failure .log_context )
352+ lines .append ((0 , "" ))
353+
354+ extra = len (reseeds ) - self .MAX_RESEEDS_PER_BUCKETED_TEST
355+ if extra > 0 :
356+ lines .append ((2 , f"* ... and { extra } more { _plural ('failure' , extra )} ." ))
357+
358+ extra = len (grouped_tests ) - self .MAX_TESTS_PER_BUCKET
359+ if extra > 0 :
360+ lines .append ((2 , f"* ... and { extra } more { _plural ('test' , extra )} ." ))
361+
362+ return _indent_by_levels (lines )
155363
156364 def render_summary (self , summary : SimResultsSummary ) -> ReportArtifacts :
157365 """Render a Markdown report of a summary of the sim flow results (overall)."""
0 commit comments