gd-tools-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,518 @@
1
+ """Coverage reporter module.
2
+
3
+ Reads instrumentation plan JSON (from the plan generator) and coverage
4
+ data JSON (from the GDScript runtime hooks), cross-references them by
5
+ ``file_id`` to compute line and branch coverage metrics, and dispatches
6
+ to format-specific reporters (HTML, LCOV, Cobertura, terminal).
7
+
8
+ This module is the final reporting layer of the hybrid coverage system
9
+ (Architecture C: Python plan gen -> GDScript runtime instrumentation ->
10
+ Python reporting).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from gd_tools.coverage.plan_generator import CoveragePlan, FilePlan
20
+ from gd_tools.errors import CoveragePlanError, CoverageThresholdError
21
+
22
+ # --- Data structures (FR-1, FR-2, FR-3) ---
23
+
24
+
25
+ @dataclass
26
+ class FileCoverage:
27
+ """Coverage data for a single file from the runtime tracker.
28
+
29
+ Attributes:
30
+ file_id: Sequential identifier matching the plan's ``file_id``.
31
+ hits: Mapping of line ID (string key) to hit count.
32
+ """
33
+
34
+ file_id: int
35
+ hits: dict[str, int]
36
+
37
+
38
+ @dataclass
39
+ class CoverageData:
40
+ """Top-level container for runtime coverage data.
41
+
42
+ Attributes:
43
+ version: Schema version (currently 1).
44
+ generated_at: ISO timestamp from the runtime tracker, or None.
45
+ files: List of per-file coverage data.
46
+ """
47
+
48
+ version: int
49
+ generated_at: str | None = None
50
+ files: list[FileCoverage] = field(default_factory=list)
51
+
52
+
53
+ @dataclass
54
+ class CoverageSummary:
55
+ """Overall coverage summary across all files.
56
+
57
+ Attributes:
58
+ line_rate: Fraction of executable lines covered (0.0-1.0).
59
+ branch_rate: Fraction of branch points covered (0.0-1.0).
60
+ covered_lines: Count of lines with hit count > 0.
61
+ total_lines: Total executable lines in the plan.
62
+ covered_branches: Count of branches with hit count > 0.
63
+ total_branches: Total branch points in the plan.
64
+ """
65
+
66
+ line_rate: float
67
+ branch_rate: float
68
+ covered_lines: int
69
+ total_lines: int
70
+ covered_branches: int
71
+ total_branches: int
72
+
73
+
74
+ @dataclass
75
+ class FileSummary:
76
+ """Per-file coverage summary.
77
+
78
+ Attributes:
79
+ file_id: Sequential identifier from the plan.
80
+ path: Godot resource path (``res://``) from the plan.
81
+ line_rate: Fraction of executable lines covered (0.0-1.0).
82
+ branch_rate: Fraction of branch points covered (0.0-1.0).
83
+ covered_lines: Count of lines with hit count > 0.
84
+ total_lines: Total executable lines in this file's plan.
85
+ covered_branches: Count of branches with hit count > 0.
86
+ total_branches: Total branch points in this file's plan.
87
+ uncovered_lines: Line numbers with zero hits.
88
+ """
89
+
90
+ file_id: int
91
+ path: str
92
+ line_rate: float
93
+ branch_rate: float
94
+ covered_lines: int
95
+ total_lines: int
96
+ covered_branches: int
97
+ total_branches: int
98
+ uncovered_lines: list[int]
99
+
100
+
101
+ @dataclass
102
+ class ReportResult:
103
+ """Result of a report generation operation.
104
+
105
+ Attributes:
106
+ output_path: Path to the generated report file or directory.
107
+ format: Report format (``"html"``, ``"lcov"``, ``"cobertura"``,
108
+ ``"text"``).
109
+ summary: Overall coverage summary.
110
+ file_summaries: Per-file coverage breakdowns.
111
+ threshold_met: Whether coverage met the minimum threshold.
112
+ """
113
+
114
+ output_path: Path
115
+ format: str
116
+ summary: CoverageSummary
117
+ file_summaries: list[FileSummary]
118
+ threshold_met: bool
119
+
120
+
121
+ # --- JSON I/O (FR-1) ---
122
+
123
+
124
+ def read_coverage_json(path: Path) -> CoverageData:
125
+ """Read a coverage data JSON file produced by the runtime tracker.
126
+
127
+ The expected format matches Track 11's runtime output: each file
128
+ entry has ``file_id`` (int) and ``hits`` (dict with string keys).
129
+ A ``path`` field is NOT required in the coverage data — path
130
+ resolution happens at report-generation time via the plan.
131
+
132
+ Args:
133
+ path: Path to the coverage data JSON file.
134
+
135
+ Returns:
136
+ The deserialized :class:`CoverageData`.
137
+
138
+ Raises:
139
+ CoveragePlanError: If the file is missing, contains invalid
140
+ JSON, or has a version mismatch.
141
+ """
142
+ cov_path = Path(path)
143
+ if not cov_path.exists():
144
+ raise CoveragePlanError(
145
+ f"[Error] Coverage data file not found: {path}\n"
146
+ f" Cause: The file does not exist at the specified path.\n"
147
+ f" Fix: Ensure the coverage data file was generated by "
148
+ "'gd-tools coverage run' and the path is correct."
149
+ )
150
+
151
+ try:
152
+ data = json.loads(cov_path.read_text(encoding="utf-8"))
153
+ except json.JSONDecodeError as exc:
154
+ raise CoveragePlanError(
155
+ f"[Error] Invalid JSON in coverage data file\n"
156
+ f" Cause: {exc}\n"
157
+ f" Fix: Ensure the file is valid JSON generated by "
158
+ "'gd-tools coverage run'."
159
+ ) from exc
160
+
161
+ if not isinstance(data, dict):
162
+ raise CoveragePlanError(
163
+ "[Error] Coverage data must be a JSON object\n"
164
+ " Cause: The top-level JSON value is not an object.\n"
165
+ " Fix: Ensure the file contains a JSON object with "
166
+ "'version' and 'files' fields."
167
+ )
168
+
169
+ version = data.get("version")
170
+ if version != 1:
171
+ raise CoveragePlanError(
172
+ f"[Error] Unsupported coverage data version: {version}\n"
173
+ f" Cause: Expected version 1, got {version}.\n"
174
+ f" Fix: Regenerate the coverage data with the current "
175
+ "version of gd-tools."
176
+ )
177
+
178
+ if "files" not in data:
179
+ raise CoveragePlanError(
180
+ "[Error] Missing required field: files\n"
181
+ " Cause: The coverage data JSON does not contain a "
182
+ "'files' field.\n"
183
+ " Fix: Ensure the coverage data was generated by "
184
+ "'gd-tools coverage run'."
185
+ )
186
+
187
+ files_data = data["files"]
188
+ if not isinstance(files_data, list):
189
+ raise CoveragePlanError(
190
+ "[Error] Coverage data 'files' field must be a list\n"
191
+ " Cause: The 'files' field is not a JSON array.\n"
192
+ " Fix: Ensure the coverage data was generated by "
193
+ "'gd-tools coverage run'."
194
+ )
195
+
196
+ files: list[FileCoverage] = []
197
+ for fdata in files_data:
198
+ if not isinstance(fdata, dict):
199
+ raise CoveragePlanError(
200
+ "[Error] Invalid coverage file entry\n"
201
+ " Cause: An entry in the 'files' array is not a "
202
+ "JSON object.\n"
203
+ " Fix: Ensure the coverage data was generated by "
204
+ "'gd-tools coverage run'."
205
+ )
206
+ if "file_id" not in fdata:
207
+ raise CoveragePlanError(
208
+ "[Error] Missing required field: file_id\n"
209
+ " Cause: A coverage file entry does not contain a "
210
+ "'file_id' field.\n"
211
+ " Fix: Ensure the coverage data was generated by "
212
+ "'gd-tools coverage run'."
213
+ )
214
+ if "hits" not in fdata:
215
+ raise CoveragePlanError(
216
+ "[Error] Missing required field: hits\n"
217
+ " Cause: A coverage file entry does not contain a "
218
+ "'hits' field.\n"
219
+ " Fix: Ensure the coverage data was generated by "
220
+ "'gd-tools coverage run'."
221
+ )
222
+ hits_data = fdata["hits"]
223
+ if not isinstance(hits_data, dict):
224
+ raise CoveragePlanError(
225
+ "[Error] Coverage 'hits' field must be a dict\n"
226
+ " Cause: The 'hits' field is not a JSON object.\n"
227
+ " Fix: Ensure the coverage data was generated by "
228
+ "'gd-tools coverage run'."
229
+ )
230
+
231
+ # Ensure all hit counts are ints (JSON may produce them as such,
232
+ # but we normalize for safety)
233
+ hits = {str(k): int(v) for k, v in hits_data.items()}
234
+
235
+ files.append(FileCoverage(file_id=fdata["file_id"], hits=hits))
236
+
237
+ return CoverageData(
238
+ version=data["version"],
239
+ generated_at=data.get("generated_at"),
240
+ files=files,
241
+ )
242
+
243
+
244
+ def merge_coverage_data(files: list[Path]) -> CoverageData:
245
+ """Merge multiple coverage data files (for parallel CI shards).
246
+
247
+ Sums hit counts per ``file_id``/``line_id`` across all input files.
248
+ Files that appear in only one shard are included as-is.
249
+
250
+ Args:
251
+ files: List of paths to coverage data JSON files.
252
+
253
+ Returns:
254
+ A merged :class:`CoverageData` with summed hit counts.
255
+ """
256
+ if not files:
257
+ return CoverageData(version=1)
258
+
259
+ merged_files: dict[int, dict[str, int]] = {}
260
+ generated_at: str | None = None
261
+
262
+ for fpath in files:
263
+ data = read_coverage_json(fpath)
264
+ if generated_at is None:
265
+ generated_at = data.generated_at
266
+
267
+ for fc in data.files:
268
+ if fc.file_id not in merged_files:
269
+ merged_files[fc.file_id] = {}
270
+ for line_id, count in fc.hits.items():
271
+ merged_files[fc.file_id][line_id] = (
272
+ merged_files[fc.file_id].get(line_id, 0) + count
273
+ )
274
+
275
+ result_files = [
276
+ FileCoverage(file_id=fid, hits=hits)
277
+ for fid, hits in merged_files.items()
278
+ ]
279
+
280
+ return CoverageData(
281
+ version=1,
282
+ generated_at=generated_at,
283
+ files=result_files,
284
+ )
285
+
286
+
287
+ def write_coverage_json(data: CoverageData, path: Path) -> None:
288
+ """Write coverage data to a JSON file.
289
+
290
+ Serializes a :class:`CoverageData` object to the same JSON format
291
+ produced by the runtime tracker, ensuring round-trip compatibility
292
+ with :func:`read_coverage_json`.
293
+
294
+ Args:
295
+ data: The coverage data to serialize.
296
+ path: Path to the output JSON file.
297
+ """
298
+ cov_path = Path(path)
299
+ cov_path.parent.mkdir(parents=True, exist_ok=True)
300
+
301
+ data_dict = {
302
+ "version": data.version,
303
+ "generated_at": data.generated_at,
304
+ "files": [
305
+ {"file_id": fc.file_id, "hits": fc.hits} for fc in data.files
306
+ ],
307
+ }
308
+ cov_path.write_text(json.dumps(data_dict, indent=2), encoding="utf-8")
309
+
310
+
311
+ # --- Coverage computation (FR-2) ---
312
+
313
+
314
+ def compute_file_summary(
315
+ file_plan: FilePlan, file_data: FileCoverage
316
+ ) -> FileSummary:
317
+ """Compute per-file coverage metrics.
318
+
319
+ Cross-references a file's instrumentation plan with its runtime
320
+ coverage data to produce coverage rates and uncovered line list.
321
+
322
+ Args:
323
+ file_plan: The file's instrumentation plan.
324
+ file_data: The file's runtime coverage data.
325
+
326
+ Returns:
327
+ A :class:`FileSummary` with coverage metrics.
328
+ """
329
+ covered_lines = 0
330
+ total_lines = 0
331
+ covered_branches = 0
332
+ total_branches = 0
333
+ uncovered_lines: list[int] = []
334
+
335
+ for line in file_plan.lines:
336
+ total_lines += 1
337
+ hit_count = file_data.hits.get(str(line.id), 0)
338
+
339
+ if hit_count > 0:
340
+ covered_lines += 1
341
+ else:
342
+ uncovered_lines.append(line.line)
343
+
344
+ if line.type == "branch":
345
+ total_branches += 1
346
+ if hit_count > 0:
347
+ covered_branches += 1
348
+
349
+ line_rate = covered_lines / total_lines if total_lines > 0 else 0.0
350
+ branch_rate = (
351
+ covered_branches / total_branches if total_branches > 0 else 0.0
352
+ )
353
+
354
+ return FileSummary(
355
+ file_id=file_plan.file_id,
356
+ path=file_plan.path,
357
+ line_rate=line_rate,
358
+ branch_rate=branch_rate,
359
+ covered_lines=covered_lines,
360
+ total_lines=total_lines,
361
+ covered_branches=covered_branches,
362
+ total_branches=total_branches,
363
+ uncovered_lines=uncovered_lines,
364
+ )
365
+
366
+
367
+ def compute_summary(plan: CoveragePlan, data: CoverageData) -> CoverageSummary:
368
+ """Compute overall coverage metrics across all files.
369
+
370
+ Aggregates per-file coverage into an overall summary. Files in the
371
+ plan but missing from coverage data are treated as 0 hits.
372
+
373
+ Args:
374
+ plan: The full instrumentation plan.
375
+ data: The runtime coverage data.
376
+
377
+ Returns:
378
+ A :class:`CoverageSummary` with aggregated coverage metrics.
379
+ """
380
+ coverage_by_id = {fc.file_id: fc for fc in data.files}
381
+
382
+ total_covered_lines = 0
383
+ total_lines = 0
384
+ total_covered_branches = 0
385
+ total_branches = 0
386
+
387
+ for file_plan in plan.files:
388
+ file_data = coverage_by_id.get(
389
+ file_plan.file_id,
390
+ FileCoverage(file_id=file_plan.file_id, hits={}),
391
+ )
392
+ fs = compute_file_summary(file_plan, file_data)
393
+ total_covered_lines += fs.covered_lines
394
+ total_lines += fs.total_lines
395
+ total_covered_branches += fs.covered_branches
396
+ total_branches += fs.total_branches
397
+
398
+ line_rate = total_covered_lines / total_lines if total_lines > 0 else 0.0
399
+ branch_rate = (
400
+ total_covered_branches / total_branches if total_branches > 0 else 0.0
401
+ )
402
+
403
+ return CoverageSummary(
404
+ line_rate=line_rate,
405
+ branch_rate=branch_rate,
406
+ covered_lines=total_covered_lines,
407
+ total_lines=total_lines,
408
+ covered_branches=total_covered_branches,
409
+ total_branches=total_branches,
410
+ )
411
+
412
+
413
+ # --- Report dispatch and threshold (FR-3) ---
414
+
415
+ _SUPPORTED_FORMATS = {"html", "lcov", "cobertura", "terminal"}
416
+
417
+
418
+ def generate_report(
419
+ plan: CoveragePlan,
420
+ data: CoverageData,
421
+ output_dir: Path,
422
+ format: str = "html",
423
+ min_threshold: float | None = None,
424
+ ) -> ReportResult:
425
+ """Generate a coverage report in the specified format.
426
+
427
+ Dispatches to a format-specific reporter after computing coverage
428
+ metrics. If ``min_threshold`` is set and the overall line coverage
429
+ rate falls below it, :class:`CoverageThresholdError` is raised
430
+ after the report file has been written.
431
+
432
+ Args:
433
+ plan: The instrumentation plan.
434
+ data: The runtime coverage data.
435
+ output_dir: Directory where the report file is written.
436
+ format: Report format — one of ``"html"``, ``"lcov"``,
437
+ ``"cobertura"``, ``"terminal"``.
438
+ min_threshold: Minimum line coverage rate (0.0-1.0). If
439
+ ``None``, no threshold check is performed.
440
+
441
+ Returns:
442
+ A :class:`ReportResult` with the report path, summary, and
443
+ file-level breakdowns.
444
+
445
+ Raises:
446
+ CoveragePlanError: If ``format`` is not a supported format.
447
+ CoverageThresholdError: If ``min_threshold`` is set and
448
+ ``line_rate < min_threshold``.
449
+ """
450
+ if format not in _SUPPORTED_FORMATS:
451
+ raise CoveragePlanError(
452
+ f"[Error] Unsupported report format: '{format}'\n"
453
+ f" Cause: The format '{format}' is not supported.\n"
454
+ f" Fix: Use one of: "
455
+ f"{', '.join(sorted(_SUPPORTED_FORMATS))}"
456
+ )
457
+
458
+ summary = compute_summary(plan, data)
459
+
460
+ coverage_by_id = {fc.file_id: fc for fc in data.files}
461
+ file_summaries: list[FileSummary] = []
462
+ for file_plan in plan.files:
463
+ file_data = coverage_by_id.get(
464
+ file_plan.file_id,
465
+ FileCoverage(file_id=file_plan.file_id, hits={}),
466
+ )
467
+ file_summaries.append(compute_file_summary(file_plan, file_data))
468
+
469
+ threshold_met = True
470
+ if min_threshold is not None:
471
+ threshold_met = summary.line_rate >= min_threshold
472
+
473
+ output_dir = Path(output_dir)
474
+ output_dir.mkdir(parents=True, exist_ok=True)
475
+
476
+ if format == "terminal":
477
+ from gd_tools.coverage.terminal_reporter import (
478
+ generate_terminal_report,
479
+ )
480
+
481
+ output_path = output_dir / "coverage_report.txt"
482
+ report_text = generate_terminal_report(plan, data)
483
+ output_path.write_text(report_text, encoding="utf-8")
484
+ elif format == "lcov":
485
+ from gd_tools.coverage.lcov_reporter import generate_lcov_report
486
+
487
+ output_path = output_dir / "coverage.info"
488
+ generate_lcov_report(plan, data, output_path)
489
+ elif format == "cobertura":
490
+ from gd_tools.coverage.cobertura_reporter import (
491
+ generate_cobertura_report,
492
+ )
493
+
494
+ output_path = output_dir / "cobertura.xml"
495
+ generate_cobertura_report(plan, data, output_path)
496
+ else: # html (format already validated above)
497
+ from gd_tools.coverage.html_reporter import generate_html_report
498
+
499
+ output_path = generate_html_report(plan, data, output_dir)
500
+
501
+ result = ReportResult(
502
+ output_path=output_path,
503
+ format=format,
504
+ summary=summary,
505
+ file_summaries=file_summaries,
506
+ threshold_met=threshold_met,
507
+ )
508
+
509
+ if not threshold_met:
510
+ raise CoverageThresholdError(
511
+ f"[Error] Coverage threshold not met\n"
512
+ f" Cause: Line coverage {summary.line_rate:.2%} is "
513
+ f"below minimum {min_threshold:.2%}.\n"
514
+ f" Fix: Increase test coverage or lower the minimum "
515
+ "threshold."
516
+ )
517
+
518
+ return result
@@ -0,0 +1,72 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ file_summary.path }} - Coverage</title>
7
+ <style>
8
+ body { font-family: sans-serif; margin: 2em; color: #333; }
9
+ h1 { margin-bottom: 0.5em; }
10
+ .file-info { margin-bottom: 1em; color: #666; }
11
+ .summary-bar {
12
+ display: flex; gap: 2em; margin-bottom: 2em;
13
+ padding: 1em; background: #f5f5f5; border-radius: 4px;
14
+ }
15
+ .summary-item { text-align: center; }
16
+ .summary-item .value { font-size: 1.5em; font-weight: bold; }
17
+ .summary-item .label { font-size: 0.9em; color: #666; }
18
+ .rate-good { color: #2e7d32; }
19
+ .rate-medium { color: #f57f17; }
20
+ .rate-low { color: #c62828; }
21
+ .source-listing {
22
+ font-family: monospace; font-size: 0.9em;
23
+ border: 1px solid #ddd; border-radius: 4px; overflow: hidden;
24
+ }
25
+ .source-line {
26
+ display: flex; padding: 0.2em 0;
27
+ border-bottom: 1px solid #eee;
28
+ }
29
+ .source-line:last-child { border-bottom: none; }
30
+ .line-number {
31
+ min-width: 3em; text-align: right;
32
+ padding: 0 1em; color: #999; user-select: none;
33
+ border-right: 1px solid #ddd;
34
+ }
35
+ .line-content { padding: 0 1em; flex-grow: 1; }
36
+ .line-hits {
37
+ min-width: 5em; text-align: right;
38
+ padding: 0 1em; color: #666; font-size: 0.85em;
39
+ }
40
+ .covered { background-color: #c8e6c9; }
41
+ .uncovered { background-color: #ffcdd2; }
42
+ .partial { background-color: #fff9c4; }
43
+ .not-tracked { color: #ccc; }
44
+ a { color: #1565c0; text-decoration: none; }
45
+ a:hover { text-decoration: underline; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <p><a href="index.html">&larr; Back to summary</a></p>
50
+ <h1>{{ file_summary.path }}</h1>
51
+ <div class="file-info">File ID: {{ file_summary.file_id }}</div>
52
+ <div class="summary-bar">
53
+ <div class="summary-item">
54
+ <div class="value rate-{{ 'good' if file_summary.line_rate >= 0.8 else ('medium' if file_summary.line_rate >= 0.5 else 'low') }}">{{ (file_summary.line_rate * 100) | round(1) }}%</div>
55
+ <div class="label">Line Coverage ({{ file_summary.covered_lines }}/{{ file_summary.total_lines }})</div>
56
+ </div>
57
+ <div class="summary-item">
58
+ <div class="value rate-{{ 'good' if file_summary.branch_rate >= 0.8 else ('medium' if file_summary.branch_rate >= 0.5 else 'low') }}">{{ (file_summary.branch_rate * 100) | round(1) }}%</div>
59
+ <div class="label">Branch Coverage ({{ file_summary.covered_branches }}/{{ file_summary.total_branches }})</div>
60
+ </div>
61
+ </div>
62
+ <div class="source-listing">
63
+ {% for line in lines %}
64
+ <div class="source-line {{ line.css_class }}">
65
+ <span class="line-number">{{ line.number }}</span>
66
+ <span class="line-content">{{ line.source }}</span>
67
+ <span class="line-hits">{{ line.hits }}x</span>
68
+ </div>
69
+ {% endfor %}
70
+ </div>
71
+ </body>
72
+ </html>
@@ -0,0 +1,90 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Coverage Report</title>
7
+ <style>
8
+ body { font-family: sans-serif; margin: 2em; color: #333; }
9
+ h1 { margin-bottom: 0.5em; }
10
+ .summary-bar {
11
+ display: flex; gap: 2em; margin-bottom: 2em;
12
+ padding: 1em; background: #f5f5f5; border-radius: 4px;
13
+ }
14
+ .summary-item { text-align: center; }
15
+ .summary-item .value { font-size: 1.8em; font-weight: bold; }
16
+ .summary-item .label { font-size: 0.9em; color: #666; }
17
+ .rate-good { color: #2e7d32; }
18
+ .rate-medium { color: #f57f17; }
19
+ .rate-low { color: #c62828; }
20
+ table { border-collapse: collapse; width: 100%; }
21
+ th, td { border: 1px solid #ddd; padding: 0.5em 1em; text-align: left; }
22
+ th { background: #f0f0f0; cursor: pointer; user-select: none; }
23
+ th:hover { background: #e0e0e0; }
24
+ th.sorted-asc::after { content: " \\25B2"; }
25
+ th.sorted-desc::after { content: " \\25BC"; }
26
+ tr:nth-child(even) { background: #f9f9f9; }
27
+ td.rate { font-family: monospace; }
28
+ a { color: #1565c0; text-decoration: none; }
29
+ a:hover { text-decoration: underline; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <h1>Coverage Report</h1>
34
+ <div class="summary-bar">
35
+ <div class="summary-item">
36
+ <div class="value rate-{{ 'good' if summary.line_rate >= 0.8 else ('medium' if summary.line_rate >= 0.5 else 'low') }}">{{ (summary.line_rate * 100) | round(1) }}%</div>
37
+ <div class="label">Line Coverage ({{ summary.covered_lines }}/{{ summary.total_lines }})</div>
38
+ </div>
39
+ <div class="summary-item">
40
+ <div class="value rate-{{ 'good' if summary.branch_rate >= 0.8 else ('medium' if summary.branch_rate >= 0.5 else 'low') }}">{{ (summary.branch_rate * 100) | round(1) }}%</div>
41
+ <div class="label">Branch Coverage ({{ summary.covered_branches }}/{{ summary.total_branches }})</div>
42
+ </div>
43
+ </div>
44
+ <table id="file-table">
45
+ <thead>
46
+ <tr>
47
+ <th data-sort="file" onclick="sortTable(0)">File</th>
48
+ <th data-sort="lines" onclick="sortTable(1)">Lines (found/hit)</th>
49
+ <th data-sort="line-pct" onclick="sortTable(2)">Line %</th>
50
+ <th data-sort="branches" onclick="sortTable(3)">Branches (found/hit)</th>
51
+ <th data-sort="branch-pct" onclick="sortTable(4)">Branch %</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ {% for fs in file_summaries %}
56
+ <tr>
57
+ <td><a href="file_{{ fs.file_id }}.html">{{ fs.path }}</a></td>
58
+ <td>{{ fs.total_lines }}/{{ fs.covered_lines }}</td>
59
+ <td class="rate">{{ (fs.line_rate * 100) | round(1) }}%</td>
60
+ <td>{{ fs.total_branches }}/{{ fs.covered_branches }}</td>
61
+ <td class="rate">{{ (fs.branch_rate * 100) | round(1) }}%</td>
62
+ </tr>
63
+ {% endfor %}
64
+ </tbody>
65
+ </table>
66
+ <script>
67
+ function sortTable(colIndex) {
68
+ var table = document.getElementById("file-table");
69
+ var tbody = table.querySelector("tbody");
70
+ var rows = Array.from(tbody.querySelectorAll("tr"));
71
+ var ths = table.querySelectorAll("th");
72
+ var th = ths[colIndex];
73
+ var ascending = !th.classList.contains("sorted-asc");
74
+ ths.forEach(function(t) { t.classList.remove("sorted-asc", "sorted-desc"); });
75
+ th.classList.add(ascending ? "sorted-asc" : "sorted-desc");
76
+ rows.sort(function(a, b) {
77
+ var av = a.cells[colIndex].textContent.trim();
78
+ var bv = b.cells[colIndex].textContent.trim();
79
+ var an = parseFloat(av);
80
+ var bn = parseFloat(bv);
81
+ if (!isNaN(an) && !isNaN(bn)) {
82
+ return ascending ? an - bn : bn - an;
83
+ }
84
+ return ascending ? av.localeCompare(bv) : bv.localeCompare(av);
85
+ });
86
+ rows.forEach(function(r) { tbody.appendChild(r); });
87
+ }
88
+ </script>
89
+ </body>
90
+ </html>