Files
plotter/tests/test_ticks.cpp
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

225 lines
9.8 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp"
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <string>
#include <vector>
namespace {
/// Fails if any colon-separated field of a clock label reaches its radix: minutes
/// and seconds must stay below 60 and hours below 24 (a value that rounds up to
/// 24 h takes the day shape instead), so "1:60" or "24:00:00" cannot slip through.
void check_no_field_reaches_its_radix(const std::string& label) {
const std::size_t colons = static_cast<std::size_t>(std::count(label.begin(), label.end(), ':'));
if (colons == 0) return; // decimal shapes have no fields
// Two colons mean h:mm:ss, one means m:ss: only the former starts with hours.
const int first_radix = colons >= 2 ? 24 : 60;
std::string clock = label;
if (!clock.empty() && (clock[0] == '-' || clock[0] == '+')) clock.erase(0, 1);
std::size_t start = 0;
std::size_t field = 0;
while (start <= clock.size()) {
const std::size_t colon = clock.find(':', start);
const std::string part =
clock.substr(start, colon == std::string::npos ? std::string::npos : colon - start);
if (!part.empty()) {
const int value = std::atoi(part.c_str());
PLOT_CHECK(value >= 0 && value < (field == 0 ? first_radix : 60));
}
if (colon == std::string::npos) break;
start = colon + 1;
++field;
}
}
} // namespace
PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) {
std::vector<plot::Tick> t;
plot::generate_ticks({0.0, 1.0}, /*px=*/500.0f, /*min_px=*/60.0f, t);
PLOT_CHECK(!t.empty());
for (auto& tick : t) PLOT_CHECK(std::fabs(tick.value * 5.0 - std::round(tick.value * 5.0)) < 1e-9);
// spacing of major ticks >= min_px
PLOT_CHECK(static_cast<double>(t.size()) <= 500.0 / 60.0 + 2.0);
}
PLOT_TEST(ticks_are_monotonic_and_cover_range) {
std::vector<plot::Tick> t;
plot::generate_ticks({-3.7, 91.2}, 800.0f, 60.0f, t);
for (size_t i = 1; i < t.size(); ++i) PLOT_CHECK(t[i].value > t[i-1].value);
PLOT_CHECK(t.front().value <= 0.0);
PLOT_CHECK(t.back().value >= 90.0);
}
PLOT_TEST(si_formatting) {
char b[32];
plot::format_si(0.0000015, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5\xC2\xB5s" || std::string(b).find("1.5") == 0);
plot::format_si(2.5e9, b, sizeof b); PLOT_CHECK(std::string(b).find("2.5") == 0);
plot::format_si(1234.0, b, sizeof b); PLOT_CHECK(std::string(b).find("1.23") == 0);
plot::format_si(0.0, b, sizeof b); PLOT_CHECK(std::string(b) == "0");
}
PLOT_TEST(degenerate_ranges_do_not_hang) {
std::vector<plot::Tick> t;
plot::generate_ticks({5.0, 5.0}, 500.0f, 60.0f, t); // must not loop forever
PLOT_CHECK(t.size() <= 64);
plot::generate_ticks({0.0, 0.0}, 0.0f, 60.0f, t);
plot::generate_ticks({1e300, 1e300}, 500.0f, 60.0f, t);
}
PLOT_TEST(hostile_tick_inputs_clear_the_output) {
std::vector<plot::Tick> t{{1.0, true, "stale"}};
plot::generate_ticks({10.0, -10.0}, 500.0f, 60.0f, t); // inverted
PLOT_CHECK(t.empty());
plot::generate_ticks({0.0, std::nan("")}, 500.0f, 60.0f, t); // non-finite max
PLOT_CHECK(t.empty());
plot::generate_ticks({std::nan(""), 1.0}, 500.0f, 60.0f, t);
PLOT_CHECK(t.empty());
plot::generate_ticks({0.0, 1.0}, 500.0f, 0.0f, t); // min_px <= 0
PLOT_CHECK(t.empty());
// Span overflows to infinity: the step would be inf, so nothing is emitted
// and, crucially, the loop never runs.
plot::generate_ticks({-1e308, 1e308}, 500.0f, 60.0f, t);
PLOT_CHECK(t.empty());
}
PLOT_TEST(minor_ticks_subdivide_and_the_total_is_capped) {
std::vector<plot::Tick> t;
plot::generate_ticks({0.0, 1.0}, 500.0f, 60.0f, t, /*include_minors=*/true);
PLOT_CHECK(t.size() > 10); // minors really added
for (size_t i = 1; i < t.size(); ++i) PLOT_CHECK(t[i].value > t[i-1].value);
// The major step is 0.2 (leading digit 2), so minors are quartered at 0.05.
for (const plot::Tick& tick : t) {
const double scale = tick.major ? 5.0 : 20.0;
PLOT_CHECK(std::fabs(tick.value * scale - std::round(tick.value * scale)) < 1e-9);
PLOT_CHECK(tick.major ? !tick.label.empty() : tick.label.empty());
}
}
/// R9: the cap counts **minors only**. A range dense enough to want more minors
/// than fit is truncated in its subdivisions and never in its labels: every major
/// of the span is present, labelled, and the far end of the axis keeps its number.
PLOT_TEST(minor_cap_spares_the_majors) {
std::vector<plot::Tick> capped;
// Step 1000 over [0, 65000] (65 major intervals): 260 minors want in, more than
// the 256 the cap holds, and every one of the 66 majors still counts.
plot::generate_ticks({0.0, 65000.0}, 65000.0f, 1000.0f, capped, /*include_minors=*/true);
plot::usize majors = 0;
plot::usize minors = 0;
for (const plot::Tick& tick : capped) {
if (tick.major) {
++majors;
} else {
++minors;
}
}
PLOT_CHECK(minors == plot::kMaxTicks); // minors capped exactly as before
PLOT_CHECK(majors == 66u); // every major of the span kept
for (plot::usize i = 1; i < capped.size(); ++i) PLOT_CHECK(capped[i].value > capped[i - 1].value);
// The far end of the axis still carries its label.
PLOT_CHECK(capped.back().major);
PLOT_CHECK(!capped.back().label.empty());
PLOT_CHECK_NEAR(capped.back().value, 65000.0, 1e-9);
}
/// B2: the major/minor split is an integer-index test, so an epoch origin with a
/// small step no longer fights a ULP-sized residue: majors are classified exactly,
/// whatever the origin's magnitude. The +/-300 s window is the reported defect's
/// shape; the sub-second window is where the old float-tolerance test actually
/// misclassified (step 0.01 at 1.75e9 carries an error far above step*1e-9).
PLOT_TEST(epoch_window_yields_labelled_majors) {
std::vector<plot::Tick> t;
plot::generate_ticks({1.75e9 - 300.0, 1.75e9 + 300.0}, 800.0f, 60.0f, t,
/*include_minors=*/true);
plot::usize majors = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
++majors;
PLOT_CHECK(!tick.label.empty());
PLOT_CHECK(tick.value >= 1.75e9 - 300.0);
PLOT_CHECK(tick.value <= 1.75e9 + 300.0);
}
// Step 50 s over a 600 s window: the labelled ticks span it.
PLOT_CHECK(majors >= 11);
std::vector<plot::Tick> deep;
plot::generate_ticks({1.75e9 - 0.05, 1.75e9 + 0.05}, 800.0f, 60.0f, deep,
/*include_minors=*/true);
plot::usize deep_majors = 0;
for (const plot::Tick& tick : deep) {
if (!tick.major) continue;
++deep_majors;
PLOT_CHECK(!tick.label.empty());
}
// Step 0.01 s over a 0.1 s window: again a full set of labelled majors.
PLOT_CHECK(deep_majors >= 9);
}
PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
char b[32];
plot::format_si(0.0000015, b, sizeof b);
PLOT_CHECK(std::string(b) == "1.5\xC2\xB5"); // U+00B5, not the Greek mu
plot::format_si(1234.0, b, sizeof b);
PLOT_CHECK(std::string(b) == "1.23k");
plot::format_si(-1234.0, b, sizeof b);
PLOT_CHECK(std::string(b) == "-1.23k");
plot::format_si(2.5e9, b, sizeof b);
PLOT_CHECK(std::string(b) == "2.5G");
plot::format_si(1.5e-6, b, sizeof b, "s");
PLOT_CHECK(std::string(b) == "1.5\xC2\xB5s"); // a caller-supplied unit suffix
plot::format_si(std::nan(""), b, sizeof b); // never silently dropped
PLOT_CHECK(std::string(b) == "nan");
plot::format_si(std::numeric_limits<double>::infinity(), b, sizeof b);
PLOT_CHECK(std::string(b) == "inf");
// A short buffer truncates; the string is always terminated inside it and
// nothing is ever written at or past `n`.
for (plot::usize n = 1; n <= 8; ++n) {
char small[8];
for (char& c : small) c = '#';
plot::format_si(-1234.567, small, n);
PLOT_CHECK(std::strlen(small) < n);
PLOT_CHECK(small[std::strlen(small)] == '\0');
}
}
PLOT_TEST(format_time_shapes) {
char b[32];
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms");
plot::format_time(1.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5s");
plot::format_time(90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:30");
plot::format_time(3661.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:01:01");
plot::format_time(-90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "-1:30");
plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
plot::format_time(std::nan(""), b, sizeof b); PLOT_CHECK(std::string(b) == "nan");
}
PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
char b[32];
// Rounding the seconds leftover on its own used to print 119.5 s as "1:60";
// every field now comes from one rounded whole-second count.
plot::format_time(119.5, b, sizeof b); PLOT_CHECK(std::string(b) == "2:00");
plot::format_time(3599.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1:00:00");
plot::format_time(59.4999, b, sizeof b); PLOT_CHECK(std::string(b) == "59.5s");
plot::format_time(59.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1:00");
plot::format_time(60.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:00");
// At 24 h the day field appears, so the hour field never reaches 24.
plot::format_time(86399.4, b, sizeof b); PLOT_CHECK(std::string(b) == "23:59:59");
plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
// Sweep the boundary neighbourhoods and check every emitted field.
const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4,
3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5,
-3599.5, -86399.5};
for (const double v : values) {
plot::format_time(v, b, sizeof b);
const std::string label(b);
PLOT_CHECK(!label.empty());
check_no_field_reaches_its_radix(label);
}
}