Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79a5ed37b | ||
|
|
897d9f0494 | ||
|
|
4deced53b9 | ||
|
|
9e99618cc3 | ||
|
|
a6b9f6029a | ||
|
|
a8c959fed4 | ||
|
|
359bea3949 |
@@ -1351,6 +1351,27 @@ impl App {
|
||||
let preview_read_only = self.process_preview_primary().is_some();
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
if let Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(captured, _)
|
||||
| crate::connection_edit::Event::Restart(captured),
|
||||
) = &message
|
||||
&& (self.route_context() != Some(*captured)
|
||||
|| self
|
||||
.connection
|
||||
.as_ref()
|
||||
.is_none_or(|session| session.context != *captured))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Selection can change without advancing the captured canvas context.
|
||||
if let Message::Route(
|
||||
crate::route_edit::Event::Commit(captured, changed, ..)
|
||||
| crate::route_edit::Event::CommitAttachment(captured, changed, ..),
|
||||
) = &message
|
||||
&& (self.route_context() != Some(*captured) || changed.edge != self.selected)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::DiagramSelection(event)
|
||||
| Message::StateDiagram(crate::behavior::state_edit::Event::Selection(event)) = &message
|
||||
&& !self.multiselect_envelope(event)
|
||||
|
||||
@@ -2,6 +2,11 @@ use super::*;
|
||||
use crate::app::multiselect::{self, Choice};
|
||||
use crate::diagram_usage::Target as SelectionTarget;
|
||||
|
||||
#[path = "group/connection_lifetime.rs"]
|
||||
mod connection_lifetime;
|
||||
#[path = "group/route_lifetime.rs"]
|
||||
mod route_lifetime;
|
||||
|
||||
struct Group {
|
||||
f: Fixture,
|
||||
y: Id,
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Delayed captured drawing packets must not retire a newer Allocation owner.
|
||||
use super::*;
|
||||
use crate::connection_edit::Event as ConnectionEvent;
|
||||
use crate::connection_tool::Tool;
|
||||
|
||||
fn drawing_tool(layer: Layer) -> Tool {
|
||||
match layer {
|
||||
Layer::Oa => Tool::Interaction,
|
||||
Layer::Sa => Tool::FunctionalExchange,
|
||||
}
|
||||
}
|
||||
|
||||
fn drawing_source(g: &mut Group, layer: Layer) -> crate::route_edit::Context {
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Choose(Some(
|
||||
drawing_tool(layer),
|
||||
))));
|
||||
let context = g.f.app.route_context().unwrap();
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Endpoint(
|
||||
context, g.f.work,
|
||||
)));
|
||||
let session = g.f.app.connection.as_ref().unwrap();
|
||||
assert_eq!(session.context, context);
|
||||
assert_eq!(session.source, Some(g.f.work));
|
||||
assert!(session.candidates.contains(&g.y));
|
||||
assert_eq!(g.f.app.route_context(), Some(context));
|
||||
context
|
||||
}
|
||||
|
||||
fn assert_captured_noop(app: &mut App, captured: crate::route_edit::Context, target: Id) {
|
||||
for event in [
|
||||
ConnectionEvent::Endpoint(captured, target),
|
||||
ConnectionEvent::Restart(captured),
|
||||
] {
|
||||
assert_connection_noop(app, event);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_connection_noop(app: &mut App, event: ConnectionEvent) {
|
||||
let document = app.document.snapshot();
|
||||
let selected = selection(app);
|
||||
let context = app.route_context();
|
||||
let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch);
|
||||
let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
|
||||
let active = app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|a| (a.press.clone(), a.crossed, a.cache.len()));
|
||||
let preview = app.allocation_drag.preview.as_ref().map(|p| {
|
||||
(
|
||||
p.token,
|
||||
p.placement.diagram.clone(),
|
||||
p.placement.delta,
|
||||
p.project().clone(),
|
||||
p.routed.clone(),
|
||||
)
|
||||
});
|
||||
let feedback = app.allocation_drag.feedback.as_ref().map(|f| {
|
||||
(
|
||||
f.press.token,
|
||||
f.text.clone(),
|
||||
f.refused,
|
||||
f.targets.clone(),
|
||||
f.inspection_targets.clone(),
|
||||
)
|
||||
});
|
||||
let connection = |app: &App| {
|
||||
app.connection
|
||||
.as_ref()
|
||||
.map(|s| (s.context, s.tool, s.source, s.candidates.clone()))
|
||||
};
|
||||
let session = connection(app);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let status = app.status.clone();
|
||||
let _ = app.update(Message::Connection(event));
|
||||
assert_eq!(selection(app), selected);
|
||||
assert_eq!(app.route_context(), context);
|
||||
assert_eq!(
|
||||
(app.pan, app.zoom, app.canvas_epoch, app.camera_epoch),
|
||||
camera
|
||||
);
|
||||
assert_eq!(
|
||||
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
|
||||
ownership,
|
||||
"rejected drawing packet must not retire the newer input owner"
|
||||
);
|
||||
match (active, app.allocation_drag.active.as_ref()) {
|
||||
(Some((press, crossed, cached)), Some(current)) => {
|
||||
assert!(Arc::ptr_eq(&press, ¤t.press));
|
||||
assert_eq!((current.crossed, current.cache.len()), (crossed, cached));
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("rejected drawing packet changed the active Allocation token"),
|
||||
}
|
||||
match (preview, app.allocation_drag.preview.as_ref()) {
|
||||
(Some((token, diagram, delta, project, routed)), Some(current)) => {
|
||||
assert_eq!(current.token, token);
|
||||
assert_eq!(current.placement.diagram, diagram);
|
||||
assert_eq!(current.placement.delta, delta);
|
||||
assert_eq!(current.project(), &project);
|
||||
assert!(Arc::ptr_eq(¤t.routed, &routed));
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("rejected drawing packet changed the Allocation preview"),
|
||||
}
|
||||
assert_eq!(
|
||||
app.allocation_drag.feedback.as_ref().map(|f| (
|
||||
f.press.token,
|
||||
f.text.clone(),
|
||||
f.refused,
|
||||
f.targets.clone(),
|
||||
f.inspection_targets.clone(),
|
||||
)),
|
||||
feedback
|
||||
);
|
||||
assert_eq!(connection(app), session);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&document, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), document.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_connection_after_cancel_preserves_arrange_arm_and_live_group() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut g = group(layer);
|
||||
let old = drawing_source(&mut g, layer);
|
||||
let _ = g.f.app.update(Message::Escape);
|
||||
assert!(g.f.app.connection.is_none());
|
||||
select(&mut g.f.app, &[g.an, g.yn]);
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
assert!(g.f.app.allocation_drag.arrange_next);
|
||||
let current = g.f.app.route_context().unwrap();
|
||||
assert_captured_noop(&mut g.f.app, old, g.y);
|
||||
// Matching context with no session isolates the missing-owner guard.
|
||||
assert_captured_noop(&mut g.f.app, current, g.y);
|
||||
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let held = selection(&g.f.app);
|
||||
let from = node_point(&g.f.app, g.an);
|
||||
let to = from + Vector::new(400., 0.);
|
||||
let mut state = CanvasState::default();
|
||||
let press = begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(press.mode, Mode::Arrange);
|
||||
assert!(!g.f.app.allocation_drag.arrange_next);
|
||||
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(_))
|
||||
));
|
||||
let current = g.f.app.route_context().unwrap();
|
||||
assert_captured_noop(&mut g.f.app, old, g.y);
|
||||
assert_captured_noop(&mut g.f.app, current, g.y);
|
||||
release(&mut g.f.app, &mut state, to);
|
||||
let mut expected = before.clone();
|
||||
let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
||||
diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
|
||||
diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
|
||||
diagram.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 captured_connection_context_mismatches_preserve_the_current_session() {
|
||||
let mut g = group(Layer::Oa);
|
||||
let current = drawing_source(&mut g, Layer::Oa);
|
||||
// Session and current context agree: each old captured field is the sole
|
||||
// invalid relationship, not hidden behind a missing session.
|
||||
for captured in [
|
||||
crate::route_edit::Context {
|
||||
document: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
crate::route_edit::Context {
|
||||
diagram: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
crate::route_edit::Context {
|
||||
revision: current.revision + 1,
|
||||
..current
|
||||
},
|
||||
crate::route_edit::Context {
|
||||
epoch: current.epoch + 1,
|
||||
..current
|
||||
},
|
||||
] {
|
||||
assert_eq!(g.f.app.connection.as_ref().unwrap().context, current);
|
||||
assert_captured_noop(&mut g.f.app, captured, g.y);
|
||||
}
|
||||
// Captured/session contexts agree, but the rendered view has advanced.
|
||||
// This isolates the live-route predicate from session ownership.
|
||||
g.f.app.canvas_epoch += 1;
|
||||
assert_ne!(g.f.app.route_context(), Some(current));
|
||||
assert_eq!(g.f.app.connection.as_ref().unwrap().context, current);
|
||||
assert_captured_noop(&mut g.f.app, current, g.y);
|
||||
g.f.app.canvas_epoch = current.epoch;
|
||||
|
||||
// Conversely, incoming/current contexts agree. Retain obsolete session
|
||||
// metadata explicitly to isolate the session-context admission branch.
|
||||
g.f.app.connection.as_mut().unwrap().context.epoch += 1;
|
||||
assert_eq!(g.f.app.route_context(), Some(current));
|
||||
assert_captured_noop(&mut g.f.app, current, g.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_connection_current_restart_and_pair_commit_after_tool_boundary() {
|
||||
for begin_tool in [false, true] {
|
||||
let mut g = group(Layer::Oa);
|
||||
select(&mut g.f.app, &[g.an, g.yn]);
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
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(400., 0.));
|
||||
assert!(g.f.app.allocation_drag.preview.is_some());
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let epoch = g.f.app.allocation_drag.epoch;
|
||||
let event = if begin_tool {
|
||||
ConnectionEvent::Begin(Tool::Interaction, g.f.work)
|
||||
} else {
|
||||
ConnectionEvent::Choose(Some(Tool::Interaction))
|
||||
};
|
||||
let _ = g.f.app.update(Message::Connection(event));
|
||||
assert!(g.f.app.allocation_drag.epoch > epoch);
|
||||
assert!(g.f.app.allocation_drag.active.is_none());
|
||||
assert!(g.f.app.allocation_drag.preview.is_none());
|
||||
assert!(!g.f.app.allocation_drag.arrange_next);
|
||||
let context = g.f.app.route_context().unwrap();
|
||||
if !begin_tool {
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Endpoint(
|
||||
context, g.f.work,
|
||||
)));
|
||||
}
|
||||
assert_eq!(g.f.app.connection.as_ref().unwrap().source, Some(g.f.work));
|
||||
assert!(
|
||||
g.f.app
|
||||
.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&g.y)
|
||||
);
|
||||
let document = g.f.app.document.snapshot();
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Restart(context)));
|
||||
let context = g.f.app.route_context().unwrap();
|
||||
let restarted = g.f.app.connection.as_ref().unwrap();
|
||||
assert_eq!(restarted.context, context);
|
||||
assert_eq!(restarted.tool, Tool::Interaction);
|
||||
assert_eq!(restarted.source, None);
|
||||
assert!(restarted.candidates.contains(&g.f.work));
|
||||
assert!(Arc::ptr_eq(&document, &g.f.app.document.snapshot()));
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Endpoint(
|
||||
context, g.f.work,
|
||||
)));
|
||||
assert_eq!(g.f.app.connection.as_ref().unwrap().source, Some(g.f.work));
|
||||
let revision = g.f.app.document.revision();
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Endpoint(context, g.y)));
|
||||
assert!(g.f.app.connection.is_none());
|
||||
assert_eq!(g.f.app.document.revision(), revision + 1);
|
||||
let relation = &g.f.app.project().relations[&g.f.app.selected];
|
||||
assert_eq!(relation.kind(), RelationKind::Interaction);
|
||||
assert_eq!((relation.source(), relation.target()), (g.f.work, g.y));
|
||||
let mut expected = before.clone();
|
||||
assert!(
|
||||
expected
|
||||
.relations
|
||||
.insert(relation.id, relation.clone())
|
||||
.is_none()
|
||||
);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Captured route commits cannot retire a newer Allocation input owner.
|
||||
use super::*;
|
||||
use crate::route_edit::{Context, Event as RouteEvent};
|
||||
|
||||
fn routed_group() -> (Group, Id) {
|
||||
let mut g = group(Layer::Oa);
|
||||
let mut project = g.f.app.project().clone();
|
||||
let edge = project.relate(
|
||||
RelationKind::Interaction,
|
||||
"Reviewed exchange",
|
||||
g.f.work,
|
||||
g.y,
|
||||
);
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&g.f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![xy(700., 400.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: xy(5., 10.),
|
||||
}),
|
||||
source_attachment: None,
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.6,
|
||||
}),
|
||||
});
|
||||
validate_candidate(&project).unwrap();
|
||||
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;
|
||||
choose(&mut g.f.app, SelectionTarget::Edge(edge), Choice::Replace);
|
||||
assert_eq!(g.f.app.selected, edge);
|
||||
let view = projection::project_view(g.f.app.project(), g.f.diagram);
|
||||
assert!(view.edges.iter().any(|e| e.id == edge));
|
||||
assert_eq!(
|
||||
crate::route_edit::attachment_node(
|
||||
g.f.app.project(),
|
||||
&g.f.app.project().diagrams[&g.f.diagram],
|
||||
&view,
|
||||
edge,
|
||||
EdgeEnd::Source,
|
||||
)
|
||||
.unwrap()
|
||||
.id,
|
||||
g.f.node
|
||||
);
|
||||
(g, edge)
|
||||
}
|
||||
|
||||
fn packet(g: &Group, edge: Id, captured: Context, attachment: bool) -> RouteEvent {
|
||||
let mut route =
|
||||
crate::route_edit::stored_route(&g.f.app.project().diagrams[&g.f.diagram], edge);
|
||||
if attachment {
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
});
|
||||
RouteEvent::CommitAttachment(captured, route, EdgeEnd::Source, g.f.node)
|
||||
} else {
|
||||
route.waypoints[0] = xy(750., 450.);
|
||||
RouteEvent::Commit(captured, route, Some(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_route_noop(app: &mut App, event: RouteEvent) {
|
||||
let document = app.document.snapshot();
|
||||
let selected = selection(app);
|
||||
let context = app.route_context();
|
||||
let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch);
|
||||
let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
|
||||
let press = app.allocation_drag.active.as_ref().map(|a| a.press.clone());
|
||||
let preview = app.allocation_drag.preview.clone();
|
||||
let feedback = app.allocation_drag.feedback.as_ref().map(|f| {
|
||||
(
|
||||
f.press.token,
|
||||
f.text.clone(),
|
||||
f.refused,
|
||||
f.targets.clone(),
|
||||
f.inspection_targets.clone(),
|
||||
)
|
||||
});
|
||||
let handles = (app.selected_bend, app.selected_attachment);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let status = app.status.clone();
|
||||
let _ = app.update(Message::Route(event));
|
||||
assert_eq!(selection(app), selected);
|
||||
assert_eq!(app.route_context(), context);
|
||||
assert_eq!(
|
||||
(app.pan, app.zoom, app.canvas_epoch, app.camera_epoch),
|
||||
camera
|
||||
);
|
||||
assert_eq!(
|
||||
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
|
||||
ownership
|
||||
);
|
||||
match (press, app.allocation_drag.active.as_ref()) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, &after.press)),
|
||||
(None, None) => {}
|
||||
_ => panic!("stale route packet changed the active Allocation token"),
|
||||
}
|
||||
match (preview, app.allocation_drag.preview.as_ref()) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("stale route packet changed the Allocation preview"),
|
||||
}
|
||||
assert_eq!(
|
||||
app.allocation_drag.feedback.as_ref().map(|f| (
|
||||
f.press.token,
|
||||
f.text.clone(),
|
||||
f.refused,
|
||||
f.targets.clone(),
|
||||
f.inspection_targets.clone(),
|
||||
)),
|
||||
feedback
|
||||
);
|
||||
assert_eq!((app.selected_bend, app.selected_attachment), handles);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&document, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), document.bundle);
|
||||
}
|
||||
|
||||
fn stale_route_preserves_new_group(attachment: bool, escape: bool) {
|
||||
let (mut g, edge) = routed_group();
|
||||
let old = g.f.app.route_context().unwrap();
|
||||
let delayed = packet(&g, edge, old, attachment);
|
||||
if escape {
|
||||
let _ = g.f.app.update(Message::Escape);
|
||||
assert_ne!(g.f.app.route_context(), Some(old));
|
||||
}
|
||||
select(&mut g.f.app, &[g.an, g.yn]);
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
assert!(g.f.app.allocation_drag.arrange_next);
|
||||
if !escape {
|
||||
assert_eq!(g.f.app.route_context(), Some(old));
|
||||
assert_ne!(g.f.app.selected, edge);
|
||||
}
|
||||
assert_route_noop(&mut g.f.app, delayed.clone());
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let selected = selection(&g.f.app);
|
||||
let from = node_point(&g.f.app, g.an);
|
||||
let to = from + Vector::new(400., 0.);
|
||||
let mut state = CanvasState::default();
|
||||
let press = begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(press.mode, Mode::Arrange);
|
||||
move_pointer(&mut g.f.app, &mut state, to);
|
||||
assert!(g.f.app.allocation_drag.preview.is_some());
|
||||
if !escape {
|
||||
assert_eq!(g.f.app.route_context(), Some(old));
|
||||
assert_ne!(g.f.app.selected, edge);
|
||||
}
|
||||
assert_route_noop(&mut g.f.app, delayed);
|
||||
release(&mut g.f.app, &mut state, to);
|
||||
let mut expected = before.clone();
|
||||
let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
||||
diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
|
||||
diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
|
||||
diagram.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
|
||||
// Both endpoint placements move by (400, 0); the saved pin follows once,
|
||||
// while label offset and endpoint attachment settings stay unchanged.
|
||||
diagram
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|r| r.edge == edge)
|
||||
.unwrap()
|
||||
.waypoints = vec![xy(1100., 400.)];
|
||||
assert_eq!(selection(&g.f.app), selected);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_route_commit_preserves_arrange_arm_and_live_group() {
|
||||
stale_route_preserves_new_group(false, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_route_attachment_preserves_arrange_arm_and_live_group() {
|
||||
stale_route_preserves_new_group(true, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_context_route_commit_after_selection_preserves_arrange_arm_and_live_group() {
|
||||
stale_route_preserves_new_group(false, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_context_route_attachment_after_selection_preserves_arrange_arm_and_live_group() {
|
||||
stale_route_preserves_new_group(true, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_route_context_fields_independently_reject_before_retirement() {
|
||||
for attachment in [false, true] {
|
||||
let (mut g, edge) = routed_group();
|
||||
let current = g.f.app.route_context().unwrap();
|
||||
// The selected edge and route payload are valid. Each capture mismatch
|
||||
// is isolated; no nonexistent route session can mask admission.
|
||||
for captured in [
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
diagram: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
revision: current.revision + 1,
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
epoch: current.epoch + 1,
|
||||
..current
|
||||
},
|
||||
] {
|
||||
let event = packet(&g, edge, captured, attachment);
|
||||
assert_route_noop(&mut g.f.app, event);
|
||||
}
|
||||
// Hold the captured packet fixed and advance the live context instead.
|
||||
let event = packet(&g, edge, current, attachment);
|
||||
g.f.app.canvas_epoch += 1;
|
||||
assert_route_noop(&mut g.f.app, event.clone());
|
||||
g.f.app.canvas_epoch = current.epoch;
|
||||
g.f.app.active_diagram = None;
|
||||
assert_route_noop(&mut g.f.app, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_route_commits_without_session_preserve_exact_route_history() {
|
||||
for attachment in [false, true] {
|
||||
let (mut g, edge) = routed_group();
|
||||
let current = g.f.app.route_context().unwrap();
|
||||
let event = packet(&g, edge, current, attachment);
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let revision = g.f.app.document.revision();
|
||||
assert!(g.f.app.connection.is_none());
|
||||
assert!(g.f.app.selected_attachment.is_none());
|
||||
let _ = g.f.app.update(Message::Route(event));
|
||||
assert_eq!(g.f.app.document.revision(), revision + 1);
|
||||
assert_eq!(g.f.app.selected, edge);
|
||||
assert_eq!(
|
||||
g.f.app.selected_bend,
|
||||
if attachment { None } else { Some((edge, 0)) }
|
||||
);
|
||||
let mut expected = before.clone();
|
||||
let route = expected
|
||||
.diagrams
|
||||
.get_mut(&g.f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|r| r.edge == edge)
|
||||
.unwrap();
|
||||
if attachment {
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
});
|
||||
} else {
|
||||
route.waypoints = vec![xy(750., 450.)];
|
||||
}
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod camera;
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_reorder;
|
||||
pub(crate) mod spacing;
|
||||
use super::scenario_layout::{
|
||||
Representation,
|
||||
@@ -1253,26 +1254,21 @@ pub(crate) fn reorder(
|
||||
occurrence: Id,
|
||||
slot: Slot,
|
||||
) -> Result<Command, String> {
|
||||
let to = slot_index(p, owner, slot)?;
|
||||
if p.elements.get(&occurrence).and_then(|e| e.parent) != Some(slot.parent) {
|
||||
return Err(
|
||||
"Reorder inside the occurrence's current sequence; dragging never changes its operand."
|
||||
.into(),
|
||||
);
|
||||
let prepared = group_reorder::Prepared::new(
|
||||
std::sync::Arc::new(p.clone()),
|
||||
owner,
|
||||
&[crate::diagram_usage::Target::ScenarioObject(occurrence)],
|
||||
)?;
|
||||
match prepared.plan(slot)? {
|
||||
group_reorder::Plan::Change(command) => Ok(*command),
|
||||
group_reorder::Plan::Noop => Ok(Command::SetSteps(
|
||||
slot.parent,
|
||||
p.elements[&slot.parent]
|
||||
.steps()
|
||||
.expect("admitted sequence")
|
||||
.clone(),
|
||||
)),
|
||||
}
|
||||
let mut steps = p.elements[&slot.parent]
|
||||
.steps()
|
||||
.expect("slot validated")
|
||||
.clone();
|
||||
let from = steps
|
||||
.iter()
|
||||
.position(|id| *id == occurrence)
|
||||
.ok_or("This occurrence is not in the sequence.")?;
|
||||
steps.remove(from);
|
||||
steps.insert(to - usize::from(from < to), occurrence);
|
||||
let command = Command::SetSteps(slot.parent, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|error| error.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
fn compatible_binding(p: &Project, referenced: Id, local: Id) -> bool {
|
||||
represented(p, referenced)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Frozen, all-or-nothing reorder of one immediate Scenario sequence.
|
||||
use super::Slot;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{Command, Id, Kind, OccurrenceBody, Project, SharedBody, execute_batch};
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Plan {
|
||||
Noop,
|
||||
Change(Box<Command>),
|
||||
}
|
||||
|
||||
pub(crate) struct Prepared {
|
||||
project: Arc<Project>,
|
||||
parent: Id,
|
||||
original: Vec<Id>,
|
||||
selected: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match project.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. }
|
||||
if matches!(
|
||||
body,
|
||||
OccurrenceBody::Exchange { .. }
|
||||
| OccurrenceBody::Action { .. }
|
||||
| OccurrenceBody::State { .. }
|
||||
| OccurrenceBody::ScenarioUse { .. }
|
||||
| OccurrenceBody::Fragment { .. }
|
||||
) =>
|
||||
{
|
||||
Some(body)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Follow the same structural edges that make rows visible; parent pointers alone
|
||||
// would admit children of an operand omitted from its fragment's branch list.
|
||||
fn reachable_parent(project: &Project, owner: Id, wanted: Id) -> bool {
|
||||
let mut pending = vec![owner];
|
||||
let mut visited = BTreeSet::new();
|
||||
while let Some(parent) = pending.pop() {
|
||||
if !visited.insert(parent) {
|
||||
continue;
|
||||
}
|
||||
if parent == wanted {
|
||||
return true;
|
||||
}
|
||||
let Some(steps) = project.elements.get(&parent).and_then(|e| e.steps()) else {
|
||||
continue;
|
||||
};
|
||||
for id in steps {
|
||||
if project.elements.get(id).and_then(|e| e.parent) != Some(parent) {
|
||||
continue;
|
||||
}
|
||||
if let Some(OccurrenceBody::Fragment { branches, .. }) = step_body(project, *id) {
|
||||
pending.extend(branches.iter().copied().filter(|branch| {
|
||||
project.elements.get(branch).is_some_and(|element| {
|
||||
element.parent == Some(*id)
|
||||
&& matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub(crate) fn new(
|
||||
project: Arc<Project>,
|
||||
owner: Id,
|
||||
targets: &[Target],
|
||||
) -> Result<Self, String> {
|
||||
if project.elements.get(&owner).map(|e| e.kind()) != Some(Kind::Scenario) {
|
||||
return Err("This scenario no longer exists.".into());
|
||||
}
|
||||
let mut selected = BTreeSet::new();
|
||||
let mut parent = None;
|
||||
for target in targets {
|
||||
let Target::ScenarioObject(id) = target else {
|
||||
return Err("Select only ordered occurrences in this scenario.".into());
|
||||
};
|
||||
if !selected.insert(*id) || step_body(&project, *id).is_none() {
|
||||
return Err(
|
||||
"Every selected subject must be a unique ordered scenario occurrence.".into(),
|
||||
);
|
||||
}
|
||||
let actual = project.elements[id]
|
||||
.parent
|
||||
.ok_or("The occurrence has no sequence.")?;
|
||||
if parent.is_some_and(|parent| parent != actual) {
|
||||
return Err(
|
||||
"Reorder inside one immediate sequence; dragging never changes operands."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
parent = Some(actual);
|
||||
}
|
||||
let parent = parent.ok_or("Select at least one occurrence.")?;
|
||||
if !reachable_parent(&project, owner, parent) {
|
||||
return Err("The selected sequence is not reachable in this scenario.".into());
|
||||
}
|
||||
let original = project.elements[&parent]
|
||||
.steps()
|
||||
.ok_or("The parent is not a sequence.")?
|
||||
.clone();
|
||||
let mut listed = BTreeSet::new();
|
||||
for id in &original {
|
||||
if !listed.insert(*id)
|
||||
|| step_body(&project, *id).is_none()
|
||||
|| project.elements[id].parent != Some(parent)
|
||||
{
|
||||
return Err("The complete sequence must contain unique, existing occurrences of this parent.".into());
|
||||
}
|
||||
}
|
||||
if !selected.is_subset(&listed) {
|
||||
return Err("A selected occurrence is not in this sequence.".into());
|
||||
}
|
||||
// Cache a validated immutable baseline, including descendants and other
|
||||
// sequences. Advisory authoring diagnostics deliberately remain allowed.
|
||||
oa_core::session::validate_candidate(&project).map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
project,
|
||||
parent,
|
||||
original,
|
||||
selected,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn plan(&self, slot: Slot) -> Result<Plan, String> {
|
||||
if slot.parent != self.parent {
|
||||
return Err("Reorder inside the occurrence's current sequence; dragging never changes its operand.".into());
|
||||
}
|
||||
if let Some(anchor) = slot.before {
|
||||
if !self.original.contains(&anchor) {
|
||||
return Err("The insertion slot changed. Choose its position again.".into());
|
||||
}
|
||||
if self.selected.contains(&anchor) {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
}
|
||||
let mut steps: Vec<_> = self
|
||||
.original
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !self.selected.contains(id))
|
||||
.collect();
|
||||
let at = slot.before.map_or(steps.len(), |anchor| {
|
||||
steps
|
||||
.iter()
|
||||
.position(|id| *id == anchor)
|
||||
.expect("admitted anchor")
|
||||
});
|
||||
steps.splice(
|
||||
at..at,
|
||||
self.original
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| self.selected.contains(id)),
|
||||
);
|
||||
if steps == self.original {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
let command = Command::SetSteps(self.parent, steps);
|
||||
execute_batch(&self.project, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(Plan::Change(Box::new(command)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,720 @@
|
||||
use super::*;
|
||||
use crate::{behavior::topology::occurrence, document::Document};
|
||||
use oa_core::{FragmentOperator, Layer, LifelineBinding, MessageKind, language};
|
||||
|
||||
fn add(p: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let e = occurrence(p, parent, name, body);
|
||||
let id = e.id;
|
||||
p.elements.insert(id, e);
|
||||
p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
fn row(p: &mut Project, parent: Id, name: &str) -> Id {
|
||||
add(
|
||||
p,
|
||||
parent,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn operand(p: &mut Project, parent: Id, operator: FragmentOperator) -> (Id, Id) {
|
||||
let fragment = add(
|
||||
p,
|
||||
parent,
|
||||
"Fragment",
|
||||
OccurrenceBody::Fragment {
|
||||
operator,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = occurrence(
|
||||
p,
|
||||
fragment,
|
||||
"Operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let id = branch.id;
|
||||
p.elements.insert(id, branch);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(id);
|
||||
}
|
||||
(fragment, id)
|
||||
}
|
||||
fn prepare(p: &Project, owner: Id, ids: &[Id]) -> Result<Prepared, String> {
|
||||
Prepared::new(
|
||||
Arc::new(p.clone()),
|
||||
owner,
|
||||
&ids.iter()
|
||||
.copied()
|
||||
.map(Target::ScenarioObject)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
fn slot(parent: Id, before: Option<Id>) -> Slot {
|
||||
Slot { parent, before }
|
||||
}
|
||||
fn ordered(p: &Project, parent: Id, ids: &[Id]) -> Project {
|
||||
let mut expected = p.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = ids.to_vec();
|
||||
expected
|
||||
}
|
||||
fn exact_transaction(p: &Project, plan: Plan, expected: Project) {
|
||||
let Plan::Change(command) = plan else {
|
||||
panic!("expected a change")
|
||||
};
|
||||
assert_eq!(
|
||||
execute_batch(p, std::slice::from_ref(&command)).unwrap(),
|
||||
expected
|
||||
);
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let original_source = doc.bundle().clone();
|
||||
doc.execute(*command).unwrap();
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(doc.project())).unwrap(),
|
||||
expected
|
||||
);
|
||||
let changed_source = doc.bundle().clone();
|
||||
assert_eq!(
|
||||
Document::from_bundle(changed_source.clone(), Default::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
&expected
|
||||
);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.project(), p);
|
||||
assert_eq!(doc.bundle(), &original_source);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(doc.bundle(), &changed_source);
|
||||
assert!(!doc.can_redo());
|
||||
}
|
||||
fn four(nested: bool) -> (Project, Id, Id, [Id; 4]) {
|
||||
four_in_layer(nested, Layer::Oa)
|
||||
}
|
||||
fn four_in_layer(nested: bool, layer: Layer) -> (Project, Id, Id, [Id; 4]) {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(oa_core::Kind::Scenario, layer, "Scenario", None)
|
||||
.unwrap();
|
||||
let parent = if nested {
|
||||
operand(&mut p, owner, FragmentOperator::Alternative).1
|
||||
} else {
|
||||
owner
|
||||
};
|
||||
let ids = ["A", "B", "C", "D"].map(|name| row(&mut p, parent, name));
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
(p, owner, parent, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_and_operand_use_semantic_block_order_with_exact_history() {
|
||||
for (nested, layer) in [
|
||||
(false, Layer::Oa),
|
||||
(true, Layer::Oa),
|
||||
(false, Layer::Sa),
|
||||
(true, Layer::Sa),
|
||||
] {
|
||||
let (p, owner, parent, [a, b, c, d]) = four_in_layer(nested, layer);
|
||||
let prepared = prepare(&p, owner, &[c, a]).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, Some(d))).unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, None)).unwrap(),
|
||||
ordered(&p, parent, &[b, d, a, c]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_wrapper_noop_preserves_existing_redo_and_source() {
|
||||
for nested in [false, true] {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(nested);
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let change = super::super::reorder(doc.project(), owner, a, slot(parent, None)).unwrap();
|
||||
doc.execute(change).unwrap();
|
||||
assert_eq!(doc.project(), &ordered(&p, parent, &[b, c, d, a]));
|
||||
let changed = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
let source = doc.bundle().clone();
|
||||
let revision = doc.revision();
|
||||
for before in [Some(a), Some(b)] {
|
||||
let noop =
|
||||
super::super::reorder(doc.project(), owner, a, slot(parent, before)).unwrap();
|
||||
doc.execute(noop).unwrap();
|
||||
assert_eq!(doc.project(), &p);
|
||||
assert_eq!(doc.bundle(), &source);
|
||||
assert_eq!(doc.revision(), revision);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.can_redo());
|
||||
}
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_anchor_and_exact_noop_preserve_redo_and_frozen_snapshot() {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(false);
|
||||
let prepared = prepare(&p, owner, &[c, a]).unwrap();
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let Plan::Change(command) = prepared.plan(slot(parent, Some(d))).unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
doc.execute(*command).unwrap();
|
||||
let changed = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
let source = doc.bundle().clone();
|
||||
let revision = doc.revision();
|
||||
for plan in [
|
||||
prepared.plan(slot(parent, Some(c))),
|
||||
prepare(&p, owner, &[a])
|
||||
.unwrap()
|
||||
.plan(slot(parent, Some(b))),
|
||||
prepare(&p, owner, &[c, d])
|
||||
.unwrap()
|
||||
.plan(slot(parent, None)),
|
||||
] {
|
||||
assert!(matches!(plan.unwrap(), Plan::Noop));
|
||||
assert_eq!(doc.project(), &p);
|
||||
assert_eq!(doc.bundle(), &source);
|
||||
assert_eq!(doc.revision(), revision);
|
||||
assert!(doc.can_redo());
|
||||
}
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &changed);
|
||||
// The earlier prepared value still plans from its original owned snapshot.
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, Some(d))).unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_rejects_every_invalid_member_list_and_destination_before_noop() {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(false);
|
||||
for ids in [vec![], vec![a, a], vec![a, Id::new_v4()]] {
|
||||
assert!(prepare(&p, owner, &ids).is_err());
|
||||
}
|
||||
for target in [Target::Whole, Target::Placement(a)] {
|
||||
assert!(
|
||||
Prepared::new(
|
||||
Arc::new(p.clone()),
|
||||
owner,
|
||||
&[Target::ScenarioObject(a), target]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut with_line = p.clone();
|
||||
let line = with_line
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Line", Some(owner))
|
||||
.unwrap();
|
||||
assert!(prepare(&with_line, owner, &[a, line]).is_err());
|
||||
let mut nested = p.clone();
|
||||
let (fragment, branch) = operand(&mut nested, owner, FragmentOperator::Alternative);
|
||||
let child = row(&mut nested, branch, "Child");
|
||||
assert!(prepare(&nested, owner, &[a, child]).is_err());
|
||||
assert!(prepare(&nested, owner, &[fragment, child]).is_err());
|
||||
let prepared = prepare(&p, owner, &[a, c]).unwrap();
|
||||
assert!(prepared.plan(slot(branch, Some(a))).is_err());
|
||||
assert!(prepared.plan(slot(parent, Some(Id::new_v4()))).is_err());
|
||||
for list in [vec![a, b, c, b], vec![a, Id::new_v4(), c, d]] {
|
||||
assert!(prepare(&ordered(&p, parent, &list), owner, &[a, c]).is_err());
|
||||
}
|
||||
let mut wrong_parent = p.clone();
|
||||
wrong_parent.elements.get_mut(&b).unwrap().parent = Some(branch);
|
||||
assert!(prepare(&wrong_parent, owner, &[a, c]).is_err());
|
||||
for body in [
|
||||
OccurrenceBody::Control {
|
||||
kind: oa_core::ControlKind::Choice,
|
||||
},
|
||||
OccurrenceBody::SequenceReference { sequence: None },
|
||||
] {
|
||||
let mut wrong_type = p.clone();
|
||||
if let Some(SharedBody::Occurrence { body: actual, .. }) =
|
||||
wrong_type.elements.get_mut(&b).unwrap().shared_mut()
|
||||
{
|
||||
*actual = body;
|
||||
}
|
||||
assert!(prepare(&wrong_type, owner, &[a, c]).is_err());
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = nested.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.clear();
|
||||
}
|
||||
assert!(
|
||||
prepare(&nested, owner, &[child]).is_err(),
|
||||
"parent ancestry cannot admit omitted operand"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlisted_warning_child_is_preserved_but_cannot_be_selected() {
|
||||
let (mut p, owner, parent, [a, b, c, d]) = four(false);
|
||||
let hidden = row(&mut p, parent, "Hidden draft");
|
||||
p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.pop();
|
||||
assert!(
|
||||
oa_core::validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "unordered_occurrence")
|
||||
);
|
||||
assert!(prepare(&p, owner, &[hidden]).is_err());
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, owner, &[c, a])
|
||||
.unwrap()
|
||||
.plan(slot(parent, Some(d)))
|
||||
.unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_reply_moves_as_explicit_stable_pair_and_invalid_baseline_refuses_noop() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
|
||||
let prepared = prepare(&p, f.owner, &[f.reply, f.call]).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
|
||||
);
|
||||
assert!(
|
||||
prepare(&p, f.owner, &[f.reply])
|
||||
.unwrap()
|
||||
.plan(slot(f.owner, Some(f.call)))
|
||||
.unwrap_err()
|
||||
.contains("reply_order")
|
||||
);
|
||||
let invalid = ordered(&p, f.owner, &[f.reply, x, f.call, y]);
|
||||
assert!(prepare(&invalid, f.owner, &[x]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_fragment_descendant_reply_is_validated_and_subtree_preserved() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
|
||||
p.elements.get_mut(&f.reply).unwrap().parent = Some(inner);
|
||||
p.elements
|
||||
.get_mut(&inner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(f.reply);
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, fragment, y]);
|
||||
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(f.call)))
|
||||
.unwrap_err()
|
||||
.contains("reply_order")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[f.call, y, x, fragment]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_lifetime_and_opaque_local_bindings_are_checked_without_expansion() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = execute_batch(
|
||||
&f.project,
|
||||
&[Command::Delete(f.reply), Command::Delete(f.call)],
|
||||
)
|
||||
.unwrap();
|
||||
let create = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Create",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(f.sender),
|
||||
receiver: Some(f.receiver),
|
||||
message_kind: MessageKind::Create,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
|
||||
add(
|
||||
&mut p,
|
||||
inner,
|
||||
"Use local",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(create)))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[create, y, x, fragment]),
|
||||
);
|
||||
let definition = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Definition", None)
|
||||
.unwrap();
|
||||
let represented = match p.elements[&f.receiver].shared().unwrap() {
|
||||
SharedBody::Lifeline { represented } => *represented,
|
||||
_ => panic!(),
|
||||
};
|
||||
let remote = p
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Formal", Some(definition))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::Lifeline { represented };
|
||||
let uses = ["First use", "Second use"].map(|name| {
|
||||
add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(definition),
|
||||
bindings: vec![LifelineBinding {
|
||||
referenced: remote,
|
||||
local: f.receiver,
|
||||
}],
|
||||
},
|
||||
)
|
||||
});
|
||||
let prepared = prepare(&p, f.owner, &[uses[1], uses[0]]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(create)))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, Some(x))).unwrap(),
|
||||
ordered(&p, f.owner, &[create, uses[0], uses[1], x, fragment, y]),
|
||||
);
|
||||
}
|
||||
|
||||
fn delete_line(p: &mut Project, parent: Id, sender: Id, receiver: Id) {
|
||||
add(
|
||||
p,
|
||||
parent,
|
||||
"Delete participant",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
message_kind: MessageKind::Delete,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn second_operand(p: &mut Project, fragment: Id) -> Id {
|
||||
let branch = occurrence(
|
||||
p,
|
||||
fragment,
|
||||
"Other operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "otherwise".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let id = branch.id;
|
||||
p.elements.insert(id, branch);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
branches.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_delete_rejects_moving_an_earlier_use_after_the_fragment() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let use_line = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Use participant",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
delete_line(&mut p, branch, f.sender, f.receiver);
|
||||
row(&mut p, f.owner, "Y");
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
let prepared = prepare(&p, f.owner, &[x, use_line]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, None))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_operand_move_is_legal_but_parallel_baseline_is_invalid() {
|
||||
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let (fragment, left) = operand(&mut p, f.owner, operator);
|
||||
let right = second_operand(&mut p, fragment);
|
||||
delete_line(&mut p, left, f.sender, f.receiver);
|
||||
let use_line = add(
|
||||
&mut p,
|
||||
right,
|
||||
"Use participant",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, right, "X");
|
||||
let y = row(&mut p, right, "Y");
|
||||
if operator == FragmentOperator::Alternative {
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[x, use_line])
|
||||
.unwrap()
|
||||
.plan(slot(right, None))
|
||||
.unwrap(),
|
||||
ordered(&p, right, &[y, use_line, x]),
|
||||
);
|
||||
} else {
|
||||
// This is an invalid input control, not a valid model broken by reorder.
|
||||
assert!(
|
||||
oa_core::session::validate_candidate(&p)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
assert!(prepare(&p, f.owner, &[x, use_line]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_reply_warning_does_not_block_independent_sibling_reorder() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Loop);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.reply);
|
||||
p.elements.get_mut(&f.reply).unwrap().parent = Some(branch);
|
||||
p.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(f.reply);
|
||||
let x = row(&mut p, branch, "X");
|
||||
let y = row(&mut p, branch, "Y");
|
||||
let z = row(&mut p, branch, "Z");
|
||||
let warning = |project: &Project| {
|
||||
oa_core::validation::validate(project).iter().any(|issue| {
|
||||
issue.element == Some(f.reply)
|
||||
&& issue.code == "reply_repetition"
|
||||
&& issue.severity == oa_core::validation::Severity::Warning
|
||||
})
|
||||
};
|
||||
assert!(warning(&p));
|
||||
let expected = ordered(&p, branch, &[f.reply, y, x, z]);
|
||||
assert!(warning(&expected));
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[z, x])
|
||||
.unwrap()
|
||||
.plan(slot(branch, None))
|
||||
.unwrap(),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_reorder_preserves_affected_and_unrelated_authored_layouts() {
|
||||
use oa_core::{
|
||||
Diagram, DiagramLayout, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition,
|
||||
ScenarioLayout, ScenarioMessagePosition,
|
||||
};
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
|
||||
for (alias, offset) in [("group_saved_a", 0.), ("group_saved_b", 1500.)] {
|
||||
let id = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: "Preserve my layout".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: -300. + offset,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.receiver,
|
||||
x: 700. + offset,
|
||||
},
|
||||
],
|
||||
messages: vec![
|
||||
ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.call,
|
||||
},
|
||||
send_y: 123. + offset,
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.reply,
|
||||
},
|
||||
send_y: 567. + offset,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
let unrelated = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Unrelated scenario", None)
|
||||
.unwrap();
|
||||
let line = p
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Unrelated clock",
|
||||
Some(unrelated),
|
||||
)
|
||||
.unwrap();
|
||||
let timer = add(
|
||||
&mut p,
|
||||
unrelated,
|
||||
"Wait",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(line),
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Timer {
|
||||
expression: "5 s".into(),
|
||||
},
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
let diagram = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "group_unrelated_saved".into(),
|
||||
name: "Unrelated arrangement".into(),
|
||||
description: "Do not touch this view".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario {
|
||||
scenario: unrelated,
|
||||
},
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: line,
|
||||
x: -123.,
|
||||
}],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: timer,
|
||||
},
|
||||
send_y: 987.,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
);
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[f.reply, f.call])
|
||||
.unwrap()
|
||||
.plan(slot(f.owner, None))
|
||||
.unwrap(),
|
||||
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
|
||||
);
|
||||
}
|
||||
+29
-6
@@ -1,14 +1,37 @@
|
||||
# Verified branch checkpoint — 2026-09-23 UTC
|
||||
# Integrated prerelease source checkpoint — 2026-09-24 UTC
|
||||
|
||||
This describes Codex-956atf's contribution, not a combined release.
|
||||
This branch assembles peer contributions for the Windows prerelease in issue37.
|
||||
[PROJECT_GOAL.md](../PROJECT_GOAL.md) remains authoritative. The complete OA/SA
|
||||
application is unfinished; published previews and finite prototypes are intermediate.
|
||||
|
||||
**Linux/WSL builds and checks remain deferred until the user explicitly reports
|
||||
networking resolved.** No guest setup, diagnosis, restart or cache transfer is
|
||||
active. The VPN remains untouched.
|
||||
**The current prerelease is Windows-only. Linux/WSL builds and checks remain
|
||||
excluded by the user's explicit request, including after networking recovers.**
|
||||
|
||||
## Current integrated candidate and evidence
|
||||
## Assembled source and acceptance boundary
|
||||
|
||||
Combined code **4deced53b98cd4d7649ba7aa60fa4b9b30d2f3b8** adds the reviewed
|
||||
Connection/Route early-admission correction to the assembled Scenario/Text,
|
||||
ordinary/State/Process/Allocation selection, direct drawing, Traceability and
|
||||
picker changes. The [source handoff](prerelease-integrated-source-20260924.md)
|
||||
identifies the incorporated commits, exact new test paths and required gates.
|
||||
The nine event regressions and existing completed-Text-Apply cases are retained.
|
||||
Direct formatting and whitespace checks pass; this source checkpoint does not
|
||||
claim combined Cargo, native, packaged or release acceptance. The independent
|
||||
[event-fix review](reviews/prerelease37-event-admission-20260924.md) found no
|
||||
further material source defect in its stated scope. A subsequent peer compile
|
||||
found a missing `projection::` qualification in the new Route test helper;
|
||||
the current code includes that one-line correction. The original compile failed
|
||||
before running tests. Combined execution remains pending.
|
||||
|
||||
Live build, final-source and publication results belong in
|
||||
[issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37).
|
||||
Human workflows are described in [the preview checklist](../docs/preview-human-checks.md).
|
||||
Private mixed-parser source is excluded from this application candidate.
|
||||
|
||||
## Contribution-specific verification
|
||||
|
||||
The following records retain their original source and runtime scope; they are
|
||||
not relabeled as acceptance of the combined source above.
|
||||
|
||||
Branch `codex/behavior-authoring-20260910`, published as `codex/release1-956atf`.
|
||||
Integrated code checkpoint **4ffff0d9d94bd1875e6b316b84ac11e5c5c9657c** combines
|
||||
|
||||
@@ -62,9 +62,11 @@ command or source schema is unnecessary. Preview and commit must consume the
|
||||
same plan; tests independently construct expected geometry rather than merely
|
||||
comparing those two uses of the implementation.
|
||||
|
||||
Process and Scenario layouts are derived from semantic order, not free XY node
|
||||
placements. Their group drag must reorder a stable block while retaining its
|
||||
internal order and applicable owner/column/operand boundaries. Existing topology
|
||||
Process reorder and Scenario occurrence reorder change semantic sequence order.
|
||||
Saved Scenario presentations can additionally store lane/message coordinates;
|
||||
semantic group reordering preserves those authored records. A sequence group drag
|
||||
must reorder a stable block while retaining its internal order and applicable
|
||||
owner/column/operand boundaries. Existing topology
|
||||
and temporal/lifecycle validation remains authoritative. Qualified reused
|
||||
Process content must not silently edit the reusable definition. These adapters
|
||||
are required work for the issue, not waived because ordinary diagrams are easier.
|
||||
@@ -84,8 +86,11 @@ The [explicit Arrange amendment](allocation-arrange-amendment-20260923.md) defin
|
||||
the bounded one-or-more work/performer footprint and diagnostic collision domain.
|
||||
Its [pure planner evidence](allocation-arrange-planner-20260923.md) and the
|
||||
[ordinary assignment foundation](allocation-assignment-planner-20260923.md)
|
||||
establish bounded source/planner behavior. App/native group input acceptance
|
||||
remains pending.
|
||||
establish bounded source/planner behavior. The [Allocation App/native checkpoint](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/57aec979f640ba1b8aa6fcbb7d9e26b29cf51596/harness/allocation-group-app-20260924.md)
|
||||
adds bounded Windows group assignment, explicit Arrange and ordinary performer
|
||||
acceptance; its limitations and source identity remain explicit. The
|
||||
[Scenario reorder policy](scenario-group-reorder-policy-20260924.md) defines the
|
||||
strict sibling-block increment without implying adapter or removal acceptance.
|
||||
|
||||
## Related group actions
|
||||
|
||||
|
||||
@@ -43,8 +43,53 @@ candidate: Allocation group `57aec979f640ba1b8aa6fcbb7d9e26b29cf51596`, drawing
|
||||
ancestry and is linked rather than merged for source. Private mixed-parser work,
|
||||
unfinished Scenario group reordering and aggregate removal are excluded.
|
||||
|
||||
## Captured event admission successor
|
||||
|
||||
Combined code `a6b9f6029aad618a84d136c5293353688d36809f` cherry-picks the narrow
|
||||
peer code commits `44217307`, `72b1bb7`, and `6f247af795b7bec1818c588b2dabf203f8f68d8a`.
|
||||
The peer's exact source and initial evidence are published at
|
||||
[`c655c5e`](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/c655c5eeaaa32c1c21074a7543844ae99a1d6fd5/harness/prerelease37-connection-admission-20260924.md).
|
||||
Only `app.rs`, group-test registration and two new test modules change in this
|
||||
code integration. Historical evidence archives are linked rather than merged.
|
||||
|
||||
Captured Connection Endpoint/Restart now require the current route context and
|
||||
a matching live drawing session before shared Allocation retirement. Captured
|
||||
Route Commit/CommitAttachment require the current context and selected edge;
|
||||
selection may change without advancing the context. Stale packets preserve
|
||||
newer Arrange/drag state and feedback. Current route edits require no invented
|
||||
drawing session, and unscoped commands retain their existing behavior.
|
||||
|
||||
The three Connection and six Route regressions are in:
|
||||
|
||||
- `crates/oa-desktop/src/app/allocation_drag_tests/group/connection_lifetime.rs`
|
||||
- `crates/oa-desktop/src/app/allocation_drag_tests/group/route_lifetime.rs`
|
||||
|
||||
The real Text Apply cases added at `542b10ac` remain in:
|
||||
|
||||
- `crates/oa-desktop/src/app/process_multiselect/tests.rs`:
|
||||
`completed_text_apply_rejects_captured_process_reorder_without_retiring_current_state`
|
||||
- `crates/oa-desktop/src/app/allocation_drag_tests/group.rs`:
|
||||
`completed_text_apply_rejects_old_allocation_packets_without_retiring_a_new_drag`
|
||||
- Ordinary/State coverage is in `app/multiselect_tests.rs`; earlier navigation
|
||||
tests in `navigation_union.rs` are a separate scope.
|
||||
|
||||
Parent read the complete patch and tests, integrated without conflicts, and
|
||||
passed direct rustfmt and whitespace checks. A fresh independent
|
||||
[source review](reviews/prerelease37-event-admission-20260924.md) found no
|
||||
actionable defect in the stated boundary. These do not substitute for the final
|
||||
combined execution. The vendor patch-description correction is incorporated at
|
||||
`17eaa03fd69741dff746f21d150dc04c341411b3`.
|
||||
|
||||
## Combined gates and package handoff
|
||||
|
||||
The first peer compilation exposed E0425 in the new Route fixture: its
|
||||
`project_view` call lacked the `projection::` qualification used by the parent
|
||||
helpers. Combined successor `4deced53b98cd4d7649ba7aa60fa4b9b30d2f3b8`
|
||||
cherry-picks peer `e68a83959900e556768d8470640e28a7df7d8cb8` to correct that
|
||||
single test line. Production code is unchanged. This concrete finding supersedes
|
||||
the earlier `9e99618c` build handoff; the failed compile ran no tests and remains
|
||||
part of the peer's evidence. Source review and formatting did not catch it.
|
||||
|
||||
Codex-ytr83r owns the exact-candidate Windows build/package on its separate
|
||||
machine. Codex-9e8r6t owns independent source/seam review and a downloaded-package
|
||||
check. Codex-956atf remains the sole integration, tag and release publication owner.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Prerelease 37 event guard integration review
|
||||
|
||||
2026-09-24. Independent source review; no confirmed actionable finding in the bounded change.
|
||||
|
||||
Parent follow-up: the later peer compile found E0425 at
|
||||
`route_lifetime.rs:40`, which this source review missed. Peer correction
|
||||
`e68a83959900e556768d8470640e28a7df7d8cb8` qualifies the call as
|
||||
`projection::project_view`; combined code `4deced53b98cd4d7649ba7aa60fa4b9b30d2f3b8`
|
||||
incorporates it. This is test-only and does not change the guard analysis below.
|
||||
The failed compile ran no tests; passing corrected execution must be recorded
|
||||
separately. The original review is retained below with its original scope.
|
||||
|
||||
Reviewed exact delta `340a5c56b0783e22d7336a07ce96ddb7c2b79b0d..6f247af795b7bec1818c588b2dabf203f8f68d8a` (code commits `44217307`, `72b1bb7`, `6f247af`) against combined candidate `17eaa03fd69741dff746f21d150dc04c341411b3`. This is compatibility review before the parent's integration, not verification of an already integrated commit. Read AGENTS.md, App update/connection/route handlers, Allocation retirement adapter, both new regression modules, group fixtures/history evaluator, and existing combined Allocation connection regressions. Formed independent judgment before reading peer reviews at `c655c5eeaaa32c1c21074a7543844ae99a1d6fd5`.
|
||||
|
||||
The 21-line early guards precede pending-reveal reconciliation, allocation_before, inner dispatch and post-dispatch reconciliation. The preceding preview/selection-boundary computations are reads. Connection Endpoint/Restart require both current route Context and extant matching session; Choose/Begin retain their intentional new-owner behavior. Current Restart remains admitted and creates a fresh Choose session. Route Commit/CommitAttachment require current Context and payload edge equal to current selected edge, without incorrectly demanding a Connection session or armed attachment.
|
||||
|
||||
The combined candidate does not change allocation_before from the reviewed base; its default retirement of Connection/Route makes early rejection necessary. The combined Allocation drawing adapter retains captured context through first endpoint selection, including mixed-layer Traceability, and changes model revision only after completed pair execution. Nothing in the new guard rejects those valid paths. Combined saved-Scenario route_context returns None, so old captured architecture events are rejected consistently.
|
||||
|
||||
The connection regressions independently isolate missing session, live route mismatch with session equal to captured context, and session mismatch with live route equal to captured context. Both Endpoint and Restart traverse these cases. The field matrix checks document, diagram, revision and epoch. Current Choose/Begin retire a live Arrange preview, then current Restart and an endpoint pair commit with exact history.
|
||||
|
||||
The route regressions independently isolate context mismatch while edge selection remains valid and same-context changed selection for both commit variants. Both armed Arrange and active preview survive rejection and then complete. Literal expected placement and pin coordinates protect against merely retaining an unusable token; both moved route endpoints translate the pin once to (1100, 400). Positive commits independently specify bend/source-attachment changes and preserve other model fields. Whole-Project/source reopen and exact one-step Undo/Redo checks are meaningful oracles rather than implementation mirrors.
|
||||
|
||||
Coverage limits and remaining checks: this reviewer ran no compiler, tests, formatter, GUI, fetch or Gitea mutation. The parent must run the new regression modules and existing connection_edit Allocation/mixed-layer tests against the actual cherry-picked Windows candidate, plus required Windows build/package and native smoke gates. New retained-owner cases exercise Arrange; they do not independently exercise every reassignment drag mode, every connection tool, or broader same-edge selection-away-and-back lifetime semantics. Existing combined tests cover positive OA/SA Allocation pairs and mixed-layer views, but their passing execution after integration is not established by source review. Linux is excluded from this acceptance claim.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Scenario group reorder policy
|
||||
|
||||
Derived from [issue 29](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/29) and the [multiple-selection contract](multiselect-contract-20260923.md). Accepted design for the next Scenario adapter; this document alone establishes no implementation, native, integration or release acceptance. The original decision inspected `495464e84b5bae8ddad93e9fc69ef353e7f1c884`; the post-prerelease port additionally inspects the combined base `897d9f049406d5be597543eb0a6755b796259302` and preserves its saved Scenario presentation model.
|
||||
|
||||
## Operation and identity
|
||||
|
||||
A Scenario drag changes semantic sequence order. It moves the exact selected local occurrences as one stable block inside their common immediate Scenario or Branch parent. It does not edit free XY positions, migrate operands, change a referenced definition or automatically select dependencies.
|
||||
|
||||
Saved Scenario layouts are separate presentation records. Semantic reorder preserves all authored lane/message coordinates, metadata and other views exactly, even when the changed order requires derived presentation to accommodate those preferences. A future group presentation move is a separate capability; this increment does not silently reinterpret stored coordinates as sequence order. App admission must distinguish the active saved/derived representation and its visit lifetime, including a same-owner representation round trip.
|
||||
|
||||
Eligible bodies are Exchange (including lifecycle and timer messages), Action, State, ScenarioUse and Fragment. A Fragment moves as one occurrence; its branches and descendant lists remain unchanged. Lifelines, Branch headers, Process-only occurrence bodies and other typed targets are unsupported movement members. Cross-parent sets and a Fragment plus one of its descendants refuse as a whole. These objects may still be selected and inspected; selection is not movement permission.
|
||||
|
||||
Every original target must exist, have its actual eligible body, belong to the same immediate parent and occur exactly once in that parent's list. Check the entire edited list, including unselected entries, for duplicate/missing IDs, body and parent mismatches. The selected parent must be reachable from the active Scenario through the actual sequence lists and Fragment branch lists. An ancestor pointer alone cannot authorize a hidden Branch omitted from its Fragment. The App must also admit against exact current rendered visibility.
|
||||
|
||||
An unlisted child is a supported warning-only authoring state: leave it unlisted and unchanged. Integrity of the edited list does not require every parented element to appear in it. Missing optional reference/binding information or loop warnings must not become blanket movement errors. The existing full-project error validator remains authoritative.
|
||||
|
||||
## Stable-block transformation
|
||||
|
||||
Let `S` be the original parent list and `T` the exact selected set. Form `B` by retaining members of `T` in their order in `S`; form `R` from the other members. The destination is an exact `Slot { parent, before }`.
|
||||
|
||||
1. Complete typed/list/reachability admission and validate the unchanged baseline before any no-op result.
|
||||
2. Require the destination's same parent and an existing exact anchor, or append.
|
||||
3. If the anchor is selected, leave `S` exactly unchanged. Do not normalize to a neighboring row or compact a noncontiguous selection.
|
||||
4. Otherwise insert all of `B` before the anchor in `R`, or append. Exact unchanged order is a no-op.
|
||||
5. Validate the complete executed candidate. On success use one `SetSteps` transaction; on error change nothing.
|
||||
|
||||
For `S=[A,B,C,D]`, selecting C then A and dropping before D produces `[B,A,C,D]`, regardless of primary or pressed member. Dropping that same set before C leaves `[A,B,C,D]`. Selecting `[Call,Reply]` preserves that internal order; it never implicitly selects another reply or expands a referenced Scenario.
|
||||
|
||||
Full candidate validation includes unselected descendants. Moving a Fragment ahead of an external Call can invalidate a nested Reply; moving an occurrence with local lifeline use before Create or after a possibly coexecuting Delete must be refused. ALT exclusivity and PAR coexecution cannot be inferred from painted row order. Opaque ScenarioUse bindings remain local lifetime uses under the existing validator, without interprocedural expansion.
|
||||
|
||||
## Gesture and transaction requirements
|
||||
|
||||
Capture immutable model and exact selection identity before movement. Cache proposals by distinct Slot, not pointer pixels. Before release, verify document, surface, revision, epoch, selection generation and complete membership; refuse a stale set before reconciliation can prune it. Recompute against the same accepted snapshot and execute once through Document. Successful source updates, Undo and Redo each remain one transaction. Refusal and no-op preserve model, source, history branch, selection and primary.
|
||||
|
||||
Use the nearest displayed slot, then apply parent admission. A farther legal slot must not replace a nearer invalid operand destination. Feedback identifies the selected count, destination/parent and refusal or no-change reason. A Fragment's interior can contain invalid operand slots even when a header anchor would be a no-op. Pointer/camera/focus lifetime, real selection modifiers and native readability need separate adapter verification; a passing pure planner does not establish them.
|
||||
|
||||
## Alternatives and acceptance
|
||||
|
||||
Contiguous-only selection rejects useful sibling groups without simplifying nested temporal validation. Stable semantic ordering is therefore preferred. Parent/child normalization silently drops selected intent, while automatic call/reply expansion has no unique closure in the presence of alternative replies, loops and nested fragments. Strict refusal keeps this first increment precise. Revisit these decisions only with an explicit user workflow and a compatible whole-set specification.
|
||||
|
||||
Discriminating evaluators include literal whole-Project transformations at root and operand levels, noncontiguous order versus click order, selected-anchor/unchanged no-op with existing redo, malformed unselected entries, omitted Branch reachability, warning-only unlisted children, nested reply/lifetime failures and legal nested-fragment movement. Successful cases must preserve every other sequence, binding, relation, value, ID and metadata record. Use independent source round trips and one-step Document history, not preview/commit agreement alone.
|
||||
|
||||
Aggregate removal remains separate required work. Until it exists, an interface displaying a group must refuse legacy primary-only Remove/Delete/reorder actions. Live ownership and progress remain in issue 29.
|
||||
Reference in New Issue
Block a user