test(plot): pin the horizontal ruler's paint, edge behaviour and orientation rules

This commit is contained in:
Martino Ferrari committed 2026-09-28 17:34:43 +02:00
1 parent 63f9655ae1
commit eb7371d7a5
1 file changed
+206
+206
View File
@@ -4319,3 +4319,209 @@ PLOT_TEST(a_frame_leaves_no_clip_for_the_host_to_inherit) {
ctx.destroy();
}
// ------------------------------------------------- horizontal rulers (spec §14)
namespace {
/// The horizontal ruler's own colour (0x35D0A8) at any coverage: a teal greener than the
/// background and -- unlike the trace's own blue -- with green *above* blue, so no other
/// element of these frames can satisfy it. A predicate rather than an exact match, because
/// a 1.5-device-px stroke is anti-aliased and no pixel is ever guaranteed whole.
bool ruler_pixel(const plot::u8 px[4]) {
return px[1] > px[0] + 50 && px[1] > px[2] + 20 && px[1] > 90;
}
/// The extent of the ink a predicate accepts anywhere in a read-back frame: its bounding
/// box in top-down framebuffer rows/columns (like every `Rect` here), and its pixel count.
struct InkExtent {
plot::u32 top = kNoColumn;
plot::u32 bottom = 0;
plot::u32 left = kNoColumn;
plot::u32 right = 0;
plot::u32 pixels = 0;
bool any() const noexcept { return top != kNoColumn; }
plot::u32 rows() const noexcept { return any() ? bottom - top + 1u : 0u; }
plot::u32 cols() const noexcept { return any() ? right - left + 1u : 0u; }
};
InkExtent ink_extent(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::u32 fb_h,
bool (*pred)(const plot::u8*)) {
InkExtent e;
for (plot::u32 y = 0; y < fb_h; ++y) {
for (plot::u32 x = 0; x < fb_w; ++x) {
if (!pred(&frame[(plot::usize(y) * fb_w + x) * 4u])) continue;
if (!e.any()) {
e.top = e.bottom = y;
e.left = e.right = x;
}
e.top = std::min(e.top, y);
e.bottom = std::max(e.bottom, y);
e.left = std::min(e.left, x);
e.right = std::max(e.right, x);
++e.pixels;
}
}
return e;
}
} // namespace
/// Spec §14, the paint half: a horizontal ruler is the marker turned a quarter turn, so its
/// ink is one *horizontal* run across `axes` at the row its y maps to -- not a column -- and,
/// Task 1's clip doing its job, nothing of it outside `axes`. The row is asserted against the
/// transform's own `to_screen_y()` rather than "some ink exists", so a ruler drawn at the
/// wrong y, or one still staged as a vertical column, fails here. The label is a second run
/// of text ink just under the ruler, inside the axes -- the value the ruler reads off its own
/// y axis, written the way that axis writes its ticks.
PLOT_TEST(horizontal_ruler_label_reads_its_value) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"t", {0.0, 300.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
// No trace: the frame is chrome and the ruler alone, so the ruler's colour is the only
// thing near its row and no trace ink can be mistaken for the label beneath it.
const plot::u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
p.layout();
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "h_ruler_paint.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
const plot::Rect& axes = layout.axes;
const plot::f32 expected_row =
static_cast<plot::f32>(p.y_transform(ay).to_screen_y(p.cursor(ruler).y));
PLOT_CHECK(expected_row >= axes.y && expected_row <= axes.bottom());
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u);
// One horizontal run: shallow in rows, spanning essentially the axes' own width.
PLOT_CHECK(ink.rows() <= 3u);
PLOT_CHECK(ink.cols() + 2u >= static_cast<plot::u32>(axes.w));
// And at the row the transform says -- a ruler drawn at the wrong y, or a vertical column
// that happens to be inside the rect, cannot satisfy both of these.
PLOT_CHECK(static_cast<plot::f32>(ink.top) <= expected_row + 1.0f);
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= expected_row - 1.0f);
// The label: text ink just under the ruler at its left end, inside the axes. The tick
// labels are outside `axes`, in their own strips, so this can only be the ruler's value.
const plot::Rect label{axes.x + 1.0f, expected_row, 60.0f, 16.0f};
PLOT_CHECK(predicate_pixels(frame, kW, label, text_pixel) > 0u);
// The clip: every ruler-coloured pixel of the frame is inside `axes`.
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
predicate_pixels(frame, kW, axes, ruler_pixel));
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, ruler_pixel) == 0u);
ctx.destroy();
}
/// Spec §14, the edge rule, discovered from the code and pinned here: a *drag* clamps the
/// ruler's y to its own y axis' range (`Controller::move_cursor`), so a pointer that has left
/// the axes cannot park the ruler at an extrapolated value -- but the direct
/// `Plot::set_cursor_y()` stays verbatim, so a caller that means to place one off range still
/// can. Either way Task 1's axes clip and the renderer's far-edge skip keep the ink inside
/// `axes`: a ruler at or past an edge puts nothing over the strips beside it. Both halves are
/// asserted from the pixels, because "drawn over the strip" is a claim about pixels.
PLOT_TEST(horizontal_ruler_stays_inside_the_axes_at_the_edges) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"t", {0.0, 300.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const plot::u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
plot::input::Controller c(p);
c.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.layout();
// Draw one frame before measuring: the renderer hands the plot its own text backend and
// style on the first draw, and with them the strip widths the layout pins -- so the axes
// the controller hit-tests is the one a drawn frame has, not the empty plot's. Without
// this the press below aims at a rect the first `frame_now()` has already replaced.
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
const plot::Rect& axes = p.layout().axes;
const plot::f32 col = axes.x + 0.5f * axes.w;
const plot::f32 row = static_cast<plot::f32>(p.y_transform(ay).to_screen_y(50.0));
const auto frame_now = [&]() {
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
return read_frame(ctx);
};
// Presses on the ruler at its own row: no modifier, the gesture the ruler shares with a
// marker. `row` is the y=50 row and does not change with a drag, so it can be reused.
const auto grab = [&]() {
p.set_cursor_y(ruler, 50.0); // back to the row the press is on
p.layout();
c.on_pointer({col, row, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(c.grabbed_cursor() == ruler);
};
const auto drag_to = [&](plot::f32 y) {
c.on_pointer({col, y, 0.0f, y - row, plot::input::Button::Left, 0u, true, false, false});
c.on_pointer({col, y, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
};
// Dragged past the *bottom*: the pointer extrapolates below the axis and the drag clamps
// to the range's minimum rather than storing a value no tick could reach.
grab();
drag_to(axes.bottom() + 400.0f);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 0.0, 1e-9);
{
const std::vector<plot::u8> frame = frame_now();
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
predicate_pixels(frame, kW, axes, ruler_pixel));
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().x_label_area, ruler_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_pixel) == 0u);
}
// Dragged past the *top*: clamped to the range's maximum, and the clamped ruler is still
// drawn -- at the axes' own top row and inside it, rather than over the strip above.
grab();
drag_to(axes.y - 400.0f);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 100.0, 1e-9);
{
const std::vector<plot::u8> frame = frame_now();
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u);
PLOT_CHECK(static_cast<plot::f32>(ink.top) < axes.y + 1.5f);
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= axes.y);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) ==
predicate_pixels(frame, kW, axes, ruler_pixel));
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_pixel) == 0u);
}
// The direct setter is verbatim -- a caller may park a ruler off range -- and the frame
// still holds the ink in: an off-range ruler is skipped rather than drawn over a strip.
p.set_cursor_y(ruler, 500.0);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 500.0, 1e-9);
{
const std::vector<plot::u8> frame = frame_now();
PLOT_CHECK(predicate_pixels(frame, kW, axes, ruler_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, ruler_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, p.layout().y_label_area, ruler_pixel) == 0u);
}
ctx.destroy();
}