Files
plotter/docs/coverage.md
T
Martino Ferrari 38ef459897
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
2026-09-30 17:32:06 +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 -- 24 files under src/, aggregate 93.9% lines (3730/3971), every file at or above 80%, aggregate target 90%
File Lines Line rate Functions
src/data/array_series.cpp 56/56 100.0% 100.0%
src/data/minmax_pyramid.cpp 170/171 99.4% 96.4%
src/data/series.cpp 161/164 98.2% 100.0%
src/data/stream_series.cpp 45/45 100.0% 100.0%
src/data/xy_series.cpp 102/120 85.0% 100.0%
src/gl/handle.cpp 259/259 100.0% 100.0%
src/gl/loader.cpp 81/81 100.0% 100.0%
src/input/controller.cpp 430/465 92.5% 97.4%
src/math/transform.cpp 28/28 100.0% 100.0%
src/plot/axis.cpp 12/12 100.0% 100.0%
src/plot/figure.cpp 229/234 97.9% 95.8%
src/plot/grid.cpp 15/16 93.8% 100.0%
src/plot/measure.cpp 21/21 100.0% 100.0%
src/plot/plot.cpp 767/793 96.7% 98.9%
src/plot/ticks.cpp 105/111 94.6% 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 75/79 94.9% 100.0%
src/render/program.cpp 26/27 96.3% 100.0%
src/render/renderer.cpp 616/722 85.3% 93.9%
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 62/62 100.0% 100.0%
src/text/true_type_font.cpp 270/291 92.8% 96.8%
src/ aggregate 3730/3971 93.9% 98.0%

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.