diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db4888c..8cc79a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - npm registry distribution: `npm install -g bashunit` (#244) - `bashunit::env::supports_color` and `bashunit::io::clear_screen` helpers (#247) - LCOV reports now include `FN`, `FNDA`, `FNF` and `FNH` function records, consumed by `genhtml`, Codecov and Coveralls +- LCOV reports now include `BRDA`, `BRF` and `BRH` branch records for `if`/`elif`/`else` chains and `case` patterns (see `adrs/adr-007-branch-coverage-mvp.md`) - `BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true` adds a per-function coverage block to the text report - `BASHUNIT_COVERAGE_SHOW_UNCOVERED=true` adds an "Uncovered Lines" block to the text report, with consecutive line numbers compressed into ranges diff --git a/adrs/adr-007-branch-coverage-mvp.md b/adrs/adr-007-branch-coverage-mvp.md new file mode 100644 index 00000000..d463162c --- /dev/null +++ b/adrs/adr-007-branch-coverage-mvp.md @@ -0,0 +1,90 @@ +# Branch Coverage MVP via Static Branch-Point Detection + +* Status: accepted +* Date: 2026-05-04 + +## Context and Problem Statement + +Coverage today reports line-level execution only. Standard tooling (genhtml, Codecov, Coveralls) consumes branch records via the LCOV `BRDA`/`BRF`/`BRH` fields, which let reviewers see whether `else`/`elif` arms and individual `case` patterns were exercised. Adding true branch coverage to a Bash framework is non-trivial because: + +1. Bash exposes no native instrumentation comparable to gcov branch counters. +2. The DEBUG trap fires on commands, not on branch decisions. +3. `BASH_COMMAND` reflects the *next* command, not the boolean outcome of a conditional. + +We need a path that yields useful, mostly-correct branch metrics in LCOV reports without breaking Bash 3.0+ compatibility or the cost profile of the existing line tracker. + +## Decision Drivers + +* Bash 3.0+ compatibility (no associative arrays, no `[[`, no Bash 4-only features). +* Reuse existing line-hit data; do not double the runtime cost of coverage. +* LCOV output must be consumable by genhtml, Codecov and Coveralls without custom processing. +* Implementation must fit in `src/coverage.sh` and remain testable with the existing unit-test patterns. +* Behavior must be predictable enough to pin in tests; "best-effort heuristic" outputs are not acceptable. + +## Considered Options + +1. **Static branch-point detection plus line-hit inference** — parse the source file for branch-introducing constructs (`if`/`elif`/`else`, `case` patterns), compute the line range owned by each outcome, then mark the outcome as "taken" iff any line inside its range was hit. +2. **Runtime decision tracing via `BASH_COMMAND`** — record the actual command being executed in the DEBUG trap and reconstruct decisions taken (`if X` followed by execution of either then-block or else-block). +3. **Patch-based instrumentation** — preprocess source files to insert hit recorders inside each branch arm, run tests against the instrumented copy, post-process the data file. + +## Decision Outcome + +Chosen option: **Option 1 (static branch-point detection plus line-hit inference)**. + +It reuses the existing line-hit data file with no DEBUG-trap changes. Bash 3.0+ compatibility is preserved because the parser is a single pass over the source with brace counting, identical in shape to the existing `extract_functions` walker. The output maps cleanly to LCOV `BRDA` records, and the contract ("an arm is taken iff any executable line inside it was hit") is precise enough to write unit tests against. + +### Positive Consequences + +* Zero runtime cost beyond the existing line tracker. Branch records are computed during report generation, not during test execution. +* Reuses `is_executable_line` and `get_all_line_hits`, which already tolerate Bash 3.0 limitations. +* LCOV output remains a single file, consumed unchanged by downstream tools. + +### Negative Consequences + +* Branch detection is line-presence based, not outcome based. A `then` arm whose only statement is a comment-line will register as `not taken` even if the conditional fired (because there are no executable lines inside). This is documented as a known limitation. +* Implicit `else` (when an `if/elif` chain has no explicit `else`) is reported only when at least one explicit arm exists; the synthetic "fall-through" outcome is omitted from this MVP and may be added in a follow-up. +* Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression. + +## Pros and Cons of the Options + +### Option 1: Static + line-hit inference (chosen) + +* Good, because reuses existing data and code paths. +* Good, because matches the implementation pattern of `extract_functions` already shipping in the codebase. +* Good, because output is deterministic and easy to test. +* Bad, because cannot distinguish "arm executed but produced no executable lines" from "arm not executed". + +### Option 2: Runtime DEBUG-trap decision tracing + +* Good, because reflects actual runtime behavior. +* Bad, because `BASH_COMMAND` semantics across Bash 3.x and 5.x diverge for `((...))`, `[[...]]` and pipelines, requiring per-version logic. +* Bad, because increases per-line overhead; the existing tracker already has measurable cost. +* Bad, because subshell context loss (already documented for line coverage) extends to branches taken inside `$(...)`. + +### Option 3: Source-rewrite instrumentation + +* Good, because most accurate signal possible. +* Bad, because requires either running tests against a rewritten source tree or hooking `source` to redirect to instrumented copies — both invasive and brittle. +* Bad, because debugging stack traces and line numbers no longer match the user's source. +* Bad, because doubles the code surface and breaks the "DEBUG-trap only" simplicity model. + +## Scope of MVP + +Included: + +* `if`/`elif`/`else` chains: each arm is one outcome. +* `case` statements: each pattern is one outcome. +* LCOV `BRDA:,,,` lines. +* `BRF:` and `BRH:` per file. + +Deferred (potential follow-ups): + +* Synthetic "implicit-else" outcomes for `if/elif` chains without an explicit `else`. +* Per-sub-expression decisions inside `if A && B`. +* `&&` / `||` short-circuit branches outside `if`. +* Loop-entry decisions (`while`/`until`). + +## Links + +* Builds on the function extractor introduced in `src/coverage.sh` (see `bashunit::coverage::extract_functions`). +* LCOV format reference: diff --git a/docs/coverage.md b/docs/coverage.md index 1ef5896e..27f9bdd4 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -278,6 +278,13 @@ end_of_record |-------|-------------|---------| | `TN:` | Test Name (usually empty) | `TN:` | | `SF:` | Source File path | `SF:/home/user/project/src/math.sh` | +| `FN:` | Function: `start_line,name` | `FN:5,multiply` | +| `FNDA:` | Function call data: `count,name` (1 if any line in body was hit, else 0) | `FNDA:1,add` | +| `FNF:` | Functions Found | `FNF:2` | +| `FNH:` | Functions Hit | `FNH:1` | +| `BRDA:` | Branch data: `decision_line,block,arm,taken` | `BRDA:12,0,1,1` | +| `BRF:` | Branches Found | `BRF:6` | +| `BRH:` | Branches Hit | `BRH:4` | | `DA:` | Line Data: `line_number,hit_count` | `DA:15,3` (line 15 hit 3 times) | | `LF:` | Lines Found (total executable lines) | `LF:25` | | `LH:` | Lines Hit (lines with hits > 0) | `LH:20` | @@ -351,6 +358,131 @@ These lines are not counted toward coverage: - Control flow keywords (`then`, `else`, `fi`, `do`, `done`, `esac`, `in`) - Case statement patterns (`--option)`, `*)`) and terminators (`;;`, `;&`, `;;&`) +## Branch Coverage + +Beyond line and function coverage, bashunit emits **branch coverage** records in the LCOV report so reviewers can see whether each `else`/`elif` arm and each `case` pattern was exercised. Branch records are produced automatically; no extra flags are needed. + +### What Counts as a Branch + +| Construct | Arms | +|-----------|------| +| `if X; then ... fi` | 1 (the `then` body) | +| `if X; then ... else ... fi` | 2 (`then` + `else`) | +| `if X; then ... elif Y; then ... else ... fi` | 3 (one per arm) | +| `case X in a) ... ;; b) ... ;; *) ... ;; esac` | one per pattern | + +An arm is reported as **taken** iff at least one executable line inside its range was hit by tests. + +### Verbose Output Helpers + +Two opt-in environment variables enrich the text report when investigating coverage gaps: + +::: code-group +```bash [Per-function block] +BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true bashunit tests/ --coverage +``` +```bash [Uncovered lines block] +BASHUNIT_COVERAGE_SHOW_UNCOVERED=true bashunit tests/ --coverage +``` +```bash [Both] +BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true \ +BASHUNIT_COVERAGE_SHOW_UNCOVERED=true \ + bashunit tests/ --coverage +``` +::: + +The default text report stays compact; opt in only when triaging. + +### Worked Example + +Given `src/route.sh`: + +```bash +#!/usr/bin/env bash +function route() { + if [ "$1" = "GET" ]; then + echo "fetch" + elif [ "$1" = "POST" ]; then + echo "create" + else + echo "405" + fi +} +``` + +If tests only call `route GET`, the LCOV record looks like: + +``` +TN: +SF:/path/to/src/route.sh +FN:2,route +FNDA:1,route +FNF:1 +FNH:1 +BRDA:3,0,0,1 +BRDA:3,0,1,0 +BRDA:3,0,2,0 +BRF:3 +BRH:1 +DA:3,1 +DA:4,1 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +LF:6 +LH:2 +end_of_record +``` + +**Reading the branch records:** +- `BRDA:3,0,0,1`: decision on line 3, block 0, arm 0 (`then`/GET), taken. +- `BRDA:3,0,1,0`: same decision, arm 1 (`elif`/POST), not taken. +- `BRDA:3,0,2,0`: same decision, arm 2 (`else`/405), not taken. +- `BRF:3` `BRH:1`: 3 branches found, 1 taken. + +### Visualizing with genhtml + +LCOV's `genhtml` renders branch coverage alongside line and function coverage: + +::: code-group +```bash [Generate] +bashunit tests/ --coverage +genhtml --branch-coverage coverage/lcov.info -o coverage/html +``` +::: + +The resulting site shows a red/green diamond next to each branch decision, mirroring `gcov`'s C/C++ output. + +### CI Integration + +Codecov and Coveralls pick up the new records without configuration. To require branch coverage in PR gates: + +::: code-group +```yaml [Codecov] +coverage: + status: + project: + default: + target: 80% + patch: + default: + target: 80% + threshold: 0% + flags: + - branch +``` +::: + +### Limitations + +- An arm whose body has no executable lines (only comments or braces) registers as not-taken even when the conditional fired. +- Implicit `else` (an `if`/`elif` chain without an explicit `else`) reports only the explicit arms; the synthetic fall-through outcome is omitted. +- Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression. +- `&&`/`||` short-circuit branches outside `if` and loop-entry decisions (`while`/`until`) are not tracked. + +See `adrs/adr-007-branch-coverage-mvp.md` for the design rationale and the rejected alternatives. + ## Limitations ### External Commands diff --git a/src/coverage.sh b/src/coverage.sh index 079346b7..0655baf7 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -776,6 +776,240 @@ function bashunit::coverage::extract_functions() { fi } +# Append "start:end" to a comma-separated arms string. Result is +# returned via the global _BASHUNIT_BRANCH_ARMS_OUT to avoid the cost +# of a subshell on a hot per-line path. Bash 3.0 cannot pass arrays +# (or namerefs) by reference, so a single output slot is the cheapest +# portable option. +_BASHUNIT_BRANCH_ARMS_OUT="" +function bashunit::coverage::_append_arm() { + local existing="$1" arm_start="$2" arm_end="$3" + if [ -z "$existing" ]; then + _BASHUNIT_BRANCH_ARMS_OUT="${arm_start}:${arm_end}" + else + _BASHUNIT_BRANCH_ARMS_OUT="${existing},${arm_start}:${arm_end}" + fi +} + +# Detect whether a trimmed line is a case-pattern opener (ends with +# `)` optionally followed by whitespace and a comment). Avoids +# matching mid-line uses such as `cmd $(other)`. +function bashunit::coverage::_is_case_pattern_line() { + local trimmed="$1" + case "$trimmed" in + *')'*) ;; + *) return 1 ;; + esac + + local before_paren="${trimmed%%')'*}" + local after="${trimmed#"$before_paren"}" + after="${after#)}" + after="${after#"${after%%[![:space:]]*}"}" + case "$after" in + '' | '#'*) return 0 ;; + esac + return 1 +} + +# Extract branch points from a Bash file. +# Output format: ||:[,:]... +# kind ∈ {if, case} +# Scope: if/elif/else chains and case patterns. See adrs/adr-007-branch-coverage-mvp.md. +# The handlers below operate on the per-construct state arrays that +# extract_branches keeps as locals. Bash 3.0 has dynamic scoping for +# `local` vars, so the helpers see and mutate the caller's state +# without needing namerefs (which would require Bash 4.3+). + +function bashunit::coverage::_branch_push_if() { + local lineno=$1 + if_decision_line[if_depth]=$lineno + if_arms[if_depth]="" + if_arm_start[if_depth]=$((lineno + 1)) + if_depth=$((if_depth + 1)) +} + +function bashunit::coverage::_branch_close_if_arm() { + local lineno=$1 idx=$((if_depth - 1)) + bashunit::coverage::_append_arm \ + "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" + if_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + if_arm_start[idx]=$((lineno + 1)) +} + +function bashunit::coverage::_branch_emit_if() { + local lineno=$1 idx=$((if_depth - 1)) + bashunit::coverage::_append_arm \ + "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" + echo "${if_decision_line[$idx]}|if|${_BASHUNIT_BRANCH_ARMS_OUT}" + if_depth=$idx +} + +function bashunit::coverage::_branch_push_case() { + local lineno=$1 + case_decision_line[case_depth]=$lineno + case_arms[case_depth]="" + case_arm_start[case_depth]=0 + case_in_pattern[case_depth]=0 + case_depth=$((case_depth + 1)) +} + +function bashunit::coverage::_branch_close_case_arm() { + local lineno=$1 idx=$((case_depth - 1)) + [ "${case_in_pattern[$idx]}" = "1" ] || return 0 + bashunit::coverage::_append_arm \ + "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$((lineno - 1))" + case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + case_in_pattern[idx]=0 +} + +function bashunit::coverage::_branch_emit_case() { + local lineno=$1 idx=$((case_depth - 1)) + bashunit::coverage::_branch_close_case_arm "$lineno" + if [ -n "${case_arms[$idx]}" ]; then + echo "${case_decision_line[$idx]}|case|${case_arms[$idx]}" + fi + case_depth=$idx +} + +function bashunit::coverage::_branch_open_case_pattern() { + local lineno=$1 idx=$((case_depth - 1)) + case_arm_start[idx]=$((lineno + 1)) + case_in_pattern[idx]=1 +} + +function bashunit::coverage::extract_branches() { + local file="$1" + + local -a lines=() + local _i=0 _l + while IFS= read -r _l || [ -n "$_l" ]; do + lines[_i]="$_l" + ((++_i)) + done <"$file" + local total_lines=$_i + + # State arrays — read and mutated by the _branch_* helpers via Bash's + # dynamic scoping. Each array is keyed by depth so nested constructs + # work without associative arrays. + local -a if_decision_line=() if_arms=() if_arm_start=() + local if_depth=0 + local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=() + local case_depth=0 + + local lineno=0 line trimmed first + while [ "$lineno" -lt "$total_lines" ]; do + line="${lines[$lineno]}" + lineno=$((lineno + 1)) + + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in '' | '#'*) continue ;; esac + first="${trimmed%%[[:space:]\;]*}" + + # Reserved-word patterns single-quoted to dodge `case ... esac` + # parser confusion. + case "$first" in + 'if') bashunit::coverage::_branch_push_if "$lineno" ;; + 'elif' | 'else') + [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_close_if_arm "$lineno" + ;; + 'fi') + [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_emit_if "$lineno" + ;; + 'case') bashunit::coverage::_branch_push_case "$lineno" ;; + 'esac') + [ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno" + ;; + *) + [ "$case_depth" -eq 0 ] && continue + case "$trimmed" in + ';;&'* | ';;'* | ';&'*) + bashunit::coverage::_branch_close_case_arm "$lineno" + ;; + *) + if bashunit::coverage::_is_case_pattern_line "$trimmed"; then + bashunit::coverage::_branch_open_case_pattern "$lineno" + fi + ;; + esac + ;; + esac + done +} + +# Returns 1 (true/taken) iff any executable line in [arm_start..arm_end] +# has a recorded hit. Caller must have populated the hits_by_line and +# src_lines arrays in scope (Bash 3.0 cannot pass arrays in). +# Result is echoed as "0" or "1" so the caller can capture it. +function bashunit::coverage::_arm_taken() { + local arm_start="$1" arm_end="$2" + local ln content h + for ((ln = arm_start; ln <= arm_end; ln++)); do + content="${src_lines[$((ln - 1))]:-}" + bashunit::coverage::is_executable_line "$content" "$ln" || continue + h=${hits_by_line[$ln]:-0} + if [ "$h" -gt 0 ]; then + echo 1 + return + fi + done + echo 0 +} + +# Compute branch hit data for a file. +# Output format: ||| +# block = sequential id per decision (0..N-1), branch_index = arm index (0..M-1). +# An arm is "taken" iff at least one executable line inside its range +# has a recorded hit. taken_count is 0 or 1 — MVP does not preserve +# per-arm hit counts. +function bashunit::coverage::compute_branch_hits() { + local file="$1" + + local -a hits_by_line=() + local _hl_ln _hl_cnt + while IFS=: read -r _hl_ln _hl_cnt; do + [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt + done < <(bashunit::coverage::get_all_line_hits "$file") + + local -a src_lines=() + local _sli=0 _sl + while IFS= read -r _sl || [ -n "$_sl" ]; do + src_lines[_sli]="$_sl" + ((++_sli)) + done <"$file" + + local block=0 + local branch_entry decision_line arms rest remaining arm arm_start arm_end taken arm_index + while IFS= read -r branch_entry; do + [ -z "$branch_entry" ] && continue + + decision_line="${branch_entry%%|*}" + rest="${branch_entry#*|}" + # Skip the kind field — reserved for future BRDA grouping but not + # needed by this MVP output. + arms="${rest#*|}" + + arm_index=0 + remaining="$arms" + while [ -n "$remaining" ]; do + arm="${remaining%%,*}" + if [ "$arm" = "$remaining" ]; then + remaining="" + else + remaining="${remaining#*,}" + fi + + arm_start="${arm%%:*}" + arm_end="${arm##*:}" + taken=$(bashunit::coverage::_arm_taken "$arm_start" "$arm_end") + + echo "${decision_line}|${block}|${arm_index}|${taken}" + arm_index=$((arm_index + 1)) + done + + block=$((block + 1)) + done < <(bashunit::coverage::extract_branches "$file") +} + # Calculate coverage for a specific function in a file # Returns: hit_lines:executable_lines:percentage function bashunit::coverage::get_function_coverage() { @@ -1120,6 +1354,17 @@ function bashunit::coverage::report_lcov() { echo "FNF:$fn_total" echo "FNH:$fn_hit" + # Branch records (BRDA/BRF/BRH) + local br_total=0 br_hit=0 br_line br_block br_idx br_taken + while IFS='|' read -r br_line br_block br_idx br_taken; do + [ -z "$br_line" ] && continue + echo "BRDA:${br_line},${br_block},${br_idx},${br_taken}" + br_total=$((br_total + 1)) + [ "$br_taken" -gt 0 ] && br_hit=$((br_hit + 1)) + done < <(bashunit::coverage::compute_branch_hits "$file") + echo "BRF:$br_total" + echo "BRH:$br_hit" + local lineno=0 executable=0 hit=0 line line_hits local -a lcov_lines=() local _lli=0 _ll diff --git a/tests/unit/coverage_branches_test.sh b/tests/unit/coverage_branches_test.sh new file mode 100644 index 00000000..895cf265 --- /dev/null +++ b/tests/unit/coverage_branches_test.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Tests for the branch-point extractor and branch-hit computation. +# See adrs/adr-007-branch-coverage-mvp.md for the design. + +_ORIG_COVERAGE_DATA_FILE="" +_ORIG_COVERAGE_TRACKED_FILES="" +_ORIG_COVERAGE_TRACKED_CACHE_FILE="" +_ORIG_COVERAGE_TEST_HITS_FILE="" +_ORIG_COVERAGE="" + +function set_up() { + _ORIG_COVERAGE_DATA_FILE="$_BASHUNIT_COVERAGE_DATA_FILE" + _ORIG_COVERAGE_TRACKED_FILES="$_BASHUNIT_COVERAGE_TRACKED_FILES" + _ORIG_COVERAGE_TRACKED_CACHE_FILE="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + _ORIG_COVERAGE_TEST_HITS_FILE="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + _ORIG_COVERAGE="${BASHUNIT_COVERAGE:-}" + + _BASHUNIT_COVERAGE_DATA_FILE="" + _BASHUNIT_COVERAGE_TRACKED_FILES="" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="" + export BASHUNIT_COVERAGE="true" +} + +function tear_down() { + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then + local coverage_dir + coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") + rm -rf "$coverage_dir" 2>/dev/null || true + fi + + _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" + _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="$_ORIG_COVERAGE_TEST_HITS_FILE" + + if [ -n "$_ORIG_COVERAGE" ]; then + export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" + else + unset BASHUNIT_COVERAGE + fi +} + +# extract_branches output format: +# ||:[,:]... +# kind ∈ {if, case} + +function test_extract_branches_finds_simple_if_else() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +else + echo "not x" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # Decision on line 2 with two arms: then (line 3) and else (line 5) + assert_contains "2|if|3:3,5:5" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_finds_if_elif_else_chain() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "a" ]; then + echo "a" +elif [ "$1" = "b" ]; then + echo "b" +else + echo "other" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # Three arms: then (line 3), elif body (line 5), else (line 7) + assert_contains "2|if|3:3,5:5,7:7" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_finds_case_patterns() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +case "$1" in +a) + echo "got a" + ;; +b) + echo "got b" + ;; +*) + echo "other" + ;; +esac +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # case decision on line 2, three pattern arms with bodies on 4, 7, 10 + assert_contains "2|case|4:4,7:7,10:10" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_returns_nothing_for_no_branches() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +echo "no branches here" +echo "still none" +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + assert_empty "$result" + + rm -f "$fixture" +} + +function test_extract_branches_handles_if_without_else() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # MVP scope: only the explicit then arm is reported. Implicit-else + # (synthetic fall-through outcome) is deferred per ADR-007. + assert_contains "2|if|3:3" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_marks_taken_arm() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "taken" +else + echo "not-taken" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + # Hit only the `then` arm body + echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + # Format: decision_line|block|arm_index|taken_count + assert_contains "2|0|0|1" "$result" + assert_contains "2|0|1|0" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_marks_all_arms_zero_when_unhit() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +else + echo "y" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + assert_contains "2|0|0|0" "$result" + assert_contains "2|0|1|0" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_assigns_distinct_blocks_per_decision() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "first" +fi +if [ "$2" = "y" ]; then + echo "second" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + echo "${fixture}:6" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + # Two decisions -> two distinct block ids (0 and 1) + assert_contains "2|0|0|1" "$result" + assert_contains "5|1|0|1" "$result" + + rm -f "$fixture" +} diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index f696aaee..079c8951 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -396,6 +396,107 @@ EOF rm -f "$temp_file" } +function test_coverage_report_lcov_includes_branch_records_for_if_else() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "taken" +else + echo "not-taken" +fi +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + assert_contains "BRDA:2,0,0,1" "$content" + assert_contains "BRDA:2,0,1,0" "$content" + assert_contains "BRF:2" "$content" + assert_contains "BRH:1" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_lcov_includes_branch_records_for_case() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +case "$1" in +a) + echo "got a" + ;; +b) + echo "got b" + ;; +*) + echo "other" + ;; +esac +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:7" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + # Three case arms: only the second was hit + assert_contains "BRDA:2,0,0,0" "$content" + assert_contains "BRDA:2,0,1,1" "$content" + assert_contains "BRDA:2,0,2,0" "$content" + assert_contains "BRF:3" "$content" + assert_contains "BRH:1" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_lcov_omits_branch_records_when_none_present() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "no branches" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + assert_not_contains "BRDA:" "$content" + assert_contains "BRF:0" "$content" + assert_contains "BRH:0" "$content" + + rm -f "$temp_file" "$report_file" +} + function test_coverage_report_lcov_includes_function_records() { BASHUNIT_COVERAGE="true" bashunit::coverage::init