Follow inspected Saved scenario through the actual arrangement picker

This commit is contained in:
2026-09-13 06:18:34 -07:00
parent 8bf7607a3f
commit 5d4a6a47ce
13 changed files with 341 additions and 29 deletions
+16 -1
View File
@@ -10,6 +10,8 @@ mod hosted;
mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
#[cfg(test)]
mod scenario_details_tests;
mod scenario_diagram;
mod scenario_spacing;
pub(crate) mod source_rename;
@@ -2041,6 +2043,11 @@ impl App {
if checked_owner(self.project(), requested) != Some(context.scenario) {
return Task::none();
}
// An explicitly selected current view record follows
// the arrangement picker, as with the Diagram picker.
// Independent occurrence/lifeline selection stays put.
let follow_view = matches!(context.representation,
Representation::Saved { diagram } if self.selected == diagram);
if !self.apply() {
return Task::none();
}
@@ -2058,7 +2065,15 @@ impl App {
},
other => other,
};
self.enter_scenario_representation(requested);
if self.enter_scenario_representation(requested) && follow_view {
self.selected = match requested {
Representation::Saved { diagram } => diagram,
// Automatic has no Diagram record; its Scenario
// owner is the corresponding inspectable target.
Representation::Derived { owner } => owner,
};
self.refresh_draft();
}
}
CanvasEvent::Focus(_) => {
self.behavior.scenario.toggle_focus();
@@ -1029,10 +1029,9 @@ fn repeated_preview_arrows_select_the_relation_and_highlight_the_exact_caller_li
let camera = app.behavior.topology.camera_boundary();
if call == calls[1]
&& let Some((previous_call, previous)) = &previous_camera
&& *previous_call != call
{
if *previous_call != call {
assert!(&camera != previous);
}
assert!(&camera != previous);
}
if let Some(epoch) = previous_epoch {
assert_ne!(app.canvas_epoch, epoch);
@@ -1375,7 +1374,7 @@ fn whole_surface_camera_fits_huge_extent_while_target_camera_remains_readable()
};
let viewport = iced::Size::new(800., 500.);
let (zoom, pan) = usage::camera_for(&Target::Whole, bounds, viewport);
assert!(zoom < 0.05 && zoom >= 0.0001);
assert!((0.0001..0.05).contains(&zoom));
assert!(bounds.width * zoom <= viewport.width - 64. + 0.01);
assert!(bounds.height * zoom <= viewport.height - 64. + 0.01);
assert!((bounds.center_x() * zoom + pan.x - viewport.width / 2.).abs() < 0.01);
@@ -0,0 +1,281 @@
//! Actual arrangement selection and current-record Details admission.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario::{CanvasEvent, Context, Shelf};
use crate::behavior::scenario_layout::Representation;
use iced::{Point, Size, advanced::widget};
const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn setup() -> (App, Id, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let owner = id(&app, "incident_response");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
choose(&mut app, Representation::Saved { diagram: a });
let _ = app.update(Message::Select(a));
app.window_size = Size::new(1440., 1000.);
(app, a, b, owner)
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn choose(app: &mut App, representation: Representation) {
send(app, |context| {
CanvasEvent::Representation(context, representation)
});
}
fn model_state(app: &App) -> (u64, bool, bool, bool) {
(
app.document.revision(),
app.document.dirty(),
app.document.can_undo(),
app.document.can_redo(),
)
}
#[tokio::test]
async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() {
let (mut app, a, b, _) = setup();
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let before = model_state(&app);
let captured = app.scenario_context().unwrap();
let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker");
assert!(
app.window_size.height - (picker.y + picker.height) > picker.y,
"fixture has room for Iced's menu below the actual picker"
);
assert_eq!(app.selected, a);
mounted.click(&mut app, picker.center()).await;
// iced_widget 0.14.2 overlay/menu.rs uses default line height plus
// vertical padding for each row. The real workspace orders Automatic,
// then the fixture's A/B/C Diagram IDs. Select B from the actual overlay.
let row_height =
f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.;
let b_row = Point::new(
picker.center_x(),
picker.y + picker.height + row_height * 2.5,
);
assert!(b_row.y < app.window_size.height);
let events = mounted.click(&mut app, b_row).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram }))
if *context == captured && *diagram == b)), "must exercise the actual arrangement callback");
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.draft.as_ref().unwrap().value["name"],
"B - Incident review negative and far"
);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
send(&mut app, CanvasEvent::Focus);
send(&mut app, |context| {
CanvasEvent::Shelf(context, Shelf::Selection)
});
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (details, offset) = geometry.scrolls[&widget::Id::new("scenario-details")];
let point = geometry.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(_, selected)) if *selected == b)));
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (inspector, offset) = geometry.scrolls[&widget::Id::new("inspector")];
assert!(
geometry
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current B Properties must expose an actual editable field"
);
assert!(!app.behavior.scenario.focused());
assert!(
!geometry
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_change_preserves_independent_semantic_and_foreign_view_selection() {
for alias in [
"initial_report",
"reporter",
"incident_response",
"incident_repair",
] {
let (mut app, _, b, _) = setup();
let selected = id(&app, alias);
let _ = app.update(Message::Select(selected));
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, selected, "independent selection {alias}");
assert_eq!(app.draft.as_ref().unwrap().original, selected);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
if alias == "incident_repair" {
assert!(!behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
selected
));
}
}
}
#[test]
fn arrangement_follows_view_record_to_automatic_owner_but_not_owner_back_to_saved() {
let (mut app, _, b, owner) = setup();
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Derived { owner });
assert_eq!(app.selected, owner);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, None);
assert!(behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
owner
));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(
app.selected, owner,
"Scenario owner is now independent semantic selection"
);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_applies_only_the_old_view_draft_then_follows_b_with_one_undo() {
let (mut app, a, b, _) = setup();
let snapshot = app.document.snapshot();
let mut expected = app.project().clone();
expected.diagrams.get_mut(&a).unwrap().name = "Reviewed A".into();
let revision = app.document.revision();
let _ = app.update(Message::Field("/name".into(), "Reviewed A".into()));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(!app.draft_dirty());
assert_eq!(app.document.revision(), revision + 1);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn same_arrangement_uuid_edit_follows_the_reidentified_record_and_round_trips_source() {
let (mut app, a, _, _) = setup();
let snapshot = app.document.snapshot();
let replacement = Id::parse_str("70200000-0000-4000-8000-000000000099").unwrap();
let mut expected = app.project().clone();
let mut diagram = expected.diagrams.remove(&a).unwrap();
diagram.id = replacement;
expected.diagrams.insert(replacement, diagram);
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, Representation::Saved { diagram: a });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, replacement);
assert_eq!(app.draft.as_ref().unwrap().original, replacement);
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(!app.document.can_redo());
}
#[test]
fn invalid_draft_and_stale_arrangement_preserve_selection_inputs_and_accepted_state() {
let (mut app, a, b, _) = setup();
let old = app.scenario_context().unwrap();
let snapshot = app.document.snapshot();
let before = model_state(&app);
let _ = app.update(Message::Field("/id".into(), "not-a-uuid".into()));
let draft_value = app.draft.as_ref().unwrap().value.clone();
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, a);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(
app.scenario_context().unwrap().representation,
old.representation
);
assert_eq!(app.draft.as_ref().unwrap().value, draft_value);
assert_eq!(app.draft.as_ref().unwrap().inputs["/id"].raw, "not-a-uuid");
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(model_state(&app), before);
let _ = app.update(Message::RevertProperties);
choose(&mut app, Representation::Saved { diagram: b });
let current = app.scenario_context().unwrap();
let _ = app.update(Message::Field("/name".into(), "Retained B input".into()));
let selected = app.selected;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
old,
Representation::Saved { diagram: a },
)));
assert_eq!(app.selected, selected);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.scenario_context().unwrap().visit, current.visit);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Retained B input"
);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
@@ -62,7 +62,7 @@ impl App {
// Document prepares and writes the source candidate before
// committing its snapshot. Failure leaves camera/selection
// and source/history at their accepted values.
match self.document.execute(command) {
match self.document.execute(*command) {
Ok(_) => {
self.selected = selected;
self.selected_node = None;
+1 -1
View File
@@ -684,7 +684,7 @@ mod scenario_scroll {
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if change < 2 || change >= 4 {
vec![if !(2..4).contains(&change) {
Vector::ZERO
} else {
Vector::new(0., 180.)
@@ -183,8 +183,9 @@ mod tests {
#[test]
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
let viewport = Some(Size::new(700., 500.));
let start = fit(bounds(), viewport.unwrap()).unwrap();
let size = Size::new(700., 500.);
let viewport = Some(size);
let start = fit(bounds(), size).unwrap();
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
let at = Point::new(210., 180.);
let world = Point::new(
@@ -218,7 +219,8 @@ mod tests {
#[test]
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
let viewport = Some(Size::new(700., 500.));
let size = Size::new(700., 500.);
let viewport = Some(size);
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
for event in [
Event::Pan(Vector::ZERO),
@@ -238,7 +240,7 @@ mod tests {
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
let start = fit(bounds(), viewport.unwrap()).unwrap();
let start = fit(bounds(), size).unwrap();
assert!(change(
&mut pan,
&mut zoom,
@@ -89,7 +89,7 @@ pub(crate) enum Finish {
Click(Id),
Noop,
Commit {
command: Command,
command: Box<Command>,
selected: Id,
scale: f32,
pan: Vector,
@@ -285,7 +285,7 @@ impl State {
Ok(match session.preview {
None => Finish::Noop,
Some(preview) => Finish::Commit {
command: preview.command,
command: Box::new(preview.command),
selected: session.drag.target.id(),
scale: session.scale,
pan: session.pan,
@@ -411,7 +411,7 @@ fn fractional_screen_packet_preserves_subpixel_motion_in_preview_and_finish() {
assert_eq!(selected, message);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
let committed = oa_core::execute_batch(doc.project(), &[command]).unwrap();
let committed = oa_core::execute_batch(doc.project(), &[*command]).unwrap();
assert_eq!(committed, expected);
assert!(state.spacing_token().is_none());
assert_eq!(doc.project(), &original.decoded.project);
@@ -10,10 +10,10 @@ fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody)
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
if !steps.contains(&id) {
steps.push(id);
}
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut()
&& !steps.contains(&id)
{
steps.push(id);
}
id
}
@@ -262,13 +262,20 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.find(|choice| choice.representation == context.representation)
.cloned();
header = header.push(
pick_list(choices, selected, move |choice: ArrangementChoice| {
Message::ScenarioCanvas(CanvasEvent::Representation(context, choice.representation))
})
.placeholder("Choose arrangement")
.text_size(12)
.padding(5)
.width(Fill),
container(
pick_list(choices, selected, move |choice: ArrangementChoice| {
Message::ScenarioCanvas(CanvasEvent::Representation(
context,
choice.representation,
))
})
.placeholder("Choose arrangement")
.text_size(12)
.padding(5)
.width(Fill),
)
.width(Fill)
.id("scenario-arrangement-picker"),
);
}
if capabilities.geometry_navigation {
@@ -458,8 +458,7 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
assert_eq!(scene.activations.len(), 2);
for (call, reply) in [(c1, r1), (c2, r2)] {
assert!(scene.activations.iter().any(|segment| {
segment.call == call
&& segment.finish == Finish::Reply(reply)
segment.call == call && segment.finish == Finish::Reply(reply)
}));
}
}