diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 7f7a04e..5a7fb23 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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& 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(kW), static_cast(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(kW), static_cast(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 frame = read_frame(ctx); + const plot::PlotLayout layout = p.layout(); + const plot::Rect& axes = layout.axes; + const plot::f32 expected_row = + static_cast(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(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(ink.top) <= expected_row + 1.0f); + PLOT_CHECK(static_cast(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(kW), static_cast(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(kW), static_cast(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(kW), static_cast(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(p.y_transform(ay).to_screen_y(50.0)); + + const auto frame_now = [&]() { + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(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 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 frame = frame_now(); + const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel); + PLOT_CHECK(ink.pixels > 0u); + PLOT_CHECK(static_cast(ink.top) < axes.y + 1.5f); + PLOT_CHECK(static_cast(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 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(); +}