Keep contextual properties and creation bound to their semantic targets

This commit is contained in:
2026-09-24 23:30:56 -07:00
parent 603a59c178
commit ac69f555af
3 changed files with 288 additions and 11 deletions
+27 -8
View File
@@ -2766,7 +2766,15 @@ impl App {
} }
self.reconcile_multiselect(); self.reconcile_multiselect();
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i)); self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
return self.update(*action); let action = match *action {
Message::EditProperties(id)
if menu.request.canvas.is_some() && id == menu.request.object =>
{
Message::InspectorTab(crate::navigation::InspectorTab::Properties)
}
action => action,
};
return self.update(action);
} }
Message::CreateContextElement(kind, child) => { Message::CreateContextElement(kind, child) => {
self.create_kind = kind; self.create_kind = kind;
@@ -3377,7 +3385,7 @@ impl App {
self.workflow = Some(crate::workflow::Workflow::relationship_kind( self.workflow = Some(crate::workflow::Workflow::relationship_kind(
self.project(), self.project(),
self.selected, self.selected,
self.layer, self.project().layer_of(self.selected).unwrap_or(self.layer),
kind, kind,
incoming, incoming,
)); ));
@@ -4403,13 +4411,18 @@ impl App {
self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into(); self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into();
return; return;
} }
let creation_layer = if child {
self.project().layer_of(self.selected).unwrap_or(self.layer)
} else {
self.layer
};
let allocated = child let allocated = child
&& self.create_kind.is_work() && self.create_kind.is_work()
&& self && self
.project() .project()
.elements .elements
.get(&self.selected) .get(&self.selected)
.is_some_and(|e| e.layer() == self.layer && e.kind().is_performer()); .is_some_and(|e| e.layer() == creation_layer && e.kind().is_performer());
let parent = if child && !allocated { let parent = if child && !allocated {
Some(self.selected) Some(self.selected)
} else { } else {
@@ -4417,7 +4430,7 @@ impl App {
}; };
let Some(e) = self.project().make_element( let Some(e) = self.project().make_element(
self.create_kind, self.create_kind,
self.layer, creation_layer,
&format!("New {}", self.create_kind.label().to_lowercase()), &format!("New {}", self.create_kind.label().to_lowercase()),
parent, parent,
) else { ) else {
@@ -4446,16 +4459,16 @@ impl App {
.project() .project()
.diagrams .diagrams
.values() .values()
.find(|d| d.layer == self.layer && d.projection == preferred) .find(|d| d.layer == creation_layer && d.projection == preferred)
.map(|d| d.id); .map(|d| d.id);
if diagram_target.is_none() { if diagram_target.is_none() {
let view = Id::new_v4(); let view = Id::new_v4();
commands.push(Command::CreateDiagram(Diagram { commands.push(Command::CreateDiagram(Diagram {
id: view, id: view,
alias: self.project().fresh_alias("engineering_view", "view", view), alias: self.project().fresh_alias("engineering_view", "view", view),
name: format!("{} {preferred}", self.layer), name: format!("{} {preferred}", creation_layer),
description: String::new(), description: String::new(),
layer: self.layer, layer: creation_layer,
projection: preferred, projection: preferred,
layout: None, layout: None,
routes: Vec::new(), routes: Vec::new(),
@@ -4588,7 +4601,13 @@ impl App {
)); ));
} }
if self.execute(Command::Batch(commands)) { if self.execute(Command::Batch(commands)) {
let changed_view = self.active_diagram != diagram_target;
self.active_diagram = diagram_target; self.active_diagram = diagram_target;
if changed_view
&& let Some(view) = diagram_target.and_then(|id| self.project().diagrams.get(&id))
{
self.layer = view.layer;
}
self.selected = id; self.selected = id;
self.inspector_tab = crate::navigation::InspectorTab::Properties; self.inspector_tab = crate::navigation::InspectorTab::Properties;
self.refresh_draft(); self.refresh_draft();
@@ -4602,7 +4621,7 @@ impl App {
self.workflow = Some(crate::workflow::Workflow::relationship( self.workflow = Some(crate::workflow::Workflow::relationship(
self.project(), self.project(),
self.selected, self.selected,
self.layer, self.project().layer_of(self.selected).unwrap_or(self.layer),
)); ));
} }
fn add_trace(&mut self) { fn add_trace(&mut self) {
@@ -295,3 +295,251 @@ fn cross_layer_port_inspection_and_connection_start_retain_the_view() {
assert_eq!(app.connection.as_ref().unwrap().source, Some(port)); assert_eq!(app.connection.as_ref().unwrap().source, Some(port));
assert!(!app.document.can_undo()); assert!(!app.document.can_undo());
} }
fn authoring_fixture(performer: bool) -> (Fixture, Id) {
let mut f = fixture();
let mut project = f.app.project().clone();
let owner = project
.add_element(
if performer {
Kind::Entity
} else {
Kind::Activity
},
Layer::Oa,
"Operational owner",
None,
)
.unwrap();
let target = project
.add_element(Kind::Activity, Layer::Oa, "Operational target", None)
.unwrap();
let placement = project.place(f.diagram, owner, Position { x: 0., y: 400. });
project.place(f.diagram, target, Position { x: 500., y: 400. });
f.app = App::with_project(project);
f.app.page = Page::Diagram;
f.app.layer = Layer::Sa;
f.app.active_diagram = Some(f.diagram);
f.app.selected = f.diagram;
f.app.refresh_draft();
f.app.reconcile_multiselect();
f.process = owner;
f.placement = placement;
(f, target)
}
fn assert_source_pair(app: &App) {
let compiled = oa_core::source_project::compile_bundle(
app.document.bundle(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(&compiled.decoded.project, app.project());
assert_eq!(&compiled.bundle, app.document.bundle());
}
#[test]
fn mixed_canvas_relationship_actions_keep_operational_source_and_commit_one_step() {
for context_action in [false, true] {
let (mut f, target) = authoring_fixture(false);
let before = f.app.document.snapshot();
let owner = f.process;
let placement = f.placement;
if context_action {
invoke(
&mut f,
owner,
Some(placement),
Message::AddRelationKind(RelationKind::Interaction, false),
);
} else {
let _ = f.app.update(Message::SelectNode(placement));
let _ = f.app.update(Message::AddRelation);
}
assert_surface(&f);
let Some(crate::workflow::Workflow::Relation(relation)) = &f.app.workflow else {
panic!("relationship form did not open: {}", f.app.status);
};
assert_eq!(
relation.source.as_ref().map(|source| source.id),
Some(owner)
);
assert_eq!(relation.kind, RelationKind::Interaction);
let saved = f
.app
.workflow
.as_ref()
.unwrap()
.recovery_snapshot()
.unwrap();
assert_eq!(saved["Relation"]["layer"], "Oa");
let _ = f
.app
.update(Message::Workflow(crate::workflow::Event::Target(Choice {
id: target,
label: "Operational target".into(),
})));
let _ = f
.app
.update(Message::Workflow(crate::workflow::Event::Apply));
assert!(f.app.workflow.is_none(), "{}", f.app.status);
let created: Vec<_> = f
.app
.project()
.relations
.values()
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
.collect();
assert_eq!(created.len(), 1);
assert_eq!(
(created[0].kind(), created[0].source(), created[0].target()),
(RelationKind::Interaction, owner, target)
);
assert_source_pair(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
}
#[test]
fn mixed_canvas_add_child_command_uses_parent_layer_for_containment_and_allocation() {
for performer in [false, true] {
let (mut f, _) = authoring_fixture(performer);
let before = f.app.document.snapshot();
let owner = f.process;
let placement = f.placement;
invoke(
&mut f,
owner,
Some(placement),
Message::CreateContextElement(Kind::Activity, true),
);
assert_surface(&f);
let created: Vec<_> = f
.app
.project()
.elements
.values()
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
.collect();
assert_eq!(created.len(), 1, "{}", f.app.status);
let child = created[0].id;
assert_eq!(created[0].kind(), Kind::Activity);
assert_eq!(created[0].layer(), Layer::Oa);
assert_eq!(
created[0].parent,
if performer { None } else { Some(owner) }
);
let allocations: Vec<_> = f
.app
.project()
.relations
.values()
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
.collect();
assert_eq!(allocations.len(), usize::from(performer));
if performer {
assert_eq!(
(
allocations[0].kind(),
allocations[0].source(),
allocations[0].target()
),
(RelationKind::Allocation, owner, child)
);
}
assert!(
f.app.project().diagrams[&f.diagram]
.nodes
.values()
.any(|node| node.element == child)
);
assert_source_pair(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
}
#[test]
fn mixed_canvas_root_creation_still_uses_the_active_perspective() {
let (mut f, _) = authoring_fixture(false);
let _ = f.app.update(Message::SelectNode(f.placement));
let before = f.app.document.snapshot();
let _ = f
.app
.update(Message::CreateContextElement(Kind::Function, false));
assert_surface(&f);
let created: Vec<_> = f
.app
.project()
.elements
.values()
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
.collect();
assert_eq!(created.len(), 1, "{}", f.app.status);
assert_eq!(created[0].kind(), Kind::Function);
assert_eq!(created[0].layer(), Layer::Sa);
assert_eq!(created[0].parent, None);
assert_source_pair(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
#[test]
fn child_creation_navigates_to_a_parent_layer_preferred_view_only_when_needed() {
for existing_preferred in [false, true] {
let (mut f, _) = authoring_fixture(false);
let mut project = f.app.project().clone();
for diagram in project
.diagrams
.values_mut()
.filter(|d| d.layer == Layer::Oa)
{
diagram.projection = if existing_preferred {
ProjectionKind::Allocation
} else {
ProjectionKind::Overview
};
}
f.app = App::with_project(project);
f.app.page = Page::Diagram;
f.app.layer = Layer::Sa;
f.app.active_diagram = Some(f.diagram);
f.app.selected = f.diagram;
f.app.refresh_draft();
let _ = f.app.update(Message::SelectNode(f.placement));
let _ = f
.app
.update(Message::Projection(ProjectionKind::Capabilities));
assert_eq!(f.app.selected, f.process);
let before = f.app.document.snapshot();
let _ = f
.app
.update(Message::CreateContextElement(Kind::Activity, true));
assert_eq!(f.app.layer, Layer::Oa, "{}", f.app.status);
let diagram = f.app.active_diagram.unwrap();
assert_ne!(diagram, f.diagram);
assert_eq!(f.app.project().diagrams[&diagram].layer, Layer::Oa);
assert_eq!(
f.app.project().diagrams[&diagram].projection,
ProjectionKind::Allocation
);
assert_eq!(
f.app.project().diagrams.len(),
before.decoded.project.diagrams.len() + usize::from(!existing_preferred)
);
assert_eq!(
f.app.project().elements[&f.app.selected].parent,
Some(f.process)
);
assert_source_pair(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
}
+13 -3
View File
@@ -29,8 +29,10 @@ Canvas inspection carries the originating route context and preserves view/layer
Explicit model navigation continues to follow the target layer. Draft validation Explicit model navigation continues to follow the target layer. Draft validation
and stale-context rejection apply before inspection. Existing production group and stale-context rejection apply before inspection. Existing production group
selection already retained the canvas; fallback edge/port selection and canvas selection already retained the canvas; fallback edge/port selection and canvas
context actions require the correction. Actual native reproduction must establish context actions require the correction. The published Windows baseline reproduces both reported failure states;
which path accounts for the user's reported click; source diagnosis alone does not. source diagnosis and those baseline results do not establish patched acceptance.
Contextual child/relationship creation must derive the semantic layer from the
selected object, while root creation continues to use the active perspective.
## Acceptance ## Acceptance
@@ -49,4 +51,12 @@ which path accounts for the user's reported click; source diagnosis alone does n
Linux remains excluded by the existing user pause. Independent design critique Linux remains excluded by the existing user pause. Independent design critique
identified saved visibility selectors and frozen migration as additional admission identified saved visibility selectors and frozen migration as additional admission
paths; both are included. Automated and native outcomes are not yet recorded. paths; both are included. At published candidate `603a59c1785dbcca324dae063ecc3405d9519a30`, 41 targeted core checks pass: legacy admission (2), projection switching (3), review regressions (15), source-6 view contract (8), and state diagrams (13). Tracked inputs were unchanged across the run. Whole-workspace formatting also passed at that commit. The first `0341bca` core attempt failed a generated-UUID test oracle; explicit fixture identities corrected the evaluator, without relaxing the admission assertion.
The desktop attempt at `603a59c` was deliberately interrupted before test execution after independent source review found canvas Properties redispatch and contextual relationship/child semantic-layer defects. Its source inputs remained unchanged; no desktop passing result is claimed. The Properties issue arose from fixing explicit navigation to an already-inspected opposite-layer object. Keep both regressions: contextual Properties stays in the view, explicit navigation follows the object's layer.
Published-preview executable SHA256 `2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399` reproduces edge selection changing SA to OA, and Architecture switching refused with `diagram_layer`. The isolated valid fixture remained byte-for-byte unchanged after the trial. An earlier fixture used reserved alias `capability` and was rejected; it was corrected before either reproduction. Initial native-dialog capture/input failures also remain separate from application acceptance. Independent evidence review confirmed the resulting screenshot states and persisted byte equality; screenshots alone do not capture the complete action sequence or transient state history.
Candidate Windows GUI save/history/reopen, HTTP regression, broader affected suites and actual rendering acceptance remain outstanding. Raw local evidence is not yet a peer-consumable handoff; publish the consolidated evidence before integration.
Scope boundary: contextual relationship forms retain the canvas while opening; successful Apply retains the existing explicit post-review navigation. The generic child-creation handler is tested for containment and allocation, but the separate architecture palette/performer workflow still offers creation kinds for the diagram layer. Thus an OA performer displayed in an SA Traceability view has no Add activity menu entry; this inherited limitation is not claimed repaired here.