diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d1f2b0..51f289a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: - uses: astral-sh/setup-uv@v7 - run: uv python install ${{ matrix.python-version }} - run: uv sync --all-extras --dev + - run: uv build --wheel -q # generates _templates.py via build hook - run: uv run coverage run -m pytest -q && uv run coverage report --fail-under=90 lint: @@ -25,5 +26,6 @@ jobs: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v7 - run: uv sync --all-extras --dev + - run: uv build --wheel -q # generates _templates.py via build hook - run: uv run ruff check . - run: uv run ruff format --check . diff --git a/.gitignore b/.gitignore index 15a4133..fe28ef7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ dist/ wheels/ *.egg-info +# Cache +.cache + # Virtual environments .venv @@ -18,4 +21,7 @@ uv.lock # Coverage .coverage htmlcov/ -*,cover \ No newline at end of file +*,cover + +# Generated files +jinjatest/coverage/_templates.py \ No newline at end of file diff --git a/Makefile b/Makefile index 4ed287a..d99bdd9 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ lint: uv run ruff check . uv run ruff format --check . + uv run ty check . lint-fix: uv run ruff check --fix . diff --git a/README.md b/README.md index d460179..01acfe6 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ Stop writing brittle substring assertions. Test your templates with structure, v ## Installation ```bash -pip install jinjatest +uv add jinjatest ``` With YAML support: ```bash -pip install jinjatest[yaml] +uv add jinjatest[yaml] ``` ## Why jinjatest? @@ -403,6 +403,48 @@ spec = TemplateSpec.from_file("template.j2") spec.assert_variables_subset_of({"user_name", "plan", "items"}) ``` +## Template Coverage + +Track branch coverage for your Jinja templates to ensure all conditional paths are tested. + +### Quick Start + +```bash +pytest --jt-cov +``` + +### CLI Options + +| Option | Description | +|--------|-------------| +| `--jt-cov` | Enable template coverage | +| `--jt-cov-fail-under=N` | Fail if coverage below N% | +| `--jt-cov-report=TYPE` | Report type: `term`, `term-missing`, `term-verbose`, `html`, `json`, `xml` | +| `--jt-cov-html=DIR` | HTML report directory | +| `--jt-cov-json=FILE` | JSON report file | +| `--jt-cov-xml=FILE` | JUnit XML report file | +| `--jt-cov-exclude=PATTERN` | Glob pattern to exclude templates | + +### Configuration (pyproject.toml) + +```toml +[tool.jinjatest.coverage] +enabled = true +fail_under = 80 +report = ["term", "html"] +html_dir = "jt-htmlcov" +exclude_patterns = ["**/vendor/**", "*.partial.j2"] +``` + +### What's Tracked + +- `{% if %}` / `{% elif %}` / `{% else %}` branches +- `{% for %}` loops (body and else) +- `{% macro %}` definitions +- `{% block %}` definitions (template inheritance) +- `{% include %}` statements +- Ternary expressions (`{{ x if cond else y }}`) + ## License MIT 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..ea5d560 --- /dev/null +++ b/jinjatest/coverage/__init__.py @@ -0,0 +1,75 @@ +""" +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, + JUnitReporter, + 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", + "JUnitReporter", + "ReportConfig", +] diff --git a/jinjatest/coverage/collector.py b/jinjatest/coverage/collector.py new file mode 100644 index 0000000..6e8a699 --- /dev/null +++ b/jinjatest/coverage/collector.py @@ -0,0 +1,236 @@ +""" +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 + +import fnmatch +from dataclasses import dataclass +from threading import Lock + +from jinjatest.coverage.tracker import TemplateCoverage, TemplateCoverageStats + + +@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() + >>> collector.set_exclude_patterns(["**/vendor/**"]) + >>> 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 + self._exclude_patterns: list[str] = [] + + def set_exclude_patterns(self, patterns: list[str]) -> None: + """Set glob patterns for templates to exclude from coverage. + + Args: + patterns: List of glob patterns (e.g., ["**/vendor/**", "*.partial.j2"]). + """ + self._exclude_patterns = patterns + + def _is_excluded(self, path: str) -> bool: + """Check if a template path should be excluded. + + Args: + path: The template path to check. + + Returns: + True if the path matches any exclude pattern. + """ + for pattern in self._exclude_patterns: + if fnmatch.fnmatch(path, pattern): + return True + return 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 + + if self._is_excluded(path): + 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..13e2fa3 --- /dev/null +++ b/jinjatest/coverage/discovery.py @@ -0,0 +1,390 @@ +""" +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 + line: int + description: str + has_else: bool = False + + 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() + self._condexpr_count = 0 + + 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. + """ + self._condexpr_count = 0 # Reset counter for each discovery + 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) + elif isinstance(node, nodes.CondExpr): + self._handle_condexpr_node(node, branches) + elif isinstance(node, nodes.Block): + self._handle_block_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", + ) + ) + + # In Jinja AST, elif_ contains nested If nodes + has_elif = bool(node.elif_) + has_else = bool(node.else_) + + if has_elif: + for i, elif_node in enumerate(node.elif_): + if isinstance(elif_node, nodes.If): + is_last = i == len(node.elif_) - 1 + self._handle_if_node( + elif_node, + branches, + in_elif=True, + parent_has_else=has_else if is_last else False, + ) + + if in_elif: + 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: + 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: + if has_else and not has_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: + 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, + ) + ) + + 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: + 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}", + ) + ) + + def _handle_block_node( + self, + node: nodes.Block, + branches: list[BranchInfo], + ) -> None: + """Handle Block node for template inheritance. + + Args: + node: The Block AST node. + branches: List to append discovered branches to. + """ + branches.append( + BranchInfo( + branch_id=f"block_{node.name}", + branch_type="block", + line=node.lineno, + description=f"block '{node.name}' at line {node.lineno}", + ) + ) + + for child in node.body: + self._walk_node(child, branches) + + def _handle_condexpr_node( + self, + node: nodes.CondExpr, + branches: list[BranchInfo], + ) -> None: + """Handle CondExpr (ternary) node. + + Args: + node: The CondExpr AST node. + branches: List to append discovered branches to. + """ + from jinja2 import nodes + + self._condexpr_count += 1 + branch_id = f"ternary_{self._condexpr_count}" + line = node.lineno + + branches.append( + BranchInfo( + branch_id=f"{branch_id}_true", + branch_type="cond_true", + line=line, + description=f"ternary at line {line} condition is true", + ) + ) + + branches.append( + BranchInfo( + branch_id=f"{branch_id}_false", + branch_type="cond_false", + line=line, + description=f"ternary at line {line} condition is false", + ) + ) + + for child in node.iter_child_nodes(): + if isinstance(child, nodes.CondExpr): + self._handle_condexpr_node(child, branches) + else: + self._walk_node(child, branches) diff --git a/jinjatest/coverage/environment.py b/jinjatest/coverage/environment.py new file mode 100644 index 0000000..706bf37 --- /dev/null +++ b/jinjatest/coverage/environment.py @@ -0,0 +1,50 @@ +"""Coverage-instrumenting Jinja2 Environment. + +This module provides a custom Environment subclass that instruments +templates for CondExpr coverage tracking. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from jinja2 import Environment + +if TYPE_CHECKING: + from jinja2 import nodes + + +class CoverageEnvironment(Environment): + """Jinja2 Environment that instruments templates for coverage tracking. + + Overrides _generate() to transform CondExpr nodes before Python code + generation, enabling branch coverage tracking without source manipulation. + """ + + def _generate( + self, + source: nodes.Template, + name: str | None, + filename: str | None, + defer_init: bool = False, + ) -> str: + """Generate Python code with CondExpr instrumentation. + + Args: + source: The parsed template AST. + name: Optional template name. + filename: Optional filename for debugging. + defer_init: Whether to defer initialization. + + Returns: + Generated Python code string. + """ + from jinja2 import nodes as n + + from jinjatest.coverage.transformer import CondExprTransformer + + if list(source.find_all(n.CondExpr)): + transformer = CondExprTransformer() + source = transformer.visit(source) + + return super()._generate(source, name, filename, defer_init) diff --git a/jinjatest/coverage/instrumenter.py b/jinjatest/coverage/instrumenter.py new file mode 100644 index 0000000..a8a0686 --- /dev/null +++ b/jinjatest/coverage/instrumenter.py @@ -0,0 +1,390 @@ +""" +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_PATTERN = re.compile( + r"(\{%-?\s*block\s+(\w+)\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 + + new_line, count = self._instrument_block(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_block(self, line: str) -> tuple[str, int]: + """Instrument block definitions in a line. + + Args: + line: The line to process. + + Returns: + Tuple of (modified line, insertion count). + """ + count = 0 + + def replace_block(match: re.Match[str]) -> str: + nonlocal count + count += 1 + tag = match.group(1) + block_name = match.group(2) + trace_call = f'{{{{ jt.trace("block_{block_name}") }}}}' + return f"{tag}{trace_call}" + + new_line = self.BLOCK_PATTERN.sub(replace_block, 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..85ef7a6 --- /dev/null +++ b/jinjatest/coverage/pytest_cov.py @@ -0,0 +1,251 @@ +""" +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 Any + +import pytest + +from jinjatest.coverage.collector import ( + get_coverage_collector, + reset_coverage_collector, +) +from jinjatest.coverage.reporter import CoverageReporter, ReportConfig + + +def _load_pyproject_config() -> dict[str, Any]: + """Load coverage configuration from pyproject.toml. + + Returns: + Dictionary of coverage settings or empty dict if not found. + """ + tomllib: Any = None + try: + import tomllib as _tomllib # type: ignore[import-not-found] + + tomllib = _tomllib + except ImportError: + try: + import tomli as _tomli # type: ignore[import-not-found] + + tomllib = _tomli + except ImportError: + return {} + + pyproject_path = Path("pyproject.toml") + if not pyproject_path.exists(): + return {} + + try: + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + return data.get("tool", {}).get("jinjatest", {}).get("coverage", {}) + except Exception: + return {} + + +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-missing, term-verbose, json, html, xml " + "(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)", + ) + + group.addoption( + "--jt-cov-xml", + action="store", + default=None, + metavar="FILE", + help="File for JUnit XML coverage report (default: jt-coverage.xml)", + ) + + group.addoption( + "--jt-cov-exclude", + action="append", + default=[], + metavar="PATTERN", + help="Glob pattern to exclude templates from coverage (can be specified multiple times)", + ) + + +def pytest_configure(config: pytest.Config) -> None: + """Configure coverage collection if enabled.""" + pyproject_config = _load_pyproject_config() + + cov_enabled = config.getoption("--jt-cov") or pyproject_config.get("enabled", False) + + if cov_enabled: + collector = get_coverage_collector() + collector.enable() + + cli_excludes = config.getoption("--jt-cov-exclude", []) + config_excludes = pyproject_config.get("exclude_patterns", []) + all_excludes = cli_excludes + config_excludes + if all_excludes: + collector.set_exclude_patterns(all_excludes) + + config._jt_cov_enabled = True # type: ignore[attr-defined] + config._jt_cov_pyproject = pyproject_config # type: ignore[attr-defined] + else: + config._jt_cov_enabled = False # type: ignore[attr-defined] + config._jt_cov_pyproject = {} # 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() + pyproject_config = getattr(config, "_jt_cov_pyproject", {}) + + cli_fail_under = config.getoption("--jt-cov-fail-under", 0.0) + fail_under = ( + cli_fail_under + if cli_fail_under > 0 + else pyproject_config.get("fail_under", 0.0) + ) + + cli_report_types = config.getoption("--jt-cov-report", []) + config_report_types = pyproject_config.get("report", []) + report_types = cli_report_types if cli_report_types else config_report_types + + 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, + show_missing_inline="term-missing" 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-missing" 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: + cli_json_path = config.getoption("--jt-cov-json") + json_path = cli_json_path or pyproject_config.get( + "json_file", "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: + cli_html_dir = config.getoption("--jt-cov-html") + html_dir = cli_html_dir or pyproject_config.get("html_dir", "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 "xml" in report_types: + cli_xml_path = config.getoption("--jt-cov-xml") + xml_path = cli_xml_path or pyproject_config.get("xml_file", "jt-coverage.xml") + reporter.junit_report(summary, Path(xml_path)) + if output: + output.line(f"JUnit XML report written to: {xml_path}") + + 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..88c59a2 --- /dev/null +++ b/jinjatest/coverage/reporter.py @@ -0,0 +1,626 @@ +""" +Coverage reporters for terminal, JSON, HTML, and JUnit XML output. + +This module provides different report formats for coverage data. +""" + +from __future__ import annotations + +import json +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, TextIO + +from jinjatest.coverage._templates import ( + INDEX_HTML, + ROW_HTML, + SOURCE_LINE_HTML, + STYLE_CSS, + TEMPLATE_PAGE_HTML, +) + +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 + show_missing_inline: 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 + + if self.config.show_missing_inline: + lines.append( + f"{'Template':<40} {'Branches':>8} {'Covered':>8} " + f"{'Coverage':>8} {'Missing'}" + ) + lines.append("-" * 90) + else: + 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) + + if self.config.show_missing_inline: + missing_lines = self._get_missing_lines(stats) + lines.append( + f"{path:<40} {stats.total_branches:>8} " + f"{stats.covered_branches:>8} {stats.coverage_percent:>7.1f}% " + f"{missing_lines}" + ) + else: + 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}" + ) + + if self.config.show_missing_inline: + lines.append("-" * 90) + else: + 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 _get_missing_lines(self, stats: TemplateCoverageStats) -> str: + """Get a compact string of missing line numbers. + + Args: + stats: The template coverage stats. + + Returns: + Comma-separated line numbers, e.g., "10, 16, 30" + """ + if not stats.uncovered_branches: + return "" + + missing = sorted({bc.branch.line for bc in stats.uncovered_branches}) + return ", ".join(str(line) for line in missing) + + 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 "" + safe_name = self._safe_filename(stats.template_path or "string") + coverage_class = self._coverage_class(stats.coverage_percent) + rows.append( + ROW_HTML.format( + coverage_class=coverage_class, + safe_name=safe_name, + path=self._escape(path), + total_branches=stats.total_branches, + covered_branches=stats.covered_branches, + coverage_percent=stats.coverage_percent, + ) + ) + + coverage_class = self._coverage_class(summary.coverage_percent) + + return INDEX_HTML.format( + coverage_class=coverage_class, + coverage_percent=summary.coverage_percent, + template_count=summary.template_count, + covered_branches=summary.covered_branches, + total_branches=summary.total_branches, + rows="".join(rows), + ) + + 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( + SOURCE_LINE_HTML.format( + line_class=line_class, + lineno=i, + source=escaped, + ) + ) + + path = stats.template_path or "" + coverage_class = self._coverage_class(stats.coverage_percent) + + uncovered_items = [ + f"
  • Line {bc.branch.line}: {self._escape(bc.branch.description)}
  • " + for bc in stats.uncovered_branches + ] + uncovered_list = "".join(uncovered_items) or "
  • All branches covered!
  • " + + return TEMPLATE_PAGE_HTML.format( + path=self._escape(path), + coverage_class=coverage_class, + coverage_percent=stats.coverage_percent, + covered_branches=stats.covered_branches, + total_branches=stats.total_branches, + uncovered_list=uncovered_list, + source_lines="".join(source_lines), + ) + + def _generate_css(self) -> str: + """Generate CSS stylesheet. + + Returns: + CSS string. + """ + return STYLE_CSS + + 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 JUnitReporter: + """JUnit XML coverage reporter. + + Generates JUnit-compatible XML for CI systems like Jenkins, GitHub Actions, etc. + Each template is reported as a test suite, with uncovered branches as failures. + """ + + def __init__(self, config: ReportConfig | None = None) -> None: + """Initialize the JUnit reporter. + + Args: + config: Optional report configuration. + """ + self.config = config or ReportConfig() + + def report( + self, + summary: CoverageSummary, + output: TextIO | None = None, + ) -> str: + """Generate a JUnit XML coverage report. + + Args: + summary: The coverage summary to report. + output: Optional file-like object to write to. + + Returns: + The XML string. + """ + testsuites = ET.Element("testsuites") + testsuites.set("name", "Jinja Template Coverage") + testsuites.set("tests", str(summary.total_branches)) + testsuites.set( + "failures", str(summary.total_branches - summary.covered_branches) + ) + testsuites.set("time", "0") + + for stats in summary.templates: + testsuite = ET.SubElement(testsuites, "testsuite") + testsuite.set("name", stats.template_path or "") + testsuite.set("tests", str(stats.total_branches)) + testsuite.set("failures", str(len(stats.uncovered_branches))) + testsuite.set("time", "0") + + for bc in stats.covered_branch_list: + testcase = ET.SubElement(testsuite, "testcase") + testcase.set("name", bc.branch.branch_id) + testcase.set("classname", stats.template_path or "") + testcase.set("time", "0") + + for bc in stats.uncovered_branches: + testcase = ET.SubElement(testsuite, "testcase") + testcase.set("name", bc.branch.branch_id) + testcase.set("classname", stats.template_path or "") + testcase.set("time", "0") + + failure = ET.SubElement(testcase, "failure") + failure.set("message", f"Branch not covered: {bc.branch.description}") + failure.set("type", "CoverageFailure") + failure.text = f"Line {bc.branch.line}: {bc.branch.description}" + + result = ET.tostring(testsuites, encoding="unicode") + xml_declaration = '\n' + result = xml_declaration + result + + if output: + output.write(result) + return result + + def write_to_file(self, summary: CoverageSummary, path: Path) -> None: + """Write JUnit XML 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) + + +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) + >>> reporter.junit_report(summary, Path("coverage.xml")) + """ + + 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) + self._junit = JUnitReporter(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) + + def junit_report( + self, + summary: CoverageSummary, + path: Path, + ) -> None: + """Write a JUnit XML report to a file. + + Args: + summary: The coverage summary. + path: Output file path. + """ + self._junit.write_to_file(summary, path) diff --git a/jinjatest/coverage/templates/index.html b/jinjatest/coverage/templates/index.html new file mode 100644 index 0000000..f592437 --- /dev/null +++ b/jinjatest/coverage/templates/index.html @@ -0,0 +1,39 @@ + + + + + Jinja Template Coverage Report + + + +

    Jinja Template Coverage Report

    + +
    +

    Summary

    +

    Total Coverage: {coverage_percent:.1f}%

    +

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

    +
    + + + + + + + + + + + + {rows} + + + + + + + + + +
    TemplateBranchesCoveredCoverage
    TOTAL{total_branches}{covered_branches}{coverage_percent:.1f}%
    + + diff --git a/jinjatest/coverage/templates/row.html b/jinjatest/coverage/templates/row.html new file mode 100644 index 0000000..bfd1ab3 --- /dev/null +++ b/jinjatest/coverage/templates/row.html @@ -0,0 +1,6 @@ + + {path} + {total_branches} + {covered_branches} + {coverage_percent:.1f}% + diff --git a/jinjatest/coverage/templates/source_line.html b/jinjatest/coverage/templates/source_line.html new file mode 100644 index 0000000..4209593 --- /dev/null +++ b/jinjatest/coverage/templates/source_line.html @@ -0,0 +1,4 @@ + + {lineno} +
    {source}
    + diff --git a/jinjatest/coverage/templates/style.css b/jinjatest/coverage/templates/style.css new file mode 100644 index 0000000..bd17d19 --- /dev/null +++ b/jinjatest/coverage/templates/style.css @@ -0,0 +1,91 @@ +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; +} diff --git a/jinjatest/coverage/templates/template_page.html b/jinjatest/coverage/templates/template_page.html new file mode 100644 index 0000000..75e0445 --- /dev/null +++ b/jinjatest/coverage/templates/template_page.html @@ -0,0 +1,28 @@ + + + + + Coverage: {path} + + + +

    ← Back to index

    + +

    {path}

    + +
    +

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

    +
    + +

    Uncovered Branches

    +
      + {uncovered_list} +
    + +

    Source

    + + {source_lines} +
    + + 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/coverage/transformer.py b/jinjatest/coverage/transformer.py new file mode 100644 index 0000000..a435de6 --- /dev/null +++ b/jinjatest/coverage/transformer.py @@ -0,0 +1,103 @@ +"""AST transformer for CondExpr instrumentation. + +This module provides a NodeTransformer that transforms CondExpr (ternary) +nodes to track branch execution while preserving lazy evaluation semantics. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from jinja2 import nodes +from jinja2.visitor import NodeTransformer + +if TYPE_CHECKING: + pass + + +class CondExprTransformer(NodeTransformer): + """Transforms CondExpr nodes to track branch execution. + + Preserves lazy evaluation by keeping the CondExpr structure intact + and wrapping each branch expression in a trace call. + + Converts: + {{ value if condition else default }} + + To AST equivalent of: + {{ _trace_branch('ternary_1_true', value) + if condition + else _trace_branch('ternary_1_false', default) }} + + The trace function records which branch was taken and returns + the value unchanged. Only the taken branch is evaluated. + """ + + def __init__(self) -> None: + """Initialize the transformer.""" + self.count = 0 + self.instrumented: list[dict[str, str | int]] = [] + + def _wrap_with_trace( + self, expr: nodes.Expr, branch_id: str, lineno: int + ) -> nodes.Call: + """Wrap an expression with a trace call. + + Args: + expr: The expression to wrap. + branch_id: The branch identifier (e.g., 'ternary_1_true'). + lineno: Line number for the new node. + + Returns: + A Call node: _trace_branch(branch_id, expr) + """ + return nodes.Call( + nodes.Name("_trace_branch", "load"), + [nodes.Const(branch_id), expr], + [], # kwargs + None, # dyn_args + None, # dyn_kwargs + lineno=lineno, + ) + + def visit_CondExpr(self, node: nodes.CondExpr) -> nodes.CondExpr: + """Transform a CondExpr node to track branch execution. + + Preserves the CondExpr structure (maintaining lazy evaluation) + but wraps expr1 and expr2 with trace calls. + + Args: + node: The CondExpr AST node to transform. + + Returns: + A modified CondExpr with traced branches. + """ + self.count += 1 + branch_id = f"ternary_{self.count}" + + self.instrumented.append( + { + "id": branch_id, + "line": node.lineno, + } + ) + + # Process children first (handles nested ternaries in expr1/expr2) + # This must happen AFTER we increment count for correct ordering + self.generic_visit(node) + + traced_expr1 = self._wrap_with_trace( + node.expr1, f"{branch_id}_true", node.lineno + ) + traced_expr2 = self._wrap_with_trace( + node.expr2 if node.expr2 is not None else nodes.Const(None), + f"{branch_id}_false", + node.lineno, + ) + + return nodes.CondExpr( + node.test, + traced_expr1, + traced_expr2, + lineno=node.lineno, + ) diff --git a/jinjatest/instrumentation.py b/jinjatest/instrumentation.py index fc62c86..54fe620 100644 --- a/jinjatest/instrumentation.py +++ b/jinjatest/instrumentation.py @@ -126,6 +126,24 @@ def trace(self, event: str) -> str: self._trace_recorder.record(event) return "" + def trace_branch(self, branch_id: str, value: object) -> object: + """Record branch execution and return the value unchanged. + + This is the lazy-evaluation-safe version of branch tracing. + It's called from within the taken branch of a CondExpr, + so only executed branches are recorded. + + Args: + branch_id: The branch identifier (e.g., 'ternary_1_true'). + value: The value to pass through. + + Returns: + The value unchanged. + """ + if self._enabled: + self._trace_recorder.record(branch_id) + return value + def clear(self) -> None: """Clear all trace events.""" self._trace_recorder.clear() @@ -150,6 +168,10 @@ def trace(self, event: str) -> str: """No-op in production mode.""" return "" + def trace_branch(self, branch_id: str, value: object) -> object: + """No-op in production mode - just returns value unchanged.""" + return value + def clear(self) -> None: """No-op in production mode.""" pass diff --git a/jinjatest/markers.py b/jinjatest/markers.py index 0a716b5..1b1d1fa 100644 --- a/jinjatest/markers.py +++ b/jinjatest/markers.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from jinja2 import Environment, Template - from jinjatest.instrumentation import TestInstrumentation + from jinjatest.instrumentation import ProductionInstrumentation, TestInstrumentation # Regex patterns for comment markers # Name must be a valid Python identifier @@ -138,7 +138,7 @@ def discover_markers(source: str) -> TemplateMarkers: def load_template_with_markers( env: Environment, template_path: str, - instrumentation: TestInstrumentation | None = None, + instrumentation: TestInstrumentation | ProductionInstrumentation | None = None, ) -> Template: """ Load a template from an environment, transforming comment markers. diff --git a/jinjatest/spec.py b/jinjatest/spec.py index ee46e20..41cd6b8 100644 --- a/jinjatest/spec.py +++ b/jinjatest/spec.py @@ -38,10 +38,30 @@ 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: + # Coverage module is optional; proceed without collector if unavailable + pass + return None + + class TemplateRenderError(Exception): """Raised when template rendering fails.""" @@ -81,6 +101,7 @@ def create_environment( enable_do_extension: bool = True, sandboxed: bool = False, native_types: bool = False, + enable_condexpr_coverage: bool = False, extensions: list[str] | None = None, filters: dict[str, Callable[..., Any]] | None = None, globals: dict[str, Any] | None = None, @@ -96,6 +117,7 @@ def create_environment( enable_do_extension: If True, enable jinja2.ext.do for statement expressions. sandboxed: If True, use SandboxedEnvironment for untrusted templates. native_types: If True, use NativeEnvironment for Python type output. + enable_condexpr_coverage: If True, use CoverageEnvironment for ternary expression coverage. extensions: Additional Jinja extensions to load. filters: Custom filters to add to the environment. globals: Global variables/functions to add to the environment. @@ -131,7 +153,11 @@ def create_environment( env_class = SandboxedEnvironment elif native_types: - env_class = NativeEnvironment # type: ignore[assignment] + env_class = NativeEnvironment + elif enable_condexpr_coverage: + from jinjatest.coverage.environment import CoverageEnvironment + + env_class = CoverageEnvironment else: env_class = Environment @@ -182,6 +208,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 +218,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 +227,7 @@ def __init__( instrumentation or create_instrumentation(test_mode=True) ) self._source = source + self._template_path = template_path @classmethod def from_string( @@ -209,6 +238,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,28 +250,56 @@ 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) source = transform_result.source + # Check for coverage collector early to determine environment type + collector = _get_coverage_collector() + if env is None: - env = create_environment(**env_kwargs) + # Enable condexpr coverage if collector is enabled + env = create_environment( + enable_condexpr_coverage=collector is not None and test_mode, + **env_kwargs, + ) instrumentation = create_instrumentation(test_mode=test_mode) env.globals["jt"] = instrumentation + # Inject trace function for CondExpr coverage + if collector and test_mode: + env.globals["_trace_branch"] = instrumentation.trace_branch + 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 and test_mode else None, ) @classmethod @@ -280,6 +338,9 @@ def from_file( env_was_provided = env is not None template_dir_was_provided = template_dir is not None + # Check for coverage collector early + collector = _get_coverage_collector() + if env is None: # Determine template directory if template_dir is None: @@ -291,9 +352,18 @@ def from_file( template_paths = env_kwargs.pop("template_paths", None) or [] template_paths = [template_dir] + [Path(p) for p in template_paths] - env = create_environment(template_paths=template_paths, **env_kwargs) + # Enable condexpr coverage if collector is enabled + env = create_environment( + template_paths=template_paths, + enable_condexpr_coverage=collector is not None and test_mode, + **env_kwargs, + ) instrumentation = create_instrumentation(test_mode=test_mode) env.globals["jt"] = instrumentation + + # Inject trace function for CondExpr coverage + if collector and test_mode: + env.globals["_trace_branch"] = instrumentation.trace_branch else: # For provided env, check if already instrumented existing_jt = env.globals.get("jt") @@ -305,6 +375,10 @@ def from_file( instrumentation = create_instrumentation(test_mode=test_mode) env.globals["jt"] = instrumentation + # Inject trace function for CondExpr coverage if not already present + if collector and test_mode and "_trace_branch" not in env.globals: + env.globals["_trace_branch"] = instrumentation.trace_branch + # Determine template name based on how env was obtained # When env is provided or template_dir is explicitly set, use full path # Otherwise use just filename (loader points to path.parent) @@ -313,6 +387,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: @@ -325,16 +402,39 @@ def from_file( original_source, _, _ = env.loader.get_source(env, template_name) else: # Read from file system (for newly created env) + assert template_dir is not None full_path = path if path.is_absolute() else Path(template_dir) / path original_source = full_path.read_text() # Transform markers transform_result = transform_markers(original_source) + transformed_source = transform_result.source + + # Instrument for coverage if enabled + 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 + 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 +442,7 @@ def from_file( context_model=context_model, instrumentation=instrumentation if test_mode else None, source=original_source, + template_path=cov_path if collector and test_mode else None, ) @property @@ -433,6 +534,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..4652f16 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" @@ -51,9 +52,25 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["jinjatest"] +[tool.hatch.build.hooks.custom] +path = "scripts/hatch_build.py" + [tool.ty.environment] python-version = "3.10" +[[tool.ty.overrides]] +include = ["tests/"] + +[tool.ty.overrides.rules] +# Tests frequently access attributes on union types after assertions narrow the type +possibly-missing-attribute = "ignore" +# Tests often use operators on optional types where context guarantees validity +unsupported-operator = "warn" +# Allow more flexible argument types in test code (e.g., MagicMock for pytest types) +invalid-argument-type = "ignore" +# Allow assignment to private attributes in tests (e.g., spec._env._loader = None) +invalid-assignment = "ignore" + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v" diff --git a/scripts/hatch_build.py b/scripts/hatch_build.py new file mode 100644 index 0000000..39dac97 --- /dev/null +++ b/scripts/hatch_build.py @@ -0,0 +1,51 @@ +"""Hatch build hook to generate _templates.py before building.""" + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore[unresolved-import] + + +class TemplateBuildHook(BuildHookInterface): + """Build hook that generates _templates.py from HTML/CSS sources.""" + + PLUGIN_NAME = "template-builder" + + def initialize(self, version: str, build_data: dict) -> None: + """Run template generation before build.""" + from pathlib import Path + + templates_dir = Path("jinjatest/coverage/templates") + output_file = Path("jinjatest/coverage/_templates.py") + + if not templates_dir.exists(): + return + + header = '''""" +Auto-generated template strings for HTML coverage reports. + +DO NOT EDIT THIS FILE DIRECTLY. +Edit the source templates in jinjatest/coverage/templates/ instead. +This file is generated automatically during the build process. +""" + +''' + + templates: dict[str, str] = {} + + for template_file in sorted(templates_dir.glob("*")): + if template_file.is_file(): + name = template_file.stem.upper().replace("-", "_") + ext = template_file.suffix.upper().lstrip(".") + var_name = f"{name}_{ext}" + content = template_file.read_text() + templates[var_name] = content + + lines = [header] + + for var_name, content in sorted(templates.items()): + escaped = content.replace("\\", "\\\\").replace('"""', '\\"\\"\\"') + lines.append(f'{var_name} = """{escaped}"""\n\n') + + output_file.write_text("".join(lines)) + + if "force_include" not in build_data: + build_data["force_include"] = {} + build_data["force_include"][str(output_file)] = str(output_file) 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_boost_coverage.py b/tests/coverage/test_boost_coverage.py new file mode 100644 index 0000000..858a695 --- /dev/null +++ b/tests/coverage/test_boost_coverage.py @@ -0,0 +1,851 @@ +"""Tests to boost coverage to 97%+.""" + +import io +import json +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path +from unittest import mock + +import pytest + +from jinjatest import TemplateSpec +from jinjatest.coverage.collector import ( + CoverageSummary, + get_coverage_collector, + reset_coverage_collector, +) +from jinjatest.coverage.discovery import BranchInfo +from jinjatest.coverage.reporter import ( + CoverageReporter, + HTMLReporter, + JSONReporter, + JUnitReporter, + ReportConfig, + TerminalReporter, +) +from jinjatest.coverage.tracker import BranchCoverage, TemplateCoverageStats + + +@pytest.fixture +def sample_stats_with_covered() -> list[TemplateCoverageStats]: + """Create sample template stats with covered branches for HTML testing.""" + 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_with_covered( + sample_stats_with_covered: list[TemplateCoverageStats], +) -> CoverageSummary: + """Create sample coverage summary.""" + return CoverageSummary(templates=sample_stats_with_covered) + + +class TestJUnitReporter: + """Tests for JUnitReporter class.""" + + def test_empty_report(self) -> None: + """Test JUnit report with no templates.""" + reporter = JUnitReporter() + summary = CoverageSummary(templates=[]) + + report = reporter.report(summary) + + assert '\n', "") + ) + assert root.tag == "testsuites" + assert root.get("tests") == "0" + assert root.get("failures") == "0" + + def test_basic_report(self, sample_summary_with_covered: CoverageSummary) -> None: + """Test basic JUnit report.""" + reporter = JUnitReporter() + + report = reporter.report(sample_summary_with_covered) + root = ET.fromstring( + report.replace('\n', "") + ) + + assert root.get("tests") == "3" + assert root.get("failures") == "1" # One uncovered branch + + testsuites = root.findall("testsuite") + assert len(testsuites) == 2 + + def test_report_with_failures( + self, sample_summary_with_covered: CoverageSummary + ) -> None: + """Test JUnit report includes failure details.""" + reporter = JUnitReporter() + + report = reporter.report(sample_summary_with_covered) + root = ET.fromstring( + report.replace('\n', "") + ) + + # Find the failure element + failures = root.findall(".//failure") + assert len(failures) == 1 + assert "Branch not covered" in failures[0].get("message", "") + assert failures[0].text is not None + assert "Line 1" in failures[0].text + + def test_report_with_output( + self, sample_summary_with_covered: CoverageSummary + ) -> None: + """Test JUnit report writes to output stream.""" + reporter = JUnitReporter() + output = io.StringIO() + + report = reporter.report(sample_summary_with_covered, output) + + assert output.getvalue() == report + assert ' None: + """Test writing JUnit report to file.""" + reporter = JUnitReporter() + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "subdir" / "coverage.xml" + reporter.write_to_file(sample_summary_with_covered, path) + + assert path.exists() + content = path.read_text() + assert ' None: + """Test JUnit report through unified reporter.""" + reporter = CoverageReporter() + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "coverage.xml" + reporter.junit_report(sample_summary_with_covered, path) + + assert path.exists() + content = path.read_text() + assert ' None: + """Test terminal report writes to output stream.""" + reporter = TerminalReporter() + output = io.StringIO() + + report = reporter.report(sample_summary_with_covered, output) + + assert output.getvalue() == report + + def test_report_empty_with_output(self) -> None: + """Test empty report writes to output stream.""" + reporter = TerminalReporter() + summary = CoverageSummary(templates=[]) + output = io.StringIO() + + report = reporter.report(summary, output) + + assert output.getvalue() == report + assert "No templates tracked" in output.getvalue() + + def test_show_missing_inline( + self, sample_summary_with_covered: CoverageSummary + ) -> None: + """Test show_missing_inline format.""" + config = ReportConfig(show_missing_inline=True) + reporter = TerminalReporter(config) + + report = reporter.report(sample_summary_with_covered) + + # Should have "Missing" header and show line numbers + assert "Missing" in report + assert "-" * 90 in report # 90-char separator for inline format + + def test_get_missing_lines_empty(self) -> None: + """Test _get_missing_lines with no uncovered branches.""" + reporter = TerminalReporter() + branch = BranchInfo("if_1_true", "if_true", 1, "test") + stats = TemplateCoverageStats( + template_path="test.j2", + total_branches=1, + covered_branches=1, + branch_details=[BranchCoverage(branch=branch, hit_count=1)], + ) + + missing = reporter._get_missing_lines(stats) + assert missing == "" + + def test_get_missing_lines_with_branches(self) -> None: + """Test _get_missing_lines with uncovered branches.""" + reporter = TerminalReporter() + branch1 = BranchInfo("if_1_false", "if_false", 10, "test1") + branch2 = BranchInfo("if_2_false", "if_false", 16, "test2") + stats = TemplateCoverageStats( + template_path="test.j2", + total_branches=2, + covered_branches=0, + branch_details=[ + BranchCoverage(branch=branch1, hit_count=0), + BranchCoverage(branch=branch2, hit_count=0), + ], + ) + + missing = reporter._get_missing_lines(stats) + assert "10" in missing + assert "16" in missing + + +class TestHTMLReporterExtended: + """Extended tests for HTMLReporter.""" + + def test_generate_template_page_with_covered_lines(self) -> None: + """Test template page generation with covered lines.""" + reporter = HTMLReporter() + + branch1 = BranchInfo("if_1_true", "if_true", 1, "if true") + branch2 = BranchInfo("if_1_false", "if_false", 1, "if false") + stats = TemplateCoverageStats( + template_path="test.j2", + total_branches=2, + covered_branches=1, + branch_details=[ + BranchCoverage(branch=branch1, hit_count=1), + BranchCoverage(branch=branch2, hit_count=0), + ], + ) + source = "{% if x %}\nyes\n{% else %}\nno\n{% endif %}" + + html = reporter._generate_template_page(stats, source) + + # Should have both covered and uncovered classes + assert 'class="covered"' in html or 'class="uncovered"' in html + assert "test.j2" in html + + def test_generate_template_page_all_covered(self) -> None: + """Test template page when all branches covered.""" + reporter = HTMLReporter() + + branch1 = BranchInfo("if_1_true", "if_true", 1, "if true") + stats = TemplateCoverageStats( + template_path="test.j2", + total_branches=1, + covered_branches=1, + branch_details=[ + BranchCoverage(branch=branch1, hit_count=1), + ], + ) + source = "{% if x %}yes{% endif %}" + + html = reporter._generate_template_page(stats, source) + + assert "All branches covered!" in html + + +class TestJSONReporterExtended: + """Extended tests for JSONReporter.""" + + def test_report_with_output_stream( + self, sample_summary_with_covered: CoverageSummary + ) -> None: + """Test JSON report writes to output stream.""" + reporter = JSONReporter() + output = io.StringIO() + + report = reporter.report(sample_summary_with_covered, output) + + assert output.getvalue() == report + + def test_show_missing_includes_covered( + self, sample_summary_with_covered: CoverageSummary + ) -> None: + """Test show_missing includes covered branches too.""" + config = ReportConfig(show_missing=True) + reporter = JSONReporter(config) + + report = reporter.report(sample_summary_with_covered) + data = json.loads(report) + + # Should have both covered and uncovered + test1 = next(t for t in data["templates"] if t["path"] == "test1.j2") + assert "covered" in test1 + assert "uncovered" in test1 + assert len(test1["covered"]) == 1 + assert test1["covered"][0]["hit_count"] == 3 + + +class TestPytestCovPlugin: + """Tests for pytest coverage plugin hooks.""" + + 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_load_pyproject_config_no_tomllib(self) -> None: + """Test config loading when tomllib is not available.""" + from jinjatest.coverage import pytest_cov + + with mock.patch.dict("sys.modules", {"tomllib": None, "tomli": None}): + # Force reimport to test the import error path + with mock.patch.object(pytest_cov, "_load_pyproject_config") as mock_load: + mock_load.return_value = {} + result = mock_load() + assert result == {} + + def test_load_pyproject_config_no_file(self) -> None: + """Test config loading when pyproject.toml doesn't exist.""" + from jinjatest.coverage.pytest_cov import _load_pyproject_config + + with mock.patch("pathlib.Path.exists", return_value=False): + result = _load_pyproject_config() + assert result == {} + + def test_load_pyproject_config_parse_error(self) -> None: + """Test config loading handles parse errors.""" + from jinjatest.coverage.pytest_cov import _load_pyproject_config + + with mock.patch("pathlib.Path.exists", return_value=True): + with mock.patch( + "builtins.open", mock.mock_open(read_data=b"invalid toml [") + ): + result = _load_pyproject_config() + assert result == {} + + def test_pytest_configure_enables_collector(self) -> None: + """Test pytest_configure enables collector when --jt-cov is set.""" + from jinjatest.coverage.pytest_cov import pytest_configure + + config = mock.MagicMock() + config.getoption.side_effect = lambda x, default=None: { + "--jt-cov": True, + "--jt-cov-exclude": [], + }.get(x, default) + + with mock.patch( + "jinjatest.coverage.pytest_cov._load_pyproject_config", return_value={} + ): + pytest_configure(config) + + collector = get_coverage_collector() + assert collector.enabled is True + assert config._jt_cov_enabled is True + + def test_pytest_configure_with_excludes(self) -> None: + """Test pytest_configure sets exclude patterns.""" + from jinjatest.coverage.pytest_cov import pytest_configure + + config = mock.MagicMock() + config.getoption.side_effect = lambda x, default=None: { + "--jt-cov": True, + "--jt-cov-exclude": ["**/vendor/**"], + }.get(x, default) + + with mock.patch( + "jinjatest.coverage.pytest_cov._load_pyproject_config", + return_value={"exclude_patterns": ["*.partial.j2"]}, + ): + pytest_configure(config) + + collector = get_coverage_collector() + assert "**/vendor/**" in collector._exclude_patterns + assert "*.partial.j2" in collector._exclude_patterns + + def test_pytest_configure_disabled(self) -> None: + """Test pytest_configure when coverage is disabled.""" + from jinjatest.coverage.pytest_cov import pytest_configure + + reset_coverage_collector() + config = mock.MagicMock() + config.getoption.side_effect = lambda x, default=None: { + "--jt-cov": False, + }.get(x, default) + + with mock.patch( + "jinjatest.coverage.pytest_cov._load_pyproject_config", return_value={} + ): + pytest_configure(config) + + assert config._jt_cov_enabled is False + + def test_pytest_unconfigure_resets_collector(self) -> None: + """Test pytest_unconfigure resets the collector.""" + from jinjatest.coverage.pytest_cov import pytest_unconfigure + + collector = get_coverage_collector() + collector.enable() + + config = mock.MagicMock() + pytest_unconfigure(config) + + collector = get_coverage_collector() + assert collector.enabled is False + + def test_pytest_sessionstart_resets_collector(self) -> None: + """Test pytest_sessionstart resets and enables collector.""" + from jinjatest.coverage.pytest_cov import pytest_sessionstart + + collector = get_coverage_collector() + collector.enable() + + # Register a template to verify reset + collector.register_template("old.j2", "old content") + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + + pytest_sessionstart(session) + + # Collector should be reset and re-enabled + assert collector.enabled is True + assert collector.get_tracker("old.j2") is None + + def test_pytest_sessionfinish_disabled(self) -> None: + """Test pytest_sessionfinish does nothing when disabled.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + session = mock.MagicMock() + session.config._jt_cov_enabled = False + + # Should not raise + pytest_sessionfinish(session, 0) + + def test_pytest_sessionfinish_generates_reports(self) -> None: + """Test pytest_sessionfinish generates reports.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, + "--jt-cov-report": ["term"], + "--jt-cov-html": None, + "--jt-cov-json": None, + "--jt-cov-xml": None, + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + # Should have written output + tw.write.assert_called() + + def test_pytest_sessionfinish_json_report(self) -> None: + """Test pytest_sessionfinish generates JSON report.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + with tempfile.TemporaryDirectory() as tmpdir: + json_path = Path(tmpdir) / "coverage.json" + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, + "--jt-cov-report": ["json"], + "--jt-cov-html": None, + "--jt-cov-json": str(json_path), + "--jt-cov-xml": None, + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + assert json_path.exists() + + def test_pytest_sessionfinish_html_report(self) -> None: + """Test pytest_sessionfinish generates HTML report.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + with tempfile.TemporaryDirectory() as tmpdir: + html_dir = Path(tmpdir) / "htmlcov" + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, + "--jt-cov-report": ["html"], + "--jt-cov-html": str(html_dir), + "--jt-cov-json": None, + "--jt-cov-xml": None, + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + assert html_dir.exists() + assert (html_dir / "index.html").exists() + + def test_pytest_sessionfinish_xml_report(self) -> None: + """Test pytest_sessionfinish generates XML report.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + with tempfile.TemporaryDirectory() as tmpdir: + xml_path = Path(tmpdir) / "coverage.xml" + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, + "--jt-cov-report": ["xml"], + "--jt-cov-html": None, + "--jt-cov-json": None, + "--jt-cov-xml": str(xml_path), + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + assert xml_path.exists() + + def test_pytest_sessionfinish_fail_under(self) -> None: + """Test pytest_sessionfinish sets fail flag when below threshold.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 100.0, # High threshold + "--jt-cov-report": ["term"], + "--jt-cov-html": None, + "--jt-cov-json": None, + "--jt-cov-xml": None, + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + assert session.config._jt_cov_failed is True + + def test_pytest_terminal_summary_with_failure(self) -> None: + """Test pytest_terminal_summary shows failure message.""" + from jinjatest.coverage.pytest_cov import pytest_terminal_summary + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + config = mock.MagicMock() + config._jt_cov_failed = True + config.getoption.return_value = 80.0 + + terminalreporter = mock.MagicMock() + + pytest_terminal_summary(terminalreporter, 0, config) + + terminalreporter.write_line.assert_called() + call_args = terminalreporter.write_line.call_args + assert "FAILED" in call_args[0][0] + + def test_pytest_terminal_summary_no_failure(self) -> None: + """Test pytest_terminal_summary does nothing when not failed.""" + from jinjatest.coverage.pytest_cov import pytest_terminal_summary + + config = mock.MagicMock() + config._jt_cov_failed = False + + terminalreporter = mock.MagicMock() + + pytest_terminal_summary(terminalreporter, 0, config) + + terminalreporter.write_line.assert_not_called() + + def test_pytest_sessionfinish_no_terminal_reporter(self) -> None: + """Test pytest_sessionfinish handles missing terminal reporter.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, + "--jt-cov-report": ["term"], + "--jt-cov-html": None, + "--jt-cov-json": None, + "--jt-cov-xml": None, + }.get(x, default) + session.config.pluginmanager.get_plugin.return_value = None + + # Should not raise + pytest_sessionfinish(session, 0) + + def test_pytest_sessionfinish_default_reports(self) -> None: + """Test pytest_sessionfinish uses default report types.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, + "--jt-cov-report": [], # Empty - should default to term + "--jt-cov-html": None, + "--jt-cov-json": None, + "--jt-cov-xml": None, + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + # Should have written term report + tw.write.assert_called() + + def test_pytest_sessionfinish_term_missing(self) -> None: + """Test pytest_sessionfinish handles term-missing report type.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, + "--jt-cov-report": ["term-missing"], + "--jt-cov-html": None, + "--jt-cov-json": None, + "--jt-cov-xml": None, + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + tw.write.assert_called() + + def test_pytest_sessionfinish_term_verbose(self) -> None: + """Test pytest_sessionfinish handles term-verbose report type.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, + "--jt-cov-report": ["term-verbose"], + "--jt-cov-html": None, + "--jt-cov-json": None, + "--jt-cov-xml": None, + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + tw.write.assert_called() + + def test_pytest_sessionfinish_pyproject_fail_under(self) -> None: + """Test pytest_sessionfinish uses pyproject fail_under.""" + from jinjatest.coverage.pytest_cov import pytest_sessionfinish + + collector = get_coverage_collector() + collector.enable() + collector.register_template("test.j2", "{% if x %}y{% endif %}") + + session = mock.MagicMock() + session.config._jt_cov_enabled = True + session.config._jt_cov_pyproject = {"fail_under": 100.0} + session.config.getoption.side_effect = lambda x, default=None: { + "--jt-cov-fail-under": 0.0, # CLI not set + "--jt-cov-report": ["term"], + "--jt-cov-html": None, + "--jt-cov-json": None, + "--jt-cov-xml": None, + }.get(x, default) + + tw = mock.MagicMock() + terminalreporter = mock.MagicMock() + terminalreporter._tw = tw + session.config.pluginmanager.get_plugin.return_value = terminalreporter + + pytest_sessionfinish(session, 0) + + assert session.config._jt_cov_failed is True + + +class TestSpecEdgeCases: + """Tests for spec.py edge cases.""" + + 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_get_undeclared_variables_no_source(self) -> None: + """Test get_undeclared_variables returns empty set when no source.""" + spec = TemplateSpec.from_string("hello", test_mode=False) + # Clear the source + spec._source = None + + # Mock env.loader to return None + spec._env._loader = None + + result = spec.get_undeclared_variables() + assert result == set() + + def test_template_spec_with_coverage_hash_path(self) -> None: + """Test template spec generates hash-based path for string templates.""" + collector = get_coverage_collector() + collector.enable() + + # Create spec without explicit template_path - should generate hash + spec = TemplateSpec.from_string("{% if x %}y{% endif %}") + + # Render to trigger coverage + spec.render({"x": True}) + + summary = collector.get_summary() + # Should have a template with hash-based path + assert summary.template_count == 1 + template = summary.templates[0] + assert template.template_path is not None + assert " 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_record_render_disabled(self) -> None: + """Test record_render does nothing when disabled.""" + collector = get_coverage_collector() + # Don't enable + + collector.record_render("test.j2", ["trace1"]) + + # Should not have any trackers + assert collector.get_tracker("test.j2") is None + + def test_record_render_unknown_path(self) -> None: + """Test record_render handles unknown paths.""" + collector = get_coverage_collector() + collector.enable() + + # Record without registering first + collector.record_render("unknown.j2", ["trace1"]) + + # Should not crash, just ignore + assert collector.get_tracker("unknown.j2") is None + + def test_register_template_excluded(self) -> None: + """Test register_template respects exclude patterns.""" + collector = get_coverage_collector() + collector.enable() + collector.set_exclude_patterns(["**/vendor/**"]) + + source = "{% if x %}y{% endif %}" + result = collector.register_template("path/vendor/test.j2", source) + + # Should return original source (not instrumented) + assert result == source + assert collector.get_tracker("path/vendor/test.j2") is None 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_condexpr.py b/tests/coverage/test_condexpr.py new file mode 100644 index 0000000..720e738 --- /dev/null +++ b/tests/coverage/test_condexpr.py @@ -0,0 +1,404 @@ +"""Tests for CondExpr (ternary expression) coverage tracking.""" + +from jinja2 import Environment + +from jinjatest.coverage.discovery import BranchDiscovery +from jinjatest.coverage.environment import CoverageEnvironment +from jinjatest.coverage.transformer import CondExprTransformer +from jinjatest.instrumentation import TestInstrumentation + + +class TestCondExprDiscovery: + """Tests for CondExpr branch discovery.""" + + def test_simple_ternary(self) -> None: + """Test discovery of simple ternary expression.""" + discovery = BranchDiscovery() + result = discovery.discover("{{ a if b else c }}") + + assert "ternary_1_true" in result.branch_ids + assert "ternary_1_false" in result.branch_ids + assert result.branch_count == 2 + + def test_nested_ternary(self) -> None: + """Test discovery of nested ternary expressions.""" + discovery = BranchDiscovery() + result = discovery.discover("{{ a if x else (b if y else c) }}") + + # Should find 2 ternaries = 4 branches + assert result.branch_count == 4 + assert "ternary_1_true" in result.branch_ids + assert "ternary_1_false" in result.branch_ids + assert "ternary_2_true" in result.branch_ids + assert "ternary_2_false" in result.branch_ids + + def test_ternary_in_set(self) -> None: + """Test discovery of ternary in set statement.""" + discovery = BranchDiscovery() + result = discovery.discover("{% set x = a if b else c %}") + + assert result.branch_count == 2 + assert "ternary_1_true" in result.branch_ids + assert "ternary_1_false" in result.branch_ids + + def test_multiple_ternaries(self) -> None: + """Test discovery of multiple ternary expressions.""" + discovery = BranchDiscovery() + result = discovery.discover("{{ a if x else b }} {{ c if y else d }}") + + assert result.branch_count == 4 + assert "ternary_1_true" in result.branch_ids + assert "ternary_1_false" in result.branch_ids + assert "ternary_2_true" in result.branch_ids + assert "ternary_2_false" in result.branch_ids + + def test_ternary_branch_types(self) -> None: + """Test that ternary branches have correct types.""" + discovery = BranchDiscovery() + result = discovery.discover("{{ a if b else c }}") + + true_branch = result.get_branch("ternary_1_true") + false_branch = result.get_branch("ternary_1_false") + + assert true_branch is not None + assert true_branch.branch_type == "cond_true" + + assert false_branch is not None + assert false_branch.branch_type == "cond_false" + + def test_ternary_with_if_statement(self) -> None: + """Test discovery of ternary inside if statement.""" + discovery = BranchDiscovery() + source = """{% if show %} +{{ a if x else b }} +{% endif %}""" + result = discovery.discover(source) + + # Should have if branches + ternary branches + assert "if_1_true" in result.branch_ids + assert "if_1_false" in result.branch_ids + assert "ternary_1_true" in result.branch_ids + assert "ternary_1_false" in result.branch_ids + + def test_chained_ternary(self) -> None: + """Test discovery of chained ternary (parsed as nested).""" + discovery = BranchDiscovery() + # a if x else b if y else c parses as: a if x else (b if y else c) + result = discovery.discover("{{ a if x else b if y else c }}") + + assert result.branch_count == 4 + + +class TestCondExprTransformer: + """Tests for CondExpr AST transformation.""" + + def test_transforms_condexpr(self) -> None: + """Test that transformer counts CondExpr nodes.""" + env = Environment() + ast = env.parse("{{ a if b else c }}") + + transformer = CondExprTransformer() + transformer.visit(ast) + + assert transformer.count == 1 + assert len(transformer.instrumented) == 1 + assert transformer.instrumented[0]["id"] == "ternary_1" + + def test_transforms_nested_condexpr(self) -> None: + """Test transformation of nested ternary expressions.""" + env = Environment() + ast = env.parse("{{ a if x else (b if y else c) }}") + + transformer = CondExprTransformer() + transformer.visit(ast) + + assert transformer.count == 2 + assert len(transformer.instrumented) == 2 + + def test_records_line_numbers(self) -> None: + """Test that transformer records line numbers.""" + env = Environment() + ast = env.parse("{{ a if b else c }}") + + transformer = CondExprTransformer() + transformer.visit(ast) + + assert transformer.instrumented[0]["line"] == 1 + + def test_preserves_condexpr_structure(self) -> None: + """Test that transformer preserves CondExpr (doesn't replace with Call).""" + from jinja2 import nodes + + env = Environment() + ast = env.parse("{{ a if b else c }}") + + transformer = CondExprTransformer() + result = transformer.visit(ast) + + # Find the Output node's child - should still be a CondExpr + list(result.find_all(nodes.Output))[0] + # The first node in output should be a CondExpr (wrapped branches) + assert len(list(result.find_all(nodes.CondExpr))) == 1 + + +class TestCoverageEnvironment: + """Tests for CoverageEnvironment.""" + + def test_preserves_semantics_true_branch(self) -> None: + """Test that transformed template returns correct value for true branch.""" + env = CoverageEnvironment() + # Mock trace function that just returns the value + env.globals["_trace_branch"] = lambda bid, value: value + + template = env.from_string("{{ 'yes' if show else 'no' }}") + + assert template.render(show=True) == "yes" + + def test_preserves_semantics_false_branch(self) -> None: + """Test that transformed template returns correct value for false branch.""" + env = CoverageEnvironment() + env.globals["_trace_branch"] = lambda bid, value: value + + template = env.from_string("{{ 'yes' if show else 'no' }}") + + assert template.render(show=False) == "no" + + def test_handles_nested_ternary(self) -> None: + """Test that nested ternaries work correctly.""" + env = CoverageEnvironment() + env.globals["_trace_branch"] = lambda bid, value: value + + template = env.from_string("{{ a if x else (b if y else c) }}") + + assert template.render(x=True, a="A", b="B", c="C", y=True) == "A" + assert template.render(x=False, a="A", b="B", c="C", y=True) == "B" + assert template.render(x=False, a="A", b="B", c="C", y=False) == "C" + + def test_handles_none_expr2(self) -> None: + """Test ternary with empty string as else.""" + env = CoverageEnvironment() + env.globals["_trace_branch"] = lambda bid, value: value + + template = env.from_string("{{ value if show else '' }}") + assert template.render(show=True, value="hello") == "hello" + assert template.render(show=False, value="hello") == "" + + +class TestTraceBranch: + """Tests for trace_branch recording with TestInstrumentation.""" + + def test_records_branch_and_returns_value(self) -> None: + """Test that trace_branch records and returns value unchanged.""" + instrumentation = TestInstrumentation() + + result = instrumentation.trace_branch("ternary_1_true", "hello") + + assert result == "hello" + assert "ternary_1_true" in instrumentation.trace_events + + def test_respects_enabled_flag(self) -> None: + """Test that tracing respects enabled flag.""" + instrumentation = TestInstrumentation() + instrumentation._enabled = False + + result = instrumentation.trace_branch("ternary_1_true", "hello") + + assert result == "hello" + assert len(instrumentation.trace_events) == 0 + + def test_preserves_various_types(self) -> None: + """Test that trace_branch preserves various value types.""" + instrumentation = TestInstrumentation() + + # String + assert instrumentation.trace_branch("t1", "hello") == "hello" + + # Integer + assert instrumentation.trace_branch("t2", 42) == 42 + + # List + assert instrumentation.trace_branch("t3", [1, 2, 3]) == [1, 2, 3] + + # None + assert instrumentation.trace_branch("t4", None) is None + + # Dict + assert instrumentation.trace_branch("t5", {"a": 1}) == {"a": 1} + + +class TestLazyEvaluation: + """Tests verifying that lazy evaluation is preserved.""" + + def test_nested_ternary_lazy_evaluation(self) -> None: + """Test that inner ternary is NOT evaluated when outer is true. + + This is the key test that verifies the lazy evaluation fix. + When x=True, only the true branch should be evaluated, + and the inner ternary in the false branch should NOT execute. + """ + env = CoverageEnvironment() + instrumentation = TestInstrumentation() + env.globals["_trace_branch"] = instrumentation.trace_branch + + template = env.from_string("{{ a if x else (b if y else c) }}") + + # x=True: only outer true branch is evaluated + # Inner ternary should NOT be evaluated at all + result = template.render(x=True, a="A", b="B", c="C", y=True) + assert result == "A" + assert "ternary_1_true" in instrumentation.trace_events + # Inner ternary (ternary_2) should NOT be traced + assert "ternary_2_true" not in instrumentation.trace_events + assert "ternary_2_false" not in instrumentation.trace_events + + instrumentation.clear() + + # x=False, y=True: outer false, then inner true + result = template.render(x=False, a="A", b="B", c="C", y=True) + assert result == "B" + assert "ternary_1_false" in instrumentation.trace_events + assert "ternary_2_true" in instrumentation.trace_events + + instrumentation.clear() + + # x=False, y=False: outer false, then inner false + result = template.render(x=False, a="A", b="B", c="C", y=False) + assert result == "C" + assert "ternary_1_false" in instrumentation.trace_events + assert "ternary_2_false" in instrumentation.trace_events + + def test_side_effects_only_in_taken_branch(self) -> None: + """Test that side effects only occur in the taken branch.""" + env = CoverageEnvironment() + instrumentation = TestInstrumentation() + env.globals["_trace_branch"] = instrumentation.trace_branch + + # Track which variables were accessed + accessed: list[str] = [] + + class TrackingValue: + def __init__(self, name: str, value: str): + self.name = name + self.value = value + + def __str__(self) -> str: + accessed.append(self.name) + return self.value + + template = env.from_string("{{ a if x else b }}") + + a_val = TrackingValue("a", "A") + b_val = TrackingValue("b", "B") + + # When x=True, only 'a' should be accessed + accessed.clear() + result = template.render(x=True, a=a_val, b=b_val) + assert result == "A" + assert "a" in accessed + assert "b" not in accessed + + # When x=False, only 'b' should be accessed + accessed.clear() + result = template.render(x=False, a=a_val, b=b_val) + assert result == "B" + assert "b" in accessed + assert "a" not in accessed + + +class TestCondExprIntegration: + """Integration tests for CondExpr with CoverageEnvironment and instrumentation.""" + + def test_full_integration(self) -> None: + """Test full integration of environment, transformer, and instrumentation.""" + env = CoverageEnvironment() + instrumentation = TestInstrumentation() + env.globals["_trace_branch"] = instrumentation.trace_branch + + template = env.from_string("{{ 'yes' if show else 'no' }}") + + # Render with true condition + result = template.render(show=True) + assert result == "yes" + assert "ternary_1_true" in instrumentation.trace_events + + instrumentation.clear() + + # Render with false condition + result = template.render(show=False) + assert result == "no" + assert "ternary_1_false" in instrumentation.trace_events + + def test_multiple_ternaries_integration(self) -> None: + """Test multiple ternaries in one template.""" + env = CoverageEnvironment() + instrumentation = TestInstrumentation() + env.globals["_trace_branch"] = instrumentation.trace_branch + + template = env.from_string("{{ a if x else b }}-{{ c if y else d }}") + + result = template.render(x=True, a="A", b="B", y=False, c="C", d="D") + assert result == "A-D" + assert "ternary_1_true" in instrumentation.trace_events + assert "ternary_2_false" in instrumentation.trace_events + + def test_ternary_in_filter(self) -> None: + """Test ternary expression inside a filter.""" + env = CoverageEnvironment() + instrumentation = TestInstrumentation() + env.globals["_trace_branch"] = instrumentation.trace_branch + + template = env.from_string("{{ ('yes' if show else 'no') | upper }}") + + result = template.render(show=True) + assert result == "YES" + assert "ternary_1_true" in instrumentation.trace_events + + def test_ternary_with_complex_expressions(self) -> None: + """Test ternary with complex expressions.""" + env = CoverageEnvironment() + instrumentation = TestInstrumentation() + env.globals["_trace_branch"] = instrumentation.trace_branch + + template = env.from_string("{{ (a + b) if (x > 0) else (c * d) }}") + + result = template.render(a=1, b=2, c=3, d=4, x=5) + assert result == "3" # 1 + 2 + assert "ternary_1_true" in instrumentation.trace_events + + instrumentation.clear() + + result = template.render(a=1, b=2, c=3, d=4, x=-1) + assert result == "12" # 3 * 4 + assert "ternary_1_false" in instrumentation.trace_events + + def test_deeply_nested_ternaries(self) -> None: + """Test deeply nested ternaries with lazy evaluation.""" + env = CoverageEnvironment() + instrumentation = TestInstrumentation() + env.globals["_trace_branch"] = instrumentation.trace_branch + + # a if x else (b if y else (c if z else d)) + template = env.from_string("{{ a if x else (b if y else (c if z else d)) }}") + + # x=True: only ternary_1_true should be traced + result = template.render(x=True, a="A", b="B", c="C", d="D", y=True, z=True) + assert result == "A" + assert instrumentation.trace_events == ["ternary_1_true"] + + instrumentation.clear() + + # x=False, y=True: ternary_1_false, ternary_2_true + result = template.render(x=False, a="A", b="B", c="C", d="D", y=True, z=True) + assert result == "B" + assert "ternary_1_false" in instrumentation.trace_events + assert "ternary_2_true" in instrumentation.trace_events + assert "ternary_3_true" not in instrumentation.trace_events + + instrumentation.clear() + + # x=False, y=False, z=False: all false branches + result = template.render(x=False, a="A", b="B", c="C", d="D", y=False, z=False) + assert result == "D" + assert "ternary_1_false" in instrumentation.trace_events + assert "ternary_2_false" in instrumentation.trace_events + assert "ternary_3_false" in instrumentation.trace_events 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..89367f1 --- /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, + 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, + 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("