test(input): region, cursor and band-edge direct-manipulation cases (spec §5)
This commit is contained in:
1 parent
e4d48cc6c3
commit
f423cf6f99
3 files changed
+819
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user