905 lines
34 KiB
Rust
905 lines
34 KiB
Rust
use super::*;
|
|
use crate::app::multiselect::{self, Choice};
|
|
use crate::diagram_usage::Target as SelectionTarget;
|
|
use iced::widget::text_editor;
|
|
|
|
#[path = "group/connection_lifetime.rs"]
|
|
mod connection_lifetime;
|
|
#[path = "group/route_lifetime.rs"]
|
|
mod route_lifetime;
|
|
|
|
struct Group {
|
|
f: Fixture,
|
|
y: Id,
|
|
yn: Id,
|
|
c: Id,
|
|
cn: Id,
|
|
an: Id,
|
|
ax: Id,
|
|
by: Id,
|
|
}
|
|
|
|
fn group(layer: Layer) -> Group {
|
|
let mut f = fixture(layer);
|
|
let mut p = f.app.project().clone();
|
|
let y = p
|
|
.add_element(p.elements[&f.work].kind(), layer, "Y", None)
|
|
.unwrap();
|
|
let c = p
|
|
.add_element(
|
|
if layer == Layer::Oa {
|
|
Kind::Actor
|
|
} else {
|
|
Kind::SystemActor
|
|
},
|
|
layer,
|
|
"C",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let yn = p.place(f.diagram, y, xy(140., 600.));
|
|
let cn = p.place(f.diagram, c, xy(1500., 100.));
|
|
let d = p.diagrams.get_mut(&f.diagram).unwrap();
|
|
d.nodes.get_mut(&f.node).unwrap().position = xy(140., 200.);
|
|
d.nodes
|
|
.values_mut()
|
|
.find(|n| n.element == f.b)
|
|
.unwrap()
|
|
.position = xy(100., 500.);
|
|
d.nodes.get_mut(&cn).unwrap().size = Size {
|
|
width: 600.,
|
|
height: 900.,
|
|
};
|
|
let an = d.nodes.values().find(|n| n.element == f.a).unwrap().id;
|
|
let ax = p.relate(
|
|
RelationKind::Allocation,
|
|
"Reviewed X responsibility",
|
|
f.a,
|
|
f.work,
|
|
);
|
|
let by = p.relate(
|
|
RelationKind::Allocation,
|
|
"Reviewed Y responsibility",
|
|
f.b,
|
|
y,
|
|
);
|
|
p.relations.get_mut(&ax).unwrap().description = "Keep X provenance".into();
|
|
p.relations.get_mut(&by).unwrap().description = "Keep Y provenance".into();
|
|
validate_candidate(&p).unwrap();
|
|
f.app = App::with_project(p);
|
|
f.app.active_diagram = Some(f.diagram);
|
|
f.app.zoom = 1.;
|
|
f.app.pan = Vector::ZERO;
|
|
f.app.fit_to_view = false;
|
|
f.app.reconcile_multiselect();
|
|
Group {
|
|
f,
|
|
y,
|
|
yn,
|
|
c,
|
|
cn,
|
|
an,
|
|
ax,
|
|
by,
|
|
}
|
|
}
|
|
|
|
fn choose(app: &mut App, target: SelectionTarget, choice: Choice) {
|
|
let context = multiselect::context(app.route_context().unwrap());
|
|
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
|
|
context, target, choice,
|
|
)));
|
|
}
|
|
|
|
fn select(app: &mut App, ids: &[Id]) {
|
|
for (i, id) in ids.iter().enumerate() {
|
|
choose(
|
|
app,
|
|
SelectionTarget::Placement(*id),
|
|
if i == 0 { Choice::Replace } else { Choice::Add },
|
|
);
|
|
}
|
|
assert_eq!(
|
|
app.diagram_selection.as_ref().unwrap().targets(),
|
|
&ids.iter()
|
|
.copied()
|
|
.map(SelectionTarget::Placement)
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
fn selection(
|
|
app: &App,
|
|
) -> (
|
|
Vec<SelectionTarget>,
|
|
Option<SelectionTarget>,
|
|
Id,
|
|
Option<Id>,
|
|
) {
|
|
let s = app.diagram_selection.as_ref().unwrap();
|
|
(
|
|
s.targets().to_vec(),
|
|
s.primary().cloned(),
|
|
app.selected,
|
|
app.selected_node,
|
|
)
|
|
}
|
|
|
|
fn node_point(app: &App, node: Id) -> Point {
|
|
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
|
let n = view.nodes.iter().find(|n| n.id == node).unwrap();
|
|
screen(xy(n.position.x + 40., n.position.y + 35.))
|
|
}
|
|
|
|
fn input(app: &mut App, state: &mut CanvasState, event: mouse::Event, at: Point) {
|
|
if let Some(message) = pointer(&canvas(app), state, event, at) {
|
|
let _ = app.update(message);
|
|
}
|
|
}
|
|
|
|
fn begin(app: &mut App, state: &mut CanvasState, from: Point) -> Arc<Press> {
|
|
input(
|
|
app,
|
|
state,
|
|
mouse::Event::CursorMoved { position: from },
|
|
from,
|
|
);
|
|
let message = pointer(
|
|
&canvas(app),
|
|
state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
from,
|
|
)
|
|
.unwrap();
|
|
let Message::WorkDrag(Event::Press(packet)) = &message else {
|
|
panic!("Allocation placement must enter its captured lifetime: {message:?}");
|
|
};
|
|
let packet = packet.clone();
|
|
let _ = app.update(message);
|
|
packet
|
|
}
|
|
|
|
fn move_pointer(app: &mut App, state: &mut CanvasState, to: Point) {
|
|
input(app, state, mouse::Event::CursorMoved { position: to }, to);
|
|
}
|
|
|
|
fn release(app: &mut App, state: &mut CanvasState, to: Point) {
|
|
input(
|
|
app,
|
|
state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
to,
|
|
);
|
|
}
|
|
|
|
fn exact_history(
|
|
app: &mut App,
|
|
before: &Project,
|
|
source: &oa_core::source_project::SourceBundle,
|
|
expected: &Project,
|
|
) {
|
|
assert_eq!(app.project(), expected);
|
|
let after = app.document.bundle().clone();
|
|
let reopened =
|
|
crate::document::Document::from_bundle(after.clone(), Default::default()).unwrap();
|
|
assert_eq!(reopened.project(), expected);
|
|
let _ = app.update(Message::Undo);
|
|
assert_eq!(app.project(), before);
|
|
assert_eq!(app.document.bundle(), source);
|
|
assert!(
|
|
!app.document.can_undo(),
|
|
"one group drag is one history step"
|
|
);
|
|
let _ = app.update(Message::Redo);
|
|
assert_eq!(app.project(), expected);
|
|
assert_eq!(app.document.bundle(), &after);
|
|
assert!(!app.document.can_redo());
|
|
}
|
|
|
|
#[test]
|
|
fn completed_text_apply_rejects_old_allocation_packets_without_retiring_a_new_drag() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut g = group(layer);
|
|
select(&mut g.f.app, &[g.f.node, g.yn]);
|
|
let before = g.f.app.document.snapshot();
|
|
assert!(!g.f.app.document.can_undo());
|
|
let from = node_point(&g.f.app, g.f.node);
|
|
let mut old_canvas = CanvasState::default();
|
|
let old = begin(&mut g.f.app, &mut old_canvas, from);
|
|
assert!(g.f.app.allocation_drag.active.is_some());
|
|
let location = g.f.app.document.compiled().index.declarations[&g.f.work].clone();
|
|
let original = &before.bundle.files[&location.path];
|
|
let quoted_name = format!("\"{}\"", g.f.app.project().elements[&g.f.work].name);
|
|
assert_eq!(original.matches(quoted_name.as_str()).count(), 1);
|
|
let edited = original.replace(quoted_name.as_str(), "\"Changed through Text\"");
|
|
let mut expected = before.decoded.project.clone();
|
|
expected.elements.get_mut(&g.f.work).unwrap().name = "Changed through Text".into();
|
|
let mut expected_bundle = before.bundle.clone();
|
|
expected_bundle
|
|
.files
|
|
.insert(location.path.clone(), edited.clone());
|
|
|
|
let _ = g.f.app.update(Message::Page(Page::Text));
|
|
assert!(g.f.app.allocation_drag.active.is_none());
|
|
let _ = g.f.app.update(Message::SourceFile(location.path));
|
|
let _ =
|
|
g.f.app
|
|
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
|
let _ =
|
|
g.f.app
|
|
.update(Message::SourceEdit(text_editor::Action::Edit(
|
|
text_editor::Edit::Paste(Arc::new(edited.clone())),
|
|
)));
|
|
assert!(g.f.app.source_dirty());
|
|
assert_eq!(g.f.app.source_editor.text(), edited);
|
|
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
|
let _ = g.f.app.update(Message::ApplySource);
|
|
assert!(!g.f.app.source_dirty(), "{}", g.f.app.status);
|
|
assert_eq!(g.f.app.project(), &expected);
|
|
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
|
|
|
|
let _ = g.f.app.update(Message::Page(Page::Diagram));
|
|
assert_eq!(g.f.app.active_diagram, Some(g.f.diagram));
|
|
select(&mut g.f.app, &[g.f.node, g.yn]);
|
|
let from = node_point(&g.f.app, g.f.node);
|
|
let to = from + Vector::new(1400., 0.);
|
|
let mut current_canvas = CanvasState::default();
|
|
let current = begin(&mut g.f.app, &mut current_canvas, from);
|
|
assert_ne!(current.token, old.token);
|
|
assert_ne!(current.stamp.context, old.stamp.context);
|
|
move_pointer(&mut g.f.app, &mut current_canvas, to);
|
|
let preview =
|
|
g.f.app
|
|
.allocation_drag
|
|
.preview
|
|
.as_ref()
|
|
.expect(&g.f.app.status)
|
|
.clone();
|
|
let Candidate::Assignment(plan) = &preview.candidate else {
|
|
panic!("the current gesture must be a real group assignment");
|
|
};
|
|
assert_eq!(plan.changed_count(), 2);
|
|
let accepted = g.f.app.document.snapshot();
|
|
let revision = g.f.app.document.revision();
|
|
let held = selection(&g.f.app);
|
|
let selected = g.f.app.multiselect_snapshot().unwrap();
|
|
let workspace = g.f.app.source_workspace.bundle();
|
|
let editor = g.f.app.source_editor.text();
|
|
let camera = (
|
|
g.f.app.zoom,
|
|
g.f.app.pan,
|
|
g.f.app.fit_to_view,
|
|
g.f.app.canvas_size,
|
|
);
|
|
let epochs = (
|
|
g.f.app.canvas_epoch,
|
|
g.f.app.camera_epoch,
|
|
g.f.app.allocation_drag.epoch,
|
|
);
|
|
let draft =
|
|
g.f.app
|
|
.draft
|
|
.as_ref()
|
|
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
|
let form = g.f.app.behavior.recovery_snapshot();
|
|
let status = g.f.app.status.clone();
|
|
let pending_reveal = g.f.app.pending_diagram_reveal.clone();
|
|
let arrange = g.f.app.allocation_drag.arrange_next;
|
|
// An idle obsolete press intentionally reports a stale-view explanation.
|
|
// With a newer real gesture active, both old packets must be fully inert.
|
|
for event in [
|
|
Event::Press(old.clone()),
|
|
Event::Release(old.stamp, old.token, Some(to)),
|
|
] {
|
|
let _ = g.f.app.update(Message::WorkDrag(event));
|
|
assert!(Arc::ptr_eq(&accepted, &g.f.app.document.snapshot()));
|
|
assert_eq!(g.f.app.project(), &expected);
|
|
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
|
|
assert_eq!(g.f.app.document.revision(), revision);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
assert_eq!(g.f.app.multiselect_snapshot(), Some(selected.clone()));
|
|
assert!(Arc::ptr_eq(
|
|
&g.f.app.allocation_drag.active.as_ref().unwrap().press,
|
|
¤t
|
|
));
|
|
assert!(Arc::ptr_eq(
|
|
g.f.app.allocation_drag.preview.as_ref().unwrap(),
|
|
&preview
|
|
));
|
|
assert_eq!(g.f.app.source_workspace.bundle(), workspace);
|
|
assert_eq!(g.f.app.source_editor.text(), editor);
|
|
assert_eq!(
|
|
(
|
|
g.f.app.zoom,
|
|
g.f.app.pan,
|
|
g.f.app.fit_to_view,
|
|
g.f.app.canvas_size
|
|
),
|
|
camera
|
|
);
|
|
assert_eq!(
|
|
(
|
|
g.f.app.canvas_epoch,
|
|
g.f.app.camera_epoch,
|
|
g.f.app.allocation_drag.epoch
|
|
),
|
|
epochs
|
|
);
|
|
assert_eq!(
|
|
g.f.app
|
|
.draft
|
|
.as_ref()
|
|
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
|
draft
|
|
);
|
|
assert_eq!(g.f.app.behavior.recovery_snapshot(), form);
|
|
assert_eq!(g.f.app.pending_diagram_reveal, pending_reveal);
|
|
assert_eq!(g.f.app.allocation_drag.arrange_next, arrange);
|
|
assert_eq!(g.f.app.status, status);
|
|
assert!(!g.f.app.source_dirty());
|
|
assert!(g.f.app.document.can_undo());
|
|
assert!(!g.f.app.document.can_redo());
|
|
}
|
|
// The current gesture remains only a preview. History contains solely
|
|
// the accepted text rename, never either stale assignment packet.
|
|
let _ = g.f.app.update(Message::Undo);
|
|
assert_eq!(g.f.app.project(), &before.decoded.project);
|
|
assert_eq!(g.f.app.document.bundle(), &before.bundle);
|
|
assert!(!g.f.app.document.can_undo());
|
|
assert!(g.f.app.document.can_redo());
|
|
let _ = g.f.app.update(Message::Redo);
|
|
assert_eq!(g.f.app.project(), &expected);
|
|
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
|
|
assert!(g.f.app.document.can_undo());
|
|
assert!(!g.f.app.document.can_redo());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn group_assignment_split_rebuild_preserves_primary_and_commits_one_complete_candidate() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
for anchor_is_primary in [false, true] {
|
|
let mut g = group(layer);
|
|
select(&mut g.f.app, &[g.f.node, g.yn]);
|
|
let held = selection(&g.f.app);
|
|
let before = g.f.app.project().clone();
|
|
let source = g.f.app.document.bundle().clone();
|
|
let anchor = if anchor_is_primary { g.yn } else { g.f.node };
|
|
let from = node_point(&g.f.app, anchor);
|
|
let to = from + Vector::new(1400., 0.);
|
|
let mut state = CanvasState::default();
|
|
let packet = begin(&mut g.f.app, &mut state, from);
|
|
assert_eq!(packet.target, SelectionTarget::Placement(anchor));
|
|
assert_eq!(
|
|
selection(&g.f.app),
|
|
held,
|
|
"member press must not inspect/collapse"
|
|
);
|
|
assert_eq!(g.f.app.document.bundle(), &source);
|
|
move_pointer(&mut g.f.app, &mut state, to);
|
|
let preview =
|
|
g.f.app
|
|
.allocation_drag
|
|
.preview
|
|
.as_ref()
|
|
.expect(&g.f.app.status);
|
|
let Candidate::Assignment(plan) = &preview.candidate else {
|
|
panic!("group must use combined assignment");
|
|
};
|
|
assert_eq!(plan.changed_count(), 2);
|
|
assert_eq!(plan.already_correct_count(), 0);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
assert_eq!(g.f.app.project(), &before, "preview is immutable");
|
|
release(&mut g.f.app, &mut state, to);
|
|
let mut expected = before.clone();
|
|
for (relation, work) in [(g.ax, g.f.work), (g.by, g.y)] {
|
|
expected.relations.get_mut(&relation).unwrap().body = RelationBody::Allocation {
|
|
source: g.c,
|
|
target: work,
|
|
};
|
|
}
|
|
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
|
d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.);
|
|
d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
exact_history(&mut g.f.app, &before, &source, &expected);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn group_arrange_work_and_performer_anchors_preserve_every_responsibility() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
for performer_anchor in [false, true] {
|
|
let mut g = group(layer);
|
|
select(&mut g.f.app, &[g.an, g.yn]);
|
|
let held = selection(&g.f.app);
|
|
let before = g.f.app.project().clone();
|
|
let source = g.f.app.document.bundle().clone();
|
|
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
|
assert!(g.f.app.allocation_drag.arrange_next);
|
|
let from = node_point(&g.f.app, if performer_anchor { g.an } else { g.yn });
|
|
let to = from + Vector::new(400., 0.);
|
|
let mut state = CanvasState::default();
|
|
let packet = begin(&mut g.f.app, &mut state, from);
|
|
assert_eq!(packet.mode, Mode::Arrange);
|
|
assert!(!g.f.app.allocation_drag.arrange_next);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
move_pointer(&mut g.f.app, &mut state, to);
|
|
assert!(
|
|
matches!(
|
|
g.f.app
|
|
.allocation_drag
|
|
.preview
|
|
.as_ref()
|
|
.map(|p| &p.candidate),
|
|
Some(Candidate::Arrange(_))
|
|
),
|
|
"{}",
|
|
g.f.app.status
|
|
);
|
|
release(&mut g.f.app, &mut state, to);
|
|
let mut expected = before.clone();
|
|
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
|
d.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
|
|
d.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
|
|
d.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
exact_history(&mut g.f.app, &before, &source, &expected);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_group_click_collapses_but_threshold_crossing_never_becomes_click() {
|
|
for performer_anchor in [false, true] {
|
|
for distance in [0., 3., 3.25] {
|
|
let mut g = group(Layer::Oa);
|
|
select(&mut g.f.app, &[g.an, g.f.node]);
|
|
let held = selection(&g.f.app);
|
|
let before = g.f.app.document.snapshot();
|
|
let anchor = if performer_anchor { g.an } else { g.f.node };
|
|
let from = node_point(&g.f.app, anchor);
|
|
let mut state = CanvasState::default();
|
|
begin(&mut g.f.app, &mut state, from);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
move_pointer(&mut g.f.app, &mut state, from + Vector::new(distance, 0.));
|
|
if distance > 3. {
|
|
assert!(
|
|
g.f.app
|
|
.allocation_drag
|
|
.feedback
|
|
.as_ref()
|
|
.is_some_and(|f| f.refused)
|
|
);
|
|
assert!(g.f.app.allocation_drag.preview.is_none());
|
|
}
|
|
move_pointer(&mut g.f.app, &mut state, from);
|
|
release(&mut g.f.app, &mut state, from);
|
|
assert!(g.f.app.allocation_drag.active.is_none());
|
|
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
|
if distance > 3. {
|
|
assert_eq!(
|
|
selection(&g.f.app),
|
|
held,
|
|
"crossed-and-returned refusal must retain group"
|
|
);
|
|
} else {
|
|
let s = g.f.app.diagram_selection.as_ref().unwrap();
|
|
assert_eq!(s.targets(), &[SelectionTarget::Placement(anchor)]);
|
|
assert_eq!(s.primary(), Some(&SelectionTarget::Placement(anchor)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn allocation_bare_moves_cannot_bypass_armed_active_or_retired_lifetime() {
|
|
for (stage, singleton) in (0..3).flat_map(|stage| [(stage, false), (stage, true)]) {
|
|
let mut g = group(Layer::Oa);
|
|
let ids = if singleton {
|
|
vec![g.an]
|
|
} else {
|
|
vec![g.an, g.cn]
|
|
};
|
|
select(&mut g.f.app, &ids);
|
|
let snapshot = g.f.app.multiselect_snapshot().unwrap();
|
|
let held = selection(&g.f.app);
|
|
let before = g.f.app.document.snapshot();
|
|
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
|
let mut state = CanvasState::default();
|
|
let from = node_point(&g.f.app, g.an);
|
|
if stage > 0 {
|
|
let press = begin(&mut g.f.app, &mut state, from);
|
|
if stage == 2 {
|
|
let _ =
|
|
g.f.app
|
|
.update(Message::WorkDrag(Event::Cancel(press.token)));
|
|
}
|
|
}
|
|
let epoch = g.f.app.allocation_drag.epoch;
|
|
let armed = g.f.app.allocation_drag.arrange_next;
|
|
let token =
|
|
g.f.app
|
|
.allocation_drag
|
|
.active
|
|
.as_ref()
|
|
.map(|a| a.press.token);
|
|
let _ = g.f.app.update(Message::MoveNode(g.an, xy(500., 100.)));
|
|
let _ =
|
|
g.f.app
|
|
.update(Message::DiagramSelection(multiselect::Event::Move(
|
|
snapshot,
|
|
xy(400., 0.),
|
|
)));
|
|
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
|
assert_eq!(selection(&g.f.app), held);
|
|
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
|
|
assert_eq!(g.f.app.allocation_drag.arrange_next, armed);
|
|
assert_eq!(
|
|
g.f.app
|
|
.allocation_drag
|
|
.active
|
|
.as_ref()
|
|
.map(|a| a.press.token),
|
|
token
|
|
);
|
|
}
|
|
// A fresh ordinary performer gesture still uses the existing layout policy.
|
|
// C's right edge leaves the viewport: explicit Arrange's header gate must not
|
|
// accidentally become a new restriction on ordinary performer movement.
|
|
let mut g = group(Layer::Oa);
|
|
select(&mut g.f.app, &[g.an, g.cn]);
|
|
let held = selection(&g.f.app);
|
|
let before = g.f.app.project().clone();
|
|
let source = g.f.app.document.bundle().clone();
|
|
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
|
let from = node_point(&g.f.app, g.an);
|
|
let mut state = CanvasState::default();
|
|
let old = begin(&mut g.f.app, &mut state, from);
|
|
let _ = g.f.app.update(Message::WorkDrag(Event::Cancel(old.token)));
|
|
let mut state = CanvasState::default();
|
|
let fresh = begin(&mut g.f.app, &mut state, from);
|
|
assert_eq!(fresh.mode, Mode::Assign);
|
|
let to = from + Vector::new(300., 0.);
|
|
move_pointer(&mut g.f.app, &mut state, to);
|
|
assert!(
|
|
matches!(
|
|
g.f.app
|
|
.allocation_drag
|
|
.preview
|
|
.as_ref()
|
|
.map(|p| &p.candidate),
|
|
Some(Candidate::Performers(_))
|
|
),
|
|
"{}",
|
|
g.f.app.status
|
|
);
|
|
release(&mut g.f.app, &mut state, to);
|
|
let mut expected = before.clone();
|
|
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
|
d.nodes.get_mut(&g.an).unwrap().position = xy(400., 100.);
|
|
d.nodes.get_mut(&g.f.node).unwrap().position = xy(440., 200.);
|
|
d.nodes.get_mut(&g.cn).unwrap().position = xy(1800., 100.);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
exact_history(&mut g.f.app, &before, &source, &expected);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_group_press_rejects_selection_generation_aba_with_unchanged_other_stamps() {
|
|
for membership_aba in [false, true] {
|
|
let mut g = group(Layer::Oa);
|
|
select(&mut g.f.app, &[g.f.node, g.yn]);
|
|
let old = press(&g.f);
|
|
let before = g.f.app.document.snapshot();
|
|
let held = selection(&g.f.app);
|
|
let epoch = g.f.app.allocation_drag.epoch;
|
|
// Deliberately isolate selection generation from App message retirement.
|
|
// Final selection/primary/context/Document values are unchanged; this
|
|
// exercises the snapshot boundary, not an ordinary user-input sequence.
|
|
let s = g.f.app.diagram_selection.as_mut().unwrap();
|
|
if membership_aba {
|
|
assert!(s.remove(&SelectionTarget::Placement(g.yn)));
|
|
assert!(s.add(SelectionTarget::Placement(g.yn), |_| true));
|
|
} else {
|
|
assert!(s.set_primary(&SelectionTarget::Placement(g.f.node)));
|
|
assert!(s.set_primary(&SelectionTarget::Placement(g.yn)));
|
|
}
|
|
assert_eq!(selection(&g.f.app), held);
|
|
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
|
|
assert_eq!(g.f.app.route_context(), Some(old.stamp.context));
|
|
let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
|
assert!(
|
|
g.f.app.allocation_drag.active.is_none(),
|
|
"generation-only stale selection must not acquire input"
|
|
);
|
|
let _ = g.f.app.update(Message::WorkDrag(Event::Release(
|
|
old.stamp,
|
|
old.token,
|
|
Some(start(&g.f)),
|
|
)));
|
|
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
|
assert_eq!(selection(&g.f.app), held);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn group_selection_aba_and_old_press_cannot_retire_a_new_token() {
|
|
let mut g = group(Layer::Oa);
|
|
select(&mut g.f.app, &[g.f.node, g.yn]);
|
|
let old = press(&g.f);
|
|
choose(
|
|
&mut g.f.app,
|
|
SelectionTarget::Placement(g.f.node),
|
|
Choice::Inspect,
|
|
);
|
|
choose(
|
|
&mut g.f.app,
|
|
SelectionTarget::Placement(g.yn),
|
|
Choice::Inspect,
|
|
);
|
|
let before = g.f.app.document.snapshot();
|
|
let from = node_point(&g.f.app, g.f.node);
|
|
let mut state = CanvasState::default();
|
|
let fresh = begin(&mut g.f.app, &mut state, from);
|
|
let held = selection(&g.f.app);
|
|
let epoch = g.f.app.allocation_drag.epoch;
|
|
let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
|
let _ = g.f.app.update(Message::WorkDrag(Event::Release(
|
|
old.stamp,
|
|
old.token,
|
|
Some(from + Vector::new(1400., 0.)),
|
|
)));
|
|
assert_eq!(
|
|
g.f.app
|
|
.allocation_drag
|
|
.active
|
|
.as_ref()
|
|
.map(|a| a.press.token),
|
|
Some(fresh.token)
|
|
);
|
|
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
|
release(&mut g.f.app, &mut state, from);
|
|
}
|
|
|
|
fn unassign(g: &mut Group, inside_c: bool) {
|
|
let mut project = g.f.app.project().clone();
|
|
project.relations.remove(&g.ax);
|
|
project.relations.remove(&g.by);
|
|
if inside_c {
|
|
let d = project.diagrams.get_mut(&g.f.diagram).unwrap();
|
|
// These unassigned cards overlap an unrelated empty performer at the
|
|
// same depth. Paint C first so the intended work press is visible and
|
|
// independent of random UUID ordering; no semantic containment exists.
|
|
let mut target = d.nodes.remove(&g.cn).unwrap();
|
|
g.cn = Id::from_u128(1);
|
|
target.id = g.cn;
|
|
assert!(d.nodes.insert(g.cn, target).is_none());
|
|
d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.);
|
|
d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.);
|
|
}
|
|
g.f.app = App::with_project(project);
|
|
g.f.app.active_diagram = Some(g.f.diagram);
|
|
g.f.app.zoom = 1.;
|
|
g.f.app.pan = Vector::ZERO;
|
|
g.f.app.fit_to_view = false;
|
|
g.f.app.reconcile_multiselect();
|
|
}
|
|
|
|
fn c_allocations(expected: &mut Project, g: &Group, ids: &BTreeMap<Id, Id>) {
|
|
for (work, name, alias) in [
|
|
(g.f.work, "C performs Work", "c_performs_work"),
|
|
(g.y, "C performs Y", "c_performs_y"),
|
|
] {
|
|
expected.relations.insert(
|
|
ids[&work],
|
|
Relation {
|
|
id: ids[&work],
|
|
alias: alias.into(),
|
|
name: name.into(),
|
|
description: String::new(),
|
|
body: RelationBody::Allocation {
|
|
source: g.c,
|
|
target: work,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn group_out_and_back_assigns_at_zero_delta_then_true_noop_preserves_redo() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut g = group(layer);
|
|
unassign(&mut g, true);
|
|
select(&mut g.f.app, &[g.f.node, g.yn]);
|
|
let held = selection(&g.f.app);
|
|
let before = g.f.app.project().clone();
|
|
let source = g.f.app.document.bundle().clone();
|
|
let from = node_point(&g.f.app, g.f.node);
|
|
let mut state = CanvasState::default();
|
|
let press = begin(&mut g.f.app, &mut state, from);
|
|
assert_eq!(press.target, SelectionTarget::Placement(g.f.node));
|
|
assert_eq!(selection(&g.f.app), held);
|
|
move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.));
|
|
let preview =
|
|
g.f.app
|
|
.allocation_drag
|
|
.preview
|
|
.as_ref()
|
|
.expect(&g.f.app.status);
|
|
assert_eq!(preview.placement.delta, xy(0., 0.));
|
|
let Candidate::Assignment(candidate) = &preview.candidate else {
|
|
panic!("exact work group must preview assignment");
|
|
};
|
|
assert_eq!(candidate.destination(), plan::Destination::Performer(g.c));
|
|
assert_eq!(candidate.changed_count(), 2);
|
|
let ids: BTreeMap<_, _> = preview
|
|
.project()
|
|
.relations
|
|
.values()
|
|
.filter(|r| r.kind() == RelationKind::Allocation)
|
|
.map(|r| (r.target(), r.id))
|
|
.collect();
|
|
assert_eq!(ids.len(), 2);
|
|
move_pointer(&mut g.f.app, &mut state, from);
|
|
release(&mut g.f.app, &mut state, from);
|
|
let mut expected = before.clone();
|
|
c_allocations(&mut expected, &g, &ids);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
exact_history(&mut g.f.app, &before, &source, &expected);
|
|
g.f.app
|
|
.document
|
|
.execute(Command::Describe(g.y, "Future rationale".into()))
|
|
.unwrap();
|
|
g.f.app.sync();
|
|
let future = g.f.app.document.bundle().clone();
|
|
let _ = g.f.app.update(Message::Undo);
|
|
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
|
let snapshot = g.f.app.document.snapshot();
|
|
let revision = g.f.app.document.revision();
|
|
let held = selection(&g.f.app);
|
|
let from = node_point(&g.f.app, g.f.node);
|
|
let mut state = CanvasState::default();
|
|
begin(&mut g.f.app, &mut state, from);
|
|
move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.));
|
|
move_pointer(&mut g.f.app, &mut state, from);
|
|
release(&mut g.f.app, &mut state, from);
|
|
assert!(
|
|
Arc::ptr_eq(&snapshot, &g.f.app.document.snapshot()),
|
|
"{}",
|
|
g.f.app.status
|
|
);
|
|
assert_eq!(g.f.app.document.revision(), revision);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
|
let _ = g.f.app.update(Message::Redo);
|
|
assert_eq!(g.f.app.document.bundle(), &future);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn group_source_refusal_retains_primary_source_and_both_history_paths() {
|
|
let mut g = group(Layer::Oa);
|
|
unassign(&mut g, false);
|
|
let source = g.f.app.document.bundle().clone();
|
|
let mut limits = oa_core::source_project::ProjectLimits::default();
|
|
limits.model.max_source_bytes = source.files.values().map(String::len).sum::<usize>() + 128;
|
|
g.f.app.document = crate::document::Document::from_bundle(source, limits).unwrap();
|
|
g.f.app
|
|
.document
|
|
.execute(Command::Describe(g.f.work, "Reviewed".into()))
|
|
.unwrap();
|
|
g.f.app
|
|
.document
|
|
.execute(Command::Rename(g.y, "Future Y".into()))
|
|
.unwrap();
|
|
let future = g.f.app.document.bundle().clone();
|
|
assert!(g.f.app.document.undo());
|
|
g.f.app.sync();
|
|
g.f.app.reconcile_multiselect();
|
|
select(&mut g.f.app, &[g.f.node, g.yn]);
|
|
let held = selection(&g.f.app);
|
|
let before = g.f.app.document.snapshot();
|
|
let source = g.f.app.document.bundle().clone();
|
|
let revision = g.f.app.document.revision();
|
|
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
|
let from = node_point(&g.f.app, g.f.node);
|
|
let to = from + Vector::new(1400., 0.);
|
|
let mut state = CanvasState::default();
|
|
begin(&mut g.f.app, &mut state, from);
|
|
move_pointer(&mut g.f.app, &mut state, to);
|
|
assert!(
|
|
g.f.app.allocation_drag.preview.is_some(),
|
|
"geometry and semantics pass before source refusal: {}",
|
|
g.f.app.status
|
|
);
|
|
release(&mut g.f.app, &mut state, to);
|
|
assert!(g.f.app.status.contains("retry"), "{}", g.f.app.status);
|
|
assert!(g.f.app.allocation_drag.active.is_none());
|
|
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
|
assert_eq!(g.f.app.document.bundle(), &source);
|
|
assert_eq!(g.f.app.document.revision(), revision);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
|
let _ = g.f.app.update(Message::Redo);
|
|
assert_eq!(g.f.app.document.bundle(), &future);
|
|
let _ = g.f.app.update(Message::Undo);
|
|
assert_eq!(g.f.app.document.bundle(), &source);
|
|
}
|
|
|
|
#[test]
|
|
fn ordinary_off_grid_performer_out_and_back_is_noop_with_redo() {
|
|
for singleton in [false, true] {
|
|
let mut g = group(Layer::Oa);
|
|
let mut project = g.f.app.project().clone();
|
|
project
|
|
.diagrams
|
|
.get_mut(&g.f.diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&g.an)
|
|
.unwrap()
|
|
.position = xy(103., 107.);
|
|
g.f.app = App::with_project(project);
|
|
g.f.app.active_diagram = Some(g.f.diagram);
|
|
g.f.app.zoom = 1.;
|
|
g.f.app.pan = Vector::ZERO;
|
|
g.f.app.fit_to_view = false;
|
|
g.f.app
|
|
.document
|
|
.execute(Command::Describe(g.f.work, "Reviewed".into()))
|
|
.unwrap();
|
|
g.f.app
|
|
.document
|
|
.execute(Command::Describe(g.y, "Future".into()))
|
|
.unwrap();
|
|
let future = g.f.app.document.bundle().clone();
|
|
assert!(g.f.app.document.undo());
|
|
g.f.app.sync();
|
|
g.f.app.reconcile_multiselect();
|
|
select(
|
|
&mut g.f.app,
|
|
&if singleton {
|
|
vec![g.an]
|
|
} else {
|
|
vec![g.an, g.cn]
|
|
},
|
|
);
|
|
let held = selection(&g.f.app);
|
|
let before = g.f.app.document.snapshot();
|
|
let source = g.f.app.document.bundle().clone();
|
|
let revision = g.f.app.document.revision();
|
|
let from = node_point(&g.f.app, g.an);
|
|
let mut state = CanvasState::default();
|
|
begin(&mut g.f.app, &mut state, from);
|
|
move_pointer(&mut g.f.app, &mut state, from + Vector::new(20., 0.));
|
|
assert!(
|
|
g.f.app.allocation_drag.preview.is_some(),
|
|
"{}",
|
|
g.f.app.status
|
|
);
|
|
move_pointer(&mut g.f.app, &mut state, from);
|
|
assert_eq!(
|
|
g.f.app
|
|
.allocation_drag
|
|
.preview
|
|
.as_ref()
|
|
.unwrap()
|
|
.placement
|
|
.delta,
|
|
xy(0., 0.)
|
|
);
|
|
release(&mut g.f.app, &mut state, from);
|
|
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
|
assert_eq!(g.f.app.document.bundle(), &source);
|
|
assert_eq!(g.f.app.document.revision(), revision);
|
|
assert_eq!(selection(&g.f.app), held);
|
|
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
|
let _ = g.f.app.update(Message::Redo);
|
|
assert_eq!(g.f.app.document.bundle(), &future);
|
|
}
|
|
}
|