Files
gridcraft/crates/ui-egui/tests/it/objects.rs
T
prxnvt 2fa94f111f Merge origin/main (6f52cc50, the squash of upstream pull request 471) into w/form-controls
Both sides added a kind of floating object (equation, form control); both are kept everywhere.
ROADMAP.md: header combined (form controls first, then equations), catalog 281 / 296 as `cargo xtask parity` measures, the form-controls progress-log and revision rows above main's equation rows.
crates/engine/src/cmd.rs: both spec lists (controls and equation).
crates/engine/src/cmd/extra.rs: the arrange params docs list both kinds.
crates/engine/src/cmd/extra/objects.rs: main's `found` match and `anchors_of` on ObjKind gain the Control arms; the unlocked check takes the parsed kind directly.
crates/engine/src/cmd/insert.rs: the object params docs and the kind error name both kinds.
crates/engine/src/tests.rs: both test modules.
crates/engine/src/tests/objects.rs: every_kind returns six objects (equation at L18, slider at P18) and the anchor lists carry both.
crates/model/src/features.rs: ObjKind has Equation and Control, ALL lists six kinds, name() both.
crates/model/src/sheet.rs: both imports, both accessor arms, the order test inserts equations (6, 8) and controls (13, 15) and documents controls after slicers with equations counted as shapes.
crates/model/src/workbook.rs: main's refresh_equations and this branch's free-id doc comment.
crates/ui-egui/src/chartview.rs: both doc comments combined.
crates/ui-egui/src/objects.rs: both OpenAction variants and arms.
crates/ui-egui/src/ribbon/insert.rs: the import list is the union.
docs/control-protocol.md: the kind list names equation and control; main's equation sentences and this branch's form-controls sentence.
docs/file-format-parity.md: header combined; the xlsx row carries both additions; the form-controls revision row above main's equation rows.
docs/parity-checklist.md: regenerated with `cargo xtask parity` (281 / 296).
docs/target-app-parity.md: header combined; catalog 281 / 296; the Insert objects row carries both additions; the form-controls revision row (277 / 292 → 281 / 296) above main's.
docs/ui-parity.md: header combined with catalog 281 / 296; main's move-and-resize row with equations plus the form-controls row; the form-controls revision row above main's.
2026-11-02 10:47:43 -05:00

497 lines
23 KiB
Rust

//! Floating objects through real pointer and keyboard input: selecting every kind, resizing from
//! each of the eight handles, deleting with Delete and Backspace, and double-click routing.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::{ObjKind, ObjRef};
use gridcraft_ui_egui::objects::{Handle, OpenAction, open_action};
use gridcraft_ui_egui::{SheetApp, chartview, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
struct Objects {
chart: ObjRef,
image: ObjRef,
shape: ObjRef,
text_box: ObjRef,
equation: ObjRef,
/// A slicer lying over the chart's right edge: slicers paint on top and take the click.
slicer: ObjRef,
}
fn at(a: &str) -> CellRef {
CellRef::parse(a).unwrap()
}
fn id(v: &serde_json::Value, key: &str) -> u32 {
v[key].as_u64().unwrap() as u32
}
fn fixture() -> (Harness, Objects) {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
let slicer = id(&s.execute("insert.slicer", json!({"column": "Region", "at": "G9", "width": 140, "height": 120})).unwrap(), "slicer");
s.execute("cell.set", json!({"cell": "R30", "input": "keep me"})).unwrap();
s.execute("selection.set", json!({"cell": "R30"})).unwrap();
let chart =
id(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D8", "width": 220, "height": 140})).unwrap(), "chart");
let shape = id(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "C20", "width": 120, "height": 80})).unwrap(), "shape");
let text_box = id(&s.execute("insert.textBox", json!({"at": "H20", "width": 140, "height": 60, "text": "Note"})).unwrap(), "shape");
let equation = id(&s.execute("insert.equation", json!({"latex": "a^2+b^2=c^2", "cell": "C26", "size": 16})).unwrap(), "equation");
let image = id(&s.execute("insert.picture", json!({"base64": tiny_png(), "at": "M8", "width": 60, "height": 60})).unwrap(), "image");
let objs = Objects {
chart: ObjRef::new(ObjKind::Chart, chart),
image: ObjRef::new(ObjKind::Image, image),
shape: ObjRef::new(ObjKind::Shape, shape),
text_box: ObjRef::new(ObjKind::Shape, text_box),
equation: ObjRef::new(ObjKind::Equation, equation),
slicer: ObjRef::new(ObjKind::Slicer, slicer),
};
let mut h = egui_kittest::Harness::builder()
.with_size(vec2(1400.0, 900.0))
.with_step_dt(1.0 / 60.0)
.with_os(egui::os::OperatingSystem::Mac)
.build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
(h, objs)
}
fn tiny_png() -> String {
let mut bytes = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([40, 120, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
.unwrap();
gridcraft_engine::io::base64_encode(&bytes)
}
fn rect(h: &Harness, o: ObjRef) -> Rect {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
chartview::object_rect(&Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll), sh, o).unwrap()
}
/// A point that selects `o` without doing anything else: the chart's lower left (clear of the
/// slicer), the slicer's header (clear of its value tiles), the middle of anything else.
fn grab_point(h: &Harness, objs: &Objects, o: ObjRef) -> Pos2 {
let r = rect(h, o);
if o == objs.chart {
r.left_bottom() + vec2(12.0, -12.0)
} else if o == objs.slicer {
r.left_top() + vec2(14.0, 10.0)
} else {
r.center()
}
}
fn exists(h: &Harness, o: ObjRef) -> bool {
h.state().session.doc().unwrap().wb.active().unwrap().has_object(o)
}
fn pointer(h: &mut Harness, p: Pos2, pressed: bool) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
fn click(h: &mut Harness, p: Pos2) {
pointer(h, p, true);
pointer(h, p, false);
h.run_steps(2);
}
fn click_with(h: &mut Harness, p: Pos2, modifiers: Modifiers) {
h.run_steps(30);
for pressed in [true, false] {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers });
h.step();
}
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn double_click(h: &mut Harness, p: Pos2) {
// Separate the gesture from earlier clicks (egui's triple-click window).
h.run_steps(45);
click(h, p);
click(h, p);
}
fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
h.run_steps(45);
pointer(h, from, true);
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * 0.25));
h.step();
h.input_mut().events.push(Event::PointerMoved(to));
h.step();
pointer(h, to, false);
h.run_steps(2);
}
fn key(h: &mut Harness, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn kept(h: &Harness) -> Value {
h.state().session.doc().unwrap().wb.active().unwrap().value(at("R30"))
}
/// A form control over a slicer's value tile is the topmost object there, so a click that
/// `form_controls::interact` leaves to the grid (Cmd+click selects the control; a click on a
/// selected control moves it) takes the generic object path and never toggles the tile beneath.
#[test]
fn a_control_over_a_slicer_tile_keeps_the_generic_path_on_a_selecting_click() {
let (mut h, o) = fixture();
let slicer = rect(&h, o.slicer);
// A button exactly over the slicer's first value tile: the tile in screen units at the
// grid's scale, then the button's anchor in sheet units from the slicer's own.
let (z, anchor, n, columns) = {
let app = h.state();
let d = app.session.doc().unwrap();
let sh = d.wb.active().unwrap();
let sl = sh.slicers.iter().find(|s| s.id == o.slicer.id).unwrap();
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
(geo.z, sl.anchor, gridcraft_engine::cmd::slicer::slicer_items(&d.wb, d.wb.active_sheet(), sl).len(), sl.columns)
};
let layout = chartview::slicer_layout(slicer, z, n, columns);
let tile = layout.tiles[0];
let params = json!({
"at": anchor.cell.a1(),
"dx": anchor.dx + (tile.left() - slicer.left()) / z,
"dy": anchor.dy + (tile.top() - slicer.top()) / z,
"width": tile.width() / z,
"height": tile.height() / z,
"text": "Over",
});
let button = ObjRef::new(ObjKind::Control, id(&h.state_mut().run("insert.button", params).unwrap(), "control"));
h.run_steps(2);
assert!(rect(&h, button).contains(tile.center()), "the button covers the tile: {:?} vs {tile:?}", rect(&h, button));
let filter =
|h: &Harness| h.state().session.doc().unwrap().wb.active().unwrap().slicers.iter().find(|s| s.id == o.slicer.id).unwrap().selected.clone();
assert_eq!(filter(&h), None);
// Cmd+click selects the control without working it, and the tile beneath stays as it was.
click_with(&mut h, tile.center(), Modifiers::COMMAND);
assert_eq!(h.state().selected_object, Some(button), "the control is selected");
assert_eq!(filter(&h), None, "the tile under the control was not toggled");
// A click on the selected control is for moving it, not for the tile beneath either.
click(&mut h, tile.center());
assert_eq!(h.state().selected_object, Some(button));
assert_eq!(filter(&h), None, "still no toggle");
// The tile itself, clear of the control, still toggles.
let free = layout.tiles[1].center();
h.state_mut().selected_object = None;
click(&mut h, free);
assert!(filter(&h).is_some(), "a click on an uncovered tile toggles it");
}
#[test]
fn a_click_selects_every_kind_and_slicers_win_over_charts() {
let (mut h, o) = fixture();
for obj in [o.chart, o.image, o.shape, o.text_box, o.equation, o.slicer] {
let p = grab_point(&h, &o, obj);
click(&mut h, p);
assert_eq!(h.state().selected_object, Some(obj), "{obj:?}");
}
let (chart, slicer) = (rect(&h, o.chart), rect(&h, o.slicer));
assert!(chart.contains(grab_point(&h, &o, o.slicer)) && slicer.intersects(chart), "the slicer's header lies over the chart");
assert_eq!(h.state().selected_chart(), None, "a slicer is not a chart");
click(&mut h, chart.right_bottom() + vec2(120.0, 160.0));
assert_eq!(h.state().selected_object, None, "a click on the cells clears the object selection");
assert_eq!(kept(&h), Value::text("keep me"));
}
#[test]
fn every_handle_resizes_only_its_own_edges() {
let (mut h, o) = fixture();
for obj in [o.shape, o.image, o.equation, o.slicer, o.chart] {
let p = grab_point(&h, &o, obj);
click(&mut h, p);
for handle in Handle::ALL {
let before = rect(&h, obj);
let (sx, sy) = handle.sides();
let d = vec2(f32::from(sx) * 20.0, f32::from(sy) * 16.0);
let grab = handle.pos(before);
drag(&mut h, grab, grab + d);
let after = rect(&h, obj);
let moved = |edge: f32, was: f32, by: f32| (edge - (was + by)).abs() < 1.0;
assert!(moved(after.left(), before.left(), if sx < 0 { d.x } else { 0.0 }), "{obj:?} {handle:?} left");
assert!(moved(after.right(), before.right(), if sx > 0 { d.x } else { 0.0 }), "{obj:?} {handle:?} right");
assert!(moved(after.top(), before.top(), if sy < 0 { d.y } else { 0.0 }), "{obj:?} {handle:?} top {before:?} {after:?}");
assert!(moved(after.bottom(), before.bottom(), if sy > 0 { d.y } else { 0.0 }), "{obj:?} {handle:?} bottom");
assert_eq!(h.state().selected_object, Some(obj), "resizing keeps the selection");
}
}
// Shrinking past the opposite edge stops short of it.
let before = rect(&h, o.shape);
click(&mut h, before.center());
drag(&mut h, Handle::Right.pos(before), Handle::Right.pos(before) - vec2(before.width() + 50.0, 0.0));
let after = rect(&h, o.shape);
assert!(after.width() > 1.0 && (after.left() - before.left()).abs() < 1.0);
// Dragging the body moves the object without resizing it.
let before = rect(&h, o.image);
click(&mut h, before.center());
drag(&mut h, before.center(), before.center() + vec2(30.0, 25.0));
let after = rect(&h, o.image);
assert!((after.min - before.min - vec2(30.0, 25.0)).length() < 1.0 && (after.size() - before.size()).length() < 0.5);
assert_eq!(kept(&h), Value::text("keep me"));
}
#[test]
fn handles_show_resize_cursors_and_the_body_a_move_cursor() {
let (mut h, o) = fixture();
let r = rect(&h, o.shape);
click(&mut h, r.center());
for (handle, icon) in [
(Handle::TopLeft, egui::CursorIcon::ResizeNwSe),
(Handle::Top, egui::CursorIcon::ResizeVertical),
(Handle::BottomLeft, egui::CursorIcon::ResizeNeSw),
] {
h.input_mut().events.push(Event::PointerMoved(handle.pos(r)));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, icon, "{handle:?}");
}
h.input_mut().events.push(Event::PointerMoved(r.center()));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
}
#[test]
fn delete_and_backspace_delete_the_selected_object_not_the_cells() {
let (mut h, o) = fixture();
let p = rect(&h, o.shape).center();
click(&mut h, p);
key(&mut h, Key::Delete);
assert!(!exists(&h, o.shape));
assert_eq!(h.state().selected_object, None);
assert_eq!(kept(&h), Value::text("keep me"));
let p = rect(&h, o.image).center();
click(&mut h, p);
key(&mut h, Key::Backspace);
assert!(!exists(&h, o.image));
assert!(h.state().editor.is_none(), "Backspace on an object must not start a cell editor");
assert_eq!(kept(&h), Value::text("keep me"));
let p = grab_point(&h, &o, o.slicer);
click(&mut h, p);
assert_eq!(h.state().selected_object, Some(o.slicer));
key(&mut h, Key::Escape);
assert_eq!(h.state().selected_object, None, "Escape drops the object selection");
key(&mut h, Key::Delete);
assert!(exists(&h, o.slicer), "with nothing selected, Delete is for the cells");
assert_eq!(kept(&h), Value::Empty);
// Undo brings the object back; a redo that removes the selected object clears the selection.
h.state_mut().run("edit.undo", json!({})).unwrap();
h.state_mut().run("edit.undo", json!({})).unwrap();
assert!(exists(&h, o.image));
h.state_mut().selected_object = Some(o.image);
h.state_mut().run("edit.redo", json!({})).unwrap();
h.run_steps(2);
assert!(!exists(&h, o.image));
assert_eq!(h.state().selected_object, None);
}
#[test]
fn double_clicks_route_by_kind() {
let (mut h, o) = fixture();
{
let sh = h.state().session.doc().unwrap().wb.active().unwrap();
assert_eq!(open_action(sh, o.text_box), OpenAction::EditText(o.text_box.id));
assert_eq!(open_action(sh, o.chart), OpenAction::FormatChart(o.chart.id));
assert_eq!(open_action(sh, o.equation), OpenAction::EditEquation(o.equation.id));
for obj in [o.shape, o.image, o.slicer] {
assert_eq!(open_action(sh, obj), OpenAction::Select, "{obj:?}");
}
}
let p = rect(&h, o.text_box).center();
double_click(&mut h, p);
assert!(h.state().text_box_editor.as_ref().is_some_and(|e| e.id == o.text_box.id), "text boxes keep in-place editing");
let outside = rect(&h, o.text_box).right_bottom() + vec2(200.0, 150.0);
click(&mut h, outside);
assert!(h.state().text_box_editor.is_none());
let p = grab_point(&h, &o, o.chart);
double_click(&mut h, p);
assert_eq!(h.state().grid.pane.as_deref(), Some("formatChart"));
assert_eq!(h.state().selected_object, Some(o.chart));
let p = grab_point(&h, &o, o.equation);
double_click(&mut h, p);
assert_eq!(h.state().selected_object, Some(o.equation));
assert_eq!(h.state().dialog.as_ref().map(|d| (d.name(), d.title.as_str())), Some(("equation", "Edit Equation")), "equations open the editor");
h.state_mut().dialog = None;
for obj in [o.shape, o.image, o.slicer] {
let p = grab_point(&h, &o, obj);
double_click(&mut h, p);
assert_eq!(h.state().selected_object, Some(obj));
assert!(h.state().text_box_editor.is_none() && h.state().editor.is_none(), "{obj:?} opens no text editor");
}
assert_eq!(kept(&h), Value::text("keep me"));
}
#[test]
fn on_a_right_to_left_sheet_the_mirrored_handles_resize() {
let (mut h, o) = fixture();
h.state_mut().run("view.rightToLeft", json!({"on": true})).unwrap();
h.run_steps(3);
let screen = |h: &Harness| {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
geo.mirror_rect(chartview::object_rect(&geo, sh, o.image).unwrap())
};
let before = screen(&h);
click(&mut h, before.center());
assert_eq!(h.state().selected_object, Some(o.image), "the picture is hit where it is drawn");
// The logical bottom-right handle is drawn at the bottom-left; dragging it leftwards widens the picture.
let grab = before.left_bottom();
h.input_mut().events.push(Event::PointerMoved(grab));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw);
drag(&mut h, grab, grab + vec2(-20.0, 16.0));
let after = screen(&h);
assert!((after.left() - (before.left() - 20.0)).abs() < 1.0 && (after.right() - before.right()).abs() < 1.0, "{before:?} {after:?}");
assert!((after.bottom() - (before.bottom() + 16.0)).abs() < 1.0 && (after.top() - before.top()).abs() < 1.0, "{before:?} {after:?}");
}
#[test]
fn a_handle_under_the_headers_does_not_take_the_press() {
let (mut h, o) = fixture();
h.state_mut().run("object.move", json!({"kind": "shape", "id": o.shape.id, "at": "A1", "dx": 0, "dy": 0})).unwrap();
h.run_steps(2);
let before = rect(&h, o.shape);
click(&mut h, before.center());
assert_eq!(h.state().selected_object, Some(o.shape));
let cells = h.state().grid.cells_rect.unwrap();
assert_eq!(before.left_top(), cells.left_top(), "the shape's top-left handle sits on the header corner");
// Within the top-left handle's reach, but over the column header: the press is the header's.
let grab = Handle::TopLeft.pos(before) + vec2(4.0, -4.0);
assert!(grab.y < cells.top());
drag(&mut h, grab, grab + vec2(30.0, 40.0));
assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized");
assert_eq!(h.state().selected_object, None, "the press went to the column header");
// Within the left handle's reach, but over the row header.
let grab = Handle::Left.pos(before) + vec2(-4.0, 0.0);
assert!(grab.x < cells.left());
h.state_mut().selected_object = Some(o.shape);
drag(&mut h, grab, grab + vec2(40.0, 0.0));
assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized");
}
#[test]
fn each_drag_of_an_object_is_its_own_undo_step() {
let (mut h, o) = fixture();
let start = rect(&h, o.image);
click(&mut h, start.center());
drag(&mut h, start.center(), start.center() + vec2(30.0, 20.0));
let moved = rect(&h, o.image);
assert!((moved.min - start.min - vec2(30.0, 20.0)).length() < 1.0);
let grab = Handle::BottomRight.pos(moved);
drag(&mut h, grab, grab + vec2(24.0, 18.0));
let resized = rect(&h, o.image);
assert!((resized.size() - moved.size() - vec2(24.0, 18.0)).length() < 1.0);
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(rect(&h, o.image), moved, "one undo takes back the resize only");
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(rect(&h, o.image), start, "a second undo takes back the move");
}
#[test]
fn dragging_an_unselected_object_by_its_edge_moves_it() {
let (mut h, o) = fixture();
assert_eq!(h.state().selected_object, None);
let before = rect(&h, o.shape);
// On the bottom-right corner, where the handle would be if the shape were selected; the
// pointer shows Move there, and the press selects and moves the shape.
let grab = Handle::BottomRight.pos(before) - vec2(2.0, 2.0);
h.input_mut().events.push(Event::PointerMoved(grab));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
drag(&mut h, grab, grab + vec2(30.0, 20.0));
let after = rect(&h, o.shape);
assert_eq!(h.state().selected_object, Some(o.shape));
assert!((after.size() - before.size()).length() < 0.5, "not resized: {before:?} {after:?}");
assert!((after.min - before.min - vec2(30.0, 20.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}");
}
fn anchor(h: &Harness, o: ObjRef) -> gridcraft_engine::model::Anchor {
h.state().session.doc().unwrap().wb.active().unwrap().object_anchor(o).unwrap()
}
/// Makes the fixture's picture 12,000 by 60 and reopens the workbook from its XLSX bytes, the way
/// a file can carry an object wider than the 10,000 points `object.move` gives a new size.
/// Returns the picture as reopened.
fn imported_oversized_picture(h: &mut Harness, o: &Objects) -> ObjRef {
{
let wb = std::sync::Arc::make_mut(&mut h.state_mut().session.doc_mut().unwrap().wb);
let a = wb.sheet_mut(0).unwrap().object_anchor_mut(o.image).unwrap();
a.width = 12_000.0;
a.height = 60.0;
}
let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap();
h.state_mut().run("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap();
h.run_steps(4);
let pic = ObjRef::new(ObjKind::Image, h.state().session.doc().unwrap().wb.active().unwrap().images[0].id);
let a = anchor(h, pic);
assert_eq!((a.width.round(), a.height.round()), (12_000.0, 60.0), "the reader keeps the file's size: {a:?}");
pic
}
/// Dragging the body of such a picture moves it and sends no size, so nothing shrinks it to the
/// limit on the way.
#[test]
fn dragging_the_body_of_an_oversized_imported_object_keeps_its_size() {
let (mut h, o) = fixture();
let pic = imported_oversized_picture(&mut h, &o);
let before = rect(&h, pic);
// Well inside the body, on the part of the picture in view and clear of the handles' reach.
let grab = before.left_top() + vec2(30.0, 30.0);
click(&mut h, grab);
assert_eq!(h.state().selected_object, Some(pic));
drag(&mut h, grab, grab + vec2(30.0, 25.0));
let a = anchor(&h, pic);
assert_eq!((a.width.round(), a.height.round()), (12_000.0, 60.0), "the size is untouched: {a:?}");
let after = rect(&h, pic);
assert!((after.min - before.min - vec2(30.0, 25.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}");
// The drag sent the position only: the engine saw no size to cap.
let (_, p) = h.state().session.journal.iter().rev().find(|(id, _)| id == "object.move").unwrap();
assert!(p.get("width").is_none() && p.get("height").is_none(), "a body move sends no size: {p}");
}
/// A handle drag on such a picture resizes it by the drag only: the left handle moved 10 px to
/// the right leaves it 11,990 wide, with no jump to 10,000.
#[test]
fn a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only() {
let (mut h, o) = fixture();
let pic = imported_oversized_picture(&mut h, &o);
let before = rect(&h, pic);
click(&mut h, before.left_top() + vec2(30.0, 30.0));
let grab = Handle::Left.pos(before);
drag(&mut h, grab, grab + vec2(10.0, 0.0));
// In window points (the grid draws at its zoom): 10 narrower, the left edge 10 further right.
let after = rect(&h, pic);
assert!((before.width() - after.width() - 10.0).abs() < 1.0, "narrower by the drag only: {before:?} {after:?}");
assert!((after.left() - before.left() - 10.0).abs() < 1.0 && (after.top() - before.top()).abs() < 0.5, "{before:?} {after:?}");
let a = anchor(&h, pic);
assert!(a.width > 11_900.0 && a.width < 12_000.0 && a.height.round() == 60.0, "no jump to the 10,000 limit: {a:?}");
}