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

163 lines
9.0 KiB
Markdown

# 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
```sh
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
<!-- BEGIN GENERATED: tools/coverage.sh -->
```
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%** |
<!-- END GENERATED: tools/coverage.sh -->
## 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:
```sh
# 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.