Files
plotter/docs/coverage.md
T
Martino Ferrari f132a5ec92 test(plot): pin the hot-handle re-index on region removal, refresh coverage
`remove_region()` now clears or decrements the published `hot_.region` with the
cache it re-indexes, so a frame after a removal highlights the region the handle
actually names. The new case covers both branches (a handle above the hole
follows it down, the removed region's handle is cleared with its zone), and the
coverage document is regenerated by the command that measured it: 95.2% of src/,
every file at or above 80%.
2026-09-28 22:42:56 +02:00

9.0 KiB

Coverage

plot measures line coverage of the compiled library bodies under src/ and enforces a floor on it, so a regression fails a command instead of quietly lowering a number in a document.

The table and the floor verdict below are generated. tools/coverage.sh rewrites the region between <!-- BEGIN GENERATED: tools/coverage.sh --> and <!-- END GENERATED: tools/coverage.sh --> from the report that run just captured, by calling tools/coverage_floor.py --write-doc. Do not edit inside those markers: the numbers there come from the command, not from a hand-typed copy, and a run on an unchanged tree rewrites the region with identical content.

The one command

tools/coverage.sh                            # from the repository root
cmake --build build/cov --target coverage    # the same thing, through CMake

The script configures build/cov from the linux-coverage preset, builds it, runs the whole ctest suite, captures src/** with lcov into build/cov/coverage.info, renders the HTML report to build/cov/html/index.html, prints the per-file table, rewrites the generated region of this file, and exits non-zero when a floor is violated. It needs cmake, ninja, ctest, lcov, genhtml and python3 (Arch: pacman -S cmake ninja lcov python); it exits 2 with a message when one is missing.

The floors

Scope Floor Enforced by
Every file under src/ 80% of its instrumented lines tools/coverage_floor.py (--per-file, default 80)
src/ as a whole 90% of instrumented lines tools/coverage_floor.py (--aggregate, default 90)

tools/coverage_floor.py build/cov/coverage.info reads the lcov tracefile on its own and exits:

  • 0 — every floor holds;
  • 1 — a floor is violated; every offending file is printed with its rate and counts;
  • 2 — the report cannot be read at all (missing file, or no src/ records in it), because a report this tool cannot find must never read as a pass.

The measured table

coverage_floor: ok -- 23 files under src/, aggregate 95.2% lines (3302/3469), every file at or above 80%, aggregate target 90%
File Lines Line rate Functions
src/data/array_series.cpp 43/43 100.0% 100.0%
src/data/minmax_pyramid.cpp 163/163 100.0% 100.0%
src/data/series.cpp 105/107 98.1% 100.0%
src/data/stream_series.cpp 39/39 100.0% 100.0%
src/gl/handle.cpp 256/256 100.0% 100.0%
src/gl/loader.cpp 81/81 100.0% 100.0%
src/input/controller.cpp 329/343 95.9% 100.0%
src/math/transform.cpp 28/28 100.0% 100.0%
src/plot/axis.cpp 12/12 100.0% 100.0%
src/plot/figure.cpp 218/223 97.8% 95.5%
src/plot/grid.cpp 15/16 93.8% 100.0%
src/plot/measure.cpp 21/21 100.0% 100.0%
src/plot/plot.cpp 743/763 97.4% 98.9%
src/plot/ticks.cpp 93/99 93.9% 100.0%
src/render/buffer.cpp 88/98 89.8% 100.0%
src/render/embedded_mono_font.cpp 45/45 100.0% 100.0%
src/render/primitives.cpp 71/75 94.7% 100.0%
src/render/program.cpp 26/27 96.3% 100.0%
src/render/renderer.cpp 530/609 87.0% 93.7%
src/render/text.cpp 27/30 90.0% 100.0%
src/render/text_atlas.cpp 40/41 97.6% 100.0%
src/render/vertex_array.cpp 59/59 100.0% 100.0%
src/text/true_type_font.cpp 270/291 92.8% 96.8%
src/ aggregate 3302/3469 95.2% 98.3%

What the report measures

  • src/** — the compiled library bodies. These are what a caller of plot::core executes.
  • Lines and functions. Branch coverage is off (--rc branch_coverage=0): the data-structure and GL code is dense with short-circuit conditions whose branch counts mostly measure how the tests were written, so a branch floor would be noise rather than a contract.
  • Three of the 26 translation units produce no record at all, because gcov only writes counters for code that runs: src/core/status.cpp (status.o is never pulled out of libplot_core.a — no test calls plot::to_string), src/render/shaders.cpp (string literals and inline definitions), and src/text/stb_truetype_impl.cpp (its lines belong to the vendored third_party/stb_truetype.h, which is filtered out). Should a record ever arrive with LF:0, the checker exempts that file from the per-file floor rather than dividing by zero.
  • Not measured: the public headers under include/plot/ (inline and template code, exercised through their callers but not held to a per-file rate); tests/ and the generated header-check translation units; adapters/ — only the two header-only seams the unit suite drives appear in a raw capture, and the adapter bodies need a window, which the demos cover and this report does not; and the C++ standard library headers gcov also records.

Reproducibility

gcov's counters accumulate in .gcda files, so a run that inherits the previous run's counters reports a superset of what it actually exercised — the classic reason a coverage number drifts on a second run with no source change, and here it is measurable: build/cov before this script existed had been reused across revisions, and its accumulated counters reported 93.0% (3206/3447) for the same tree that a clean single run reports as 92.9% (3203/3447). Three lines of hit in plot.cpp, renderer.cpp and buffer.cpp were left over from an earlier build.

tools/coverage.sh therefore deletes every .gcda under the build tree (find "$BUILD" -name '*.gcda' -delete) before it runs the suite, so the captured counters are exactly the ones that run produced. lcov --zerocounters --directory "$BUILD" is the equivalent call — on lcov 2.5 it unlinks the .gcda files and reports success even on a tree that has none (checked) — and the script keeps the find because it is one explicit step whose meaning does not depend on the lcov version, and because clearing counters must never be the thing that breaks a fresh tree. The capture also runs with --rc geninfo_unexecuted_blocks=1 (the "unexecuted block" notes come from libstdc++ headers on this toolchain; this records those blocks as unexecuted, which is what they are) and --ignore-errors inconsistent,inconsistent for the same source. lcov --extract then narrows the tracefile to src/**, and the HTML directory is removed before genhtml re-creates it.

The numbers are stable; the raw counts are not. Two consecutive runs on the same tree produce per-file LF/LH/FNF/FNH tallies that are identical for all 23 files — verified by diffing the two tracefiles — but the per-line hit counts differ by a few parts per million, because a handful of tests drive a producer thread whose iteration count follows wall-clock timing. Everything the floor reads (the LF/LH tallies, and therefore every rate in the generated table above) is unaffected.

Adjusting the floors, and seeing the gate fail

Both floors are flags, which is how the failure path is demonstrated — and how a later task tightens the contract. Against the report in the generated table above, a floor raised above every file's rate fails the check and names each offender:

# Any file not at 100% fails: a floor that is guaranteed to bite, whatever the
# current rates are. The list of offenders is the files in the table above whose
# rate is below 100%.
python3 tools/coverage_floor.py --per-file 100 build/cov/coverage.info
#   coverage_floor: FAILED -- <n> floor(s) violated in build/cov/coverage.info:
#     <file>: <rate>% lines (<hit>/<found>) is below the 100% per-file floor
#     ...
#   $? == 1

# The same, for the aggregate: a target above the measured aggregate fails with one line.
python3 tools/coverage_floor.py --aggregate 99 build/cov/coverage.info
#   coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
#     src/ aggregate: <rate>% lines (<hit>/<found>) is below the 99% aggregate floor
#   $? == 1

# A floor at or below the measured rates passes.
python3 tools/coverage_floor.py build/cov/coverage.info
#   coverage_floor: ok -- ...
#   $? == 0

Why the check is a CMake target and not a ctest case

A ctest entry registered only in the coverage preset would either fail in a freshly configured tree — no report exists until tools/coverage.sh has run once, so the preset's own clean-tree command (cmake --preset linux-coverage && cmake --build build/cov && ctest --test-dir build/cov) would then fail for a reason that has nothing to do with the code — or it would have to build the library and re-run the whole suite inside the test, duplicating the script. A custom target instead: it is not part of all, so it cannot slow or destabilise the linux-debug, linux-asan or linux-release builds, and it is not even created in those presets, because they leave PLOT_COVERAGE off and the option's guard rejects a tree that carries no --coverage flags.