test(input): region, cursor and band-edge direct-manipulation cases (spec §5)

This commit is contained in:
Martino Ferrari committed 2026-09-28 13:42:26 +02:00
1 parent e4d48cc6c3
commit f423cf6f99
3 files changed
+819

No files matched your search

+179
View File
@@ -1411,3 +1411,182 @@ PLOT_TEST(a_degenerate_range_cannot_freeze_a_live_axis) {
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9);
}
// --- regions: exact interval statistics (spec §5) ------------------------------
/// A region is an interval a user drags, so it lands *between* samples: the stats
/// must be over exactly the samples inside the bounds, which is what
/// `Series::reduce/sum/sumsq` answer for and what the pyramid's `*_buckets` forms,
/// counting whole buckets, cannot. The grid here is deliberately nothing a caller
/// could divide by -- `x0` is not 0 and `dx` is not 1 -- and the interval is not
/// bucket-aligned, so a bucket-grained or off-by-one answer changes the count.
///
/// The brute-force scan below applies the rule `RegionStats` documents -- the samples
/// whose x is at or after `x0` and at or before `x1`, both ends included -- so the two
/// are comparable at the last bit. Two spikes *outside* the interval are what make the
/// extremes an assertion about the interval rather than about the series.
PLOT_TEST(region_stats_match_bruteforce_over_an_unaligned_interval) {
constexpr f64 kDx = 0.7;
constexpr f64 kX0 = 3.0;
constexpr f64 kLo = 10.4;
constexpr f64 kHi = 47.6;
constexpr u64 kCount = 53u; // x = 3.0 + 0.7*i, i = 11..63, both ends inside
std::vector<f32> ys = ramp(120);
ys[20] = -100.0f; // inside the interval: the minimum
ys[40] = 200.0f; // inside the interval: the maximum
ys[5] = 500.0f; // before it, on a sample
ys[70] = -300.0f; // after it, on a sample
const std::vector<f32> samples = ys; // the brute-force copy
plot::data::ArraySeries s(ys, kX0, kDx); // takes the vector by value
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", s.x_bounds(), false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-200.0, 200.0}, false, plot::AxisPlace::Left, false});
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
plot::Region reg;
reg.x0 = kLo;
reg.x1 = kHi;
reg.snap = plot::RegionSnap::Free; // stored verbatim: this is the interval under test
const u32 rid = p.add_region(reg);
const plot::RegionStats st = p.region_stats(rid, tr);
PLOT_CHECK(st.valid);
PLOT_CHECK(st.count == kCount);
PLOT_CHECK_NEAR(st.x0, kLo, 1e-12);
PLOT_CHECK_NEAR(st.x1, kHi, 1e-12);
PLOT_CHECK_NEAR(st.width, kHi - kLo, 1e-9);
PLOT_CHECK_NEAR(st.min, -100.0, 1e-9); // the spike inside, never the one outside
PLOT_CHECK_NEAR(st.max, 200.0, 1e-9);
u64 count = 0;
f64 sum = 0.0, sq = 0.0, lo = 0.0, hi = 0.0;
for (u64 i = 0; i < samples.size(); ++i) {
const f64 x = kX0 + kDx * static_cast<f64>(i);
if (x < kLo || x > kHi) continue;
const f64 v = static_cast<f64>(samples[static_cast<plot::usize>(i)]);
if (count == 0u) {
lo = v;
hi = v;
}
lo = std::min(lo, v);
hi = std::max(hi, v);
sum += v;
sq += v * v;
++count;
}
PLOT_CHECK(count == kCount); // the scan and the query agree on the interval itself
PLOT_CHECK_NEAR(st.mean, sum / static_cast<f64>(count), 1e-6);
PLOT_CHECK_NEAR(st.rms, std::sqrt(sq / static_cast<f64>(count)), 1e-6);
PLOT_CHECK_NEAR(st.min, lo, 1e-9);
PLOT_CHECK_NEAR(st.max, hi, 1e-9);
PLOT_CHECK_NEAR(st.integral, sum * kDx, 1e-6); // left-Riemann, the measurement's rule
// An interval whose bounds *are* sample x's takes both of those samples: the rule is
// inclusive at each end, so this is one sample wider at the top than a half-open one.
plot::Region exact;
exact.x0 = kX0 + kDx * 11.0; // 10.7
exact.x1 = kX0 + kDx * 63.0; // 47.1
exact.snap = plot::RegionSnap::Free;
const u32 rid_exact = p.add_region(exact);
PLOT_CHECK(p.region_stats(rid_exact, tr).count == kCount);
// Between two adjacent samples there is nothing to average, so the answer is
// invalid and every field zero rather than a mean of no samples.
plot::Region gap;
gap.x0 = 10.71;
gap.x1 = 10.79;
gap.snap = plot::RegionSnap::Free;
const u32 rid_gap = p.add_region(gap);
const plot::RegionStats none = p.region_stats(rid_gap, tr);
PLOT_CHECK(!none.valid);
PLOT_CHECK(none.count == 0u && none.x0 == 0.0 && none.x1 == 0.0 && none.mean == 0.0);
// A trace with no series and an id that names no region are invalid too.
const u32 tr_null = p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
PLOT_CHECK(!p.region_stats(rid, tr_null).valid);
PLOT_CHECK(!p.region_stats(99u, tr).valid);
// Moving a region moves the interval the statistics are over.
p.set_region(rid, 32.0, 47.6);
PLOT_CHECK(p.region_stats(rid, tr).count == 22u); // x = 32.4 .. 47.1, i = 42..63
PLOT_CHECK_NEAR(p.region_stats(rid, tr).min, 42.0, 1e-9); // both spikes are outside it now
PLOT_CHECK_NEAR(p.region_stats(rid, tr).max, 63.0, 1e-9);
}
/// A saturated ring pins `size()` at capacity while `x0()` advances every acquire, so
/// a region whose own bounds never move still covers a different set of samples each
/// frame. The cache key includes `x0()` for exactly this: with (series, sample count)
/// alone the readout would freeze on the first saturated frame, which is the bug the
/// measurement cache's third key exists to prevent and this statistic must not
/// reintroduce.
///
/// The signal is a ramp in absolute x, so a sample's value names its x: what the
/// statistics report once the window has slid past a cursor is the current window's
/// oldest sample, which a frozen cache cannot produce.
PLOT_TEST(region_stats_track_a_sliding_streaming_window) {
constexpr u64 kCapacity = 64;
plot::data::StreamSeries s(kCapacity, 0.0, 1.0);
for (u64 i = 0; i < kCapacity; ++i) s.push(static_cast<f32>(i)); // y == absolute x
s.acquire();
PLOT_CHECK(s.size() == kCapacity); // the published window is full from here on
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
plot::Region reg;
reg.x0 = 10.0;
reg.x1 = 60.0;
reg.snap = plot::RegionSnap::Free; // the region never moves; the window does
const u32 rid = p.add_region(reg);
plot::Region behind;
behind.x0 = 0.0;
behind.x1 = 5.0;
behind.snap = plot::RegionSnap::Free;
const u32 rid_behind = p.add_region(behind);
// The returned reference aliases the cached slot the next read updates, so every
// comparison here keeps a copy of the frame it is about.
const plot::RegionStats first = p.region_stats(rid, tr);
PLOT_CHECK(first.valid);
PLOT_CHECK(first.count == 51u); // absolute x 10..60
PLOT_CHECK_NEAR(first.min, 10.0, 1e-9);
PLOT_CHECK_NEAR(first.max, 60.0, 1e-9);
PLOT_CHECK_NEAR(first.mean, 35.0, 1e-9);
PLOT_CHECK_NEAR(first.integral, 35.0 * 51.0, 1e-9);
PLOT_CHECK(p.region_stats(rid_behind, tr).valid); // it is inside the window for now
// Slide the window past the region's lower bound: 40 more samples and an acquire,
// with the sample count unchanged and the region untouched.
for (u64 i = kCapacity; i < kCapacity + 40u; ++i) s.push(static_cast<f32>(i));
s.acquire();
PLOT_CHECK(s.size() == kCapacity); // size() cannot be the cache key
PLOT_CHECK_NEAR(s.x0(), 40.0, 1e-9); // x0() is what moved under it
const plot::RegionStats slid = p.region_stats(rid, tr);
PLOT_CHECK(slid.valid);
PLOT_CHECK(slid.count == 21u); // absolute x 40..60, the part the ring still holds
PLOT_CHECK(slid.count != first.count);
PLOT_CHECK_NEAR(slid.min, 40.0, 1e-9);
PLOT_CHECK_NEAR(slid.max, 60.0, 1e-9);
PLOT_CHECK_NEAR(slid.mean, 50.0, 1e-9);
PLOT_CHECK_NEAR(slid.integral, 50.0 * 21.0, 1e-9);
// The window has walked out from under the other region: no sample is left in it.
PLOT_CHECK(!p.region_stats(rid_behind, tr).valid);
// And it keeps tracking, one acquire at a time, with nothing but x0() moving.
for (u32 i = 0; i < 5u; ++i) {
s.push(static_cast<f32>(kCapacity + 40u + i));
s.acquire();
const plot::RegionStats step = p.region_stats(rid, tr);
PLOT_CHECK(step.valid);
PLOT_CHECK(step.count == 20u - i); // absolute x (41 + i)..60
PLOT_CHECK_NEAR(step.min, 41.0 + static_cast<f64>(i), 1e-9);
PLOT_CHECK_NEAR(step.mean, 0.5 * (step.min + 60.0), 1e-9);
}
}