A host zooming into a sub-second window cannot label it absolutely: Time formatting uses three significant digits, so 12.345671s and 12.345672s are the same string and a 1 ms step around a multi-second time is unlabelled. The axis gains an `origin`: every value it writes is `v - origin`, and the ticks are generated on the shifted grid, so the window reads 0, 500us, 1ms. `origin_follows_range` re-takes the origin from the axis' own range on every refresh -- the mode a *moving* window needs, since the edge is derived from the newest sample at layout time and a value the host wrote a frame earlier is a frame of scroll stale. `format_time` now scales its sub-second values by prefix, the way `format_si` does on a y axis: the unit that keeps the mantissa in [1, 1000). A fixed millisecond unit printed 0.005ms where 5us is the reading, and 1e-05ms where 10ns is. An exact zero prints `0`, the same convention `format_si` states. The origin subtraction's dust is snapped by a *relative* epsilon, so a tick on the origin reads 0 rather than 1.78e-12ms. Tested by `ticks_are_written_relative_to_an_axis_origin`, `a_range_origin_puts_the_zero_tick_on_the_left_edge` and the extended `format_time_shapes`; the suite is 359 cases, 0 failed.
353 lines
16 KiB
C++
353 lines
16 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "harness.hpp"
|
|
#include <plot/plot.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);
|
|
}
|
|
|
|
/// The tick that *means* zero reads `0`. The walk starts at `ceil(lo/stride)*stride`,
|
|
/// inexact for every stride that is not a power of two, so a range straddling the
|
|
/// origin used to reach a few e-16 there and label it from the far end of the SI
|
|
/// table: `-1 -500m -139a 500m 1`. The tolerance is relative to the stride, which is
|
|
/// also the smallest magnitude a non-zero tick can have, so a range whose ticks are
|
|
/// *legitimately* that small keeps them.
|
|
PLOT_TEST(the_tick_at_the_origin_reads_zero) {
|
|
std::vector<plot::Tick> t;
|
|
const float widths[] = {80.0f, 40.0f, 10.0f};
|
|
for (const float min_px : widths) {
|
|
for (const bool minors : {false, true}) {
|
|
plot::generate_ticks({-1.0, 1.0}, 400.0f, min_px, t, minors);
|
|
plot::usize zeros = 0;
|
|
for (const plot::Tick& tick : t) {
|
|
if (!tick.major) continue;
|
|
PLOT_CHECK(!tick.label.empty());
|
|
// Nothing between -500m and 500m but the origin itself, so no major may
|
|
// carry a prefix from below `m`.
|
|
PLOT_CHECK(tick.label.find('a') == std::string::npos);
|
|
PLOT_CHECK(tick.label.find('f') == std::string::npos);
|
|
if (tick.label == "0") {
|
|
++zeros;
|
|
PLOT_CHECK(tick.value == 0.0); // exact, so a Time axis relabels it as 0 too
|
|
}
|
|
}
|
|
PLOT_CHECK(zeros == 1u);
|
|
}
|
|
}
|
|
|
|
// A range whose ticks really are atto-scale is untouched: only the origin folds.
|
|
plot::generate_ticks({-2e-16, 2e-16}, 400.0f, 80.0f, t, /*include_minors=*/true);
|
|
plot::usize tiny = 0;
|
|
plot::usize tiny_zeros = 0;
|
|
for (const plot::Tick& tick : t) {
|
|
if (!tick.major) continue;
|
|
if (tick.value == 0.0) {
|
|
++tiny_zeros;
|
|
} else {
|
|
++tiny;
|
|
PLOT_CHECK(tick.label.find('a') != std::string::npos);
|
|
}
|
|
}
|
|
PLOT_CHECK(tiny_zeros == 1u);
|
|
PLOT_CHECK(tiny >= 2u);
|
|
}
|
|
|
|
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(0.005, b, sizeof b); PLOT_CHECK(std::string(b) == "5ms");
|
|
plot::format_time(0.0005, b, sizeof b); PLOT_CHECK(std::string(b) == "500\u00b5s");
|
|
plot::format_time(5.0e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "5\u00b5s");
|
|
plot::format_time(5.0e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "5ns");
|
|
plot::format_time(5.0e-12, b, sizeof b); PLOT_CHECK(std::string(b) == "5ps");
|
|
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);
|
|
}
|
|
}
|
|
|
|
// An axis origin shifts what the labels *say* and which grid the ticks sit on: a window
|
|
// zoomed into 12.345 s must read `0, 0.5ms, 1ms` against its own left edge, because three
|
|
// significant digits cannot tell 12.345671 s from 12.345672 s. A host prints the origin once
|
|
// beside the axis and the user adds.
|
|
PLOT_TEST(ticks_are_written_relative_to_an_axis_origin) {
|
|
// The same window twice: absolute, and relative to its own start.
|
|
const plot::Range window{12.345, 12.346};
|
|
std::vector<plot::Tick> absolute;
|
|
plot::generate_ticks(window, 400.0f, 80.0f, absolute, /*include_minors=*/false, 0.0);
|
|
PLOT_CHECK(!absolute.empty());
|
|
PLOT_CHECK(absolute.front().value >= window.min);
|
|
|
|
std::vector<plot::Tick> relative;
|
|
plot::generate_ticks(window, 400.0f, 80.0f, relative, /*include_minors=*/false, window.min);
|
|
PLOT_CHECK(!relative.empty());
|
|
// The walk starts on the origin's grid: the first major is the origin itself, and every
|
|
// major is an exact multiple of the step away from it.
|
|
PLOT_CHECK_NEAR(relative.front().value, window.min, 1e-12);
|
|
PLOT_CHECK_NEAR(relative[1].value - relative[0].value,
|
|
(absolute[1].value - absolute[0].value), 1e-12);
|
|
for (const plot::Tick& t : relative) {
|
|
PLOT_CHECK(t.value >= window.min - 1e-12);
|
|
PLOT_CHECK(t.value <= window.max + 1e-12);
|
|
}
|
|
|
|
// And through an axis: the *labels* are the relative values, the positions the absolute
|
|
// ones. That is the whole contract -- `format_value()` is where the origin is applied.
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
|
|
plot::TickFormat::Time});
|
|
p.x_axis(ax);
|
|
PLOT_CHECK_NEAR(p.x_axis_origin(ax), 0.0, 1e-12);
|
|
p.set_x_axis_origin(ax, window.min);
|
|
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
|
|
char buf[64];
|
|
p.x_axis(ax).format_value(window.min, buf, sizeof buf);
|
|
PLOT_CHECK(std::string(buf) == "0"); // the window's start reads zero
|
|
p.x_axis(ax).format_value(window.min + 0.0005, buf, sizeof buf);
|
|
PLOT_CHECK(std::string(buf) == "500\u00b5s"); // half a millisecond reads in microseconds
|
|
// An id that names no axis is ignored; the axis that exists is untouched by it.
|
|
p.set_x_axis_origin(7u, 1234.0);
|
|
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
|
|
}
|
|
|
|
// The moving-window case, which is why the mode exists: the origin is re-taken from the range
|
|
// at refresh, so the tick on the left edge reads exactly zero however far the window has
|
|
// scrolled since the host last looked. A host cannot predict the edge (the follow places it
|
|
// from the newest sample), and a value it wrote a frame earlier is a frame of scroll stale --
|
|
// at 1 MS/s that is more than a millisecond-scale window shows.
|
|
PLOT_TEST(a_range_origin_puts_the_zero_tick_on_the_left_edge) {
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const plot::Range window{12.345, 12.347};
|
|
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
|
|
plot::TickFormat::Time});
|
|
p.set_x_axis_origin_from_range(ax, true);
|
|
|
|
// `layout()` is the library's own path to the ticks (it ends in `refresh_ticks()`), so this
|
|
// is the order a frame really runs in.
|
|
p.layout();
|
|
PLOT_CHECK(!p.x_axis(ax).ticks.empty());
|
|
const plot::Tick& first = p.x_axis(ax).ticks.front();
|
|
PLOT_CHECK(first.major);
|
|
PLOT_CHECK_NEAR(f64(first.value), window.min, 1e-12); // exactly the left edge ...
|
|
PLOT_CHECK(first.label == "0"); // ... and it reads zero
|
|
|
|
// The window slides: the origin follows it, so the zero tick is on the *new* edge.
|
|
const plot::Range moved{12.6, 12.602};
|
|
p.set_x_range(ax, moved);
|
|
p.layout();
|
|
PLOT_CHECK_NEAR(f64(p.x_axis(ax).ticks.front().value), moved.min, 1e-12);
|
|
PLOT_CHECK(p.x_axis(ax).ticks.front().label == "0");
|
|
PLOT_CHECK_NEAR(p.x_axis_origin(ax), moved.min, 1e-12); // and the host reads it back
|
|
}
|