feat(plot): nice-number ticks, axes, grid, traces, cursors, measurement queries

This commit is contained in:
Martino Ferrari committed 2026-09-27 22:21:22 +02:00
1 parent b4e88fe0c1
commit 93fc06f050
18 files changed
+1582

No files matched your search

+3
View File
@@ -32,6 +32,9 @@ add_executable(plot_tests
test_ring_buffer.cpp
test_minmax_pyramid.cpp
test_series.cpp
test_ticks.cpp
test_measure.cpp
test_plot.cpp
egl_headless.cpp
)
target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library} Threads::Threads)
+43
View File
@@ -0,0 +1,43 @@
#include "harness.hpp"
#include <plot/plot/measure.hpp>
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <vector>
PLOT_TEST(measurement_over_interval_matches_direct_computation) {
std::vector<f32> y; for (int i = 0; i < 1000; ++i) y.push_back(f32(std::sin(static_cast<double>(i) * 0.01)));
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const auto m = plot::Measure::evaluate(s, 100, 800);
double sum = 0, sumsq = 0, integral = 0; float lo = 1e30f, hi = -1e30f;
for (u64 i = 100; i < 800; ++i) {
const double v = std::sin(static_cast<double>(i) * 0.01);
sum += v; sumsq += v * v; integral += v; // dx == 1
lo = std::min(lo, float(v)); hi = std::max(hi, float(v));
}
const double n = 700;
PLOT_CHECK_NEAR(m.mean, sum / n, 1e-5);
PLOT_CHECK_NEAR(m.rms, std::sqrt(sumsq / n), 1e-5);
PLOT_CHECK_NEAR(m.integral, integral, 1e-3);
PLOT_CHECK_NEAR(m.ymin, lo, 1e-6);
PLOT_CHECK_NEAR(m.ymax, hi, 1e-6);
PLOT_CHECK_NEAR(m.dx, 699.0, 1e-9);
}
PLOT_TEST(measurement_uses_pyramid_not_scan) {
// Guard against a future regression that linear-scans the series: a huge
// series must evaluate a 1-sample-wide interval in well under a millisecond.
std::vector<f32> y(1u << 22, 1.0f); // 4M samples
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; ++i) (void)plot::Measure::evaluate(s, 1'000'000, 1'000'001);
const auto us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - t0).count();
PLOT_CHECK(us < 50'000); // 1000 evals in < 50 ms
}
PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
std::vector<f32> y{3.0f};
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid);
PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid);
}
+291
View File
@@ -0,0 +1,291 @@
#include "harness.hpp"
#include <plot/plot/plot.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/render/bitmap_font.hpp>
#include <cmath>
#include <vector>
namespace {
std::vector<f32> ramp(u64 n) {
std::vector<f32> y(n);
for (u64 i = 0; i < n; ++i) y[i] = f32(i);
return y;
}
f32 widest_major_label(const plot::Axis& a, const plot::render::TextBackend& t) {
f32 w = 0.0f;
for (const plot::Tick& tick : a.ticks) {
if (!tick.major) continue;
const f32 m = t.measure(tick.label.c_str());
if (m > w) w = m;
}
return w;
}
bool has_vertical(const plot::Grid& g, f64 x, bool major) {
for (const plot::GridLine& l : g.lines()) {
if (l.x0 == l.x1 && l.x0 == x && l.major == major) return true;
}
return false;
}
bool has_horizontal(const plot::Grid& g, f64 y, bool major) {
for (const plot::GridLine& l : g.lines()) {
if (l.y0 == l.y1 && l.y0 == y && l.major == major) return true;
}
return false;
}
} // namespace
PLOT_TEST(layout_without_text_backend_uses_default_label_areas) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const plot::PlotLayout& l = p.layout();
PLOT_CHECK_NEAR(l.y_axis_width, 56.0f, 1e-6);
PLOT_CHECK_NEAR(l.x_axis_height, 22.0f, 1e-6);
PLOT_CHECK_NEAR(l.axes.x, 56.0f, 1e-6);
PLOT_CHECK_NEAR(l.axes.w, 744.0f, 1e-6);
PLOT_CHECK_NEAR(l.axes.h, 378.0f, 1e-6);
PLOT_CHECK_NEAR(l.y_label_area.x, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.y_label_area.w, 56.0f, 1e-6);
PLOT_CHECK_NEAR(l.y_label_area.h, 378.0f, 1e-6);
PLOT_CHECK_NEAR(l.x_label_area.y, 378.0f, 1e-6);
PLOT_CHECK_NEAR(l.x_label_area.w, 744.0f, 1e-6);
PLOT_CHECK_NEAR(l.x_label_area.h, 22.0f, 1e-6);
}
PLOT_TEST(layout_derives_label_areas_from_font_metrics) {
plot::render::BitmapFont font;
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ay = p.add_y_axis({"v", {-1000.0, 1000.0}, false, plot::AxisPlace::Left, false});
const plot::PlotLayout& l = p.layout();
const f32 pad = p.style().grid_label_pad;
PLOT_CHECK_NEAR(l.y_axis_width, widest_major_label(p.y_axis(ay), font) + 2.0f * pad, 1e-4);
PLOT_CHECK_NEAR(l.x_axis_height, font.height() + 2.0f * pad, 1e-4);
PLOT_CHECK_NEAR(l.axes.x, l.y_axis_width, 1e-4);
PLOT_CHECK_NEAR(l.axes.w, 800.0f - l.y_axis_width, 1e-4);
PLOT_CHECK_NEAR(l.axes.h, 400.0f - l.x_axis_height, 1e-4);
}
PLOT_TEST(time_axis_tick_labels_use_format_time) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis(
{"t", {0.0, 120.0}, false, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
(void)p.layout();
const std::vector<plot::Tick>& ticks = p.x_axis(ax).ticks;
PLOT_CHECK(!ticks.empty());
bool saw_minute_label = false;
for (const plot::Tick& tick : ticks) {
if (!tick.major) continue;
PLOT_CHECK(!tick.label.empty());
if (tick.value == 60.0) {
PLOT_CHECK(tick.label == "1:00"); // format_time, not format_si
saw_minute_label = true;
}
}
PLOT_CHECK(saw_minute_label);
}
PLOT_TEST(x_transform_maps_range_onto_axes_rect) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {100.0, 200.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false});
const plot::Rect a = p.layout().axes;
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
PLOT_CHECK_NEAR(tx.to_screen_x(100.0), a.x, 1e-9);
PLOT_CHECK_NEAR(tx.to_screen_x(200.0), a.right(), 1e-9);
PLOT_CHECK_NEAR(ty.to_screen_y(-2.0), a.bottom(), 1e-9); // range min sits at the bottom
PLOT_CHECK_NEAR(ty.to_screen_y(2.0), a.y, 1e-9); // range max sits at the top
PLOT_CHECK_NEAR(tx.to_data_x(tx.to_screen_x(137.0)), 137.0, 1e-9);
PLOT_CHECK_NEAR(ty.to_data_y(ty.to_screen_y(0.5)), 0.5, 1e-9);
// An axis index that does not exist must not crash and must stay invertible.
PLOT_CHECK_NEAR(p.x_transform(9).to_data_x(p.x_transform(9).to_screen_x(0.25)), 0.25, 1e-9);
PLOT_CHECK_NEAR(p.y_transform(9).to_data_y(p.y_transform(9).to_screen_y(0.25)), 0.25, 1e-9);
}
PLOT_TEST(auto_fit_uses_trace_bounds_with_margin) {
std::vector<f32> y = {0.0f, 1.0f, 2.0f, 3.0f};
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 1000.0}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.auto_fit_x();
p.auto_fit_y(ay);
const plot::Range xr = p.x_range(ax);
const plot::Range yr = p.y_range(ay);
PLOT_CHECK_NEAR(xr.min, 0.0 - 3.0 * 0.02, 1e-12); // 4 samples span x 0..3, 2% margin
PLOT_CHECK_NEAR(xr.max, 3.0 + 3.0 * 0.02, 1e-12);
PLOT_CHECK_NEAR(yr.min, 0.0 - 3.0 * 0.02, 1e-12);
PLOT_CHECK_NEAR(yr.max, 3.0 + 3.0 * 0.02, 1e-12);
// An explicit range pins the axis; a later fit re-enables auto scaling.
p.set_y_range(ay, {0.0, 1.0});
PLOT_CHECK(!p.y_axis(ay).auto_scale);
p.auto_fit_y(ay);
PLOT_CHECK(p.y_axis(ay).auto_scale);
PLOT_CHECK_NEAR(p.y_range(ay).max, 3.0 + 3.0 * 0.02, 1e-12);
// auto_scale = false survives layout(): the pinned range is not snapped back.
p.set_y_range(ay, {0.0, 1.0});
(void)p.layout();
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-12);
}
PLOT_TEST(layout_refits_axes_left_on_auto_scale) {
std::vector<f32> y = {0.0f, 1.0f, 2.0f, 3.0f};
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 1000.0}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
(void)p.layout(); // no explicit fit needed
PLOT_CHECK_NEAR(p.x_range(ax).max, 3.0 + 3.0 * 0.02, 1e-12);
PLOT_CHECK_NEAR(p.y_range(ay).max, 3.0 + 3.0 * 0.02, 1e-12);
// An explicit range wins from then on: layout() must not snap it back.
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {0.0, 2.0});
(void)p.layout();
PLOT_CHECK_NEAR(p.x_range(ax).max, 1.0, 1e-12);
PLOT_CHECK_NEAR(p.y_range(ay).max, 2.0, 1e-12);
}
PLOT_TEST(auto_fit_handles_degenerate_and_null_series) {
std::vector<f32> one{5.0f};
plot::data::ArraySeries s(std::move(one), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); // skipped, not a crash
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}) == 1u);
PLOT_CHECK(p.trace_count() == 2u);
p.auto_fit_x();
p.auto_fit_y(ay);
const plot::Range xr = p.x_range(ax);
const plot::Range yr = p.y_range(ay);
PLOT_CHECK(xr.span() > 0.0); // degenerate {0,0} widened
PLOT_CHECK(xr.min < 0.0 && xr.max > 0.0);
PLOT_CHECK(yr.span() > 0.0); // degenerate {5,5} widened
PLOT_CHECK(yr.min < 5.0 && yr.max > 5.0);
(void)p.layout(); // must survive the null trace
}
PLOT_TEST(measurement_matches_direct_computation_and_tracks_cursor_moves) {
plot::data::ArraySeries s(ramp(100), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
const u32 a = p.add_cursor(10.0, plot::Color::hex(0xFFB020));
const u32 b = p.add_cursor(19.0, plot::Color::hex(0xFFB020));
const u32 m = p.add_measure(a, b);
const plot::Measurement& r = p.measurement(m);
PLOT_CHECK(r.valid);
double sum = 0.0, sq = 0.0;
for (u64 i = 10; i <= 19; ++i) {
sum += double(i);
sq += double(i) * double(i);
}
PLOT_CHECK_NEAR(r.mean, sum / 10.0, 1e-9);
PLOT_CHECK_NEAR(r.rms, std::sqrt(sq / 10.0), 1e-9);
PLOT_CHECK_NEAR(r.integral, sum, 1e-9); // dx == 1
PLOT_CHECK_NEAR(r.ymin, 10.0, 1e-9);
PLOT_CHECK_NEAR(r.ymax, 19.0, 1e-9);
PLOT_CHECK_NEAR(r.dx, 9.0, 1e-9);
PLOT_CHECK_NEAR(r.dy, 9.0, 1e-9);
PLOT_CHECK_NEAR(r.slope, 1.0, 1e-9);
// Moving a cursor the measure depends on must invalidate the cached result.
p.set_cursor_x(b, 14.0);
const plot::Measurement& r2 = p.measurement(m);
PLOT_CHECK(r2.valid);
PLOT_CHECK_NEAR(r2.ymax, 14.0, 1e-9);
PLOT_CHECK_NEAR(r2.dx, 4.0, 1e-9);
}
PLOT_TEST(measurement_recomputes_when_the_series_grows) {
plot::data::StreamSeries s(1024, 0.0, 1.0);
for (u64 i = 0; i < 10; ++i) s.push(f32(i));
s.acquire();
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 39.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 39.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
const u32 a = p.add_cursor(2.0, plot::Color::hex(0xFFB020));
const u32 b = p.add_cursor(20.0, plot::Color::hex(0xFFB020));
const u32 m = p.add_measure(a, b);
PLOT_CHECK(p.measurement(m).valid);
// Only 10 samples exist: x = 20 snaps to the last one, index 9.
PLOT_CHECK_NEAR(p.measurement(m).ymax, 9.0, 1e-9);
PLOT_CHECK_NEAR(p.measurement(m).dx, 7.0, 1e-9);
for (u64 i = 10; i < 40; ++i) s.push(f32(i));
s.acquire();
// Same cursors, longer series: the cached result must be recomputed.
PLOT_CHECK(p.measurement(m).valid);
PLOT_CHECK_NEAR(p.measurement(m).ymax, 20.0, 1e-9);
PLOT_CHECK_NEAR(p.measurement(m).dx, 18.0, 1e-9);
}
PLOT_TEST(inverted_and_out_of_range_measurements_are_invalid) {
plot::data::ArraySeries s(ramp(10), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 9.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 9.0}, false, plot::AxisPlace::Left, false});
const u32 a = p.add_cursor(8.0, plot::Color::hex(0xFFB020));
const u32 b = p.add_cursor(2.0, plot::Color::hex(0xFFB020));
const u32 m_inverted = p.add_measure(a, b);
PLOT_CHECK(!p.measurement(m_inverted).valid);
p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); // trace 0 has no series
const u32 m_null = p.add_measure(b, a, 0);
PLOT_CHECK(!p.measurement(m_null).valid);
const u32 m_bad_cursors = p.add_measure(50u, 51u);
PLOT_CHECK(!p.measurement(m_bad_cursors).valid);
PLOT_CHECK(!p.measurement(99u).valid); // unknown measure id
}
PLOT_TEST(grid_lines_are_tick_aligned_data_space_segments) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
(void)p.layout();
const plot::Grid& g = p.grid();
const std::vector<plot::Tick>& xt = p.x_axis(ax).ticks;
const std::vector<plot::Tick>& yt = p.y_axis(ay).ticks;
PLOT_CHECK(!xt.empty());
PLOT_CHECK(!yt.empty());
PLOT_CHECK(g.size() == xt.size() + yt.size());
for (const plot::Tick& t : xt) PLOT_CHECK(has_vertical(g, t.value, t.major));
for (const plot::Tick& t : yt) PLOT_CHECK(has_horizontal(g, t.value, t.major));
// Vertical grid lines span the y range, horizontal ones the x range: data
// space only, so the renderer's uniform transform can place them.
for (const plot::GridLine& l : g.lines()) {
if (l.x0 == l.x1) {
PLOT_CHECK_NEAR(l.y0, -1.0, 1e-12);
PLOT_CHECK_NEAR(l.y1, 1.0, 1e-12);
} else {
PLOT_CHECK_NEAR(l.y0, l.y1, 1e-12);
PLOT_CHECK_NEAR(l.x0, 0.0, 1e-12);
PLOT_CHECK_NEAR(l.x1, 10.0, 1e-12);
}
}
u32 minor = 0;
for (const plot::GridLine& l : g.lines()) minor += l.major ? 0u : 1u;
PLOT_CHECK(minor > 0); // minor grid is generated too
}
+109
View File
@@ -0,0 +1,109 @@
#include "harness.hpp"
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <cstring>
#include <limits>
#include <string>
#include <vector>
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());
}
// A range dense enough to want millions of minors stops at the hard cap.
std::vector<plot::Tick> capped;
plot::generate_ticks({0.0, 1e9}, 1e6f, 1.0f, capped, /*include_minors=*/true);
PLOT_CHECK(capped.size() == plot::kMaxTicks);
}
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");
}