@@ -33,6 +33,7 @@ def __init__(self, trx_path, coverage_path=None):
3333 'statements_pct' : 0 ,
3434 'functions_pct' : 0
3535 }
36+ self .file_coverage = []
3637
3738 # ──────────────────── TRX PARSING ────────────────────
3839
@@ -161,9 +162,100 @@ def parse_coverage(self):
161162 covered_methods += 1
162163 if total_methods > 0 :
163164 self .coverage ['functions_pct' ] = (covered_methods / total_methods ) * 100
165+
166+ self ._parse_file_coverage (root )
164167 except Exception as e :
165168 print (f"Warning: Could not parse coverage file: { e } " )
166169
170+ def _parse_file_coverage (self , root ):
171+ file_data = {}
172+ for cls in root .iter ('class' ):
173+ filename = cls .get ('filename' , '' )
174+ if not filename :
175+ continue
176+
177+ if filename not in file_data :
178+ file_data [filename ] = {
179+ 'lines' : {},
180+ 'branches_covered' : 0 ,
181+ 'branches_total' : 0 ,
182+ 'methods_total' : 0 ,
183+ 'methods_covered' : 0 ,
184+ }
185+
186+ entry = file_data [filename ]
187+
188+ for method in cls .findall ('methods/method' ):
189+ entry ['methods_total' ] += 1
190+ if float (method .get ('line-rate' , 0 )) > 0 :
191+ entry ['methods_covered' ] += 1
192+
193+ for line in cls .iter ('line' ):
194+ num = int (line .get ('number' , 0 ))
195+ hits = int (line .get ('hits' , 0 ))
196+ is_branch = line .get ('branch' , 'False' ).lower () == 'true'
197+
198+ if num in entry ['lines' ]:
199+ entry ['lines' ][num ]['hits' ] = max (entry ['lines' ][num ]['hits' ], hits )
200+ if is_branch :
201+ entry ['lines' ][num ]['is_branch' ] = True
202+ cond = line .get ('condition-coverage' , '' )
203+ covered , total = self ._parse_condition_coverage (cond )
204+ entry ['lines' ][num ]['br_covered' ] = max (entry ['lines' ][num ].get ('br_covered' , 0 ), covered )
205+ entry ['lines' ][num ]['br_total' ] = max (entry ['lines' ][num ].get ('br_total' , 0 ), total )
206+ else :
207+ br_covered , br_total = 0 , 0
208+ if is_branch :
209+ cond = line .get ('condition-coverage' , '' )
210+ br_covered , br_total = self ._parse_condition_coverage (cond )
211+ entry ['lines' ][num ] = {
212+ 'hits' : hits ,
213+ 'is_branch' : is_branch ,
214+ 'br_covered' : br_covered ,
215+ 'br_total' : br_total ,
216+ }
217+
218+ self .file_coverage = []
219+ for filename in sorted (file_data .keys ()):
220+ entry = file_data [filename ]
221+ lines_total = len (entry ['lines' ])
222+ lines_covered = sum (1 for l in entry ['lines' ].values () if l ['hits' ] > 0 )
223+ uncovered = sorted (num for num , l in entry ['lines' ].items () if l ['hits' ] == 0 )
224+
225+ br_total = sum (l .get ('br_total' , 0 ) for l in entry ['lines' ].values () if l .get ('is_branch' ))
226+ br_covered = sum (l .get ('br_covered' , 0 ) for l in entry ['lines' ].values () if l .get ('is_branch' ))
227+
228+ self .file_coverage .append ({
229+ 'filename' : filename ,
230+ 'lines_pct' : (lines_covered / lines_total * 100 ) if lines_total > 0 else 100 ,
231+ 'statements_pct' : (lines_covered / lines_total * 100 ) if lines_total > 0 else 100 ,
232+ 'branches_pct' : (br_covered / br_total * 100 ) if br_total > 0 else 100 ,
233+ 'functions_pct' : (entry ['methods_covered' ] / entry ['methods_total' ] * 100 ) if entry ['methods_total' ] > 0 else 100 ,
234+ 'uncovered_lines' : uncovered ,
235+ })
236+
237+ @staticmethod
238+ def _parse_condition_coverage (cond_str ):
239+ m = re .match (r'(\d+)%\s*\((\d+)/(\d+)\)' , cond_str )
240+ if m :
241+ return int (m .group (2 )), int (m .group (3 ))
242+ return 0 , 0
243+
244+ @staticmethod
245+ def _collapse_line_ranges (lines ):
246+ if not lines :
247+ return ''
248+ ranges = []
249+ start = prev = lines [0 ]
250+ for num in lines [1 :]:
251+ if num == prev + 1 :
252+ prev = num
253+ else :
254+ ranges .append (f"{ start } -{ prev } " if start != prev else str (start ))
255+ start = prev = num
256+ ranges .append (f"{ start } -{ prev } " if start != prev else str (start ))
257+ return ',' .join (ranges )
258+
167259 # ──────────────────── STRUCTURED OUTPUT ────────────────────
168260
169261 def _parse_structured_output (self , text ):
@@ -252,6 +344,7 @@ def generate_html(self, output_path):
252344 html += self ._html_pass_rate (pass_rate )
253345 html += self ._html_coverage_table ()
254346 html += self ._html_test_navigation (by_file )
347+ html += self ._html_file_coverage_table ()
255348 html += self ._html_footer ()
256349 html += self ._html_scripts ()
257350 html += "</div></body></html>"
@@ -331,6 +424,17 @@ def _html_head(self):
331424 .cov-good {{ color: #28a745; }}
332425 .cov-warn {{ color: #ffc107; }}
333426 .cov-bad {{ color: #dc3545; }}
427+ .file-coverage-section {{ margin-top: 0; border-top: 3px solid #e9ecef; }}
428+ .file-cov-table td {{ font-size: 0.95em; font-weight: 600; padding: 10px 15px; border-bottom: 1px solid #e9ecef; }}
429+ .file-cov-table tr:last-child td {{ border-bottom: none; }}
430+ .file-cov-table tbody tr:hover {{ background: #f8f9fa; }}
431+ .fc-summary-row {{ background: #f0f2ff; }}
432+ .fc-summary-row td {{ border-bottom: 2px solid #667eea !important; }}
433+ .fc-file-col {{ text-align: left !important; }}
434+ .fc-file-cell {{ text-align: left !important; font-family: 'Consolas', 'Monaco', monospace; font-size: 0.88em !important; }}
435+ .fc-dir {{ color: #888; }}
436+ .fc-uncov-col {{ text-align: left !important; }}
437+ .fc-uncov-cell {{ text-align: left !important; font-family: 'Consolas', 'Monaco', monospace; font-size: 0.82em !important; color: #dc3545; font-weight: 400 !important; }}
334438 .test-results {{ padding: 40px; }}
335439 .test-results > h2 {{ margin-bottom: 30px; font-size: 2em; }}
336440 .category {{ margin-bottom: 30px; }}
@@ -474,6 +578,69 @@ def cov_class(pct):
474578 </div>
475579"""
476580
581+ def _html_file_coverage_table (self ):
582+ if not self .file_coverage :
583+ return ""
584+
585+ def cov_class (pct ):
586+ if pct >= 80 : return 'cov-good'
587+ if pct >= 50 : return 'cov-warn'
588+ return 'cov-bad'
589+
590+ c = self .coverage
591+ html = """
592+ <div class="coverage-section file-coverage-section">
593+ <h2>File-wise Code Coverage</h2>
594+ <table class="coverage-table file-cov-table">
595+ <thead><tr>
596+ <th class="fc-file-col">File</th>
597+ <th>% Stmts</th><th>% Branch</th><th>% Funcs</th><th>% Lines</th>
598+ <th class="fc-uncov-col">Uncovered Line #s</th>
599+ </tr></thead>
600+ <tbody>
601+ """
602+ html += f""" <tr class="fc-summary-row">
603+ <td class="fc-file-cell"><strong>All files</strong></td>
604+ <td class="{ cov_class (c ['statements_pct' ])} ">{ c ['statements_pct' ]:.1f} %</td>
605+ <td class="{ cov_class (c ['branches_pct' ])} ">{ c ['branches_pct' ]:.1f} %</td>
606+ <td class="{ cov_class (c ['functions_pct' ])} ">{ c ['functions_pct' ]:.1f} %</td>
607+ <td class="{ cov_class (c ['lines_pct' ])} ">{ c ['lines_pct' ]:.1f} %</td>
608+ <td class="fc-uncov-cell"></td>
609+ </tr>
610+ """
611+
612+ for fc in self .file_coverage :
613+ uncovered = fc ['uncovered_lines' ]
614+ if len (uncovered ) == 0 :
615+ uncov_str = ''
616+ elif len (uncovered ) == 1 :
617+ uncov_str = str (uncovered [0 ])
618+ else :
619+ uncov_str = f"{ uncovered [0 ]} -{ uncovered [- 1 ]} "
620+ display_name = fc ['filename' ]
621+ parts = display_name .replace ('\\ ' , '/' ).rsplit ('/' , 1 )
622+ if len (parts ) == 2 :
623+ dir_part , base = parts
624+ display_name = f'<span class="fc-dir">{ self ._esc (dir_part )} /</span>{ self ._esc (base )} '
625+ else :
626+ display_name = self ._esc (display_name )
627+
628+ html += f""" <tr>
629+ <td class="fc-file-cell">{ display_name } </td>
630+ <td class="{ cov_class (fc ['statements_pct' ])} ">{ fc ['statements_pct' ]:.1f} %</td>
631+ <td class="{ cov_class (fc ['branches_pct' ])} ">{ fc ['branches_pct' ]:.1f} %</td>
632+ <td class="{ cov_class (fc ['functions_pct' ])} ">{ fc ['functions_pct' ]:.1f} %</td>
633+ <td class="{ cov_class (fc ['lines_pct' ])} ">{ fc ['lines_pct' ]:.1f} %</td>
634+ <td class="fc-uncov-cell">{ self ._esc (uncov_str )} </td>
635+ </tr>
636+ """
637+
638+ html += """ </tbody>
639+ </table>
640+ </div>
641+ """
642+ return html
643+
477644 def _html_test_navigation (self , by_file ):
478645 html = '<div class="test-results"><h2>Test Results by Integration File</h2>'
479646
0 commit comments