Files
plotter/tests/test_plot.cpp
T

292 lines
12 KiB
C++

#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
}