From da61905836f11268831aeb9fccd1ddff469cd796 Mon Sep 17 00:00:00 2001 From: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:12:48 +0800 Subject: [PATCH 1/2] fix(edit): discard stale vertex drag gestures --- crates/ui-egui/src/mapview.rs | 18 + crates/ui-egui/src/vertexedit.rs | 68 +++- crates/ui-egui/tests/vertex_drag_ownership.rs | 311 ++++++++++++++++++ 3 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/vertex_drag_ownership.rs diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index cad34f9e6..63c19f28c 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -145,6 +145,8 @@ pub struct MapView { pub vertex: Option<(u32, usize, rustgis_engine::data::VertexId)>, /// The vertex being dragged with the Edit Vertices tool. pub vertex_drag: Option, + /// Non-owning source identity for the held vertex gesture; never persisted with the project. + pub(crate) vertex_drag_owner: Option, /// The magnifying lens follows the pointer. pub magnifier: bool, pub popup: Option, @@ -213,6 +215,7 @@ pub struct MapView { } impl Default for MapView { + /// Starts with no chosen node, preview or retained gesture owner, alongside empty map caches. fn default() -> Self { MapView { texture: None, @@ -239,6 +242,7 @@ impl Default for MapView { split_mode: false, vertex: None, vertex_drag: None, + vertex_drag_owner: None, magnifier: false, popup: None, popup_rev: 0, @@ -338,6 +342,7 @@ impl MapView { self.render_ms } + /// Drops unfinished map sketches, selection shapes and node previews without committing them. pub fn cancel_sketch(&mut self) { self.sketch.clear(); self.sketch_done = false; @@ -346,8 +351,18 @@ impl MapView { } self.selection_gesture = None; self.radius_preview = None; + self.cancel_vertex_drag(); + } + + /// Cancels a vertex preview and its pending release together, leaving other map gestures + /// alone. The chosen node is cleared because its row/address may now belong to another shape. + pub(crate) fn cancel_vertex_drag(&mut self) { self.vertex = None; self.vertex_drag = None; + self.vertex_drag_owner = None; + if self.drag.is_some_and(|(_, kind)| kind == DragKind::Vertex) { + self.drag = None; + } } pub fn sketch_len(&self) -> usize { self.sketch.len() @@ -358,10 +373,13 @@ fn now(ui: &Ui) -> f64 { ui.input(|i| i.time) } +/// Shows the active map workspace, discarding stale node gestures after map-tab changes and +/// before rendering their overlays or consuming pointer releases. pub fn show(app: &mut RustGisApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); crate::modelbuilder::follow_view(app); view_tabs(app, ui, &t); + crate::vertexedit::cancel_stale_drag(app); if app.mb.shown() { crate::modelbuilder::show(app, ui); return; diff --git a/crates/ui-egui/src/vertexedit.rs b/crates/ui-egui/src/vertexedit.rs index 1a8ace0f0..f5d7ea9da 100644 --- a/crates/ui-egui/src/vertexedit.rs +++ b/crates/ui-egui/src/vertexedit.rs @@ -4,7 +4,7 @@ //! so undo, attribute rules and the edit counts work as for any other edit. use egui::{Color32, Painter, Pos2, Rect, Stroke, vec2}; -use rustgis_engine::data::{Geometry, VertexId}; +use rustgis_engine::data::{Feature, Geometry, VertexId}; use rustgis_engine::geom::Coord; use serde_json::json; @@ -34,6 +34,56 @@ pub struct VertexDrag { pub snapped: bool, } +/// The source of a held gesture, kept separately from its copyable preview. A weak row identity +/// detects any mutable row access, including edits without `touch_geometry`, without retaining +/// the rows or copying the geometry. A different project, map slot or map name also invalidates +/// it: a duplicated map can share rows and later occupy the original map's slot. +pub(crate) struct DragOwner { + project: u64, + map: usize, + map_name: String, + rows: std::sync::Weak>, +} + +impl DragOwner { + /// Captures the selected source's allocation, not a global revision: styling or moving the + /// camera can change the session revision without changing the gesture's source. + fn capture(app: &RustGisApp, d: VertexDrag) -> Option { + let map = app.session.map(); + let layer = map.layer(d.layer)?; + Some(Self { + project: app.session.opened, + map: app.session.project.active_map, + map_name: map.name.clone(), + rows: layer.dataset.features.allocation_key(), + }) + } + + /// A gesture is usable only in its original context while its source is still an editable + /// target. Any row edit in that layer cancels conservatively; styles and camera changes do not. + fn matches(&self, app: &RustGisApp, d: VertexDrag) -> bool { + let map = app.session.map(); + self.project == app.session.opened + && self.map == app.session.project.active_map + && self.map_name == map.name + && app.session.tool == rustgis_engine::Tool::EditVertices + && map.layer(d.layer).is_some_and(|l| self.rows.ptr_eq(&l.dataset.features.allocation_key())) + && targets(app).iter().any(|(l, r, g)| *l == d.layer && *r == d.row && g.vertex(d.vertex).is_some()) + } +} + +/// Drops a stale preview and its pending pointer release before either can act on new source +/// rows. This also runs when a map is shown without pointer movement; cancellation creates no edit. +pub(crate) fn cancel_stale_drag(app: &mut RustGisApp) { + let Some(d) = app.map.vertex_drag else { + app.map.vertex_drag_owner = None; + return; + }; + if !app.map.vertex_drag_owner.as_ref().is_some_and(|owner| owner.matches(app, d)) { + app.map.cancel_vertex_drag(); + } +} + /// What is under the pointer. #[derive(Clone, Copy, Debug)] enum Handle { @@ -124,13 +174,16 @@ fn handle_at(app: &RustGisApp, rect: Rect, p: Pos2) -> Option { best.map(|(_, h)| h) } -/// A press at `origin` starts dragging the vertex or midpoint there, if there is one. +/// A press at `origin` starts dragging the vertex or midpoint there, capturing the source rows +/// and map context before previewing. A later source change cancels rather than retargets it. pub fn drag_start(app: &mut RustGisApp, rect: Rect, origin: Pos2) -> bool { let drag = match handle_at(app, rect, origin) { Some(Handle::Vertex(layer, row, vertex, at)) => VertexDrag { layer, row, vertex, insert: false, at, snapped: false }, Some(Handle::Edge(layer, row, vertex, at)) => VertexDrag { layer, row, vertex, insert: true, at, snapped: false }, None => return false, }; + let Some(owner) = DragOwner::capture(app, drag) else { return false }; + app.map.vertex_drag_owner = Some(owner); app.map.vertex = (!drag.insert).then_some((drag.layer, drag.row, drag.vertex)); app.map.vertex_drag = Some(drag); true @@ -138,8 +191,10 @@ pub fn drag_start(app: &mut RustGisApp, rect: Rect, origin: Pos2) -> bool { /// A dragged line/polygon vertex follows the pointer, excluding itself and its incident edges. /// Point/multipoint moves keep whole-feature exclusion. An insertion also excludes its whole -/// feature, so its source edge cannot become a target. +/// feature, so its source edge cannot become a target. Stale source ownership cancels first, +/// even when this pointer position cannot be projected. pub fn drag_to(app: &mut RustGisApp, rect: Rect, p: Pos2) { + cancel_stale_drag(app); let Some(c) = screen_to_lonlat(app, rect, p) else { return }; let Some(d) = app.map.vertex_drag else { return }; // Asked directly, not as a command: commands mark the map changed, redrawing it every frame. @@ -154,9 +209,12 @@ pub fn drag_to(app: &mut RustGisApp, rect: Rect, p: Pos2) { } } -/// Letting go commits the move or insertion as one edit. +/// Letting go commits a still-owned move or insertion as one edit. A queued release after a +/// source/context change is discarded, preserving the current geometry and undo/redo history. pub fn drag_stop(app: &mut RustGisApp) { + cancel_stale_drag(app); let Some(d) = app.map.vertex_drag.take() else { return }; + app.map.vertex_drag_owner = None; // A vertex let go where it was is no edit (and no undo step). let at_start = targets(app).iter().any(|(l, r, g)| { *l == d.layer && *r == d.row && g.vertex(d.vertex).is_some_and(|c| (c.x - d.at.x).abs() < 1e-9 && (c.y - d.at.y).abs() < 1e-9) @@ -166,6 +224,8 @@ pub fn drag_stop(app: &mut RustGisApp) { } } +/// Sends one move or insertion through the existing command contract, then chooses the result. +/// Held drags check ownership before reaching here; edge clicks commit immediately. fn commit(app: &mut RustGisApp, d: VertexDrag) { let action = if d.insert { "insert" } else { "move" }; let p = json!({ diff --git a/crates/ui-egui/tests/vertex_drag_ownership.rs b/crates/ui-egui/tests/vertex_drag_ownership.rs new file mode 100644 index 000000000..8ad1d19ed --- /dev/null +++ b/crates/ui-egui/tests/vertex_drag_ownership.rs @@ -0,0 +1,311 @@ +//! Real pointer releases must not apply an old vertex preview to changed source rows. +use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use rustgis_engine::{ + Project, Session, + data::{Dataset, Feature, Geometry, VertexId}, + geom::{Coord, merc_to_lonlat}, +}; +use rustgis_ui_egui::RustGisApp; +use serde_json::json; + +/// Places synthetic metre-based fixtures in the source longitude/latitude coordinate system. +fn c(x: f64, y: f64) -> Coord { + merc_to_lonlat(Coord::new(x, y)) +} + +/// Shows one selected line in the normal application, with snapping disabled for exact ownership assertions. +fn harness() -> Harness<'static, RustGisApp> { + harness_with(line(0.)) +} + +/// Gives lines and polygon rings the same production map/command path without changing their topology. +fn harness_with(geometry: Geometry) -> Harness<'static, RustGisApp> { + let mut session = Session::new(Project::new("Vertex drag ownership")); + let mut data = Dataset::new("Paths", geometry.kind(), vec![]); + data.features = vec![Feature { id: 1, geometry: Some(geometry), attrs: vec![] }].into(); + let id = session.map_mut().add_dataset(data); + session.map_mut().layer_mut(id).unwrap().selection.push(0); + session.map_mut().camera.center = Coord::new(80., 80.); + session.map_mut().camera.res = 1.; + session.map_mut().editing.snapping.enabled = false; + let mut app = RustGisApp::new(session); + app.run("tool.set", json!({"tool":"editVertices"})).unwrap(); + app.ui.tab = "Edit".into(); + app.session.clear_history(); + let mut h = Harness::builder().with_accessibility_check(false).with_size(egui::vec2(1480., 900.)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + h.step(); + } + h +} + +/// Keeps the replacement topology and node address valid, so command validation alone cannot reject it. +fn line(x: f64) -> Geometry { + Geometry::Line(vec![vec![c(x, 0.), c(x, 160.)]]) +} + +/// Converts source coordinates through the actual map rectangle and camera. +fn screen(h: &Harness<'_, RustGisApp>, at: Coord) -> Pos2 { + let rect = h.state().map.view_rect.unwrap().0; + let map = h.state().session.map(); + let (x, y) = map.camera.to_screen(map.to_map(at).unwrap(), rect.width() as f64, rect.height() as f64); + rect.min + egui::vec2(x as f32, y as f32) +} + +/// Starts a held production drag, leaving the stored feature untouched until release. +fn drag(h: &mut Harness<'_, RustGisApp>, insert: bool) -> Pos2 { + let from = screen(h, c(0., if insert { 80. } else { 0. })); + let to = screen(h, c(90., 40.)); + h.event(Event::PointerMoved(from)); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for i in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 8.))); + h.step(); + } + assert_eq!(h.state().map.vertex_drag.unwrap().insert, insert); + to +} + +/// Delivers a queued release to mapview rather than directly invoking the commit helper. +fn release(h: &mut Harness<'_, RustGisApp>, at: Pos2) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + for _ in 0..3 { + h.step(); + } +} + +/// Reads the source node, independently of the drag overlay. +fn first(h: &Harness<'_, RustGisApp>) -> Coord { + h.state().session.map().layers[0].dataset.features[0].geometry.as_ref().unwrap().vertex(VertexId::new(0, 0, 0)).unwrap() +} + +/// A queued release must not overwrite a valid replacement at the same layer, row and vertex address. +#[test] +fn replaced_geometry_rejects_queued_release() { + let mut h = harness(); + let to = drag(&mut h, false); + h.state_mut() + .run( + "edit.setGeometry", + json!({"layer":"Paths", "row":0, "geometry":{"type":"LineString", "coordinates":[[0.001,0.],[0.001,0.002]]}}), + ) + .unwrap(); + let before = first(&h); + let depth = h.state().session.undo_depth(); + release(&mut h, to); + assert_eq!(first(&h), before, "old preview overwrote the replacement geometry"); + assert_eq!(h.state().session.undo_depth(), depth); + assert!(h.state().map.vertex_drag.is_none()); +} + +/// Undo while holding a drag must not turn the eventual release into a new edit of restored rows. +#[test] +fn undo_during_drag_rejects_queued_release() { + let mut h = harness(); + h.state_mut().run("edit.vertex", json!({"layer":"Paths", "row":0, "action":"move", "index":1, "lon":0., "lat":0.002})).unwrap(); + h.step(); + let to = drag(&mut h, false); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + let before = first(&h); + release(&mut h, to); + assert_eq!(first(&h), before, "old preview became a new edit after undo"); + assert_eq!(h.state().session.undo_depth(), 0); + assert!(h.state().session.can_redo()); +} +/// Redo replaces rows too; the pending release must preserve the redone geometry and history. +#[test] +fn redo_during_drag_rejects_queued_release() { + let mut h = harness(); + h.state_mut().run("edit.vertex", json!({"layer":"Paths", "row":0, "action":"move", "index":1, "lon":0., "lat":0.002})).unwrap(); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + h.step(); + let to = drag(&mut h, false); + h.state_mut().run("edit.redo", json!(null)).unwrap(); + let expected = h.state().session.map().layers[0].dataset.features[0].geometry.clone(); + release(&mut h, to); + assert_eq!(h.state().session.map().layers[0].dataset.features[0].geometry, expected); + assert_eq!(h.state().session.undo_depth(), 1); +} + +/// Reusing a row and even the same feature id cannot revive an old gesture, without a geometry revision bump. +#[test] +fn row_reuse_without_touch_rejects_queued_release() { + let mut h = harness(); + let to = drag(&mut h, false); + let layer = &mut h.state_mut().session.map_mut().layers[0]; + let revision = layer.geometry_rev; + layer.dataset.features = vec![Feature { id: 1, geometry: Some(line(30.)), attrs: vec![] }].into(); + assert_eq!(layer.geometry_rev, revision); + release(&mut h, to); + assert_eq!(first(&h), c(30., 0.)); + assert_eq!(h.state().session.undo_depth(), 0); +} + +/// A clone can share the same feature allocation while belonging to a different map slot. +#[test] +fn activating_cloned_map_rejects_queued_release() { + let mut h = harness(); + let mut other = h.state().session.map().clone(); + other.name = "Other".into(); + h.state_mut().session.project.maps.push(other); + let to = drag(&mut h, false); + h.state_mut().run("map.activate", json!({"name":"Other"})).unwrap(); + release(&mut h, to); + assert_eq!(first(&h), c(0., 0.)); + assert_eq!(h.state().session.undo_depth(), 0); + assert!(h.state().map.vertex_drag.is_none()); +} + +/// Deleting the original map can replace its slot with a clone sharing the same source allocation. +#[test] +fn replacing_map_slot_with_clone_rejects_queued_release() { + let mut h = harness(); + let old_name = h.state().session.map().name.clone(); + h.state_mut().run("map.duplicate", json!({"name":"Other"})).unwrap(); + // Duplication clears selection; keep the replacement editable to exercise context identity. + h.state_mut().session.map_mut().layers[0].selection.push(0); + h.state_mut().run("map.activate", json!({"name":old_name})).unwrap(); + h.step(); + let to = drag(&mut h, false); + h.state_mut().run("map.remove", json!({"name":old_name})).unwrap(); + let depth = h.state().session.undo_depth(); + release(&mut h, to); + assert_eq!(first(&h), c(0., 0.)); + assert_eq!(h.state().session.undo_depth(), depth); + assert!(h.state().map.vertex_drag.is_none()); +} + +/// Reopening a serialized project must not allow the previous project's queued release to edit it. +#[test] +fn reopening_project_rejects_queued_release() { + let mut h = harness(); + let text = serde_json::to_string(&h.state().session.project).unwrap(); + let to = drag(&mut h, false); + let opened = h.state().session.opened; + h.state_mut().run("project.openText", json!({"text":text})).unwrap(); + assert_ne!(h.state().session.opened, opened); + release(&mut h, to); + assert_eq!(first(&h), c(0., 0.)); + assert_eq!(h.state().session.undo_depth(), 0); +} + +/// Losing selection, visibility or editability cancels both movement and midpoint insertion. +#[test] +fn ineligible_source_cancels_move_and_insertion() { + for insert in [false, true] { + for change in 0..3 { + let mut h = harness(); + let to = drag(&mut h, insert); + let before = h.state().session.map().layers[0].dataset.features[0].geometry.clone(); + match change { + 0 => h.state_mut().session.map_mut().layers[0].selection.clear(), + 1 => h.state_mut().session.map_mut().layers[0].visible = false, + _ => h.state_mut().session.map_mut().editing.settings.enabled = false, + } + h.step(); + assert!(h.state().map.vertex_drag.is_none(), "stale preview remained visible"); + assert!(h.state().map.vertex.is_none()); + release(&mut h, to); + assert_eq!(h.state().session.map().layers[0].dataset.features[0].geometry, before); + assert_eq!(h.state().session.undo_depth(), 0); + } + } +} + +/// A source changed through the public mutable rows API invalidates an insertion without retaining a strong row owner. +#[test] +fn in_place_geometry_change_cancels_insertion() { + let mut h = harness(); + let to = drag(&mut h, true); + h.state_mut().session.map_mut().layers[0].dataset.features[0].geometry = Some(line(30.)); + let expected = h.state().session.map().layers[0].dataset.features[0].geometry.clone(); + h.step(); + assert!(h.state().map.vertex_drag.is_none()); + release(&mut h, to); + assert_eq!(h.state().session.map().layers[0].dataset.features[0].geometry, expected); + assert_eq!(h.state().session.undo_depth(), 0); +} + +/// Style and camera changes can bump Session revision without changing which source rows own the gesture. +#[test] +fn style_and_camera_changes_keep_a_valid_drag() { + let mut h = harness(); + let to = drag(&mut h, false); + let revision = h.state().session.revision; + h.state_mut().run("layer.color", json!({"layer":"Paths", "color":"#3684B5"})).unwrap(); + h.state_mut().run("map.setBearing", json!({"bearing":27.})).unwrap(); + assert_ne!(h.state().session.revision, revision); + h.step(); + assert!(h.state().map.vertex_drag.is_some()); + let depth = h.state().session.undo_depth(); + release(&mut h, to); + assert_ne!(first(&h), c(0., 0.)); + assert_eq!(h.state().session.undo_depth(), depth + 1); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(first(&h), c(0., 0.)); +} + +/// Normal movement and insertion stay read-only until release, commit once, and undo/redo once. +#[test] +fn unchanged_source_commits_once_and_undoes_once() { + for insert in [false, true] { + let mut h = harness(); + let before = h.state().session.map().layers[0].dataset.features[0].geometry.clone(); + let to = drag(&mut h, insert); + assert_eq!(h.state().session.map().layers[0].dataset.features[0].geometry, before); + assert_eq!(h.state().session.undo_depth(), 0); + release(&mut h, to); + let after = h.state().session.map().layers[0].dataset.features[0].geometry.clone(); + assert_ne!(after, before); + assert_eq!(h.state().session.undo_depth(), 1); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(h.state().session.map().layers[0].dataset.features[0].geometry, before); + h.state_mut().run("edit.redo", json!(null)).unwrap(); + assert_eq!(h.state().session.map().layers[0].dataset.features[0].geometry, after); + } +} + +/// Returning to the source node before release must still leave history unchanged. +#[test] +fn returning_to_origin_keeps_noop_semantics() { + let mut h = harness(); + drag(&mut h, false); + let origin = screen(&h, c(0., 0.)); + h.event(Event::PointerMoved(origin)); + h.step(); + release(&mut h, origin); + assert_eq!(first(&h), c(0., 0.)); + assert_eq!(h.state().session.undo_depth(), 0); +} + +/// The owner guard preserves the independent ends of a closed line and the alias of a polygon ring's closure. +#[test] +fn closed_line_and_polygon_keep_their_existing_closure_semantics() { + for polygon in [false, true] { + let path = vec![c(0., 0.), c(0., 160.), c(160., 160.), c(160., 0.), c(0., 0.)]; + let source = if polygon { Geometry::Polygon(vec![vec![path]]) } else { Geometry::Line(vec![path]) }; + let mut h = harness_with(source.clone()); + let to = drag(&mut h, false); + let preview = h.state().map.vertex_drag.unwrap(); + release(&mut h, to); + let actual = h.state().session.map().layers[0].dataset.features[0].geometry.as_ref().unwrap(); + assert_eq!(actual.vertex(VertexId::new(0, 0, 0)), Some(preview.at)); + match actual { + Geometry::Polygon(parts) => assert_eq!(parts[0][0].first(), parts[0][0].last()), + Geometry::Line(parts) => assert_eq!(parts[0].last(), Some(&c(0., 0.))), + _ => panic!("fixture changed kind"), + } + assert_eq!(h.state().session.undo_depth(), 1); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(h.state().session.map().layers[0].dataset.features[0].geometry, Some(source)); + } +} From b3db9ffbbcdda4d2f3bf20c72e32e4fa50325cd0 Mon Sep 17 00:00:00 2001 From: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:41:07 +0800 Subject: [PATCH 2/2] test(edit): cover map rename during vertex drags --- crates/ui-egui/src/vertexedit.rs | 1 + crates/ui-egui/tests/vertex_drag_ownership.rs | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/vertexedit.rs b/crates/ui-egui/src/vertexedit.rs index f5d7ea9da..90b53e8dd 100644 --- a/crates/ui-egui/src/vertexedit.rs +++ b/crates/ui-egui/src/vertexedit.rs @@ -212,6 +212,7 @@ pub fn drag_to(app: &mut RustGisApp, rect: Rect, p: Pos2) { /// Letting go commits a still-owned move or insertion as one edit. A queued release after a /// source/context change is discarded, preserving the current geometry and undo/redo history. pub fn drag_stop(app: &mut RustGisApp) { + // Cancellation also drops the pending Vertex pointer action, swallowing a stale release. cancel_stale_drag(app); let Some(d) = app.map.vertex_drag.take() else { return }; app.map.vertex_drag_owner = None; diff --git a/crates/ui-egui/tests/vertex_drag_ownership.rs b/crates/ui-egui/tests/vertex_drag_ownership.rs index 8ad1d19ed..92777f9aa 100644 --- a/crates/ui-egui/tests/vertex_drag_ownership.rs +++ b/crates/ui-egui/tests/vertex_drag_ownership.rs @@ -121,6 +121,7 @@ fn undo_during_drag_rejects_queued_release() { assert_eq!(h.state().session.undo_depth(), 0); assert!(h.state().session.can_redo()); } + /// Redo replaces rows too; the pending release must preserve the redone geometry and history. #[test] fn redo_during_drag_rejects_queued_release() { @@ -165,6 +166,31 @@ fn activating_cloned_map_rejects_queued_release() { assert!(h.state().map.vertex_drag.is_none()); } +/// Renaming in place cancels movement and insertion even when rows, map slot, project and +/// history are unchanged; the queued release must preserve the whole source geometry. +#[test] +fn renaming_active_map_rejects_queued_release() { + for insert in [false, true] { + let mut h = harness(); + let rows = h.state().session.map().layers[0].dataset.features.allocation_key(); + let map = h.state().session.project.active_map; + let project = h.state().session.opened; + let before = h.state().session.map().layers[0].dataset.features[0].geometry.clone(); + let to = drag(&mut h, insert); + // Isolate the rename-only branch rather than introducing a row edit or an undo step. + h.state_mut().session.map_mut().name = "Renamed".into(); + assert!(rows.ptr_eq(&h.state().session.map().layers[0].dataset.features.allocation_key())); + assert_eq!(h.state().session.project.active_map, map); + assert_eq!(h.state().session.opened, project); + h.step(); + assert!(h.state().map.vertex_drag.is_none()); + assert!(h.state().map.vertex.is_none()); + release(&mut h, to); + assert_eq!(h.state().session.map().layers[0].dataset.features[0].geometry, before); + assert_eq!(h.state().session.undo_depth(), 0); + } +} + /// Deleting the original map can replace its slot with a clone sharing the same source allocation. #[test] fn replacing_map_slot_with_clone_rejects_queued_release() {