Integrate allocation drag candidate with guarded input and atomic history

This commit is contained in:
2026-09-12 03:47:45 -07:00
parent 0bd7886608
commit f52d81c1b9
19 changed files with 5067 additions and 20 deletions
@@ -0,0 +1,601 @@
//! Allocation work-body input admission. Gesture authority lives here, while
//! the canvas retains ordered native pointer positions and frozen hit geometry.
use super::{App, Message, Page};
use crate::{allocation_drag as plan, route_edit::Context};
use iced::{Point, Rectangle, Task, Vector};
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
use std::{collections::BTreeMap, sync::Arc};
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stamp {
pub context: Context,
pub epoch: u64,
pub snapshot: usize,
pub bounds: Rectangle,
pub pan: Vector,
pub zoom: f32,
}
impl Stamp {
pub fn world(self, screen: Point) -> Point {
Point::new(
(screen.x - self.bounds.x - self.pan.x) / self.zoom,
(screen.y - self.bounds.y - self.pan.y) / self.zoom,
)
}
pub fn viewport(self) -> plan::Rect {
let p = self.world(Point::new(self.bounds.x, self.bounds.y));
plan::Rect {
position: Position { x: p.x, y: p.y },
size: Size {
width: self.bounds.width / self.zoom,
height: self.bounds.height / self.zoom,
},
}
}
}
#[derive(Debug, Clone)]
pub struct Target {
pub element: Id,
pub placement: Id,
pub region: Rectangle,
pub label: plan::Rect,
pub name: String,
pub ancestors: Vec<Id>,
}
#[derive(Debug, Clone)]
pub struct Frozen {
pub targets: Vec<Target>,
cards: Vec<Rectangle>,
controls: Vec<Rectangle>,
edges: Vec<(Point, Point)>,
radius: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Hit {
Destination(plan::Destination, Option<usize>),
OutsideViewport,
Occluded,
Ambiguous(Vec<usize>),
}
pub fn contains(r: Rectangle, p: Point) -> bool {
p.x >= r.x && p.y >= r.y && p.x < r.x + r.width && p.y < r.y + r.height
}
impl Frozen {
pub fn capture(
project: &Project,
scope: &plan::Scope,
view: &ProjectedView,
routed: &RoutedView,
zoom: f32,
) -> Self {
let mut targets = Vec::new();
let mut cards = Vec::new();
for n in &view.nodes {
if scope.placements.contains_key(&n.id) {
continue;
}
let region = Rectangle::new(
Point::new(n.position.x, n.position.y),
iced::Size::new(n.size.width, n.size.height),
);
let Some(e) = project.elements.get(&n.element) else {
cards.push(region);
continue;
};
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
let mut ancestors = Vec::new();
let mut parent = e.parent;
while let Some(id) = parent {
if ancestors.contains(&id) {
break;
}
ancestors.push(id);
parent = project.elements.get(&id).and_then(|e| e.parent);
}
// This is also the exact frozen label painted above the ghost.
let name = crate::views::short(
project.named(e.id),
(((region.width - 28.).max(10.) / 10.) as usize).min(34),
);
let label = plan::Rect {
position: Position {
x: region.x + 10.,
y: region.y + 31.,
},
size: Size {
width: (name.chars().count() as f32 * 10. + 8.)
.min((region.width - 20.).max(1.)),
height: 22.,
},
};
let collapsed = scope.diagram.nodes[&n.id].collapsed;
targets.push(Target {
element: e.id,
placement: n.id,
region: if collapsed {
Rectangle {
height: region.height.min(62.),
..region
}
} else {
region
},
label,
name,
ancestors,
});
} else {
cards.push(region);
}
}
let radius = (10. / zoom).max(10.);
let mut controls: Vec<_> = view
.ports
.iter()
.filter(|port| !scope.placements.contains_key(&port.owner))
.map(|port| {
Rectangle::new(
Point::new(port.position.x - radius, port.position.y - radius),
iced::Size::new(radius * 2., radius * 2.),
)
})
.collect();
let mut edges = Vec::new();
for edge in routed.edges.values() {
if let Some(relation) = project.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, edge) {
if !end.label.text.is_empty() {
controls.push(Rectangle::new(
Point::new(end.label.bounds.position.x, end.label.bounds.position.y),
iced::Size::new(
end.label.bounds.size.width,
end.label.bounds.size.height,
),
));
}
}
}
if !edge.label.text.is_empty() {
controls.push(Rectangle::new(
Point::new(edge.label.bounds.position.x, edge.label.bounds.position.y),
iced::Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
));
}
for pin in &edge.pins {
controls.push(Rectangle::new(
Point::new(pin.x - 8. / zoom, pin.y - 8. / zoom),
iced::Size::new(16. / zoom, 16. / zoom),
));
}
for points in edge.points.windows(2) {
edges.push((
Point::new(points[0].x, points[0].y),
Point::new(points[1].x, points[1].y),
));
}
}
Self {
targets,
cards,
controls,
edges,
radius: 6. / zoom,
}
}
pub fn resolve(&self, stamp: Stamp, screen: Option<Point>, arrange: bool) -> Hit {
let Some(screen) = screen.filter(|p| contains(stamp.bounds, *p)) else {
return Hit::OutsideViewport;
};
let p = stamp.world(screen);
// Drag status lives in the toolbar, outside this actual canvas viewport.
if self
.controls
.iter()
.chain(&self.cards)
.any(|r| contains(*r, p))
|| self.edges.iter().any(|(a, b)| {
let d = *b - *a;
let length = d.x * d.x + d.y * d.y;
let t = if length == 0. {
0.
} else {
(((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length).clamp(0., 1.)
};
p.distance(*a + d * t) <= self.radius
})
{
return Hit::Occluded;
}
if arrange {
return Hit::Destination(plan::Destination::LayoutOnly, None);
}
let hits: Vec<_> = self
.targets
.iter()
.enumerate()
.filter(|(_, t)| contains(t.region, p))
.map(|(i, _)| i)
.collect();
for (i, a) in hits.iter().enumerate() {
for b in &hits[i + 1..] {
let a = &self.targets[*a];
let b = &self.targets[*b];
if !a.ancestors.contains(&b.element) && !b.ancestors.contains(&a.element) {
return Hit::Ambiguous(hits);
}
}
}
match hits
.into_iter()
.max_by_key(|i| self.targets[*i].ancestors.len())
{
Some(i) => Hit::Destination(
plan::Destination::Performer(self.targets[i].element),
Some(i),
),
None => Hit::Destination(plan::Destination::OutsidePerformers, None),
}
}
}
#[derive(Debug, Clone)]
pub struct Press {
pub stamp: Stamp,
pub token: Id,
pub start: Point,
pub scope: plan::Scope,
pub frozen: Frozen,
pub arrange: bool,
}
#[derive(Debug, Clone)]
pub enum Event {
Press(Arc<Press>),
Preview(Stamp, Id, Option<Point>),
Release(Stamp, Id, Option<Point>),
Refused(u64, String),
Cancel(Id),
ArrangeNext,
Unfocused,
KeyboardBoundary,
}
pub struct Preview {
pub token: Id,
pub semantic: Arc<plan::SemanticPlan>,
pub placement: plan::PlacementPlan,
pub routed: Arc<RoutedView>,
}
struct Active {
press: Arc<Press>,
ids: BTreeMap<Id, Id>,
cache: Vec<(
plan::Destination,
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
)>,
}
pub struct Feedback {
pub press: Arc<Press>,
pub targets: Vec<usize>,
pub text: String,
pub refused: bool,
}
#[derive(Default)]
pub struct State {
pub epoch: u64,
pub arrange_next: bool,
active: Option<Active>,
pub preview: Option<Arc<Preview>>,
pub feedback: Option<Feedback>,
}
#[derive(Clone, Copy)]
pub struct Capability<'a> {
pub state: &'a State,
pub clean: bool,
pub snapshot: usize,
}
impl State {
fn retire(&mut self) {
self.epoch = self.epoch.wrapping_add(1);
self.active = None;
self.preview = None;
self.feedback = None;
self.arrange_next = false;
}
}
pub fn refusal(r: &plan::Refusal) -> String {
match r {
plan::Refusal::Ineligible(s) | plan::Refusal::InvalidCandidate(s) | plan::Refusal::LayoutRequired(s) => s.clone(),
plan::Refusal::StaleScope => "The view changed. Start this drag again.".into(),
plan::Refusal::MixedLayoutRequired => "This work has a distributed or mixed assignment. Drag a specific leaf; arranging this group needs a layout step.".into(),
plan::Refusal::RoleExitRequired { message, .. } => message.clone(),
plan::Refusal::MissingAllocationId(_) => "Assignment identity expired. Start this drag again.".into(),
plan::Refusal::CollateralChangeRequired(records) => format!(
"Assignment needs dependent edits to {}. Resolve these before dragging again.",
records.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ")
),
}
}
impl App {
pub fn allocation_drag_clean(&self) -> bool {
self.allocation_input_owner()
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
}
pub(crate) fn allocation_input_owner(&self) -> bool {
self.page == Page::Diagram
&& self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
&& self.architecture.is_none()
&& self.connection.is_none()
&& self.model_menu.is_none()
&& !self.reference_input_active()
&& !self.busy
&& !self.hosted.visible
&& self.workflow.is_none()
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
}
pub fn allocation_capability(&self) -> Capability<'_> {
Capability {
state: &self.allocation_drag,
clean: self.allocation_drag_clean(),
snapshot: Arc::as_ptr(&self.document.snapshot()) as usize,
}
}
fn allocation_stamp_valid(&self, stamp: Stamp) -> bool {
self.allocation_drag_clean()
&& self.route_context() == Some(stamp.context)
&& self.allocation_drag.epoch == stamp.epoch
&& Arc::as_ptr(&self.document.snapshot()) as usize == stamp.snapshot
&& self.pan == stamp.pan
&& self.zoom == stamp.zoom
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
&& stamp.bounds.width > 0.
&& stamp.bounds.height > 0.
}
/// Called before general input dispatch. Even equal-valued actions (Fit,
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
pub(super) fn allocation_before(&mut self, message: &Message) {
if !matches!(
message,
Message::WorkDrag(_)
| Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::CanvasResized(_)
| Message::WindowResized(_)
| Message::SourceViewport
) {
self.allocation_drag.retire();
}
}
pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> {
(
self.route_context(),
self.pan,
self.zoom,
self.canvas_size,
self.window_size,
self.allocation_drag_clean(),
Arc::as_ptr(&self.document.snapshot()) as usize,
)
}
pub(super) fn allocation_retire(&mut self) {
self.allocation_drag.retire();
}
fn select_work_clean(&mut self, press: &Press) -> bool {
if !self.allocation_stamp_valid(press.stamp) {
return false;
}
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.selected_bend = None;
self.refresh_draft();
true
}
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::ArrangeNext => {
self.allocation_drag.retire();
if self.allocation_drag_clean() {
self.allocation_drag.arrange_next = true;
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
} else {
self.status = FINISH_EDIT.into();
}
}
Event::Unfocused => self.allocation_drag.retire(),
Event::KeyboardBoundary => {
if self.allocation_drag.active.is_some() {
self.allocation_drag.retire();
}
}
Event::Cancel(token) => {
if self
.allocation_drag
.active
.as_ref()
.is_some_and(|a| a.press.token == token)
{
self.allocation_drag.retire();
self.status = "Work drag canceled".into();
}
}
Event::Refused(epoch, text) => {
if epoch == self.allocation_drag.epoch {
self.allocation_drag.retire();
self.status = text;
}
}
Event::Press(press) => {
if self.allocation_drag.active.is_some() {
return Task::none();
}
if !self.allocation_stamp_valid(press.stamp)
|| press.arrange != self.allocation_drag.arrange_next
{
self.status = if self.allocation_drag_clean() {
"The view changed. Start this drag again.".into()
} else {
FINISH_EDIT.into()
};
return Task::none();
}
let checked = plan::capture(
self.project(),
press.scope.diagram.id,
press.scope.work,
press.scope.placement,
);
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
self.status = checked
.err()
.as_ref()
.map(refusal)
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
self.allocation_drag.retire();
return Task::none();
}
let ids = press
.scope
.leaves
.keys()
.map(|id| (*id, Id::new_v4()))
.collect();
self.allocation_drag.arrange_next = false;
self.allocation_drag.preview = None;
self.allocation_drag.feedback = None;
self.allocation_drag.active = Some(Active {
press,
ids,
cache: Vec::new(),
});
}
Event::Preview(stamp, token, pointer) => {
self.allocation_motion(stamp, token, pointer, false)
}
Event::Release(stamp, token, pointer) => {
self.allocation_motion(stamp, token, pointer, true)
}
}
Task::none()
}
fn allocation_motion(
&mut self,
stamp: Stamp,
token: Id,
pointer: Option<Point>,
release: bool,
) {
if self
.allocation_drag
.active
.as_ref()
.is_none_or(|a| a.press.token != token)
{
return; // An old packet cannot cancel a newer gesture.
}
if !self.allocation_stamp_valid(stamp) {
self.allocation_drag.retire();
self.status = if self.allocation_drag_clean() {
"The view changed. Start this drag again.".into()
} else {
FINISH_EDIT.into()
};
return;
}
let Some(mut active) = self.allocation_drag.active.take() else {
return;
};
if active.press.token != token || active.press.stamp != stamp {
self.allocation_drag.active = Some(active);
return;
}
let press = active.press.clone();
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
let mut targets = Vec::new();
let mut preview = None;
let result: Result<String, String> = match hit {
Hit::OutsideViewport => Err("Work drag canceled outside the canvas.".into()),
_ if !moved => Ok("Work selected".into()),
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
Hit::Ambiguous(indices) => {
targets = indices;
Err("Two unrelated performers overlap here. Use an exposed region.".into())
}
Hit::Destination(destination, target) => {
targets.extend(target);
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
result.clone()
} else {
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
active.cache.push((destination, result.clone()));
result
};
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
if stamp.zoom * 16. < 9. {
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
}
let pointer = pointer.unwrap();
let delta = pointer - press.start;
let original = press.scope.placements[&press.scope.placement].position;
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
let presentation = plan::PresentationContext {
viewport: stamp.viewport(), zoom: stamp.zoom,
target: target.map(|i| {
let t = &press.frozen.targets[i];
plan::TargetPresentation { placement: t.placement, label: t.label }
}),
};
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
};
if release {
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
if let Some(command) = command {
// Bypass applying selectors and unrelated repair helpers. The
// planner's exact Batch enters the authoritative source pipeline.
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
self.sync();
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.refresh_draft();
}
} else {
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
}
Ok(label)
})
}
};
let refused = result.is_err();
let text = result.unwrap_or_else(|text| text);
self.status = text.clone();
if release {
self.allocation_drag.retire();
} else {
self.allocation_drag.active = Some(active);
self.allocation_drag.preview = preview;
self.allocation_drag.feedback = Some(Feedback {
press,
targets,
text,
refused,
});
}
}
}
#[cfg(test)]
#[path = "allocation_drag_tests.rs"]
mod tests;
@@ -0,0 +1,290 @@
//! Actual App root, retained Iced tree, property widgets and production renderer.
//! Unlike packet-only tests, these prove the next key still reaches the field.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{Event as Input, keyboard};
fn character(text: &str, modifiers: keyboard::Modifiers) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(text.into()),
modified_key: keyboard::Key::Character(text.into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
location: keyboard::Location::Standard,
modifiers,
text: Some(text.into()),
repeat: false,
})
}
async fn mount() -> (Fixture, Mounted) {
let mut f = fixture(Layer::Oa);
f.app.window_size = iced::Size::new(2000., 1400.);
let _ = f.app.update(Message::Select(f.work));
let mut mounted = Mounted::new(&mut f.app).await;
// First sensor measurement intentionally Fits; now use a fixed known camera.
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
f.app.fit_to_view = false;
mounted.settle(&mut f.app).await;
(f, mounted)
}
fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
mounted
.geometry(app)
.bounds(crate::allocation_input::CANVAS_ID)
}
fn at(mounted: &mut Mounted, f: &Fixture, world: Position) -> Point {
let bounds = canvas_bounds(mounted, &f.app);
Point::new(
bounds.x + world.x * f.app.zoom + f.app.pan.x,
bounds.y + world.y * f.app.zoom + f.app.pan.y,
)
}
fn name_field(mounted: &mut Mounted, app: &App) -> Rectangle {
mounted
.geometry(app)
.text_inputs
.into_iter()
.filter(|b| b.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("actual inspector Name TextInput")
}
fn description_field(mounted: &mut Mounted, app: &App) -> Rectangle {
mounted
.geometry(app)
.focused
.into_iter()
.map(|(_, bounds, _)| bounds)
.filter(|b| b.x > app.window_size.width - 320. && b.height >= 90.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("actual inspector Description TextEditor")
}
fn is_focused(mounted: &mut Mounted, app: &App, field: Rectangle) -> bool {
mounted
.geometry(app)
.focused
.iter()
.any(|(_, bounds, focused)| *bounds == field && *focused)
}
async fn type_key(mounted: &mut Mounted, app: &mut App, text: &str) -> Vec<Message> {
mounted
.batch(
app,
&[character(text, keyboard::Modifiers::empty())],
mouse::Cursor::Unavailable,
)
.await
}
async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field: Rectangle) {
let before = f.app.document.bundle().clone();
let selected = (f.app.selected, f.app.selected_node);
let point = at(mounted, f, xy(140., 535.));
let messages = mounted.click(&mut f.app, point).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::Refused(..))))
);
assert!(!messages.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
)));
assert_eq!(f.app.document.bundle(), &before);
assert_eq!((f.app.selected, f.app.selected_node), selected);
assert!(
is_focused(mounted, &f.app, field),
"refused press must not reach the outside-press unfocus handler"
);
}
#[tokio::test]
async fn actual_app_tree_dirty_name_retains_focus_and_middle_caret_for_next_key() {
let (mut f, mut mounted) = mount().await;
let field = name_field(&mut mounted, &f.app);
mounted.click(&mut f.app, field.center()).await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::Home)],
mouse::Cursor::Unavailable,
)
.await;
type_key(&mut mounted, &mut f.app, "X").await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::ArrowLeft)],
mouse::Cursor::Unavailable,
)
.await;
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XWork");
refused_click_keeps_field(&mut mounted, &mut f, field).await;
let messages = type_key(&mut mounted, &mut f.app, "Y").await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::Field(path, _) if path == "/name"))
);
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXWork");
assert!(!f.app.document.can_undo());
}
#[tokio::test]
async fn actual_app_tree_dirty_description_retains_focus_and_middle_caret_for_next_key() {
let (mut f, mut mounted) = mount().await;
let field = description_field(&mut mounted, &f.app);
mounted
.click(&mut f.app, Point::new(field.x + 16., field.y + 16.))
.await;
type_key(&mut mounted, &mut f.app, "A").await;
type_key(&mut mounted, &mut f.app, "B").await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::ArrowLeft)],
mouse::Cursor::Unavailable,
)
.await;
assert_eq!(
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
"AB"
);
refused_click_keeps_field(&mut mounted, &mut f, field).await;
let messages = type_key(&mut mounted, &mut f.app, "X").await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::FieldText(path, _) if path == "/description"))
);
assert_eq!(
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
"AXB"
);
assert!(!f.app.document.can_undo());
}
#[tokio::test]
async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty_owner_refuses() {
let (mut f, mut mounted) = mount().await;
let shortcut = character("a", keyboard::Modifiers::ALT);
let messages = mounted
.batch(&mut f.app, &[shortcut.clone()], mouse::Cursor::Unavailable)
.await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::ArrangeNext)))
);
assert!(f.app.allocation_drag.arrange_next);
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::Escape)],
mouse::Cursor::Unavailable,
)
.await;
assert!(!f.app.allocation_drag.arrange_next);
mounted
.batch(&mut f.app, &[shortcut.clone()], mouse::Cursor::Unavailable)
.await;
let from = at(&mut mounted, &f, xy(140., 535.));
let to = from + Vector::new(30., 0.);
let messages = mounted
.batch(
&mut f.app,
&[
Input::Mouse(mouse::Event::CursorMoved { position: from }),
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Input::Mouse(mouse::Event::CursorMoved { position: to }),
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(to),
)
.await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange))
);
assert!(!f.app.allocation_drag.arrange_next);
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
assert!(f.app.project().relations.is_empty());
let field = name_field(&mut mounted, &f.app);
mounted.click(&mut f.app, field.center()).await;
type_key(&mut mounted, &mut f.app, "X").await;
let raw = f.app.draft.as_ref().unwrap().inputs["/name"].raw.clone();
let before = f.app.document.bundle().clone();
mounted
.batch(&mut f.app, &[shortcut], mouse::Cursor::Unavailable)
.await;
assert!(!f.app.allocation_drag.arrange_next);
assert_eq!(f.app.status, FINISH_EDIT);
assert!(is_focused(&mut mounted, &f.app, field));
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, raw);
assert_eq!(f.app.document.bundle(), &before);
}
fn canvas_pixels(pixels: &[u8], width: usize, canvas: Rectangle) -> Vec<u8> {
let x = canvas.x.ceil() as usize;
let y = canvas.y.ceil() as usize;
let right = (canvas.x + canvas.width).floor() as usize;
let bottom = (canvas.y + canvas.height).floor() as usize;
(y..bottom)
.flat_map(|row| {
pixels[(row * width + x) * 4..(row * width + right) * 4]
.iter()
.copied()
})
.collect()
}
#[tokio::test]
async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_changes() {
let (mut f, mut mounted) = mount().await;
let mut project = f.app.project().clone();
let target = project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == f.a)
.unwrap();
target.position.y = -41.;
target.size.height = 300.;
replace(&mut f, project);
f.app.zoom = 0.75;
f.app.pan = Vector::new(15., 15.);
let from = at(&mut mounted, &f, xy(140., 535.));
let to = at(&mut mounted, &f, xy(200., 80.));
mounted
.batch(
&mut f.app,
&[
Input::Mouse(mouse::Event::CursorMoved { position: from }),
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Input::Mouse(mouse::Event::CursorMoved { position: to }),
],
mouse::Cursor::Available(to),
)
.await;
let feedback = f
.app
.allocation_drag
.feedback
.as_ref()
.expect("active frozen destination");
assert!(!feedback.refused, "{}", feedback.text);
let target = &feedback.press.frozen.targets[feedback.targets[0]];
assert!(target.label.position.y * f.app.zoom + f.app.pan.y < 14.);
let canvas = canvas_bounds(&mut mounted, &f.app);
let first = mounted.pixels(&f.app);
f.app.allocation_drag.feedback.as_mut().unwrap().text =
"Very different drag status, retained outside the readable canvas. ".repeat(10);
let second = mounted.pixels(&f.app);
assert_ne!(first, second, "the actual toolbar status changes");
assert_eq!(
canvas_pixels(&first, f.app.window_size.width as usize, canvas),
canvas_pixels(&second, f.app.window_size.width as usize, canvas),
"status must not obscure any candidate header or frozen target label"
);
}
@@ -0,0 +1,955 @@
use super::*;
use crate::diagram::{DiagramCanvas, State as CanvasState};
use iced::{
mouse,
widget::canvas::{self, Program},
};
#[path = "allocation_drag_full_tree_tests.rs"]
mod full_tree;
struct Fixture {
app: App,
a: Id,
b: Id,
work: Id,
node: Id,
diagram: Id,
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(20., 30.), iced::Size::new(2200., 1400.))
}
fn screen(p: Position) -> Point {
Point::new(bounds().x + p.x, bounds().y + p.y)
}
fn fixture(layer: Layer) -> Fixture {
let mut p = Project::empty();
let (performer, other, work) = if layer == Layer::Oa {
(Kind::Entity, Kind::Actor, Kind::Activity)
} else {
(Kind::System, Kind::SystemActor, Kind::Function)
};
let a = p.add_element(performer, layer, "A", None).unwrap();
let b = p.add_element(other, layer, "B", None).unwrap();
let work = p.add_element(work, layer, "Work", None).unwrap();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
p.place(diagram, a, xy(100., 100.));
p.place(diagram, b, xy(900., 100.));
let node = p.place(diagram, work, xy(100., 500.));
let mut app = App::with_project(p);
app.active_diagram = Some(diagram);
app.zoom = 1.;
app.pan = Vector::ZERO;
app.fit_to_view = false;
Fixture {
app,
a,
b,
work,
node,
diagram,
}
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
diagram: app.active_diagram.unwrap(),
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
}
}
fn pointer(
canvas: &DiagramCanvas<'_>,
state: &mut CanvasState,
event: mouse::Event,
final_cursor: Point,
) -> Option<Message> {
canvas
.update(
state,
&canvas::Event::Mouse(event),
bounds(),
mouse::Cursor::Available(final_cursor),
)
.and_then(|a| a.into_inner().0)
}
fn start(f: &Fixture) -> Point {
let p = f.app.project().diagrams[&f.diagram].nodes[&f.node].position;
screen(xy(p.x + 40., p.y + 35.))
}
fn batch(app: &App, from: Point, to: Point) -> Vec<Message> {
let canvas = canvas(app);
let mut state = CanvasState::default();
[
mouse::Event::CursorMoved { position: from },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: to },
mouse::Event::ButtonReleased(mouse::Button::Left),
]
.into_iter()
.filter_map(|e| pointer(&canvas, &mut state, e, to))
.collect()
}
fn send(app: &mut App, events: Vec<Message>) {
for e in events {
let _ = app.update(e);
}
}
fn press(f: &Fixture) -> Arc<Press> {
batch(&f.app, start(f), start(f))
.into_iter()
.find_map(|m| match m {
Message::WorkDrag(Event::Press(p)) => Some(p),
_ => None,
})
.expect("actual clean work-body press")
}
fn drag(f: &mut Fixture, to: Point) {
let events = batch(&f.app, start(f), to);
assert!(matches!(
events.first(),
Some(Message::WorkDrag(Event::Press(_)))
));
assert!(events.iter().all(|m| matches!(m, Message::WorkDrag(_))));
send(&mut f.app, events);
}
#[test]
fn mounted_oa_sa_ordered_incoming_between_outgoing_are_exact_single_history_steps() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let original = f.app.project().clone();
let original_source = f.app.document.bundle().clone();
drag(&mut f, screen(xy(160., 140.)));
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
let relation = f
.app
.project()
.relations
.values()
.find(|r| r.kind() == RelationKind::Allocation)
.unwrap()
.clone();
assert_eq!((relation.source(), relation.target()), (f.a, f.work));
assert_eq!(f.app.project().elements, original.elements);
let after_in = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.document.bundle(), &original_source);
assert!(!f.app.document.can_undo());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.document.bundle(), &after_in);
drag(&mut f, screen(xy(960., 140.)));
let reassigned = &f.app.project().relations[&relation.id];
assert_eq!(reassigned.source(), f.b, "{}", f.app.status);
assert_eq!(reassigned.name, relation.name);
assert_eq!(reassigned.description, relation.description);
let before_out = f.app.document.bundle().clone();
drag(&mut f, screen(xy(600., 700.)));
assert!(
!f.app.project().relations.contains_key(&relation.id),
"{}",
f.app.status
);
assert_eq!(f.app.project().elements, original.elements);
let after_out = f.app.document.bundle().clone();
assert_eq!(
oa_core::source_project::compile_bundle(&after_out, Default::default())
.unwrap()
.decoded
.project,
*f.app.project()
);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.document.bundle(), &before_out);
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.document.bundle(), &after_out);
}
}
#[test]
fn mounted_clean_stationary_selects_exact_work_without_revision_or_legacy_apply() {
let mut f = fixture(Layer::Oa);
let before = f.app.document.snapshot();
let events = batch(&f.app, start(&f), start(&f));
send(&mut f.app, events);
assert_eq!(f.app.selected, f.work);
assert_eq!(f.app.selected_node, Some(f.node));
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(!f.app.document.can_undo());
assert!(f.app.allocation_drag.active.is_none());
}
#[test]
fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
for same_selection in [false, true] {
let mut f = fixture(Layer::Oa);
if same_selection {
let _ = f.app.update(Message::Select(f.work));
}
let _ = f.app.update(Message::Field(
"/name".into(),
"Keep my unfinished name".into(),
));
let selection = (f.app.selected, f.app.selected_node);
let before = f.app.document.snapshot();
let dirty = f.app.draft.as_ref().unwrap().value.clone();
let input = &f.app.draft.as_ref().unwrap().inputs["/name"];
let raw = input.raw.clone();
let cursor = input.editor.cursor();
let events = batch(&f.app, start(&f), screen(xy(160., 140.)));
assert!(matches!(
events.first(),
Some(Message::WorkDrag(Event::Refused(..)))
));
assert!(!events.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
)));
send(&mut f.app, events);
assert_eq!((f.app.selected, f.app.selected_node), selection);
assert_eq!(f.app.draft.as_ref().unwrap().value, dirty);
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, raw);
assert_eq!(
f.app.draft.as_ref().unwrap().inputs["/name"]
.editor
.cursor(),
cursor
);
assert!(f.app.draft_dirty());
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.status, FINISH_EDIT);
}
}
#[test]
fn source_and_modal_admission_reject_prebuilt_clean_press_without_selecting() {
for mode in 0..4 {
let mut f = fixture(Layer::Oa);
let p = press(&f);
let selection = f.app.selected;
let source = f.app.document.bundle().clone();
match mode {
0 => {
let _ = f.app.update(Message::SourceEdit(
iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Insert('x'),
),
));
}
1 => f.app.busy = true,
2 => f.app.delete_pending = true,
_ => f.app.hosted.visible = true,
}
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
let _ = f.app.update(Message::WorkDrag(Event::Release(
p.stamp,
p.token,
Some(screen(xy(160., 140.))),
)));
assert_eq!(f.app.selected, selection);
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo());
assert!(f.app.allocation_drag.active.is_none());
}
}
#[test]
fn modal_roundtrip_equal_fit_undo_resize_and_focus_retire_press_before_replay() {
for interruption in 0..6 {
let mut f = fixture(Layer::Oa);
let p = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
let before = f.app.document.bundle().clone();
match interruption {
0 => {
f.app.delete_pending = true;
let _ = f.app.update(Message::CancelDelete);
}
1 => {
let _ = f.app.update(Message::Fit);
f.app.zoom = p.stamp.zoom;
f.app.pan = p.stamp.pan;
}
2 => {
let _ = f.app.update(Message::Undo);
}
3 => {
let _ = f
.app
.update(Message::CanvasResized(iced::Size::new(2300., 1400.)));
f.app.canvas_size = None;
}
4 => {
let _ = f.app.update(Message::WorkDrag(Event::Unfocused));
}
_ => {
let _ = f.app.update(Message::Escape);
}
}
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
let _ = f.app.update(Message::WorkDrag(Event::Release(
p.stamp,
p.token,
Some(screen(xy(160., 140.))),
)));
assert_eq!(f.app.document.bundle(), &before);
assert!(!f.app.document.can_undo());
assert!(f.app.allocation_drag.active.is_none());
}
}
#[test]
fn stale_packets_do_not_cancel_a_newer_token_and_each_release_is_consumed_once() {
let mut f = fixture(Layer::Oa);
let old = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
let _ = f.app.update(Message::Escape);
let new = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(new.clone())));
let _ = f.app.update(Message::WorkDrag(Event::Preview(
old.stamp,
old.token,
Some(screen(xy(160., 140.))),
)));
assert_eq!(
f.app.allocation_drag.active.as_ref().unwrap().press.token,
new.token
);
let release = Message::WorkDrag(Event::Release(
new.stamp,
new.token,
Some(screen(xy(160., 140.))),
));
let _ = f.app.update(release.clone());
let revision = f.app.document.revision();
let _ = f.app.update(release);
assert_eq!(f.app.document.revision(), revision);
assert_eq!(revision, 1, "{}", f.app.status);
}
#[test]
fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
let mut f = fixture(Layer::Oa);
let _ = f.app.update(Message::Command(Command::Rename(
f.work,
"Temporary".into(),
)));
let _ = f.app.update(Message::Undo);
let before = f.app.document.bundle().clone();
let revision = f.app.document.revision();
let _ = f.app.update(Message::WorkDrag(Event::ArrangeNext));
assert!(f.app.allocation_drag.arrange_next);
let p = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
assert!(p.arrange);
assert!(!f.app.allocation_drag.arrange_next);
let _ = f.app.update(Message::WorkDrag(Event::Release(
p.stamp,
p.token,
Some(p.start + Vector::new(4., 0.)),
)));
assert_eq!(f.app.document.bundle(), &before);
assert_eq!(f.app.document.revision(), revision);
assert!(f.app.document.can_redo());
assert!(f.app.project().relations.is_empty());
assert!(!press(&f).arrange);
}
#[test]
fn outside_release_is_cancel_and_does_not_remove_an_assignment() {
let mut f = fixture(Layer::Oa);
drag(&mut f, screen(xy(160., 140.)));
let before = f.app.document.bundle().clone();
let revision = f.app.document.revision();
drag(
&mut f,
Point::new(bounds().x + bounds().width, bounds().y + 300.),
);
assert_eq!(f.app.document.bundle(), &before);
assert_eq!(f.app.document.revision(), revision);
}
#[test]
fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index() {
let mut f = fixture(Layer::Oa);
let p = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
let a = screen(xy(160., 140.));
let b = screen(xy(960., 140.));
let _ = f
.app
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
let first = f
.app
.allocation_drag
.preview
.as_ref()
.expect(&f.app.status)
.semantic
.clone();
let stable = first
.project()
.relations
.values()
.find(|r| r.kind() == RelationKind::Allocation)
.unwrap()
.id;
let _ = f.app.update(Message::WorkDrag(Event::Preview(
p.stamp,
p.token,
Some(a + Vector::new(1., 0.)),
)));
assert!(Arc::ptr_eq(
&first,
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
));
let _ = f
.app
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(b))));
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
assert!(!Arc::ptr_eq(&first, &preview.semantic));
assert_eq!(preview.semantic.project().relations[&stable].source(), f.b);
assert_eq!(
preview.placement.view,
projection::project_diagram_with_index(
preview.semantic.project(),
&preview.placement.diagram,
preview.semantic.index()
)
);
assert_ne!(
preview.placement.view,
projection::project_diagram_with_index(
f.app.project(),
&preview.placement.diagram,
f.app.document.view_index()
)
);
let _ = f
.app
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
assert!(Arc::ptr_eq(
&first,
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
));
assert_eq!(
f.app.allocation_drag.active.as_ref().unwrap().cache.len(),
2
);
}
#[test]
fn frozen_targets_use_half_open_semantic_priority_and_refuse_incomparable_overlap() {
let f = fixture(Layer::Oa);
let p = press(&f);
let mut frozen = p.frozen.clone();
let ai = frozen
.targets
.iter()
.position(|t| t.element == f.a)
.unwrap();
let bi = frozen
.targets
.iter()
.position(|t| t.element == f.b)
.unwrap();
frozen.targets[ai].region = Rectangle::new(Point::new(100., 100.), iced::Size::new(200., 200.));
frozen.targets[bi].region = Rectangle::new(Point::new(300., 100.), iced::Size::new(200., 200.));
assert!(
matches!(frozen.resolve(p.stamp, Some(screen(xy(300., 110.))), false),
Hit::Destination(plan::Destination::Performer(id), _) if id == f.b)
);
assert!(matches!(
frozen.resolve(p.stamp, Some(screen(xy(500., 110.))), false),
Hit::Destination(plan::Destination::OutsidePerformers, None)
));
frozen.targets[bi].region = Rectangle::new(Point::new(150., 150.), iced::Size::new(50., 50.));
assert!(matches!(
frozen.resolve(p.stamp, Some(screen(xy(160., 160.))), false),
Hit::Ambiguous(_)
));
frozen.targets[bi].ancestors.push(f.a);
assert!(
matches!(frozen.resolve(p.stamp, Some(screen(xy(160., 160.))), false),
Hit::Destination(plan::Destination::Performer(id), _) if id == f.b)
);
frozen.cards.push(Rectangle::new(
Point::new(160., 160.),
iced::Size::new(20., 20.),
));
assert_eq!(
frozen.resolve(p.stamp, Some(screen(xy(160., 160.))), false),
Hit::Occluded
);
assert_eq!(frozen.resolve(p.stamp, None, false), Hit::OutsideViewport);
assert_eq!(
frozen.resolve(
p.stamp,
Some(Point::new(bounds().x + 50., bounds().y + 15.)),
false
),
Hit::Destination(plan::Destination::OutsidePerformers, None)
);
}
#[test]
fn release_uses_ordered_native_position_before_later_motion_in_same_batch() {
let mut f = fixture(Layer::Oa);
let from = start(&f);
let a = screen(xy(160., 140.));
let b = screen(xy(960., 140.));
let mut state = CanvasState::default();
let canvas = canvas(&f.app);
let events: Vec<_> = [
mouse::Event::CursorMoved { position: from },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: a },
mouse::Event::ButtonReleased(mouse::Button::Left),
mouse::Event::CursorMoved { position: b },
]
.into_iter()
.filter_map(|e| pointer(&canvas, &mut state, e, b))
.collect();
send(&mut f.app, events);
assert_eq!(
validation::effective_performer(f.app.project(), f.work),
Some(f.a),
"{}",
f.app.status
);
}
fn replace(f: &mut Fixture, project: Project) {
f.app.document = crate::document::Document::from_project(project, Default::default()).unwrap();
f.app.sync();
}
#[test]
fn same_snapped_position_on_opposite_shared_border_rebuilds_semantics_even_if_layout_refuses() {
let mut f = fixture(Layer::Oa);
let mut project = f.app.project().clone();
let diagram = project.diagrams.get_mut(&f.diagram).unwrap();
diagram
.nodes
.values_mut()
.find(|n| n.element == f.b)
.unwrap()
.position
.x = 324.;
replace(&mut f, project);
let p = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
let left = screen(xy(323.5, 140.));
let right = screen(xy(324.5, 140.));
assert_eq!(
((left.x - p.start.x + 100.) / 10.).round(),
((right.x - p.start.x + 100.) / 10.).round()
);
for at in [left, right] {
let _ = f.app.update(Message::WorkDrag(Event::Preview(
p.stamp,
p.token,
Some(at),
)));
}
let active = f.app.allocation_drag.active.as_ref().unwrap();
assert_eq!(active.cache.len(), 2);
let a = active
.cache
.iter()
.find(|(d, _)| *d == plan::Destination::Performer(f.a))
.unwrap()
.1
.as_ref()
.unwrap();
let b = active
.cache
.iter()
.find(|(d, _)| *d == plan::Destination::Performer(f.b))
.unwrap()
.1
.as_ref()
.unwrap();
assert!(!Arc::ptr_eq(a, b));
let stable = a
.project()
.relations
.values()
.find(|r| r.kind() == RelationKind::Allocation)
.unwrap()
.id;
assert_eq!(b.project().relations[&stable].source(), f.b);
assert!(f.app.project().relations.is_empty());
}
#[test]
fn source_refusal_is_atomic_and_a_new_gesture_can_retry_against_live_revision() {
let mut f = fixture(Layer::Oa);
let before = f.app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes = before.files.values().map(String::len).sum();
f.app.document = crate::document::Document::from_bundle(before.clone(), limits).unwrap();
f.app.sync();
let snapshot = f.app.document.snapshot();
drag(&mut f, screen(xy(160., 140.)));
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
assert_eq!(f.app.document.bundle(), &before);
assert!(!f.app.document.can_undo());
assert!(f.app.status.contains("retry"), "{}", f.app.status);
assert!(f.app.allocation_drag.active.is_none());
f.app.document = crate::document::Document::from_bundle(before, Default::default()).unwrap();
f.app.sync();
drag(&mut f, screen(xy(160., 140.)));
assert_eq!(
validation::effective_performer(f.app.project(), f.work),
Some(f.a),
"{}",
f.app.status
);
assert_eq!(f.app.document.revision(), 1);
}
#[test]
fn mounted_role_only_outgoing_refusal_retains_placement_and_same_performer_creates_no_allocation() {
let mut f = fixture(Layer::Oa);
let mut project = f.app.project().clone();
let role = project
.add_element(Kind::Role, Layer::Oa, "Responder", None)
.unwrap();
project.relate(RelationKind::RoleAssignment, "Responsible", role, f.work);
project.relate(RelationKind::RoleRealization, "Realizes", f.a, role);
project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&f.node)
.unwrap()
.position = xy(150., 200.);
replace(&mut f, project);
let relations = f.app.project().relations.clone();
let source = f.app.document.bundle().clone();
drag(&mut f, screen(xy(650., 700.)));
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo());
assert!(
f.app.status.to_lowercase().contains("role"),
"{}",
f.app.status
);
let target = press(&f)
.frozen
.targets
.iter()
.find(|t| t.element == f.a)
.unwrap()
.region;
drag(&mut f, screen(xy(target.x + 45., target.y + 40.)));
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
assert_eq!(f.app.project().relations, relations);
assert!(
!f.app
.project()
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation)
);
}
#[test]
fn mounted_uniform_parent_moves_hidden_placements_and_assigns_only_leaves() {
let mut f = fixture(Layer::Oa);
let mut project = f.app.project().clone();
let child = project
.add_element(Kind::Activity, Layer::Oa, "Child", Some(f.work))
.unwrap();
let hidden = project.place(f.diagram, child, xy(150., 600.));
project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&f.node)
.unwrap()
.collapsed = true;
replace(&mut f, project);
let before = f.app.project().diagrams[&f.diagram].nodes.clone();
drag(&mut f, screen(xy(960., 140.)));
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
let after = &f.app.project().diagrams[&f.diagram].nodes;
let dx = after[&f.node].position.x - before[&f.node].position.x;
let dy = after[&f.node].position.y - before[&f.node].position.y;
assert_eq!(
after[&hidden].position,
xy(
before[&hidden].position.x + dx,
before[&hidden].position.y + dy
)
);
assert!(after[&f.node].collapsed);
let assigned: Vec<_> = f
.app
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::Allocation)
.collect();
assert_eq!(assigned.len(), 1);
assert_eq!((assigned[0].source(), assigned[0].target()), (f.b, child));
assert_eq!(f.app.project().elements[&child].parent, Some(f.work));
}
#[test]
fn mounted_dirty_port_press_cannot_fall_through_to_applying_port_selection() {
let mut f = fixture(Layer::Sa);
let mut project = f.app.project().clone();
let port = Id::new_v4();
project.elements.insert(
port,
make_port(
&project,
port,
f.work,
"Send",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap(),
);
replace(&mut f, project);
let view = projection::project_view(f.app.project(), f.diagram);
let at = screen(
view.ports
.iter()
.find(|p| p.element == port)
.unwrap()
.position,
);
let _ = f
.app
.update(Message::Field("/name".into(), "Keep dirty name".into()));
let selection = f.app.selected;
let before = f.app.document.bundle().clone();
let events = batch(&f.app, at, at + Vector::new(30., 0.));
assert!(matches!(
events.first(),
Some(Message::WorkDrag(Event::Refused(..)))
));
send(&mut f.app, events);
assert_eq!(f.app.selected, selection);
assert_eq!(f.app.document.bundle(), &before);
assert!(f.app.draft_dirty());
}
#[test]
fn frozen_collapsed_target_uses_only_visible_header_and_port_edge_controls_occlude() {
let mut f = fixture(Layer::Oa);
let mut project = f.app.project().clone();
project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == f.a)
.unwrap()
.collapsed = true;
replace(&mut f, project);
let p = press(&f);
let mut frozen = p.frozen.clone();
let t = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
let bottom = xy(t.region.x + 30., t.region.y + t.region.height);
assert!(t.region.height <= 62.);
assert_eq!(
frozen.resolve(p.stamp, Some(screen(bottom)), false),
Hit::Destination(plan::Destination::OutsidePerformers, None)
);
frozen.controls.push(Rectangle::new(
Point::new(500., 500.),
iced::Size::new(20., 20.),
));
assert_eq!(
frozen.resolve(p.stamp, Some(screen(xy(500., 500.))), false),
Hit::Occluded
);
frozen
.edges
.push((Point::new(600., 600.), Point::new(700., 600.)));
assert_eq!(
frozen.resolve(p.stamp, Some(screen(xy(650., 605.))), false),
Hit::Occluded
);
}
#[test]
fn pending_behavior_draft_refuses_actual_work_press_and_equal_document_replacement_retires_packet()
{
let mut f = fixture(Layer::Oa);
let p = press(&f);
let project = f.app.project().clone();
let _ = f.app.behavior.update(
&project,
crate::behavior::Event::Creation(crate::behavior::creation::Event::Open(Layer::Oa)),
);
assert!(f.app.behavior.has_pending());
let events = batch(&f.app, start(&f), screen(xy(160., 140.)));
assert!(matches!(
events.first(),
Some(Message::WorkDrag(Event::Refused(..)))
));
let source = f.app.document.bundle().clone();
send(&mut f.app, events);
assert!(f.app.behavior.has_pending());
assert_eq!(f.app.document.bundle(), &source);
let _ = f.app.behavior.update(
&project,
crate::behavior::Event::Creation(crate::behavior::creation::Event::Cancel),
);
replace(&mut f, project);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
let _ = f.app.update(Message::WorkDrag(Event::Release(
p.stamp,
p.token,
Some(screen(xy(160., 140.))),
)));
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo());
}
#[test]
fn actual_canvas_origin_change_cancels_held_work_before_any_legacy_release() {
let mut f = fixture(Layer::Oa);
let from = start(&f);
let mut state = CanvasState::default();
let events = {
let canvas = canvas(&f.app);
let mut events = Vec::new();
for event in [
mouse::Event::CursorMoved { position: from },
mouse::Event::ButtonPressed(mouse::Button::Left),
] {
if let Some(message) = pointer(&canvas, &mut state, event, from) {
events.push(message);
}
}
let moved_bounds = Rectangle {
x: bounds().x + 1.,
..bounds()
};
let canceled = canvas
.update(
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
moved_bounds,
mouse::Cursor::Available(screen(xy(160., 140.))),
)
.and_then(|a| a.into_inner().0)
.unwrap();
assert!(matches!(canceled, Message::WorkDrag(Event::Cancel(_))));
events.push(canceled);
events
};
let source = f.app.document.bundle().clone();
send(&mut f.app, events);
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo());
assert!(f.app.allocation_drag.active.is_none());
}
#[test]
fn toolbar_status_leaves_transformed_top_edge_work_header_readable() {
for zoom in [0.75, 1.25] {
let mut f = fixture(Layer::Oa);
f.app.zoom = zoom;
f.app.pan = Vector::new(20. * zoom, 20. * zoom);
let screen = |world: Position| {
Point::new(
bounds().x + (world.x + 20.) * zoom,
bounds().y + (world.y + 20.) * zoom,
)
};
let from = screen(xy(140., 580.)); // 80-unit offset: release pointer clears the old band.
let to = screen(xy(640., 60.));
let mut events = batch(&f.app, from, to);
let release = events.pop().unwrap();
send(&mut f.app, events);
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
let header = preview
.placement
.view
.nodes
.iter()
.find(|n| n.id == f.node)
.unwrap();
assert_eq!(header.position.y, -20.);
assert!((screen(header.position).y - bounds().y).abs() < 0.01);
assert!(
f.app
.allocation_drag
.feedback
.as_ref()
.unwrap()
.targets
.is_empty()
);
let _ = f.app.update(release);
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
}
}
#[test]
fn toolbar_status_leaves_transformed_top_edge_frozen_target_label_readable() {
for zoom in [0.75, 1.25] {
let mut f = fixture(Layer::Oa);
let mut project = f.app.project().clone();
let target = project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == f.a)
.unwrap();
target.position.y = -41.;
target.size.height = 300.;
replace(&mut f, project);
f.app.zoom = zoom;
f.app.pan = Vector::new(20. * zoom, 20. * zoom);
let screen = |world: Position| {
Point::new(
bounds().x + (world.x + 20.) * zoom,
bounds().y + (world.y + 20.) * zoom,
)
};
let mut events = batch(&f.app, screen(xy(140., 535.)), screen(xy(200., 80.)));
let release = events.pop().unwrap();
send(&mut f.app, events);
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
assert!(!feedback.refused, "{}", feedback.text);
let target = &feedback.press.frozen.targets[feedback.targets[0]];
let top = screen(target.label.position).y - bounds().y;
assert!(
top > 0. && top < 14.,
"label starts in the formerly obscured band: {top}"
);
assert!(f.app.allocation_drag.preview.is_some());
let _ = f.app.update(release);
assert_eq!(
validation::effective_performer(f.app.project(), f.work),
Some(f.a)
);
}
}
@@ -77,6 +77,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
@@ -232,6 +232,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
editable: true,
placing: true,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let action = canvas
@@ -306,6 +307,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
editable: true,
placing: false,
connection: None,
allocation: None,
};
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));