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.
- gd_tools/__init__.py +3 -0
- gd_tools/__main__.py +31 -0
- gd_tools/addons/__init__.py +0 -0
- gd_tools/addons/gd-tools-coverage/coverage.gd +43 -0
- gd_tools/addons/gd-tools-coverage/post_run_hook.gd +113 -0
- gd_tools/addons/gd-tools-coverage/pre_run_hook.gd +229 -0
- gd_tools/cli.py +329 -0
- gd_tools/config.py +289 -0
- gd_tools/coverage/__init__.py +23 -0
- gd_tools/coverage/cobertura_reporter.py +136 -0
- gd_tools/coverage/html_reporter.py +95 -0
- gd_tools/coverage/lcov_reporter.py +90 -0
- gd_tools/coverage/orchestrator.py +282 -0
- gd_tools/coverage/plan_generator.py +377 -0
- gd_tools/coverage/reporter.py +518 -0
- gd_tools/coverage/templates/file.html +72 -0
- gd_tools/coverage/templates/index.html +90 -0
- gd_tools/coverage/terminal_reporter.py +107 -0
- gd_tools/doctor.py +495 -0
- gd_tools/errors.py +79 -0
- gd_tools/file_discovery.py +41 -0
- gd_tools/format_runner.py +118 -0
- gd_tools/godot.py +346 -0
- gd_tools/init.py +619 -0
- gd_tools/lint_runner.py +223 -0
- gd_tools/test_runner.py +474 -0
- gd_tools_cli-0.1.0.dist-info/METADATA +191 -0
- gd_tools_cli-0.1.0.dist-info/RECORD +31 -0
- gd_tools_cli-0.1.0.dist-info/WHEEL +5 -0
- gd_tools_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gd_tools_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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">← 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>
|