test(plot): pin the horizontal ruler's paint, edge behaviour and orientation rules
This commit is contained in:
1 parent
63f9655ae1
commit
eb7371d7a5
1 file changed
+206
@@ -4319,3 +4319,209 @@ PLOT_TEST(a_frame_leaves_no_clip_for_the_host_to_inherit) {
|
|||||||
|
|
||||||
ctx.destroy();
|
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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user