From 70d51c3fab554d0656a6ebfc030da37a4e8b08d5 Mon Sep 17 00:00:00 2001 From: Kevin Castro Date: Sun, 1 Feb 2026 09:52:34 -0500 Subject: [PATCH 1/9] Added coverage; branch-level --- jinjatest/__init__.py | 1 + jinjatest/coverage/__init__.py | 73 +++ jinjatest/coverage/collector.py | 212 ++++++++ jinjatest/coverage/discovery.py | 333 ++++++++++++ jinjatest/coverage/instrumenter.py | 361 +++++++++++++ jinjatest/coverage/pytest_cov.py | 169 ++++++ jinjatest/coverage/reporter.py | 621 ++++++++++++++++++++++ jinjatest/coverage/tracker.py | 176 ++++++ jinjatest/spec.py | 81 ++- pyproject.toml | 1 + tests/coverage/__init__.py | 1 + tests/coverage/test_collector.py | 228 ++++++++ tests/coverage/test_discovery.py | 348 ++++++++++++ tests/coverage/test_instrumenter.py | 381 +++++++++++++ tests/coverage/test_pytest_integration.py | 340 ++++++++++++ tests/coverage/test_reporter.py | 312 +++++++++++ tests/coverage/test_tracker.py | 243 +++++++++ tests/test_coverage_boost.py | 6 +- 18 files changed, 3882 insertions(+), 5 deletions(-) create mode 100644 jinjatest/coverage/__init__.py create mode 100644 jinjatest/coverage/collector.py create mode 100644 jinjatest/coverage/discovery.py create mode 100644 jinjatest/coverage/instrumenter.py create mode 100644 jinjatest/coverage/pytest_cov.py create mode 100644 jinjatest/coverage/reporter.py create mode 100644 jinjatest/coverage/tracker.py create mode 100644 tests/coverage/__init__.py create mode 100644 tests/coverage/test_collector.py create mode 100644 tests/coverage/test_discovery.py create mode 100644 tests/coverage/test_instrumenter.py create mode 100644 tests/coverage/test_pytest_integration.py create mode 100644 tests/coverage/test_reporter.py create mode 100644 tests/coverage/test_tracker.py diff --git a/jinjatest/__init__.py b/jinjatest/__init__.py index c7daa11..6b55d60 100644 --- a/jinjatest/__init__.py +++ b/jinjatest/__init__.py @@ -97,6 +97,7 @@ def test_welcome_pro_user(): "TemplateMarkers", # Utilities "normalize_text", + # Coverage (lazy import via jinjatest.coverage) ] # Optional YAML exports diff --git a/jinjatest/coverage/__init__.py b/jinjatest/coverage/__init__.py new file mode 100644 index 0000000..131d0ba --- /dev/null +++ b/jinjatest/coverage/__init__.py @@ -0,0 +1,73 @@ +""" +Jinja template branch coverage tracking. + +This module provides automatic branch coverage tracking for Jinja templates. + +Example: + from jinjatest.coverage import ( + get_coverage_collector, + CoverageReporter, + ReportConfig, + ) + + # Enable coverage collection + collector = get_coverage_collector() + collector.enable() + + # ... run tests with TemplateSpec ... + + # Generate reports + summary = collector.get_summary() + reporter = CoverageReporter(ReportConfig(fail_under=80)) + reporter.terminal_report(summary) +""" + +from jinjatest.coverage.collector import ( + CoverageCollector, + CoverageSummary, + get_coverage_collector, + reset_coverage_collector, + set_coverage_collector, +) +from jinjatest.coverage.discovery import ( + BranchDiscovery, + BranchInfo, + DiscoveryResult, +) +from jinjatest.coverage.instrumenter import ( + AutoInstrumenter, + InstrumentationResult, +) +from jinjatest.coverage.reporter import ( + CoverageReporter, + HTMLReporter, + JSONReporter, + ReportConfig, + TerminalReporter, +) +from jinjatest.coverage.tracker import ( + BranchCoverage, + TemplateCoverage, + TemplateCoverageStats, +) + +__all__ = [ + "CoverageCollector", + "CoverageSummary", + "get_coverage_collector", + "set_coverage_collector", + "reset_coverage_collector", + "BranchDiscovery", + "BranchInfo", + "DiscoveryResult", + "AutoInstrumenter", + "InstrumentationResult", + "TemplateCoverage", + "TemplateCoverageStats", + "BranchCoverage", + "CoverageReporter", + "TerminalReporter", + "JSONReporter", + "HTMLReporter", + "ReportConfig", +] diff --git a/jinjatest/coverage/collector.py b/jinjatest/coverage/collector.py new file mode 100644 index 0000000..9529e8f --- /dev/null +++ b/jinjatest/coverage/collector.py @@ -0,0 +1,212 @@ +""" +Coverage collector for aggregating coverage across multiple templates. + +This module provides a global singleton collector that can be used +during pytest sessions to track coverage across all template renders. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from threading import Lock +from typing import TYPE_CHECKING + +from jinjatest.coverage.tracker import TemplateCoverage, TemplateCoverageStats + +if TYPE_CHECKING: + pass + + +@dataclass +class CoverageSummary: + """Aggregated coverage summary across all templates.""" + + templates: list[TemplateCoverageStats] + total_branches: int = 0 + covered_branches: int = 0 + + def __post_init__(self) -> None: + """Calculate totals from template stats.""" + self.total_branches = sum(t.total_branches for t in self.templates) + self.covered_branches = sum(t.covered_branches for t in self.templates) + + @property + def coverage_percent(self) -> float: + """Get overall coverage percentage (0-100).""" + if self.total_branches == 0: + return 100.0 + return (self.covered_branches / self.total_branches) * 100 + + @property + def template_count(self) -> int: + """Get number of templates tracked.""" + return len(self.templates) + + def get_template_stats(self, path: str) -> TemplateCoverageStats | None: + """Get stats for a specific template. + + Args: + path: The template path. + + Returns: + TemplateCoverageStats or None if not found. + """ + for stats in self.templates: + if stats.template_path == path: + return stats + return None + + +class CoverageCollector: + """Global collector for tracking template coverage. + + Thread-safe singleton that aggregates coverage data across + all template renders during a test session. + + Example: + >>> collector = get_coverage_collector() + >>> instrumented = collector.register_template("my.j2", source) + >>> # ... render template ... + >>> collector.record_render("my.j2", trace_events) + >>> summary = collector.get_summary() + """ + + def __init__(self) -> None: + """Initialize the coverage collector.""" + self._trackers: dict[str, TemplateCoverage] = {} + self._lock = Lock() + self._enabled = False + + @property + def enabled(self) -> bool: + """Check if coverage collection is enabled.""" + return self._enabled + + def enable(self) -> None: + """Enable coverage collection.""" + self._enabled = True + + def disable(self) -> None: + """Disable coverage collection.""" + self._enabled = False + + def reset(self) -> None: + """Reset all coverage data.""" + with self._lock: + self._trackers.clear() + + def register_template( + self, + path: str, + source: str, + ) -> str: + """Register a template for coverage tracking. + + Args: + path: The template path (used as identifier). + source: The template source code. + + Returns: + The instrumented source code. + """ + if not self._enabled: + return source + + with self._lock: + if path not in self._trackers: + tracker = TemplateCoverage(source, path) + self._trackers[path] = tracker + + return self._trackers[path].instrumented_source + + def record_render( + self, + path: str, + trace_events: list[str], + ) -> None: + """Record coverage data from a template render. + + Args: + path: The template path. + trace_events: List of trace events from the render. + """ + if not self._enabled: + return + + with self._lock: + if path in self._trackers: + self._trackers[path].record_hits(trace_events) + + def get_tracker(self, path: str) -> TemplateCoverage | None: + """Get the tracker for a specific template. + + Args: + path: The template path. + + Returns: + TemplateCoverage or None if not registered. + """ + with self._lock: + return self._trackers.get(path) + + def get_summary(self) -> CoverageSummary: + """Get aggregated coverage summary. + + Returns: + CoverageSummary with stats for all templates. + """ + with self._lock: + template_stats = [ + tracker.get_stats() for tracker in self._trackers.values() + ] + + return CoverageSummary(templates=template_stats) + + def get_all_trackers(self) -> dict[str, TemplateCoverage]: + """Get all registered trackers. + + Returns: + Dictionary mapping paths to trackers. + """ + with self._lock: + return dict(self._trackers) + + +# Global singleton instance +_collector: CoverageCollector | None = None +_collector_lock = Lock() + + +def get_coverage_collector() -> CoverageCollector: + """Get the global coverage collector singleton. + + Returns: + The global CoverageCollector instance. + """ + global _collector + with _collector_lock: + if _collector is None: + _collector = CoverageCollector() + return _collector + + +def set_coverage_collector(collector: CoverageCollector | None) -> None: + """Set the global coverage collector. + + Useful for testing or resetting state. + + Args: + collector: The collector to set, or None to reset. + """ + global _collector + with _collector_lock: + _collector = collector + + +def reset_coverage_collector() -> None: + """Reset the global coverage collector to a fresh state.""" + global _collector + with _collector_lock: + if _collector is not None: + _collector.reset() + _collector.disable() diff --git a/jinjatest/coverage/discovery.py b/jinjatest/coverage/discovery.py new file mode 100644 index 0000000..6acaf9c --- /dev/null +++ b/jinjatest/coverage/discovery.py @@ -0,0 +1,333 @@ +""" +Branch discovery for Jinja templates. + +This module provides functionality to walk the Jinja2 AST and discover +all conditional branches that can be tracked for coverage. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from jinja2 import Environment + +if TYPE_CHECKING: + from jinja2 import nodes + + +@dataclass +class BranchInfo: + """Information about a single branch in a template.""" + + branch_id: str + branch_type: str # "if_true", "if_false", "for_body", "for_else", "elif" + line: int + description: str + has_else: bool = False # Whether parent if/elif has an else block + + def __hash__(self) -> int: + return hash(self.branch_id) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BranchInfo): + return NotImplemented + return self.branch_id == other.branch_id + + +@dataclass +class DiscoveryResult: + """Result of branch discovery for a template.""" + + branches: list[BranchInfo] = field(default_factory=list) + template_path: str | None = None + + @property + def branch_ids(self) -> set[str]: + """Get set of all branch IDs.""" + return {b.branch_id for b in self.branches} + + @property + def branch_count(self) -> int: + """Get total number of branches.""" + return len(self.branches) + + def get_branch(self, branch_id: str) -> BranchInfo | None: + """Get branch info by ID.""" + for branch in self.branches: + if branch.branch_id == branch_id: + return branch + return None + + +class BranchDiscovery: + """Discovers conditional branches in Jinja templates by walking the AST. + + Handles the following node types: + - If: Generates if__true and if__false branches + - For: Generates for__body and for__else branches + - Macro: Generates macro_ branches (for tracking if macro was called) + - Include: Generates include_ branches + + For elif chains, the AST represents them as nested If nodes, + so we handle them recursively. + """ + + def __init__(self, env: Environment | None = None) -> None: + """Initialize the branch discovery. + + Args: + env: Optional Jinja environment to use for parsing. + If not provided, a default environment is created. + """ + self._env = env or Environment() + + def discover( + self, source: str, template_path: str | None = None + ) -> DiscoveryResult: + """Discover all branches in a template source. + + Args: + source: The template source code. + template_path: Optional path for identification in reports. + + Returns: + DiscoveryResult containing all discovered branches. + """ + ast = self._env.parse(source) + branches: list[BranchInfo] = [] + self._walk_node(ast, branches) + + return DiscoveryResult(branches=branches, template_path=template_path) + + def _walk_node( + self, + node: nodes.Node, + branches: list[BranchInfo], + in_elif: bool = False, + ) -> None: + """Recursively walk AST nodes to find branches. + + Args: + node: The current AST node. + branches: List to append discovered branches to. + in_elif: Whether we're processing an elif chain. + """ + from jinja2 import nodes + + if isinstance(node, nodes.If): + self._handle_if_node(node, branches, in_elif) + elif isinstance(node, nodes.For): + self._handle_for_node(node, branches) + elif isinstance(node, nodes.Macro): + self._handle_macro_node(node, branches) + elif isinstance(node, nodes.Include): + self._handle_include_node(node, branches) + else: + for child in node.iter_child_nodes(): + self._walk_node(child, branches) + + def _handle_if_node( + self, + node: nodes.If, + branches: list[BranchInfo], + in_elif: bool = False, + parent_has_else: bool = False, + ) -> None: + """Handle If node (includes elif handling). + + Args: + node: The If AST node. + branches: List to append discovered branches to. + in_elif: Whether this If is part of an elif chain. + parent_has_else: Whether the parent if/elif has an else clause. + """ + from jinja2 import nodes + + line = node.lineno + prefix = "elif" if in_elif else "if" + + branches.append( + BranchInfo( + branch_id=f"{prefix}_{line}_true", + branch_type=f"{prefix}_true", + line=line, + description=f"{prefix} condition at line {line} is true", + ) + ) + + # Check if there are elif branches + # In Jinja AST, elif_ contains If nodes for each elif clause + has_elif = bool(node.elif_) + has_else = bool(node.else_) + + if has_elif: + # Process elif chain - the elif_ contains If nodes + # Pass along whether there's an else clause at the end + for i, elif_node in enumerate(node.elif_): + if isinstance(elif_node, nodes.If): + is_last = i == len(node.elif_) - 1 + # The else_ of this node applies to the last elif + self._handle_if_node( + elif_node, + branches, + in_elif=True, + parent_has_else=has_else if is_last else False, + ) + + # Add false branch based on the structure + if in_elif: + # For elif nodes, the parent handles the false branch + # We only need to add it if we're the last elif AND parent has else + if parent_has_else: + branches.append( + BranchInfo( + branch_id=f"{prefix}_{line}_false", + branch_type=f"{prefix}_false", + line=line, + description=f"{prefix} condition at line {line} is false (else taken)", + has_else=True, + ) + ) + elif not has_elif: + # Last elif in chain with no else - implicit false + branches.append( + BranchInfo( + branch_id=f"{prefix}_{line}_false", + branch_type=f"{prefix}_false", + line=line, + description=f"{prefix} condition at line {line} is false (no else)", + has_else=False, + ) + ) + else: + # For top-level if + if has_else and not has_elif: + # Plain else branch (no elif) + branches.append( + BranchInfo( + branch_id=f"{prefix}_{line}_false", + branch_type=f"{prefix}_false", + line=line, + description=f"{prefix} condition at line {line} is false (else taken)", + has_else=True, + ) + ) + elif not has_else and not has_elif: + # No else or elif branch - implicit false case + branches.append( + BranchInfo( + branch_id=f"{prefix}_{line}_false", + branch_type=f"{prefix}_false", + line=line, + description=f"{prefix} condition at line {line} is false (no else)", + has_else=False, + ) + ) + # If has_elif, the false branch is handled by the last elif + + for child in node.body: + self._walk_node(child, branches) + + for child in node.else_: + self._walk_node(child, branches) + + def _handle_for_node( + self, + node: nodes.For, + branches: list[BranchInfo], + ) -> None: + """Handle For node. + + Args: + node: The For AST node. + branches: List to append discovered branches to. + """ + line = node.lineno + + branches.append( + BranchInfo( + branch_id=f"for_{line}_body", + branch_type="for_body", + line=line, + description=f"for loop at line {line} has items", + ) + ) + + if node.else_: + branches.append( + BranchInfo( + branch_id=f"for_{line}_else", + branch_type="for_else", + line=line, + description=f"for loop at line {line} has no items (else taken)", + has_else=True, + ) + ) + else: + # No else block - implicit else case (nothing happens when empty) + branches.append( + BranchInfo( + branch_id=f"for_{line}_else", + branch_type="for_else", + line=line, + description=f"for loop at line {line} has no items (no else)", + has_else=False, + ) + ) + + for child in node.body: + self._walk_node(child, branches) + + for child in node.else_: + self._walk_node(child, branches) + + def _handle_macro_node( + self, + node: nodes.Macro, + branches: list[BranchInfo], + ) -> None: + """Handle Macro node. + + Args: + node: The Macro AST node. + branches: List to append discovered branches to. + """ + # Track macro definition (not calls - those are harder to track) + branches.append( + BranchInfo( + branch_id=f"macro_{node.name}", + branch_type="macro", + line=node.lineno, + description=f"macro '{node.name}' defined at line {node.lineno}", + ) + ) + + for child in node.body: + self._walk_node(child, branches) + + def _handle_include_node( + self, + node: nodes.Include, + branches: list[BranchInfo], + ) -> None: + """Handle Include node. + + Args: + node: The Include AST node. + branches: List to append discovered branches to. + """ + from jinja2 import nodes + + template_name = "dynamic" + if isinstance(node.template, nodes.Const): + template_name = str(node.template.value) + + branches.append( + BranchInfo( + branch_id=f"include_{node.lineno}_{template_name}", + branch_type="include", + line=node.lineno, + description=f"include '{template_name}' at line {node.lineno}", + ) + ) diff --git a/jinjatest/coverage/instrumenter.py b/jinjatest/coverage/instrumenter.py new file mode 100644 index 0000000..11a7364 --- /dev/null +++ b/jinjatest/coverage/instrumenter.py @@ -0,0 +1,361 @@ +""" +Auto-instrumenter for Jinja templates. + +This module provides functionality to automatically insert trace calls +into Jinja templates based on discovered branches. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from jinjatest.coverage.discovery import BranchDiscovery, DiscoveryResult + + +@dataclass +class InstrumentationResult: + """Result of auto-instrumenting a template.""" + + source: str + original_source: str + discovery: DiscoveryResult + insertions: int + + @property + def was_modified(self) -> bool: + """Check if the source was modified.""" + return self.source != self.original_source + + +class AutoInstrumenter: + """Auto-instruments Jinja templates with trace calls for branch coverage. + + Inserts `{{ jt.trace("branch_id") }}` calls after block tags to track + which branches are executed during rendering. + + Example: + >>> instrumenter = AutoInstrumenter() + >>> result = instrumenter.instrument(''' + ... {% if show_header %} + ... Header + ... {% else %} + ... No header + ... {% endif %} + ... ''') + >>> '{{ jt.trace("if_2_true") }}' in result.source + True + """ + + IF_PATTERN = re.compile( + r"(\{%-?\s*if\s+.*?-?%\})", + re.DOTALL, + ) + ELIF_PATTERN = re.compile( + r"(\{%-?\s*elif\s+.*?-?%\})", + re.DOTALL, + ) + ELSE_PATTERN = re.compile( + r"(\{%-?\s*else\s*-?%\})", + re.DOTALL, + ) + FOR_PATTERN = re.compile( + r"(\{%-?\s*for\s+.*?-?%\})", + re.DOTALL, + ) + MACRO_PATTERN = re.compile( + r"(\{%-?\s*macro\s+(\w+)\s*\(.*?\)\s*-?%\})", + re.DOTALL, + ) + + BLOCK_TAG_PATTERN = re.compile( + r"\{%-?\s*(if|elif|else|endif|for|endfor)\b.*?-?%\}", + re.DOTALL, + ) + + def __init__(self) -> None: + """Initialize the auto-instrumenter.""" + self._discovery = BranchDiscovery() + + def instrument( + self, + source: str, + template_path: str | None = None, + ) -> InstrumentationResult: + """Instrument a template source with trace calls. + + Args: + source: The template source code. + template_path: Optional path for identification. + + Returns: + InstrumentationResult containing the instrumented source. + """ + discovery = self._discovery.discover(source, template_path) + + line_branches: dict[int, list[str]] = {} + for branch in discovery.branches: + if branch.line not in line_branches: + line_branches[branch.line] = [] + line_branches[branch.line].append(branch.branch_id) + + lines = source.split("\n") + instrumented_lines: list[str] = [] + insertions = 0 + + for line_num, line in enumerate(lines, start=1): + new_line = line + + new_line, count = self._instrument_if(new_line, line_num) + insertions += count + + new_line, count = self._instrument_elif(new_line, line_num) + insertions += count + + new_line, count = self._instrument_else(new_line, line_num, discovery) + insertions += count + + new_line, count = self._instrument_for(new_line, line_num) + insertions += count + + new_line, count = self._instrument_macro(new_line) + insertions += count + + instrumented_lines.append(new_line) + + instrumented_source = "\n".join(instrumented_lines) + + instrumented_source, implicit_count = self._instrument_implicit_false( + instrumented_source, discovery + ) + insertions += implicit_count + + return InstrumentationResult( + source=instrumented_source, + original_source=source, + discovery=discovery, + insertions=insertions, + ) + + def _instrument_if(self, line: str, line_num: int) -> tuple[str, int]: + """Instrument if statements in a line. + + Args: + line: The line to process. + line_num: The line number. + + Returns: + Tuple of (modified line, insertion count). + """ + count = 0 + + def replace_if(match: re.Match[str]) -> str: + nonlocal count + count += 1 + tag = match.group(1) + trace_call = f'{{{{ jt.trace("if_{line_num}_true") }}}}' + return f"{tag}{trace_call}" + + new_line = self.IF_PATTERN.sub(replace_if, line) + return new_line, count + + def _instrument_elif(self, line: str, line_num: int) -> tuple[str, int]: + """Instrument elif statements in a line. + + Args: + line: The line to process. + line_num: The line number. + + Returns: + Tuple of (modified line, insertion count). + """ + count = 0 + + def replace_elif(match: re.Match[str]) -> str: + nonlocal count + count += 1 + tag = match.group(1) + trace_call = f'{{{{ jt.trace("elif_{line_num}_true") }}}}' + return f"{tag}{trace_call}" + + new_line = self.ELIF_PATTERN.sub(replace_elif, line) + return new_line, count + + def _instrument_else( + self, + line: str, + line_num: int, + discovery: DiscoveryResult, + ) -> tuple[str, int]: + """Instrument else statements in a line. + + Args: + line: The line to process. + line_num: The line number. + discovery: The discovery result to find associated if/for. + + Returns: + Tuple of (modified line, insertion count). + """ + count = 0 + + def replace_else(match: re.Match[str]) -> str: + nonlocal count + + branch_id = self._find_else_branch_id(line_num, discovery) + if branch_id: + count += 1 + tag = match.group(1) + trace_call = f'{{{{ jt.trace("{branch_id}") }}}}' + return f"{tag}{trace_call}" + + return match.group(0) + + new_line = self.ELSE_PATTERN.sub(replace_else, line) + return new_line, count + + def _find_else_branch_id( + self, + else_line: int, + discovery: DiscoveryResult, + ) -> str | None: + """Find the branch ID for an else statement. + + This looks for the nearest if/elif/for that would + have a false/else branch at this line. + + Args: + else_line: The line number of the else statement. + discovery: The discovery result. + + Returns: + The branch ID or None if not found. + """ + candidates: list[tuple[int, str]] = [] + for branch in discovery.branches: + if branch.branch_type in ("if_false", "elif_false", "for_else"): + if branch.has_else and branch.line <= else_line: + candidates.append((branch.line, branch.branch_id)) + + if not candidates: + return None + + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + def _instrument_for(self, line: str, line_num: int) -> tuple[str, int]: + """Instrument for loop statements in a line. + + Args: + line: The line to process. + line_num: The line number. + + Returns: + Tuple of (modified line, insertion count). + """ + count = 0 + + def replace_for(match: re.Match[str]) -> str: + nonlocal count + count += 1 + tag = match.group(1) + trace_call = f'{{{{ jt.trace("for_{line_num}_body") }}}}' + return f"{tag}{trace_call}" + + new_line = self.FOR_PATTERN.sub(replace_for, line) + return new_line, count + + def _instrument_macro(self, line: str) -> tuple[str, int]: + """Instrument macro definitions in a line. + + Args: + line: The line to process. + + Returns: + Tuple of (modified line, insertion count). + """ + count = 0 + + def replace_macro(match: re.Match[str]) -> str: + nonlocal count + count += 1 + tag = match.group(1) + macro_name = match.group(2) + trace_call = f'{{{{ jt.trace("macro_{macro_name}") }}}}' + return f"{tag}{trace_call}" + + new_line = self.MACRO_PATTERN.sub(replace_macro, line) + return new_line, count + + def _instrument_implicit_false( + self, + source: str, + discovery: DiscoveryResult, + ) -> tuple[str, int]: + """Instrument implicit false/else branches for bare if and for statements. + + For bare if statements like {% if x %}...{% endif %}, we inject + {% else %}{{ jt.trace("if_X_false") }} before {% endif %}. + + For bare for loops like {% for x in items %}...{% endfor %}, we inject + {% else %}{{ jt.trace("for_X_else") }} before {% endfor %}. + + Args: + source: The template source (already line-instrumented). + discovery: The discovery result containing branch info. + + Returns: + Tuple of (modified source, insertion count). + """ + implicit_branches: dict[int, str] = {} + for branch in discovery.branches: + if not branch.has_else: + if branch.branch_type.endswith("_false"): + implicit_branches[branch.line] = branch.branch_id + elif branch.branch_type == "for_else": + implicit_branches[branch.line] = branch.branch_id + + if not implicit_branches: + return source, 0 + + # Stack entry: (block_type, start_line, last_branch_line, has_seen_else) + stack: list[tuple[str, int, int, bool]] = [] + insertions: list[tuple[int, str]] = [] + + for match in self.BLOCK_TAG_PATTERN.finditer(source): + keyword = match.group(1) + tag_start = match.start() + line_num = source[:tag_start].count("\n") + 1 + + if keyword == "if": + stack.append(("if", line_num, line_num, False)) + elif keyword == "for": + stack.append(("for", line_num, line_num, False)) + elif keyword == "elif": + if stack and stack[-1][0] == "if": + block_type, start_line, _, has_else = stack.pop() + stack.append((block_type, start_line, line_num, has_else)) + elif keyword == "else": + if stack: + block_type, start_line, last_branch_line, _ = stack.pop() + stack.append((block_type, start_line, last_branch_line, True)) + elif keyword == "endif": + if stack and stack[-1][0] == "if": + _, start_line, last_branch_line, has_else = stack.pop() + if not has_else and last_branch_line in implicit_branches: + branch_id = implicit_branches[last_branch_line] + insert_text = f'{{% else %}}{{{{ jt.trace("{branch_id}") }}}}' + insertions.append((tag_start, insert_text)) + elif keyword == "endfor": + if stack and stack[-1][0] == "for": + _, start_line, _, has_else = stack.pop() + if not has_else and start_line in implicit_branches: + branch_id = implicit_branches[start_line] + insert_text = f'{{% else %}}{{{{ jt.trace("{branch_id}") }}}}' + insertions.append((tag_start, insert_text)) + + insertions.sort(key=lambda x: x[0], reverse=True) + for pos, text in insertions: + source = source[:pos] + text + source[pos:] + + return source, len(insertions) diff --git a/jinjatest/coverage/pytest_cov.py b/jinjatest/coverage/pytest_cov.py new file mode 100644 index 0000000..5061360 --- /dev/null +++ b/jinjatest/coverage/pytest_cov.py @@ -0,0 +1,169 @@ +""" +Pytest plugin for Jinja template coverage. + +Provides CLI options and hooks for coverage collection during test runs. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from jinjatest.coverage.collector import ( + get_coverage_collector, + reset_coverage_collector, +) +from jinjatest.coverage.reporter import CoverageReporter, ReportConfig + +if TYPE_CHECKING: + pass + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add command-line options for jinja template coverage.""" + group = parser.getgroup("jinjatest-cov", "Jinja template coverage") + + group.addoption( + "--jt-cov", + action="store_true", + default=False, + help="Enable Jinja template branch coverage tracking", + ) + + group.addoption( + "--jt-cov-fail-under", + action="store", + type=float, + default=0.0, + metavar="MIN", + help="Fail if template coverage is below MIN percent", + ) + + group.addoption( + "--jt-cov-report", + action="append", + default=[], + metavar="TYPE", + help=( + "Coverage report type: term, term-verbose, json, html " + "(can be specified multiple times)" + ), + ) + + group.addoption( + "--jt-cov-html", + action="store", + default=None, + metavar="DIR", + help="Directory for HTML coverage report (default: jt-htmlcov)", + ) + + group.addoption( + "--jt-cov-json", + action="store", + default=None, + metavar="FILE", + help="File for JSON coverage report (default: jt-coverage.json)", + ) + + +def pytest_configure(config: pytest.Config) -> None: + """Configure coverage collection if enabled.""" + if config.getoption("--jt-cov"): + collector = get_coverage_collector() + collector.enable() + config._jt_cov_enabled = True # type: ignore[attr-defined] + else: + config._jt_cov_enabled = False # type: ignore[attr-defined] + + +def pytest_unconfigure(config: pytest.Config) -> None: + """Clean up coverage collector.""" + reset_coverage_collector() + + +def pytest_sessionfinish( + session: pytest.Session, + exitstatus: int, +) -> None: + """Generate coverage reports at end of session.""" + config = session.config + + if not getattr(config, "_jt_cov_enabled", False): + return + + collector = get_coverage_collector() + summary = collector.get_summary() + + fail_under = config.getoption("--jt-cov-fail-under", 0.0) + report_types = config.getoption("--jt-cov-report", []) + + if not report_types: + report_types = ["term"] + + report_types = [r.lower() for r in report_types] + + report_config = ReportConfig( + fail_under=fail_under, + show_missing=True, + verbose="term-verbose" in report_types, + ) + reporter = CoverageReporter(report_config) + + terminalreporter = config.pluginmanager.get_plugin("terminalreporter") + output = terminalreporter._tw if terminalreporter else None + + if "term" in report_types or "term-verbose" in report_types: + report_text = reporter.terminal_report(summary) + if output: + output.write(report_text) + output.line() + + if "json" in report_types: + json_path = config.getoption("--jt-cov-json") or "jt-coverage.json" + reporter.json_report(summary, Path(json_path)) + if output: + output.line(f"JSON report written to: {json_path}") + + if "html" in report_types: + html_dir = config.getoption("--jt-cov-html") or "jt-htmlcov" + sources: dict[str, str] = {} + for path, tracker in collector.get_all_trackers().items(): + if tracker.source: + sources[path] = tracker.source + reporter.html_report(summary, Path(html_dir), sources) + if output: + output.line(f"HTML report written to: {html_dir}/") + + if fail_under > 0 and summary.coverage_percent < fail_under: + session.config._jt_cov_failed = True # type: ignore[attr-defined] + + +def pytest_terminal_summary( + terminalreporter: pytest.TerminalReporter, + exitstatus: int, + config: pytest.Config, +) -> None: + """Add coverage failure message to terminal summary.""" + if getattr(config, "_jt_cov_failed", False): + collector = get_coverage_collector() + summary = collector.get_summary() + fail_under = config.getoption("--jt-cov-fail-under", 0.0) + + terminalreporter.write_line( + f"\nFAILED: Jinja template coverage {summary.coverage_percent:.1f}% " + f"< required {fail_under:.1f}%", + red=True, + bold=True, + ) + + +@pytest.hookimpl(trylast=True) +def pytest_sessionstart(session: pytest.Session) -> None: + """Reset coverage at session start.""" + if getattr(session.config, "_jt_cov_enabled", False): + collector = get_coverage_collector() + collector.reset() + collector.enable() diff --git a/jinjatest/coverage/reporter.py b/jinjatest/coverage/reporter.py new file mode 100644 index 0000000..e2a9cbd --- /dev/null +++ b/jinjatest/coverage/reporter.py @@ -0,0 +1,621 @@ +""" +Coverage reporters for terminal, JSON, and HTML output. + +This module provides different report formats for coverage data. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, TextIO + +if TYPE_CHECKING: + from jinjatest.coverage.collector import CoverageSummary + from jinjatest.coverage.tracker import TemplateCoverageStats + + +@dataclass +class ReportConfig: + """Configuration for coverage reports.""" + + fail_under: float = 0.0 + show_missing: bool = True + verbose: bool = False + + +class TerminalReporter: + """Terminal coverage reporter. + + Outputs coverage information to the terminal in a tabular format. + """ + + def __init__(self, config: ReportConfig | None = None) -> None: + """Initialize the terminal reporter. + + Args: + config: Optional report configuration. + """ + self.config = config or ReportConfig() + + def report( + self, + summary: CoverageSummary, + output: TextIO | None = None, + ) -> str: + """Generate a terminal coverage report. + + Args: + summary: The coverage summary to report. + output: Optional file-like object to write to. + + Returns: + The formatted report string. + """ + lines: list[str] = [] + + lines.append("") + lines.append("=" * 70) + lines.append("JINJA TEMPLATE COVERAGE") + lines.append("=" * 70) + lines.append("") + + if not summary.templates: + lines.append("No templates tracked.") + lines.append("") + result = "\n".join(lines) + if output: + output.write(result) + return result + + lines.append( + f"{'Template':<40} {'Branches':>10} {'Covered':>10} {'Coverage':>10}" + ) + lines.append("-" * 70) + + for stats in sorted(summary.templates, key=lambda s: s.template_path or ""): + path = self._truncate_path(stats.template_path or "", 40) + lines.append( + f"{path:<40} {stats.total_branches:>10} " + f"{stats.covered_branches:>10} {stats.coverage_percent:>9.1f}%" + ) + + if self.config.verbose and self.config.show_missing: + for branch_cov in stats.uncovered_branches: + lines.append( + f" - {branch_cov.branch.branch_id}: " + f"{branch_cov.branch.description}" + ) + + lines.append("-" * 70) + lines.append( + f"{'TOTAL':<40} {summary.total_branches:>10} " + f"{summary.covered_branches:>10} {summary.coverage_percent:>9.1f}%" + ) + lines.append("") + + if self.config.fail_under > 0: + if summary.coverage_percent < self.config.fail_under: + lines.append( + f"FAIL: Coverage {summary.coverage_percent:.1f}% " + f"< required {self.config.fail_under:.1f}%" + ) + else: + lines.append( + f"OK: Coverage {summary.coverage_percent:.1f}% " + f">= required {self.config.fail_under:.1f}%" + ) + lines.append("") + + result = "\n".join(lines) + if output: + output.write(result) + return result + + def _truncate_path(self, path: str, max_len: int) -> str: + """Truncate a path to fit in a column. + + Args: + path: The path to truncate. + max_len: Maximum length. + + Returns: + Truncated path with ellipsis if needed. + """ + if len(path) <= max_len: + return path + return "..." + path[-(max_len - 3) :] + + +class JSONReporter: + """JSON coverage reporter. + + Outputs coverage information in a machine-readable JSON format. + """ + + def __init__(self, config: ReportConfig | None = None) -> None: + """Initialize the JSON reporter. + + Args: + config: Optional report configuration. + """ + self.config = config or ReportConfig() + + def report( + self, + summary: CoverageSummary, + output: TextIO | None = None, + ) -> str: + """Generate a JSON coverage report. + + Args: + summary: The coverage summary to report. + output: Optional file-like object to write to. + + Returns: + The JSON string. + """ + data = { + "summary": { + "total_branches": summary.total_branches, + "covered_branches": summary.covered_branches, + "coverage_percent": round(summary.coverage_percent, 2), + "template_count": summary.template_count, + }, + "templates": [self._template_to_dict(stats) for stats in summary.templates], + } + + if self.config.fail_under > 0: + data["fail_under"] = self.config.fail_under + data["passed"] = summary.coverage_percent >= self.config.fail_under + + result = json.dumps(data, indent=2) + if output: + output.write(result) + return result + + def write_to_file(self, summary: CoverageSummary, path: Path) -> None: + """Write JSON report to a file. + + Args: + summary: The coverage summary. + path: The output file path. + """ + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + self.report(summary, f) + + def _template_to_dict(self, stats: TemplateCoverageStats) -> dict: + """Convert template stats to a dictionary. + + Args: + stats: The template coverage stats. + + Returns: + Dictionary representation. + """ + data = { + "path": stats.template_path, + "total_branches": stats.total_branches, + "covered_branches": stats.covered_branches, + "coverage_percent": round(stats.coverage_percent, 2), + } + + if self.config.show_missing: + data["uncovered"] = [ + { + "branch_id": bc.branch.branch_id, + "line": bc.branch.line, + "type": bc.branch.branch_type, + "description": bc.branch.description, + } + for bc in stats.uncovered_branches + ] + data["covered"] = [ + { + "branch_id": bc.branch.branch_id, + "line": bc.branch.line, + "type": bc.branch.branch_type, + "hit_count": bc.hit_count, + } + for bc in stats.covered_branch_list + ] + + return data + + +class HTMLReporter: + """HTML coverage reporter. + + Generates an HTML report with source highlighting. + """ + + def __init__(self, config: ReportConfig | None = None) -> None: + """Initialize the HTML reporter. + + Args: + config: Optional report configuration. + """ + self.config = config or ReportConfig() + + def report( + self, + summary: CoverageSummary, + output_dir: Path, + sources: dict[str, str] | None = None, + ) -> None: + """Generate an HTML coverage report. + + Args: + summary: The coverage summary. + output_dir: Directory to write HTML files to. + sources: Optional dict mapping template paths to source code. + """ + output_dir.mkdir(parents=True, exist_ok=True) + + index_html = self._generate_index(summary) + (output_dir / "index.html").write_text(index_html) + + if sources: + for stats in summary.templates: + if stats.template_path and stats.template_path in sources: + template_html = self._generate_template_page( + stats, sources[stats.template_path] + ) + safe_name = self._safe_filename(stats.template_path) + (output_dir / f"{safe_name}.html").write_text(template_html) + + css = self._generate_css() + (output_dir / "style.css").write_text(css) + + def _generate_index(self, summary: CoverageSummary) -> str: + """Generate the index HTML page. + + Args: + summary: The coverage summary. + + Returns: + HTML string. + """ + rows = [] + for stats in sorted(summary.templates, key=lambda s: s.template_path or ""): + path = stats.template_path or "<string>" + safe_name = self._safe_filename(stats.template_path or "string") + coverage_class = self._coverage_class(stats.coverage_percent) + rows.append(f""" + + {self._escape(path)} + {stats.total_branches} + {stats.covered_branches} + {stats.coverage_percent:.1f}% + + """) + + coverage_class = self._coverage_class(summary.coverage_percent) + + return f""" + + + + Jinja Template Coverage Report + + + +

Jinja Template Coverage Report

+ +
+

Summary

+

Total Coverage: {summary.coverage_percent:.1f}%

+

Templates: {summary.template_count} | Branches: {summary.covered_branches}/{summary.total_branches}

+
+ + + + + + + + + + + + {"".join(rows)} + + + + + + + + + +
TemplateBranchesCoveredCoverage
TOTAL{summary.total_branches}{summary.covered_branches}{summary.coverage_percent:.1f}%
+ + +""" + + def _generate_template_page( + self, + stats: TemplateCoverageStats, + source: str, + ) -> str: + """Generate an HTML page for a single template. + + Args: + stats: The template coverage stats. + source: The template source code. + + Returns: + HTML string. + """ + covered_lines: set[int] = set() + uncovered_lines: set[int] = set() + + for bc in stats.covered_branch_list: + covered_lines.add(bc.branch.line) + for bc in stats.uncovered_branches: + uncovered_lines.add(bc.branch.line) + + lines = source.split("\n") + source_lines = [] + for i, line in enumerate(lines, start=1): + if i in uncovered_lines: + line_class = "uncovered" + elif i in covered_lines: + line_class = "covered" + else: + line_class = "" + + escaped = self._escape(line) or " " + source_lines.append( + f'' + f'{i}' + f'
{escaped}
' + f"" + ) + + path = stats.template_path or "<string>" + coverage_class = self._coverage_class(stats.coverage_percent) + + return f""" + + + + Coverage: {self._escape(path)} + + + +

← Back to index

+ +

{self._escape(path)}

+ +
+

Coverage: {stats.coverage_percent:.1f}% + ({stats.covered_branches}/{stats.total_branches} branches)

+
+ +

Uncovered Branches

+
    + {"".join(f"
  • Line {bc.branch.line}: {self._escape(bc.branch.description)}
  • " for bc in stats.uncovered_branches) or "
  • All branches covered!
  • "} +
+ +

Source

+ + {"".join(source_lines)} +
+ + +""" + + def _generate_css(self) -> str: + """Generate CSS stylesheet. + + Returns: + CSS string. + """ + return """ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 2em; + line-height: 1.6; +} + +h1 { + border-bottom: 2px solid #333; + padding-bottom: 0.5em; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +th, td { + border: 1px solid #ddd; + padding: 8px 12px; + text-align: left; +} + +th { + background: #f5f5f5; +} + +.num { + text-align: right; +} + +.summary { + padding: 1em; + border-radius: 4px; + margin: 1em 0; +} + +.high { + background-color: #d4edda; +} + +.medium { + background-color: #fff3cd; +} + +.low { + background-color: #f8d7da; +} + +tr.covered { + background-color: #d4edda; +} + +tr.uncovered { + background-color: #f8d7da; +} + +.source-table { + font-family: monospace; +} + +.source-table .lineno { + width: 50px; + text-align: right; + color: #999; + user-select: none; + background: #f5f5f5; +} + +.source-table .source pre { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +.branch-list { + margin: 1em 0; +} + +.branch-list li { + margin: 0.5em 0; +} + +a { + color: #007bff; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} +""" + + def _coverage_class(self, percent: float) -> str: + """Get CSS class based on coverage percentage. + + Args: + percent: Coverage percentage. + + Returns: + CSS class name. + """ + if percent >= 80: + return "high" + elif percent >= 50: + return "medium" + return "low" + + def _safe_filename(self, path: str | None) -> str: + """Convert a path to a safe filename. + + Args: + path: The path. + + Returns: + Safe filename. + """ + if not path: + return "string" + safe = path.replace("/", "_").replace("\\", "_").replace(":", "_") + safe = safe.replace(" ", "_").replace(".", "_") + return safe + + def _escape(self, text: str) -> str: + """Escape HTML special characters. + + Args: + text: Text to escape. + + Returns: + Escaped text. + """ + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +class CoverageReporter: + """Unified reporter that can output multiple formats. + + Example: + >>> reporter = CoverageReporter(config) + >>> reporter.terminal_report(summary) + >>> reporter.json_report(summary, Path("coverage.json")) + >>> reporter.html_report(summary, Path("htmlcov"), sources) + """ + + def __init__(self, config: ReportConfig | None = None) -> None: + """Initialize the coverage reporter. + + Args: + config: Optional report configuration. + """ + self.config = config or ReportConfig() + self._terminal = TerminalReporter(self.config) + self._json = JSONReporter(self.config) + self._html = HTMLReporter(self.config) + + def terminal_report( + self, + summary: CoverageSummary, + output: TextIO | None = None, + ) -> str: + """Generate a terminal report. + + Args: + summary: The coverage summary. + output: Optional output stream. + + Returns: + The report string. + """ + return self._terminal.report(summary, output) + + def json_report( + self, + summary: CoverageSummary, + path: Path, + ) -> None: + """Write a JSON report to a file. + + Args: + summary: The coverage summary. + path: Output file path. + """ + self._json.write_to_file(summary, path) + + def html_report( + self, + summary: CoverageSummary, + output_dir: Path, + sources: dict[str, str] | None = None, + ) -> None: + """Generate an HTML report. + + Args: + summary: The coverage summary. + output_dir: Output directory. + sources: Optional dict mapping paths to sources. + """ + self._html.report(summary, output_dir, sources) diff --git a/jinjatest/coverage/tracker.py b/jinjatest/coverage/tracker.py new file mode 100644 index 0000000..3e7308f --- /dev/null +++ b/jinjatest/coverage/tracker.py @@ -0,0 +1,176 @@ +""" +Template coverage tracking. + +This module provides the TemplateCoverage class that combines +discovery and instrumentation to track coverage for a single template. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from jinjatest.coverage.discovery import BranchInfo, DiscoveryResult +from jinjatest.coverage.instrumenter import AutoInstrumenter + + +@dataclass +class BranchCoverage: + """Coverage information for a single branch.""" + + branch: BranchInfo + hit_count: int = 0 + + @property + def was_hit(self) -> bool: + """Check if this branch was executed at least once.""" + return self.hit_count > 0 + + +@dataclass +class TemplateCoverageStats: + """Coverage statistics for a template.""" + + template_path: str | None + total_branches: int + covered_branches: int + branch_details: list[BranchCoverage] + + @property + def coverage_percent(self) -> float: + """Get coverage percentage (0-100).""" + if self.total_branches == 0: + return 100.0 + return (self.covered_branches / self.total_branches) * 100 + + @property + def uncovered_branches(self) -> list[BranchCoverage]: + """Get list of branches that were not covered.""" + return [b for b in self.branch_details if not b.was_hit] + + @property + def covered_branch_list(self) -> list[BranchCoverage]: + """Get list of branches that were covered.""" + return [b for b in self.branch_details if b.was_hit] + + +class TemplateCoverage: + """Tracks branch coverage for a single template. + + Combines branch discovery and instrumentation to provide + coverage tracking for a template. + + Example: + >>> tracker = TemplateCoverage(source, "my_template.j2") + >>> instrumented = tracker.instrumented_source + >>> # ... render template and get trace events ... + >>> tracker.record_hits(trace_events) + >>> stats = tracker.get_stats() + >>> print(f"Coverage: {stats.coverage_percent:.1f}%") + """ + + def __init__( + self, + source: str, + template_path: str | None = None, + ) -> None: + """Initialize coverage tracking for a template. + + Args: + source: The template source code. + template_path: Optional path for identification in reports. + """ + self._source = source + self._template_path = template_path + + self._instrumenter = AutoInstrumenter() + self._instrumentation_result = self._instrumenter.instrument( + source, template_path + ) + + self._hits: dict[str, int] = { + branch.branch_id: 0 + for branch in self._instrumentation_result.discovery.branches + } + + @property + def source(self) -> str: + """Get the original template source.""" + return self._source + + @property + def instrumented_source(self) -> str: + """Get the instrumented template source.""" + return self._instrumentation_result.source + + @property + def discovery(self) -> DiscoveryResult: + """Get the branch discovery result.""" + return self._instrumentation_result.discovery + + @property + def template_path(self) -> str | None: + """Get the template path.""" + return self._template_path + + @property + def branch_ids(self) -> set[str]: + """Get all branch IDs for this template.""" + return self._instrumentation_result.discovery.branch_ids + + def record_hits(self, trace_events: list[str]) -> None: + """Record branch hits from trace events. + + Args: + trace_events: List of trace events from a template render. + """ + for event in trace_events: + if event in self._hits: + self._hits[event] += 1 + + def record_hit(self, branch_id: str) -> None: + """Record a single branch hit. + + Args: + branch_id: The branch ID that was hit. + """ + if branch_id in self._hits: + self._hits[branch_id] += 1 + + def get_hit_count(self, branch_id: str) -> int: + """Get the hit count for a branch. + + Args: + branch_id: The branch ID. + + Returns: + Number of times the branch was hit. + """ + return self._hits.get(branch_id, 0) + + def get_stats(self) -> TemplateCoverageStats: + """Get coverage statistics. + + Returns: + TemplateCoverageStats with coverage information. + """ + branch_details = [ + BranchCoverage( + branch=branch, + hit_count=self._hits.get(branch.branch_id, 0), + ) + for branch in self._instrumentation_result.discovery.branches + ] + + covered = sum(1 for b in branch_details if b.was_hit) + + return TemplateCoverageStats( + template_path=self._template_path, + total_branches=len(branch_details), + covered_branches=covered, + branch_details=branch_details, + ) + + def reset(self) -> None: + """Reset all hit counts to zero.""" + for branch_id in self._hits: + self._hits[branch_id] = 0 diff --git a/jinjatest/spec.py b/jinjatest/spec.py index ee46e20..0296503 100644 --- a/jinjatest/spec.py +++ b/jinjatest/spec.py @@ -38,10 +38,29 @@ if TYPE_CHECKING: from collections.abc import Mapping + from jinjatest.coverage.collector import CoverageCollector + # Type variable for context model TContext = TypeVar("TContext", bound=BaseModel) +def _get_coverage_collector() -> CoverageCollector | None: + """Get the coverage collector if enabled. + + Returns: + CoverageCollector if enabled, None otherwise. + """ + try: + from jinjatest.coverage.collector import get_coverage_collector + + collector = get_coverage_collector() + if collector.enabled: + return collector + except ImportError: + pass + return None + + class TemplateRenderError(Exception): """Raised when template rendering fails.""" @@ -182,6 +201,7 @@ def __init__( context_model: type[TContext] | None = None, instrumentation: Instrumentation | None = None, source: str | None = None, + template_path: str | None = None, ) -> None: """Initialize a TemplateSpec. @@ -191,6 +211,7 @@ def __init__( context_model: Optional Pydantic model for context validation. instrumentation: Optional instrumentation for anchors/traces. source: Optional original template source (used for AST analysis). + template_path: Optional path for coverage tracking identification. """ self._template = template self._env = env @@ -199,6 +220,7 @@ def __init__( instrumentation or create_instrumentation(test_mode=True) ) self._source = source + self._template_path = template_path @classmethod def from_string( @@ -209,6 +231,7 @@ def from_string( env: Environment | None = None, test_mode: bool = True, use_comment_markers: bool = True, + template_path: str | None = None, **env_kwargs: Any, ) -> TemplateSpec[TContext]: """Create a TemplateSpec from a template string. @@ -220,11 +243,14 @@ def from_string( test_mode: If True, enable instrumentation. use_comment_markers: If True, transform {#jt:...#} comments to function calls. Only applies when test_mode is True. Default True. + template_path: Optional path identifier for coverage tracking. **env_kwargs: Arguments passed to create_environment if env is None. Returns: A configured TemplateSpec. """ + original_source = source + # Transform comment markers if enabled and in test mode if use_comment_markers and test_mode: transform_result = transform_markers(source) @@ -236,12 +262,29 @@ def from_string( instrumentation = create_instrumentation(test_mode=test_mode) env.globals["jt"] = instrumentation + # Check for coverage collector and instrument if enabled + collector = _get_coverage_collector() + if collector and test_mode: + # Generate unique path for string templates to avoid collisions + if template_path: + cov_path = template_path + else: + import hashlib + + source_hash = hashlib.md5(source.encode()).hexdigest()[:8] + cov_path = f"" + source = collector.register_template(cov_path, source) + else: + cov_path = template_path + template = env.from_string(source) return cls( template, env=env, context_model=context_model, instrumentation=instrumentation if test_mode else None, + source=original_source, + template_path=cov_path if collector else None, ) @classmethod @@ -313,6 +356,9 @@ def from_file( else: template_name = path.name + # Use full path for coverage tracking + cov_path = str(path.resolve()) if path.is_absolute() else str(path) + # If using comment markers and in test mode, read and transform the source original_source: str | None = None if use_comment_markers and test_mode: @@ -330,11 +376,35 @@ def from_file( # Transform markers transform_result = transform_markers(original_source) + transformed_source = transform_result.source + + # Check for coverage collector and instrument if enabled + collector = _get_coverage_collector() + if collector and test_mode: + transformed_source = collector.register_template( + cov_path, transformed_source + ) # Compile from transformed source - template = env.from_string(transform_result.source) + template = env.from_string(transformed_source) else: - template = env.get_template(template_name) + # Check for coverage without marker transformation + collector = _get_coverage_collector() + if collector and test_mode: + # Need to read source for instrumentation + if env.loader is not None: + try: + src, _, _ = env.loader.get_source(env, template_name) + original_source = src + instrumented_src = collector.register_template(cov_path, src) + template = env.from_string(instrumented_src) + except Exception: + # Fall back to regular loading + template = env.get_template(template_name) + else: + template = env.get_template(template_name) + else: + template = env.get_template(template_name) return cls( template, @@ -342,6 +412,7 @@ def from_file( context_model=context_model, instrumentation=instrumentation if test_mode else None, source=original_source, + template_path=cov_path if _get_coverage_collector() else None, ) @property @@ -433,6 +504,12 @@ def render(self, context: TContext | Mapping[str, Any]) -> RenderedPrompt: trace_events = self._instrumentation.trace_events anchor_index = AnchorIndex.from_text(text) + # Record coverage if enabled + if self._template_path: + collector = _get_coverage_collector() + if collector: + collector.record_render(self._template_path, trace_events) + return RenderedPrompt( text=text, trace_events=trace_events, diff --git a/pyproject.toml b/pyproject.toml index 357707c..52234e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dev = [ [project.entry-points.pytest11] jinjatest = "jinjatest.pytest_plugin" +jinjatest_cov = "jinjatest.coverage.pytest_cov" [project.urls] Homepage = "https://github.com/jinjatest/jinjatest" diff --git a/tests/coverage/__init__.py b/tests/coverage/__init__.py new file mode 100644 index 0000000..ff5345b --- /dev/null +++ b/tests/coverage/__init__.py @@ -0,0 +1 @@ +"""Tests for the coverage module.""" diff --git a/tests/coverage/test_collector.py b/tests/coverage/test_collector.py new file mode 100644 index 0000000..eb8cb06 --- /dev/null +++ b/tests/coverage/test_collector.py @@ -0,0 +1,228 @@ +"""Tests for coverage collector.""" + +from jinjatest.coverage.collector import ( + CoverageCollector, + CoverageSummary, + get_coverage_collector, + reset_coverage_collector, + set_coverage_collector, +) + + +class TestCoverageSummary: + """Tests for CoverageSummary dataclass.""" + + def test_empty_summary(self) -> None: + """Test empty summary.""" + summary = CoverageSummary(templates=[]) + assert summary.total_branches == 0 + assert summary.covered_branches == 0 + assert summary.coverage_percent == 100.0 + assert summary.template_count == 0 + + def test_summary_with_templates(self) -> None: + """Test summary with template stats.""" + from jinjatest.coverage.tracker import TemplateCoverageStats + + stats1 = TemplateCoverageStats( + template_path="test1.j2", + total_branches=4, + covered_branches=2, + branch_details=[], + ) + stats2 = TemplateCoverageStats( + template_path="test2.j2", + total_branches=6, + covered_branches=6, + branch_details=[], + ) + + summary = CoverageSummary(templates=[stats1, stats2]) + assert summary.total_branches == 10 + assert summary.covered_branches == 8 + assert summary.coverage_percent == 80.0 + assert summary.template_count == 2 + + def test_get_template_stats(self) -> None: + """Test getting stats for specific template.""" + from jinjatest.coverage.tracker import TemplateCoverageStats + + stats1 = TemplateCoverageStats( + template_path="test1.j2", + total_branches=4, + covered_branches=2, + branch_details=[], + ) + + summary = CoverageSummary(templates=[stats1]) + result = summary.get_template_stats("test1.j2") + assert result is not None + assert result.template_path == "test1.j2" + + def test_get_template_stats_not_found(self) -> None: + """Test getting stats for non-existent template.""" + summary = CoverageSummary(templates=[]) + assert summary.get_template_stats("nonexistent.j2") is None + + +class TestCoverageCollector: + """Tests for CoverageCollector class.""" + + def test_initial_state(self) -> None: + """Test collector initial state.""" + collector = CoverageCollector() + assert collector.enabled is False + + def test_enable_disable(self) -> None: + """Test enabling and disabling collector.""" + collector = CoverageCollector() + + collector.enable() + assert collector.enabled is True + + collector.disable() + assert collector.enabled is False + + def test_register_template_when_disabled(self) -> None: + """Test register_template returns source unchanged when disabled.""" + collector = CoverageCollector() + source = "{% if x %}y{% endif %}" + + result = collector.register_template("test.j2", source) + assert result == source + + def test_register_template_when_enabled(self) -> None: + """Test register_template instruments source when enabled.""" + collector = CoverageCollector() + collector.enable() + + source = "{% if x %}y{% endif %}" + result = collector.register_template("test.j2", source) + + assert "jt.trace" in result + + def test_register_template_idempotent(self) -> None: + """Test registering same template twice returns same source.""" + collector = CoverageCollector() + collector.enable() + + source = "{% if x %}y{% endif %}" + result1 = collector.register_template("test.j2", source) + result2 = collector.register_template("test.j2", source) + + assert result1 == result2 + + def test_record_render_when_disabled(self) -> None: + """Test record_render does nothing when disabled.""" + collector = CoverageCollector() + collector.record_render("test.j2", ["if_1_true"]) + + def test_record_render_when_enabled(self) -> None: + """Test record_render records hits when enabled.""" + collector = CoverageCollector() + collector.enable() + + source = "{% if x %}y{% endif %}" + collector.register_template("test.j2", source) + collector.record_render("test.j2", ["if_1_true"]) + + tracker = collector.get_tracker("test.j2") + assert tracker is not None + assert tracker.get_hit_count("if_1_true") == 1 + + def test_get_tracker(self) -> None: + """Test getting tracker for template.""" + collector = CoverageCollector() + collector.enable() + + collector.register_template("test.j2", "{% if x %}y{% endif %}") + tracker = collector.get_tracker("test.j2") + + assert tracker is not None + assert tracker.template_path == "test.j2" + + def test_get_tracker_not_found(self) -> None: + """Test getting tracker for unregistered template.""" + collector = CoverageCollector() + assert collector.get_tracker("nonexistent.j2") is None + + def test_get_summary(self) -> None: + """Test getting summary.""" + collector = CoverageCollector() + collector.enable() + + collector.register_template("test1.j2", "{% if a %}b{% endif %}") + collector.register_template("test2.j2", "{% if c %}d{% endif %}") + collector.record_render("test1.j2", ["if_1_true"]) + + summary = collector.get_summary() + assert summary.template_count == 2 + assert summary.total_branches == 4 + assert summary.covered_branches == 1 + + def test_reset(self) -> None: + """Test resetting collector.""" + collector = CoverageCollector() + collector.enable() + + collector.register_template("test.j2", "{% if x %}y{% endif %}") + collector.record_render("test.j2", ["if_1_true"]) + + collector.reset() + + summary = collector.get_summary() + assert summary.template_count == 0 + + def test_get_all_trackers(self) -> None: + """Test getting all trackers.""" + collector = CoverageCollector() + collector.enable() + + collector.register_template("test1.j2", "{% if a %}b{% endif %}") + collector.register_template("test2.j2", "{% if c %}d{% endif %}") + + trackers = collector.get_all_trackers() + assert len(trackers) == 2 + assert "test1.j2" in trackers + assert "test2.j2" in trackers + + +class TestGlobalCollector: + """Tests for global collector functions.""" + + def test_get_coverage_collector(self) -> None: + """Test getting global collector.""" + set_coverage_collector(None) + + collector = get_coverage_collector() + assert collector is not None + assert isinstance(collector, CoverageCollector) + + def test_get_coverage_collector_singleton(self) -> None: + """Test global collector is singleton.""" + set_coverage_collector(None) + + collector1 = get_coverage_collector() + collector2 = get_coverage_collector() + assert collector1 is collector2 + + def test_set_coverage_collector(self) -> None: + """Test setting global collector.""" + custom = CoverageCollector() + set_coverage_collector(custom) + + assert get_coverage_collector() is custom + set_coverage_collector(None) + + def test_reset_coverage_collector(self) -> None: + """Test resetting global collector.""" + set_coverage_collector(None) + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + reset_coverage_collector() + + summary = collector.get_summary() + assert summary.template_count == 0 + assert collector.enabled is False diff --git a/tests/coverage/test_discovery.py b/tests/coverage/test_discovery.py new file mode 100644 index 0000000..8241466 --- /dev/null +++ b/tests/coverage/test_discovery.py @@ -0,0 +1,348 @@ +"""Tests for branch discovery.""" + +from jinjatest.coverage.discovery import BranchDiscovery, BranchInfo, DiscoveryResult + + +class TestBranchInfo: + """Tests for BranchInfo dataclass.""" + + def test_branch_info_creation(self) -> None: + """Test creating a BranchInfo.""" + info = BranchInfo( + branch_id="if_1_true", + branch_type="if_true", + line=1, + description="if condition at line 1 is true", + ) + assert info.branch_id == "if_1_true" + assert info.branch_type == "if_true" + assert info.line == 1 + + def test_branch_info_hash(self) -> None: + """Test BranchInfo is hashable.""" + info1 = BranchInfo("if_1_true", "if_true", 1, "desc") + info2 = BranchInfo("if_1_true", "if_true", 1, "desc") + assert hash(info1) == hash(info2) + + def test_branch_info_equality(self) -> None: + """Test BranchInfo equality based on branch_id.""" + info1 = BranchInfo("if_1_true", "if_true", 1, "desc1") + info2 = BranchInfo("if_1_true", "if_false", 2, "desc2") + assert info1 == info2 # Same branch_id + + def test_branch_info_not_equal_to_other_types(self) -> None: + """Test BranchInfo not equal to other types.""" + info = BranchInfo("if_1_true", "if_true", 1, "desc") + assert info != "if_1_true" + assert info != 42 + + def test_branch_info_has_else_default(self) -> None: + """Test BranchInfo has_else defaults to False.""" + info = BranchInfo("if_1_false", "if_false", 1, "desc") + assert info.has_else is False + + def test_branch_info_has_else_explicit(self) -> None: + """Test BranchInfo has_else can be set explicitly.""" + info = BranchInfo("if_1_false", "if_false", 1, "desc", has_else=True) + assert info.has_else is True + + +class TestDiscoveryResult: + """Tests for DiscoveryResult dataclass.""" + + def test_empty_result(self) -> None: + """Test empty discovery result.""" + result = DiscoveryResult() + assert result.branches == [] + assert result.branch_ids == set() + assert result.branch_count == 0 + + def test_result_with_branches(self) -> None: + """Test discovery result with branches.""" + branches = [ + BranchInfo("if_1_true", "if_true", 1, "desc"), + BranchInfo("if_1_false", "if_false", 1, "desc"), + ] + result = DiscoveryResult(branches=branches, template_path="test.j2") + + assert result.branch_count == 2 + assert result.branch_ids == {"if_1_true", "if_1_false"} + assert result.template_path == "test.j2" + + def test_get_branch(self) -> None: + """Test getting a branch by ID.""" + branches = [ + BranchInfo("if_1_true", "if_true", 1, "desc"), + BranchInfo("if_1_false", "if_false", 1, "desc"), + ] + result = DiscoveryResult(branches=branches) + + branch = result.get_branch("if_1_true") + assert branch is not None + assert branch.branch_id == "if_1_true" + + def test_get_branch_not_found(self) -> None: + """Test getting a non-existent branch.""" + result = DiscoveryResult() + assert result.get_branch("nonexistent") is None + + +class TestBranchDiscovery: + """Tests for BranchDiscovery class.""" + + def test_discover_empty_template(self) -> None: + """Test discovery on empty template.""" + discovery = BranchDiscovery() + result = discovery.discover("") + + assert result.branch_count == 0 + + def test_discover_simple_if(self) -> None: + """Test discovery of simple if statement.""" + discovery = BranchDiscovery() + source = """{% if show_header %} +Header content +{% endif %}""" + result = discovery.discover(source) + + assert result.branch_count == 2 + assert "if_1_true" in result.branch_ids + assert "if_1_false" in result.branch_ids + + def test_discover_if_else(self) -> None: + """Test discovery of if-else statement.""" + discovery = BranchDiscovery() + source = """{% if show_header %} +Header +{% else %} +No header +{% endif %}""" + result = discovery.discover(source) + + assert result.branch_count == 2 + assert "if_1_true" in result.branch_ids + assert "if_1_false" in result.branch_ids + + def test_discover_elif_chain(self) -> None: + """Test discovery of elif chain.""" + discovery = BranchDiscovery() + source = """{% if level == 1 %} +Level 1 +{% elif level == 2 %} +Level 2 +{% elif level == 3 %} +Level 3 +{% else %} +Other +{% endif %}""" + result = discovery.discover(source) + + # Should have if_true, elif_true, elif_true, and elif_false + assert "if_1_true" in result.branch_ids + # elif branches are nested + assert any("elif" in bid for bid in result.branch_ids) + + def test_discover_for_loop(self) -> None: + """Test discovery of for loop.""" + discovery = BranchDiscovery() + source = """{% for item in items %} +{{ item }} +{% endfor %}""" + result = discovery.discover(source) + + assert "for_1_body" in result.branch_ids + + def test_discover_for_loop_with_else(self) -> None: + """Test discovery of for loop with else.""" + discovery = BranchDiscovery() + source = """{% for item in items %} +{{ item }} +{% else %} +No items +{% endfor %}""" + result = discovery.discover(source) + + assert "for_1_body" in result.branch_ids + assert "for_1_else" in result.branch_ids + + def test_discover_nested_conditions(self) -> None: + """Test discovery of nested conditions.""" + discovery = BranchDiscovery() + source = """{% if outer %} +{% if inner %} +Inner content +{% endif %} +{% endif %}""" + result = discovery.discover(source) + + # Should have branches for both if statements + assert "if_1_true" in result.branch_ids + assert "if_1_false" in result.branch_ids + assert "if_2_true" in result.branch_ids + assert "if_2_false" in result.branch_ids + + def test_discover_macro(self) -> None: + """Test discovery of macro definition.""" + discovery = BranchDiscovery() + source = """{% macro greet(name) %} +Hello, {{ name }}! +{% endmacro %}""" + result = discovery.discover(source) + + assert "macro_greet" in result.branch_ids + + def test_discover_include(self) -> None: + """Test discovery of include statement.""" + discovery = BranchDiscovery() + source = """{% include "header.j2" %} +Content +{% include "footer.j2" %}""" + result = discovery.discover(source) + + # Should have includes + assert any("include" in bid for bid in result.branch_ids) + assert any("header" in bid for bid in result.branch_ids) + assert any("footer" in bid for bid in result.branch_ids) + + def test_discover_with_template_path(self) -> None: + """Test discovery records template path.""" + discovery = BranchDiscovery() + source = "{% if x %}y{% endif %}" + result = discovery.discover(source, template_path="test.j2") + + assert result.template_path == "test.j2" + + def test_discover_complex_template(self) -> None: + """Test discovery on complex template.""" + discovery = BranchDiscovery() + source = """{% if user.is_admin %} +Admin panel +{% else %} +{% for item in menu_items %} +
  • {{ item }}
  • +{% else %} +No menu items +{% endfor %} +{% endif %} + +{% macro render_item(item) %} +{% if item.active %} +{{ item.name }} +{% else %} +{{ item.name }} +{% endif %} +{% endmacro %}""" + result = discovery.discover(source) + + # Should discover multiple branches + assert result.branch_count >= 6 + + def test_discover_bare_if_has_else_false(self) -> None: + """Test that bare if statements have has_else=False.""" + discovery = BranchDiscovery() + source = """{% if show %} +Content +{% endif %}""" + result = discovery.discover(source) + + false_branch = result.get_branch("if_1_false") + assert false_branch is not None + assert false_branch.has_else is False + + def test_discover_if_with_else_has_else_true(self) -> None: + """Test that if statements with else have has_else=True.""" + discovery = BranchDiscovery() + source = """{% if show %} +Content +{% else %} +Other +{% endif %}""" + result = discovery.discover(source) + + false_branch = result.get_branch("if_1_false") + assert false_branch is not None + assert false_branch.has_else is True + + def test_discover_elif_chain_without_else(self) -> None: + """Test elif chain without final else has has_else=False.""" + discovery = BranchDiscovery() + source = """{% if level == 1 %} +Level 1 +{% elif level == 2 %} +Level 2 +{% endif %}""" + result = discovery.discover(source) + + # Last elif should have has_else=False + elif_false = result.get_branch("elif_3_false") + assert elif_false is not None + assert elif_false.has_else is False + + def test_discover_elif_chain_with_else(self) -> None: + """Test elif chain with final else has has_else=True.""" + discovery = BranchDiscovery() + source = """{% if level == 1 %} +Level 1 +{% elif level == 2 %} +Level 2 +{% else %} +Other +{% endif %}""" + result = discovery.discover(source) + + # Last elif should have has_else=True + elif_false = result.get_branch("elif_3_false") + assert elif_false is not None + assert elif_false.has_else is True + + def test_discover_nested_ifs_has_else(self) -> None: + """Test nested ifs track has_else correctly.""" + discovery = BranchDiscovery() + source = """{% if outer %} +{% if inner %} +Content +{% else %} +Inner else +{% endif %} +{% endif %}""" + result = discovery.discover(source) + + # Outer if has no else + outer_false = result.get_branch("if_1_false") + assert outer_false is not None + assert outer_false.has_else is False + + # Inner if has else + inner_false = result.get_branch("if_2_false") + assert inner_false is not None + assert inner_false.has_else is True + + def test_discover_bare_for_has_else_false(self) -> None: + """Test that bare for loops have has_else=False on else branch.""" + discovery = BranchDiscovery() + source = """{% for item in items %} +{{ item }} +{% endfor %}""" + result = discovery.discover(source) + + # Should have body and else branches + assert "for_1_body" in result.branch_ids + assert "for_1_else" in result.branch_ids + + else_branch = result.get_branch("for_1_else") + assert else_branch is not None + assert else_branch.has_else is False + + def test_discover_for_with_else_has_else_true(self) -> None: + """Test that for loops with else have has_else=True.""" + discovery = BranchDiscovery() + source = """{% for item in items %} +{{ item }} +{% else %} +No items +{% endfor %}""" + result = discovery.discover(source) + + else_branch = result.get_branch("for_1_else") + assert else_branch is not None + assert else_branch.has_else is True diff --git a/tests/coverage/test_instrumenter.py b/tests/coverage/test_instrumenter.py new file mode 100644 index 0000000..da6539b --- /dev/null +++ b/tests/coverage/test_instrumenter.py @@ -0,0 +1,381 @@ +"""Tests for auto-instrumenter.""" + +from jinjatest.coverage.instrumenter import AutoInstrumenter, InstrumentationResult + + +class TestInstrumentationResult: + """Tests for InstrumentationResult dataclass.""" + + def test_was_modified_true(self) -> None: + """Test was_modified returns True when source changed.""" + result = InstrumentationResult( + source="modified", + original_source="original", + discovery=None, # type: ignore + insertions=1, + ) + assert result.was_modified is True + + def test_was_modified_false(self) -> None: + """Test was_modified returns False when source unchanged.""" + result = InstrumentationResult( + source="same", + original_source="same", + discovery=None, # type: ignore + insertions=0, + ) + assert result.was_modified is False + + +class TestAutoInstrumenter: + """Tests for AutoInstrumenter class.""" + + def test_instrument_empty_template(self) -> None: + """Test instrumenting empty template.""" + instrumenter = AutoInstrumenter() + result = instrumenter.instrument("") + + assert result.source == "" + assert result.insertions == 0 + + def test_instrument_no_branches(self) -> None: + """Test instrumenting template with no branches.""" + instrumenter = AutoInstrumenter() + source = "Hello, {{ name }}!" + result = instrumenter.instrument(source) + + assert result.source == source + assert result.insertions == 0 + + def test_instrument_simple_if(self) -> None: + """Test instrumenting simple if statement.""" + instrumenter = AutoInstrumenter() + source = """{% if show %} +Content +{% endif %}""" + result = instrumenter.instrument(source) + + assert '{{ jt.trace("if_1_true") }}' in result.source + assert result.insertions >= 1 + + def test_instrument_if_else(self) -> None: + """Test instrumenting if-else statement.""" + instrumenter = AutoInstrumenter() + source = """{% if show %} +Yes +{% else %} +No +{% endif %}""" + result = instrumenter.instrument(source) + + assert '{{ jt.trace("if_1_true") }}' in result.source + # else branch should also have trace + assert result.insertions >= 2 + + def test_instrument_elif(self) -> None: + """Test instrumenting elif statement.""" + instrumenter = AutoInstrumenter() + source = """{% if level == 1 %} +One +{% elif level == 2 %} +Two +{% else %} +Other +{% endif %}""" + result = instrumenter.instrument(source) + + assert '{{ jt.trace("if_1_true") }}' in result.source + assert 'jt.trace("elif_' in result.source + + def test_instrument_for_loop(self) -> None: + """Test instrumenting for loop.""" + instrumenter = AutoInstrumenter() + source = """{% for item in items %} +{{ item }} +{% endfor %}""" + result = instrumenter.instrument(source) + + assert '{{ jt.trace("for_1_body") }}' in result.source + assert result.insertions >= 1 + + def test_instrument_macro(self) -> None: + """Test instrumenting macro definition.""" + instrumenter = AutoInstrumenter() + source = """{% macro greet(name) %} +Hello, {{ name }}! +{% endmacro %}""" + result = instrumenter.instrument(source) + + assert '{{ jt.trace("macro_greet") }}' in result.source + assert result.insertions >= 1 + + def test_instrument_preserves_whitespace_control(self) -> None: + """Test that whitespace control characters are preserved.""" + instrumenter = AutoInstrumenter() + source = """{%- if show -%} +Content +{%- endif -%}""" + result = instrumenter.instrument(source) + + # Should still work with whitespace control + assert 'jt.trace("if_1_true")' in result.source + + def test_instrument_nested_conditions(self) -> None: + """Test instrumenting nested conditions.""" + instrumenter = AutoInstrumenter() + source = """{% if outer %} +{% if inner %} +Content +{% endif %} +{% endif %}""" + result = instrumenter.instrument(source) + + assert '{{ jt.trace("if_1_true") }}' in result.source + assert '{{ jt.trace("if_2_true") }}' in result.source + + def test_instrument_template_path(self) -> None: + """Test template path is recorded.""" + instrumenter = AutoInstrumenter() + source = "{% if x %}y{% endif %}" + result = instrumenter.instrument(source, template_path="test.j2") + + assert result.discovery.template_path == "test.j2" + + def test_instrument_complex_template(self) -> None: + """Test instrumenting complex template.""" + instrumenter = AutoInstrumenter() + source = """{% if user.is_authenticated %} +Welcome, {{ user.name }}! +{% if user.is_admin %} +Admin Panel +{% endif %} +{% for notification in notifications %} +
    {{ notification.message }}
    +{% else %} +
    No notifications
    +{% endfor %} +{% else %} +Please log in +{% endif %}""" + result = instrumenter.instrument(source) + + # Should have multiple insertions + assert result.insertions >= 4 + # Discovery should match + assert result.discovery.branch_count >= 4 + + def test_instrument_idempotent_discovery(self) -> None: + """Test that discovery is consistent with instrumentation.""" + instrumenter = AutoInstrumenter() + source = """{% if a %} +{% for b in items %} +{{ b }} +{% endfor %} +{% endif %}""" + result = instrumenter.instrument(source) + + # Number of discovered branches should match what we instrument + discovered_branches = result.discovery.branch_ids + + # Check that traces for discovered branches exist + for branch_id in discovered_branches: + if "true" in branch_id or "body" in branch_id: + # These should have traces inserted + assert branch_id in result.source or "jt.trace" in result.source + + +class TestImplicitFalseInstrumentation: + """Tests for implicit false branch instrumentation.""" + + def test_bare_if_gets_implicit_false(self) -> None: + """Test bare if statement gets implicit false trace injected.""" + instrumenter = AutoInstrumenter() + source = "{% if x %}content{% endif %}" + result = instrumenter.instrument(source) + + # Should inject else with trace before endif + assert '{% else %}{{ jt.trace("if_1_false") }}{% endif %}' in result.source + + def test_if_with_else_no_injection(self) -> None: + """Test if with else does not get implicit false injection.""" + instrumenter = AutoInstrumenter() + source = """{% if x %} +content +{% else %} +other +{% endif %}""" + result = instrumenter.instrument(source) + + # Should not have double else + assert result.source.count("{% else %}") == 1 + # The else trace should be handled by normal else instrumentation + assert '{{ jt.trace("if_1_false") }}' in result.source + + def test_nested_ifs_implicit_false(self) -> None: + """Test nested ifs get correct implicit false injection.""" + instrumenter = AutoInstrumenter() + source = """{% if outer %} +{% if inner %} +content +{% endif %} +{% endif %}""" + result = instrumenter.instrument(source) + + # Both should get implicit false traces + assert '{% else %}{{ jt.trace("if_1_false") }}{% endif %}' in result.source + assert '{% else %}{{ jt.trace("if_2_false") }}{% endif %}' in result.source + + def test_nested_inner_has_else_outer_doesnt(self) -> None: + """Test nested if where inner has else but outer doesn't.""" + instrumenter = AutoInstrumenter() + source = """{% if outer %} +{% if inner %} +content +{% else %} +inner else +{% endif %} +{% endif %}""" + result = instrumenter.instrument(source) + + # Outer should get implicit false + assert '{% else %}{{ jt.trace("if_1_false") }}{% endif %}' in result.source + # Inner should NOT get implicit false (has else) + assert "if_2_false" in result.source # From normal else instrumentation + # Count elses - should have 2: inner's original + outer's injected + assert result.source.count("{% else %}") == 2 + + def test_elif_chain_without_else(self) -> None: + """Test elif chain without final else gets implicit false.""" + instrumenter = AutoInstrumenter() + source = """{% if level == 1 %} +One +{% elif level == 2 %} +Two +{% endif %}""" + result = instrumenter.instrument(source) + + # Last elif should get implicit false + assert "elif_3_false" in result.source + assert '{% else %}{{ jt.trace("elif_3_false") }}{% endif %}' in result.source + + def test_elif_chain_with_else_no_injection(self) -> None: + """Test elif chain with else does not get extra injection.""" + instrumenter = AutoInstrumenter() + source = """{% if level == 1 %} +One +{% elif level == 2 %} +Two +{% else %} +Other +{% endif %}""" + result = instrumenter.instrument(source) + + # Should have normal else trace, not injected + assert result.source.count("{% else %}") == 1 + # elif false should be traced via normal else handling + assert "elif_3_false" in result.source + + def test_whitespace_control_preserved(self) -> None: + """Test whitespace control characters work with implicit false.""" + instrumenter = AutoInstrumenter() + source = """{%- if x -%} +content +{%- endif -%}""" + result = instrumenter.instrument(source) + + # Should inject before endif + assert "if_1_false" in result.source + assert "{% else %}" in result.source + + def test_single_line_if(self) -> None: + """Test single line if gets implicit false injection.""" + instrumenter = AutoInstrumenter() + source = "prefix{% if x %}content{% endif %}suffix" + result = instrumenter.instrument(source) + + # Should inject between content and endif + assert '{% else %}{{ jt.trace("if_1_false") }}{% endif %}' in result.source + assert "prefix" in result.source + assert "suffix" in result.source + + def test_multiple_bare_ifs_on_different_lines(self) -> None: + """Test multiple bare ifs each get implicit false.""" + instrumenter = AutoInstrumenter() + source = """{% if a %}A{% endif %} +{% if b %}B{% endif %}""" + result = instrumenter.instrument(source) + + assert "if_1_false" in result.source + assert "if_2_false" in result.source + # Both should have injected elses + assert result.source.count("{% else %}") == 2 + + def test_complex_nesting(self) -> None: + """Test complex nesting with mixed else/no-else.""" + instrumenter = AutoInstrumenter() + source = """{% if a %} + {% if b %}B{% endif %} + {% if c %}C{% else %}not C{% endif %} +{% endif %}""" + result = instrumenter.instrument(source) + + # 'a' is bare - should get injection + assert "if_1_false" in result.source + # 'b' is bare - should get injection + assert "if_2_false" in result.source + # 'c' has else - should NOT get injection, but should have trace + assert "if_3_false" in result.source + # Count injected elses: a + b = 2, plus c's original = 3 + assert result.source.count("{% else %}") == 3 + + def test_bare_for_gets_implicit_else(self) -> None: + """Test bare for loop gets implicit else trace injected.""" + instrumenter = AutoInstrumenter() + source = "{% for item in items %}{{ item }}{% endfor %}" + result = instrumenter.instrument(source) + + # Should inject else with trace before endfor + assert '{% else %}{{ jt.trace("for_1_else") }}{% endfor %}' in result.source + + def test_for_with_else_no_injection(self) -> None: + """Test for with else does not get implicit else injection.""" + instrumenter = AutoInstrumenter() + source = """{% for item in items %} +{{ item }} +{% else %} +No items +{% endfor %}""" + result = instrumenter.instrument(source) + + # Should not have double else + assert result.source.count("{% else %}") == 1 + # The else trace should be handled by normal else instrumentation + assert '{{ jt.trace("for_1_else") }}' in result.source + + def test_nested_for_implicit_else(self) -> None: + """Test nested for loops get correct implicit else injection.""" + instrumenter = AutoInstrumenter() + source = """{% for outer in outers %} +{% for inner in inners %} +{{ inner }} +{% endfor %} +{% endfor %}""" + result = instrumenter.instrument(source) + + # Both should get implicit else traces + assert "for_1_else" in result.source + assert "for_2_else" in result.source + # Both should have injected elses + assert result.source.count("{% else %}") == 2 + + def test_for_inside_if_implicit(self) -> None: + """Test for inside bare if both get implicit branches.""" + instrumenter = AutoInstrumenter() + source = """{% if show %} +{% for item in items %}{{ item }}{% endfor %} +{% endif %}""" + result = instrumenter.instrument(source) + + # Both if and for should have implicit branches + assert "if_1_false" in result.source + assert "for_2_else" in result.source diff --git a/tests/coverage/test_pytest_integration.py b/tests/coverage/test_pytest_integration.py new file mode 100644 index 0000000..42ef91e --- /dev/null +++ b/tests/coverage/test_pytest_integration.py @@ -0,0 +1,340 @@ +"""Tests for pytest plugin integration.""" + +from jinjatest import TemplateSpec +from jinjatest.coverage.collector import ( + get_coverage_collector, + reset_coverage_collector, +) + + +class TestPytestIntegration: + """Tests for pytest plugin integration with coverage.""" + + def setup_method(self) -> None: + """Reset coverage collector before each test.""" + reset_coverage_collector() + + def teardown_method(self) -> None: + """Clean up after each test.""" + reset_coverage_collector() + + def test_coverage_disabled_by_default(self) -> None: + """Test that coverage is disabled by default.""" + collector = get_coverage_collector() + assert collector.enabled is False + + def test_template_spec_without_coverage(self) -> None: + """Test TemplateSpec works normally without coverage.""" + spec = TemplateSpec.from_string("""{% if show %} +Content +{% else %} +Hidden +{% endif %}""") + + rendered = spec.render({"show": True}) + assert "Content" in rendered.text + + def test_template_spec_with_coverage_enabled(self) -> None: + """Test TemplateSpec integrates with coverage when enabled.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% if show %} +Content +{% else %} +Hidden +{% endif %}""", + template_path="test_inline.j2", + ) + + rendered = spec.render({"show": True}) + assert "Content" in rendered.text + + # Check coverage was recorded + summary = collector.get_summary() + assert summary.template_count == 1 + assert summary.covered_branches >= 1 + + def test_coverage_tracks_multiple_renders(self) -> None: + """Test coverage tracks multiple renders of same template.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% if show %} +Content +{% else %} +Hidden +{% endif %}""", + template_path="multi_render.j2", + ) + + # Render with show=True + spec.render({"show": True}) + + # Render with show=False + spec.render({"show": False}) + + summary = collector.get_summary() + stats = summary.get_template_stats("multi_render.j2") + assert stats is not None + # Both branches should now be covered + assert stats.covered_branches == 2 + + def test_coverage_tracks_for_loops(self) -> None: + """Test coverage tracks for loop branches.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% for item in items %} +- {{ item }} +{% else %} +No items +{% endfor %}""", + template_path="for_loop.j2", + ) + + # Render with items + spec.render({"items": ["a", "b", "c"]}) + + summary = collector.get_summary() + assert summary.covered_branches >= 1 + + def test_coverage_with_nested_conditions(self) -> None: + """Test coverage tracks nested conditions.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% if level1 %} +{% if level2 %} +Deep content +{% endif %} +{% endif %}""", + template_path="nested.j2", + ) + + spec.render({"level1": True, "level2": True}) + + summary = collector.get_summary() + assert summary.covered_branches >= 2 + + def test_coverage_coexists_with_traces(self) -> None: + """Test coverage works alongside manual traces.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% if show %} +{{ jt.trace("manual_trace") }} +Content +{% endif %}""", + template_path="with_traces.j2", + ) + + rendered = spec.render({"show": True}) + + # Manual trace should still work + assert rendered.has_trace("manual_trace") + + # Coverage should also be tracked + summary = collector.get_summary() + assert summary.covered_branches >= 1 + + def test_coverage_coexists_with_anchors(self) -> None: + """Test coverage works alongside anchors.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{{ jt.anchor("header") }} +{% if show %} +Header content +{% endif %} +{{ jt.anchor("footer") }} +Footer""", + template_path="with_anchors.j2", + ) + + rendered = spec.render({"show": True}) + + # Anchors should still work + assert rendered.has_section("header") + assert rendered.has_section("footer") + + # Coverage should also be tracked + summary = collector.get_summary() + assert summary.covered_branches >= 1 + + +class TestImplicitFalseBranchIntegration: + """Tests for implicit false branch coverage integration.""" + + def setup_method(self) -> None: + """Reset coverage collector before each test.""" + reset_coverage_collector() + + def teardown_method(self) -> None: + """Clean up after each test.""" + reset_coverage_collector() + + def test_implicit_false_trace_fires_when_condition_false(self) -> None: + """Test that implicit false trace fires when if condition is false.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% if show %} +Shown +{% endif %}""", + template_path="implicit_false.j2", + ) + + # Render with show=False - should trigger implicit false branch + rendered = spec.render({"show": False}) + + # The false branch trace should have fired + assert rendered.has_trace("if_1_false") + + # Coverage should show the false branch was hit + summary = collector.get_summary() + stats = summary.get_template_stats("implicit_false.j2") + assert stats is not None + covered_ids = [bc.branch.branch_id for bc in stats.covered_branch_list] + assert "if_1_false" in covered_ids + + def test_implicit_false_both_branches_covered(self) -> None: + """Test that both true and false branches can be covered.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% if show %} +Shown +{% endif %}""", + template_path="both_branches.j2", + ) + + # Render with show=True + result_true = spec.render({"show": True}) + assert result_true.has_trace("if_1_true") + + # Render with show=False + result_false = spec.render({"show": False}) + assert result_false.has_trace("if_1_false") + + # Both branches should be covered + summary = collector.get_summary() + stats = summary.get_template_stats("both_branches.j2") + assert stats is not None + assert stats.covered_branches == 2 + assert stats.coverage_percent == 100.0 + + def test_nested_implicit_false(self) -> None: + """Test nested bare ifs both track implicit false.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% if outer %} +{% if inner %} +Content +{% endif %} +{% endif %}""", + template_path="nested_bare.j2", + ) + + # All branches false + rendered = spec.render({"outer": False, "inner": False}) + assert rendered.has_trace("if_1_false") + # Inner won't fire because outer is false + + # Now test with outer true, inner false + rendered2 = spec.render({"outer": True, "inner": False}) + assert rendered2.has_trace("if_1_true") + assert rendered2.has_trace("if_2_false") + + def test_elif_implicit_false(self) -> None: + """Test elif chain without else tracks implicit false.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% if level == 1 %} +One +{% elif level == 2 %} +Two +{% endif %}""", + template_path="elif_bare.j2", + ) + + # Neither condition true - should hit elif false + rendered = spec.render({"level": 3}) + # When if is false, the elif is evaluated, and if that's also false, + # the implicit false branch fires + assert rendered.has_trace("elif_3_false") + + def test_for_implicit_else_fires_when_empty(self) -> None: + """Test that implicit else trace fires when for loop is empty.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{% for item in items %} +{{ item }} +{% endfor %}""", + template_path="for_bare.j2", + ) + + # Render with empty items - should trigger implicit else + rendered = spec.render({"items": []}) + assert rendered.has_trace("for_1_else") + + # Render with items - should trigger body + rendered2 = spec.render({"items": ["a", "b"]}) + assert rendered2.has_trace("for_1_body") + + # Both branches should be covered + summary = collector.get_summary() + stats = summary.get_template_stats("for_bare.j2") + assert stats is not None + assert stats.covered_branches == 2 + + +class TestCoverageWithCommentMarkers: + """Tests for coverage with comment-based markers.""" + + def setup_method(self) -> None: + """Reset coverage collector before each test.""" + reset_coverage_collector() + + def teardown_method(self) -> None: + """Clean up after each test.""" + reset_coverage_collector() + + def test_coverage_with_jt_markers(self) -> None: + """Test coverage works with {#jt:...#} markers.""" + collector = get_coverage_collector() + collector.enable() + + spec = TemplateSpec.from_string( + """{#jt:anchor:start#} +{% if show %} +{#jt:trace:showed_content#} +Content +{% endif %} +{#jt:anchor:end#}""", + template_path="with_markers.j2", + ) + + rendered = spec.render({"show": True}) + + # Markers should work + assert rendered.has_trace("showed_content") + assert rendered.has_section("start") + + # Coverage should also be tracked + summary = collector.get_summary() + assert summary.covered_branches >= 1 diff --git a/tests/coverage/test_reporter.py b/tests/coverage/test_reporter.py new file mode 100644 index 0000000..a6c0bdb --- /dev/null +++ b/tests/coverage/test_reporter.py @@ -0,0 +1,312 @@ +"""Tests for coverage reporters.""" + +import json +import tempfile +from pathlib import Path + +import pytest + +from jinjatest.coverage.collector import CoverageSummary +from jinjatest.coverage.discovery import BranchInfo +from jinjatest.coverage.reporter import ( + CoverageReporter, + HTMLReporter, + JSONReporter, + ReportConfig, + TerminalReporter, +) +from jinjatest.coverage.tracker import BranchCoverage, TemplateCoverageStats + + +@pytest.fixture +def sample_stats() -> list[TemplateCoverageStats]: + """Create sample template stats.""" + branch1 = BranchInfo("if_1_true", "if_true", 1, "if condition at line 1 is true") + branch2 = BranchInfo("if_1_false", "if_false", 1, "if condition at line 1 is false") + branch3 = BranchInfo("for_5_body", "for_body", 5, "for loop at line 5 has items") + + stats1 = TemplateCoverageStats( + template_path="test1.j2", + total_branches=2, + covered_branches=1, + branch_details=[ + BranchCoverage(branch=branch1, hit_count=3), + BranchCoverage(branch=branch2, hit_count=0), + ], + ) + stats2 = TemplateCoverageStats( + template_path="test2.j2", + total_branches=1, + covered_branches=1, + branch_details=[ + BranchCoverage(branch=branch3, hit_count=5), + ], + ) + return [stats1, stats2] + + +@pytest.fixture +def sample_summary(sample_stats: list[TemplateCoverageStats]) -> CoverageSummary: + """Create sample coverage summary.""" + return CoverageSummary(templates=sample_stats) + + +class TestReportConfig: + """Tests for ReportConfig dataclass.""" + + def test_defaults(self) -> None: + """Test default values.""" + config = ReportConfig() + assert config.fail_under == 0.0 + assert config.show_missing is True + assert config.verbose is False + + +class TestTerminalReporter: + """Tests for TerminalReporter class.""" + + def test_empty_report(self) -> None: + """Test report with no templates.""" + reporter = TerminalReporter() + summary = CoverageSummary(templates=[]) + + report = reporter.report(summary) + + assert "No templates tracked" in report + + def test_basic_report(self, sample_summary: CoverageSummary) -> None: + """Test basic terminal report.""" + reporter = TerminalReporter() + + report = reporter.report(sample_summary) + + assert "JINJA TEMPLATE COVERAGE" in report + assert "test1.j2" in report + assert "test2.j2" in report + assert "TOTAL" in report + assert "66.7%" in report # 2/3 branches covered + + def test_verbose_report(self, sample_summary: CoverageSummary) -> None: + """Test verbose terminal report shows missing branches.""" + config = ReportConfig(verbose=True, show_missing=True) + reporter = TerminalReporter(config) + + report = reporter.report(sample_summary) + + assert "if_1_false" in report + + def test_fail_under_pass(self, sample_summary: CoverageSummary) -> None: + """Test fail_under message when passing.""" + config = ReportConfig(fail_under=50.0) + reporter = TerminalReporter(config) + + report = reporter.report(sample_summary) + + assert "OK:" in report + + def test_fail_under_fail(self, sample_summary: CoverageSummary) -> None: + """Test fail_under message when failing.""" + config = ReportConfig(fail_under=80.0) + reporter = TerminalReporter(config) + + report = reporter.report(sample_summary) + + assert "FAIL:" in report + + def test_truncate_path(self) -> None: + """Test path truncation.""" + reporter = TerminalReporter() + + short = reporter._truncate_path("short.j2", 40) + assert short == "short.j2" + + long_path = "a" * 50 + truncated = reporter._truncate_path(long_path, 40) + assert len(truncated) == 40 + assert truncated.startswith("...") + + +class TestJSONReporter: + """Tests for JSONReporter class.""" + + def test_empty_report(self) -> None: + """Test JSON report with no templates.""" + reporter = JSONReporter() + summary = CoverageSummary(templates=[]) + + report = reporter.report(summary) + data = json.loads(report) + + assert data["summary"]["total_branches"] == 0 + assert data["summary"]["coverage_percent"] == 100.0 + assert data["templates"] == [] + + def test_basic_report(self, sample_summary: CoverageSummary) -> None: + """Test basic JSON report.""" + reporter = JSONReporter() + + report = reporter.report(sample_summary) + data = json.loads(report) + + assert data["summary"]["total_branches"] == 3 + assert data["summary"]["covered_branches"] == 2 + assert data["summary"]["template_count"] == 2 + assert len(data["templates"]) == 2 + + def test_report_with_missing_branches( + self, sample_summary: CoverageSummary + ) -> None: + """Test JSON report includes uncovered branches.""" + config = ReportConfig(show_missing=True) + reporter = JSONReporter(config) + + report = reporter.report(sample_summary) + data = json.loads(report) + + # test1.j2 should have uncovered branches + test1 = next(t for t in data["templates"] if t["path"] == "test1.j2") + assert len(test1["uncovered"]) == 1 + assert test1["uncovered"][0]["branch_id"] == "if_1_false" + + def test_fail_under_in_report(self, sample_summary: CoverageSummary) -> None: + """Test fail_under included in JSON report.""" + config = ReportConfig(fail_under=80.0) + reporter = JSONReporter(config) + + report = reporter.report(sample_summary) + data = json.loads(report) + + assert data["fail_under"] == 80.0 + assert data["passed"] is False # 66.7% < 80% + + def test_write_to_file(self, sample_summary: CoverageSummary) -> None: + """Test writing JSON report to file.""" + reporter = JSONReporter() + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "coverage.json" + reporter.write_to_file(sample_summary, path) + + assert path.exists() + data = json.loads(path.read_text()) + assert data["summary"]["total_branches"] == 3 + + +class TestHTMLReporter: + """Tests for HTMLReporter class.""" + + def test_empty_report(self) -> None: + """Test HTML report with no templates.""" + reporter = HTMLReporter() + summary = CoverageSummary(templates=[]) + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + reporter.report(summary, output_dir) + + assert (output_dir / "index.html").exists() + assert (output_dir / "style.css").exists() + + def test_basic_report(self, sample_summary: CoverageSummary) -> None: + """Test basic HTML report.""" + reporter = HTMLReporter() + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + reporter.report(sample_summary, output_dir) + + index = (output_dir / "index.html").read_text() + assert "test1.j2" in index + assert "test2.j2" in index + assert "66.7%" in index + + def test_report_with_sources(self, sample_summary: CoverageSummary) -> None: + """Test HTML report with source files.""" + reporter = HTMLReporter() + sources = { + "test1.j2": "{% if x %}y{% else %}z{% endif %}", + "test2.j2": "{% for i in items %}{{ i }}{% endfor %}", + } + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + reporter.report(sample_summary, output_dir, sources) + + # Should have per-template pages + assert (output_dir / "test1_j2.html").exists() + assert (output_dir / "test2_j2.html").exists() + + # Check content + test1_html = (output_dir / "test1_j2.html").read_text() + assert "if x" in test1_html or "{% if x %}" in test1_html + + def test_coverage_class(self) -> None: + """Test coverage class selection.""" + reporter = HTMLReporter() + + assert reporter._coverage_class(100) == "high" + assert reporter._coverage_class(80) == "high" + assert reporter._coverage_class(79) == "medium" + assert reporter._coverage_class(50) == "medium" + assert reporter._coverage_class(49) == "low" + assert reporter._coverage_class(0) == "low" + + def test_safe_filename(self) -> None: + """Test safe filename generation.""" + reporter = HTMLReporter() + + assert reporter._safe_filename("test.j2") == "test_j2" + assert reporter._safe_filename("path/to/template.j2") == "path_to_template_j2" + assert reporter._safe_filename(None) == "string" + + def test_escape_html(self) -> None: + """Test HTML escaping.""" + reporter = HTMLReporter() + + assert reporter._escape("