Merge verified navigation with delivered Help and Save

This commit is contained in:
2026-10-02 00:28:17 -07:00
673 changed files with 97345 additions and 99 deletions
+60 -6
View File
@@ -340,6 +340,18 @@ pub(crate) fn diagnostic(source: &str, error: Error) -> Diagnostic {
},
}
}
pub(crate) fn diagnostic_with_issue(
source: &str,
error: Error,
issue: Option<validation::Issue>,
) -> Diagnostic {
let mut result = diagnostic(source, error);
if let Some(issue) = issue {
result.object_id = issue.element;
result.field = issue.field;
}
result
}
fn reserved(word: &str) -> bool {
crate::reserved_alias(word)
|| declaration_kind(word).is_some()
@@ -622,6 +634,7 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
return v2_source::parse(source, tokens, limits);
}
let mut migration_issue = None;
Parser {
source,
tokens,
@@ -629,8 +642,8 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
value_depth: 0,
limits,
}
.document()
.map_err(|e| vec![diagnostic(source, e)])
.document(&mut migration_issue)
.map_err(|e| vec![diagnostic_with_issue(source, e, migration_issue)])
}
fn offset_for(source: &str, line: usize, column: usize) -> usize {
let mut offset = 0;
@@ -1449,7 +1462,10 @@ impl Parser<'_> {
},
}
}
fn document(&mut self) -> Result<ParsedDocument, Error> {
fn document(
&mut self,
migration_issue: &mut Option<validation::Issue>,
) -> Result<ParsedDocument, Error> {
let unit = self.unit(true, false)?;
let header = unit.header.expect("entry header");
let mut aliases = BTreeMap::from([(header.project.alias.clone(), header.project.id)]);
@@ -1476,9 +1492,14 @@ impl Parser<'_> {
.map(|(key, span)| ((d.id, key.clone()), span.start)),
);
}
let decoded =
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
.map_err(|(_, error)| error)?;
let decoded = resolve_declarations(
header.project,
&unit.declarations,
&aliases,
self.limits,
migration_issue,
)
.map_err(|(_, error)| error)?;
Ok(ParsedDocument {
project: decoded.project,
migration: decoded.migration,
@@ -1682,6 +1703,7 @@ pub(crate) fn resolve_declarations(
declarations: &[Declaration],
aliases: &BTreeMap<String, Id>,
limits: ParseLimits,
migration_issue: &mut Option<validation::Issue>,
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
let legacy = project.schema_version == 3;
let mut legacy_elements = serde_json::Map::new();
@@ -1989,6 +2011,38 @@ pub(crate) fn resolve_declarations(
crate::persistence::migrate_v6(project, limits)
})
.map_err(|error| {
// These frozen admission checks formerly ran in final validation.
// Keep their diagnostic code and reference location after moving
// them before schema normalization.
if let crate::persistence::FileError::Model { issues, .. } = &error
&& let Some(issue) = issues.first()
&& let Some(code) = match issue.code.as_str() {
"diagram_layer" => Some("diagram_layer"),
"view_options" => Some("view_options"),
_ => None,
}
{
let index = issue
.element
.and_then(|id| declarations.iter().position(|d| d.id == id))
.unwrap_or(usize::MAX);
let offset = declarations.get(index).map_or(0, |d| {
issue
.field
.as_ref()
.and_then(|field| d.field_spans.get(field))
.map_or(d.offset, |span| span.start)
});
*migration_issue = Some(issue.clone());
return (
index,
Error {
code,
offset,
message: issue.message.clone(),
},
);
}
(
usize::MAX,
Error {
+9 -3
View File
@@ -69,8 +69,8 @@ impl Project4 {
}
crate::persistence::check_pre6_relations(self.relations.values())?;
crate::persistence::check_pre6_elements(self.elements.values())?;
Ok(Project {
schema_version: SCHEMA_VERSION,
let mut project = Project {
schema_version: self.schema_version,
id: self.id,
alias: self.alias,
name: self.name,
@@ -84,7 +84,11 @@ impl Project4 {
.into_iter()
.map(|(id, trace)| (id, trace.into()))
.collect(),
})
};
// Schema3's adapter also enters here, without calling `finish`.
crate::persistence::check_pre7_diagram_admission(&project)?;
project.schema_version = SCHEMA_VERSION;
Ok(project)
}
}
@@ -104,6 +108,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
// Source4 enters here directly rather than through `Project4`.
crate::persistence::check_pre7_diagram_admission(&project)?;
crate::persistence::check_pre6_relations(project.relations.values())?;
crate::persistence::check_pre6_elements(project.elements.values())?;
project.schema_version = SCHEMA_VERSION;
+46
View File
@@ -341,6 +341,50 @@ pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), Fil
}
Ok(())
}
/// Frozen sources 3–6 admit opposite-layer placements and visibility references
/// only in Traceability. Check before migration discards the advertised version;
/// current ordinary projections may retain these records as latent presentation.
pub(crate) fn check_pre7_diagram_admission(project: &Project) -> Result<(), FileError> {
let mut issues = Vec::new();
for diagram in project.diagrams.values() {
let option_issues = if diagram.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(project, &diagram.view_options)
} else {
crate::view_options::validate_options(project, diagram.layer, &diagram.view_options)
};
for issue in option_issues {
validation::error(
&mut issues,
diagram.id,
"view_options",
issue.field,
issue.message,
);
}
for node in diagram.nodes.values() {
if !project.elements.get(&node.element).is_some_and(|element| {
diagram.projection == ProjectionKind::Traceability
|| element.layer() == diagram.layer
}) {
validation::error(
&mut issues,
node.id,
"diagram_layer",
"element",
"Diagram nodes reference existing elements in the selected layer.",
);
}
}
}
if let Some(issue) = issues.into_iter().next() {
return Err(FileError::Model {
message: issue.message.clone(),
issues: vec![issue],
});
}
Ok(())
}
pub(crate) fn migrate_v5(
mut project: Project,
limits: crate::language::ParseLimits,
@@ -348,6 +392,7 @@ pub(crate) fn migrate_v5(
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
check_pre7_diagram_admission(&project)?;
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
@@ -420,6 +465,7 @@ pub(crate) fn migrate_v6(
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
check_pre7_diagram_admission(&project)?;
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
+1
View File
@@ -85,6 +85,7 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
.filter(|n| {
p.elements.get(&n.element).is_some_and(|e| {
e.kind() != Kind::Port
&& (d.projection == ProjectionKind::Traceability || e.layer() == d.layer)
&& d.projection.includes(e.kind())
&& d.projection
.state_machine()
+11
View File
@@ -589,6 +589,17 @@ fn apply_command(
.map(|d| d.id)
.ok_or_else(missing)?;
if source != *diagram {
let destination = &p.diagrams[diagram];
let element = p.diagrams[&source].nodes[node].element;
if destination.projection != ProjectionKind::Traceability
&& p.elements
.get(&element)
.is_none_or(|e| e.layer() != destination.layer)
{
return Err(ModelError(
"Move the placement to a view in its layer or a traceability view.".into(),
));
}
let placement = p
.diagrams
.get_mut(&source)
+20 -10
View File
@@ -573,16 +573,26 @@ fn compile_once(
span,
}),
};
let decoded =
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: language::diagnostic(&bundle.files[path], error),
related: Vec::new(),
}]
})?;
let mut migration_issue = None;
let decoded = language::resolve_declarations(
header.project,
&declarations,
&aliases,
limits.model,
&mut migration_issue,
)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: language::diagnostic_with_issue(
&bundle.files[path],
error,
migration_issue,
),
related: Vec::new(),
}]
})?;
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
let errors: Vec<_> = diagnostics
.iter()
+12 -7
View File
@@ -620,11 +620,14 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
crate::scenario_layout::validate_diagram(p, d, &mut out);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
crate::view_options::validate_options(p, d.layer, &d.view_options)
};
// Ordinary projections are reversible filters over the same authored view.
// Retain opposite-layer visibility choices when leaving Traceability.
let option_issues =
if d.projection.state_machine().is_none() && d.projection.scenario().is_none() {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
crate::view_options::validate_options(p, d.layer, &d.view_options)
};
for issue in option_issues {
error(&mut out, d.id, "view_options", issue.field, issue.message);
}
@@ -798,8 +801,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
);
}
match p.elements.get(&n.element) {
Some(e) if d.projection == ProjectionKind::Traceability || e.layer() == d.layer => {
}
Some(e)
if (d.projection.state_machine().is_none()
&& d.projection.scenario().is_none())
|| e.layer() == d.layer => {}
_ => error(
&mut out,
n.id,
+3 -1
View File
@@ -218,7 +218,9 @@ pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec
validate_options_in(p, Some(layer), options)
}
/// Traceability views admit references from either architecture layer.
/// Ordinary saved views retain references from either architecture layer so
/// projection changes preserve authored visibility choices. Projection decides
/// which layer is visible; typed State/Scenario views use layer-scoped validation.
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, None, options)
}
@@ -0,0 +1,205 @@
//! Current latent presentation must not broaden frozen source/JSON admission.
use oa_core::{view_options::*, *};
fn legacy_json(project: &Project, version: u32) -> Vec<u8> {
let mut value = serde_json::to_value(project).unwrap();
value["schema_version"] = version.into();
for diagram in value["diagrams"].as_object_mut().unwrap().values_mut() {
let fields = diagram.as_object_mut().unwrap();
fields.remove("layout");
if version < 6 {
fields.remove("view_options");
}
}
serde_json::to_vec(&value).unwrap()
}
fn model_issue(error: persistence::FileError) -> validation::Issue {
match error {
persistence::FileError::Model { mut issues, .. } => issues.remove(0),
other => panic!("Expected a located model issue, got {other:?}"),
}
}
#[test]
fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() {
let node_id = Id::parse_str("00000000-0000-4000-8000-000000000004").unwrap();
for version in 3..=6 {
let source = format!(
"arcadia {version}; project compat \"Compatibility\" id \"00000000-0000-4000-8000-000000000001\" {{\n\
sa_function work \"Counterpart\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n\
diagram view \"OA concern\" id \"00000000-0000-4000-8000-000000000003\" {{ layer Oa; projection Traceability; }}\n\
node occurrence id \"00000000-0000-4000-8000-000000000004\" {{ diagram @view; element @work; }}\n}}"
);
let mixed = language::parse(&source).unwrap();
assert_eq!(
persistence::decode(&legacy_json(&mixed, version)).unwrap(),
mixed
);
let ordinary = source.replace("projection Traceability", "projection Overview");
let same_layer = ordinary.replace("layer Oa", "layer Sa");
let ordinary_project = language::parse(&same_layer).unwrap();
assert_eq!(
persistence::decode(&legacy_json(&ordinary_project, version)).unwrap(),
ordinary_project
);
let errors = language::parse(&ordinary).unwrap_err();
// Source3 already reported migration errors with the offending location.
let code = if version == 3 {
"migration_error"
} else {
"diagram_layer"
};
assert!(
errors.iter().any(|error| error.code == code
&& error.line == 4
&& error.message.contains("selected layer")),
"{version}: {errors:?}"
);
let mut latent = mixed.clone();
let diagram = latent.diagrams.values_mut().next().unwrap();
diagram.projection = ProjectionKind::Overview;
let node = diagram.nodes.values().next().unwrap().id;
assert_eq!(node, node_id);
if version >= 4 {
let diagnostic = errors
.iter()
.find(|error| error.code == "diagram_layer")
.unwrap();
assert_eq!(diagnostic.object_id, Some(node_id));
assert_eq!(diagnostic.field.as_deref(), Some("element"));
let bundle = source_project::SourceBundle {
entry: "model.arcadia".into(),
files: std::collections::BTreeMap::from([("model.arcadia".into(), ordinary)]),
};
let errors =
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default())
.unwrap_err();
let diagnostic = &errors
.iter()
.find(|error| error.diagnostic.code == "diagram_layer")
.unwrap()
.diagnostic;
assert_eq!(diagnostic.object_id, Some(node_id));
assert_eq!(diagnostic.field.as_deref(), Some("element"));
}
let issue = model_issue(persistence::decode(&legacy_json(&latent, version)).unwrap_err());
assert_eq!(issue.code, "diagram_layer");
assert_eq!(issue.element, Some(node));
assert_eq!(issue.field.as_deref(), Some("element"));
// The exact same record is valid under the current presentation contract.
assert_eq!(language::parse(&language::format(&latent)).unwrap(), latent);
assert_eq!(
persistence::decode(&serde_json::to_vec(&latent).unwrap()).unwrap(),
latent
);
let mut dangling = mixed;
dangling
.diagrams
.values_mut()
.next()
.unwrap()
.nodes
.values_mut()
.next()
.unwrap()
.element = Id::parse_str("00000000-0000-4000-8000-000000000005").unwrap();
let issue = model_issue(persistence::decode(&legacy_json(&dangling, version)).unwrap_err());
assert_eq!(issue.code, "diagram_layer");
assert_eq!(issue.element, Some(node));
assert_eq!(issue.field.as_deref(), Some("element"));
}
}
#[test]
fn frozen_6_checks_every_layer_sensitive_saved_selector_before_upgrade() {
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "A", None)
.unwrap();
let b = project
.add_element(Kind::Activity, Layer::Oa, "B", None)
.unwrap();
let edge = project.relate(RelationKind::Interaction, "Interaction", a, b);
let category = project
.add_element(Kind::ExchangeCategory, Layer::Oa, "Work category", None)
.unwrap();
let participant_category = project
.add_element(
Kind::ExchangeCategory,
Layer::Oa,
"Participant category",
None,
)
.unwrap();
*project
.elements
.get_mut(&participant_category)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Participant,
exchanges: vec![],
};
let context = project
.add_element(Kind::Process, Layer::Oa, "Process", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Sa);
for (field, options) in [
(
"hidden_edges",
ViewOptions {
hidden_edges: vec![edge],
..Default::default()
},
),
(
"work_categories",
ViewOptions {
work_categories: CategorySelection::Any(vec![category]),
..Default::default()
},
),
(
"participant_categories",
ViewOptions {
participant_categories: CategorySelection::Any(vec![participant_category]),
..Default::default()
},
),
(
"work_contexts",
ViewOptions {
work_contexts: ContextSelection::Any(vec![context]),
..Default::default()
},
),
] {
project.diagrams.get_mut(&diagram).unwrap().view_options = options;
let issue = model_issue(persistence::decode(&legacy_json(&project, 6)).unwrap_err());
assert_eq!(issue.code, "view_options");
assert_eq!(issue.element, Some(diagram));
assert_eq!(issue.field.as_deref(), Some(field));
let current_source = language::format(&project);
assert_eq!(language::parse(&current_source).unwrap(), project);
let source6 = current_source.replacen("arcadia 7;", "arcadia 6;", 1);
let errors = language::parse(&source6).unwrap_err();
let diagnostic = errors
.iter()
.find(|error| error.code == "view_options")
.unwrap();
assert_eq!(diagnostic.object_id, Some(diagram));
assert_eq!(diagnostic.field.as_deref(), Some(field));
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
assert_eq!(
persistence::decode(&legacy_json(&project, 6)).unwrap(),
project
);
let source6 = language::format(&project).replacen("arcadia 7;", "arcadia 6;", 1);
assert_eq!(language::parse(&source6).unwrap(), project);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
}
}
@@ -0,0 +1,304 @@
//! Populated presentation survives mixed-layer projection switches.
//! Complements projection_switching.rs without external fixture dependencies.
use oa_core::{
projection::*, routing::route_diagram, source_project::*, view_options::ViewIndex, *,
};
struct Populated {
project: Project,
diagram: Id,
groups: [Id; 2],
children: [[Id; 2]; 2],
edges: [Id; 2],
ports: [Id; 2],
trace: Id,
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn apply(project: &mut Project, command: Command) {
*project = prepare_command(project, &command).unwrap().project;
}
fn fixture(layer: Layer, collapsed: bool) -> Populated {
let mut p = Project::empty();
let oa_group = p
.add_element(Kind::Activity, Layer::Oa, "Operational response", None)
.unwrap();
let oa_send = p
.add_element(Kind::Activity, Layer::Oa, "Request repair", Some(oa_group))
.unwrap();
let oa_receive = p
.add_element(Kind::Activity, Layer::Oa, "Perform repair", Some(oa_group))
.unwrap();
let sa_group = p
.add_element(Kind::Function, Layer::Sa, "System response", None)
.unwrap();
let sa_send = p
.add_element(Kind::Function, Layer::Sa, "Dispatch repair", Some(sa_group))
.unwrap();
let sa_receive = p
.add_element(Kind::Function, Layer::Sa, "Execute repair", Some(sa_group))
.unwrap();
let oa_edge = p.relate(
RelationKind::Interaction,
"Repair request",
oa_send,
oa_receive,
);
let sa_edge = p.relate(
RelationKind::FunctionalExchange,
"Repair dispatch",
sa_send,
sa_receive,
);
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&sa_edge].body
else {
unreachable!()
};
let trace = Id::new_v4();
let refinement = Trace {
id: trace,
alias: p.fresh_alias("dispatch_refinement", "trace", trace),
name: "Dispatch refines repair request".into(),
description: "Keep this cross-layer trace and its authored route.".into(),
oa: oa_send,
sa: sa_send,
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(oa_send).unwrap(),
sa: p.properties(sa_send).unwrap(),
semantic: None,
}),
};
apply(&mut p, Command::CreateTrace(refinement));
let d = p.ensure_diagram(layer);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
let oa_group_node = p.place(d, oa_group, xy(0., 0.));
let oa_send_node = p.place(d, oa_send, xy(40., 120.));
let oa_receive_node = p.place(d, oa_receive, xy(360., 120.));
let sa_group_node = p.place(d, sa_group, xy(0., 500.));
let sa_send_node = p.place(d, sa_send, xy(40., 620.));
let sa_receive_node = p.place(d, sa_receive, xy(360., 620.));
for (node, port, side, offset) in [
(sa_send_node, source_port, PortAnchorSide::Bottom, 0.25),
(sa_receive_node, target_port, PortAnchorSide::Top, 0.75),
] {
apply(
&mut p,
Command::SetPortAnchor {
diagram: d,
node,
anchor: PortAnchor { port, side, offset },
},
);
}
for (edge, y, port_backed) in [
(oa_edge, 260., false),
(sa_edge, 860., true),
(trace, 420., false),
] {
apply(
&mut p,
Command::SetEdgeRoute {
diagram: d,
route: EdgeRoute {
edge,
source_attachment: (!port_backed).then_some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.3,
}),
target_attachment: (!port_backed).then_some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.7,
}),
waypoints: vec![xy(680., y), xy(740., y)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: xy(13., -27.),
}),
},
},
);
}
for node in [oa_group_node, sa_group_node] {
let mut placement = p.diagrams[&d].nodes[&node].clone();
placement.collapsed = collapsed;
placement.size = Size {
width: 310.,
height: 170.,
};
apply(
&mut p,
Command::EditNode {
diagram: d,
original: node,
node: placement,
},
);
}
validate_candidate(&p).unwrap();
Populated {
project: p,
diagram: d,
groups: [oa_group_node, sa_group_node],
children: [
[oa_send_node, oa_receive_node],
[sa_send_node, sa_receive_node],
],
edges: [oa_edge, sa_edge],
ports: [source_port, target_port],
trace,
}
}
fn assert_round_trips(p: &Project) {
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
assert_eq!(
persistence::decode(&serde_json::to_vec(p).unwrap()).unwrap(),
*p
);
assert_eq!(
compile_bundle(&format_bundle(p), ProjectLimits::default())
.unwrap()
.decoded
.project,
*p
);
}
fn assert_layer_only(p: &Project, d: Id, view: &ProjectedView) {
let layer = p.diagrams[&d].layer;
for node in &view.nodes {
assert_eq!(p.elements[&node.element].layer(), layer);
if let Some(parent) = node.visual_parent {
let parent = view.nodes.iter().find(|n| n.id == parent).unwrap();
assert_eq!(p.elements[&parent.element].layer(), layer);
}
}
for port in &view.ports {
assert_eq!(p.elements[&port.element].layer(), layer);
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
assert_eq!(p.elements[&owner.element].layer(), layer);
}
for edge in &view.edges {
assert!(
!edge.trace,
"cross-layer trace leaked into ordinary projection"
);
assert_eq!(p.layer_of(edge.id), Some(layer));
for endpoint in [edge.source, edge.target] {
let owner = view.nodes.iter().find(|n| n.id == endpoint).unwrap();
assert_eq!(p.elements[&owner.element].layer(), layer);
}
for port in [edge.source_port, edge.target_port].into_iter().flatten() {
assert_eq!(p.elements[&port].layer(), layer);
}
}
}
#[test]
fn populated_mixed_projection_switch_conserves_authorship_and_restores_geometry() {
for layer in [Layer::Oa, Layer::Sa] {
for collapsed in [false, true] {
let f = fixture(layer, collapsed);
let baseline = &f.project;
let d = f.diagram;
let before_view = project_view(baseline, d);
let before_routes = route_diagram(baseline, &baseline.diagrams[&d], &before_view);
// Guard against a vacuous fixture: Traceability deliberately flattens parents.
assert_eq!(before_view.nodes.len(), 6);
assert!(before_view.nodes.iter().all(|n| n.visual_parent.is_none()));
for id in [f.edges[0], f.edges[1], f.trace] {
assert!(before_view.edges.iter().any(|e| e.id == id));
let route = baseline.diagrams[&d]
.routes
.iter()
.find(|r| r.edge == id)
.unwrap();
assert_eq!(route.waypoints.len(), 2);
assert!(route.label.is_some());
}
for port in f.ports {
assert!(before_view.ports.iter().any(|p| p.element == port));
}
assert_round_trips(baseline);
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Allocation,
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
let mut expected = baseline.clone();
expected.diagrams.get_mut(&d).unwrap().projection = projection;
let mut session = Session::new(baseline.clone());
session
.execute(Command::EditDiagram(d, expected.diagrams[&d].clone()))
.unwrap();
assert_eq!(session.project(), &expected);
assert_round_trips(session.project());
let view = project_view(session.project(), d);
assert_eq!(
view,
project_view_with_index(&expected, d, &ViewIndex::new(&expected))
);
assert_layer_only(session.project(), d, &view);
if projection == ProjectionKind::Overview {
let own = if layer == Layer::Oa { 0 } else { 1 };
assert!(view.nodes.iter().any(|n| n.id == f.groups[own]));
for child in f.children[own] {
if collapsed {
assert!(!view.nodes.iter().any(|n| n.id == child));
} else {
assert_eq!(
view.nodes
.iter()
.find(|n| n.id == child)
.unwrap()
.visual_parent,
Some(f.groups[own])
);
}
}
if !collapsed {
assert!(view.edges.iter().any(|e| e.id == f.edges[own]));
if layer == Layer::Sa {
assert_eq!(view.ports.len(), 2);
}
}
}
session.undo();
assert_eq!(session.project(), baseline);
assert!(
!session.can_undo(),
"projection change must be one model history step"
);
assert_round_trips(session.project());
session.redo();
assert_eq!(session.project(), &expected);
session
.execute(Command::EditDiagram(d, baseline.diagrams[&d].clone()))
.unwrap();
assert_eq!(session.project(), baseline);
let restored = project_view(session.project(), d);
assert_eq!(restored, before_view);
assert_eq!(
route_diagram(
session.project(),
&session.project().diagrams[&d],
&restored
),
before_routes
);
assert_round_trips(session.project());
}
}
}
}
@@ -0,0 +1,156 @@
use oa_core::{source_project::*, view_options::*, *};
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "Assess alarm", None)
.unwrap();
let oa2 = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "Assess alarm", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Alarm", oa, oa2);
let category = p
.add_element(Kind::ExchangeCategory, Layer::Oa, "Alarm data", None)
.unwrap();
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Work,
exchanges: vec![edge],
};
let context = p
.add_element(Kind::Process, Layer::Oa, "Alarm handling", None)
.unwrap();
let d = p.ensure_diagram(layer);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
for (i, element) in [oa, oa2, sa].into_iter().enumerate() {
p.place(
d,
element,
Position {
x: 300. * i as f32,
y: 40.,
},
);
}
p.diagrams.get_mut(&d).unwrap().view_options = ViewOptions {
hidden_edges: vec![edge],
work_categories: CategorySelection::Any(vec![category]),
work_contexts: ContextSelection::Any(vec![context]),
..Default::default()
};
validate_candidate(&p).unwrap();
(p, d, oa, sa, category)
}
#[test]
fn projection_switching_preserves_complete_model_history_and_all_serializations() {
for layer in [Layer::Oa, Layer::Sa] {
let (baseline, diagram, _, _, _) = fixture(layer);
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Allocation,
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
let mut expected = baseline.clone();
expected.diagrams.get_mut(&diagram).unwrap().projection = projection;
let mut session = Session::new(baseline.clone());
session
.execute(Command::EditDiagram(
diagram,
expected.diagrams[&diagram].clone(),
))
.unwrap();
assert_eq!(session.project(), &expected);
let view = projection::project_view(session.project(), diagram);
assert!(
view.nodes
.iter()
.all(|n| expected.elements[&n.element].layer() == layer)
);
assert_eq!(
view,
projection::project_view_with_index(&expected, diagram, &ViewIndex::new(&expected))
);
let source = language::format(&expected);
assert_eq!(language::parse(&source).unwrap(), expected);
assert_eq!(
persistence::decode(&serde_json::to_vec(&expected).unwrap()).unwrap(),
expected
);
let bundle = format_bundle(&expected);
assert_eq!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
session.undo();
assert_eq!(session.project(), &baseline);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &expected);
session
.execute(Command::EditDiagram(
diagram,
baseline.diagrams[&diagram].clone(),
))
.unwrap();
assert_eq!(session.project(), &baseline);
assert_eq!(
projection::project_view(session.project(), diagram),
projection::project_view(&baseline, diagram)
);
}
}
}
#[test]
fn projection_switching_does_not_admit_missing_elements_or_invalid_filter_kinds() {
let (mut p, d, oa, _, category) = fixture(Layer::Sa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
validate_candidate(&p).unwrap();
let valid = p.clone();
let node = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == oa)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.element = Id::new_v4();
assert!(validate_candidate(&p).is_err());
p = valid.clone();
p.diagrams.get_mut(&d).unwrap().view_options.work_categories = CategorySelection::Any(vec![oa]);
assert!(validate_candidate(&p).is_err());
p = valid;
p.elements.remove(&category);
assert!(validate_candidate(&p).is_err());
}
#[test]
fn projection_switching_keeps_typed_state_membership_strict() {
let (mut p, d, _, _, _) = fixture(Layer::Sa);
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
.unwrap();
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
assert!(
validation::validate(&p)
.iter()
.any(|i| i.code == "state_diagram_member")
);
assert!(validate_candidate(&p).is_err());
}
+12 -1
View File
@@ -67,7 +67,18 @@ fn source6_traceability_default_keeps_both_layers_and_indexed_projection_matches
.any(|e| e.id == interaction)
);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Overview;
assert!(validate_candidate(&p).is_err());
validate_candidate(&p).unwrap();
let ordinary = projection::project_view_with_index(&p, d, &index);
assert!(
ordinary
.nodes
.iter()
.all(|n| p.elements[&n.element].layer() == Layer::Oa)
);
assert!(ordinary.edges.iter().any(|e| e.id == interaction));
assert!(!ordinary.edges.iter().any(|e| e.id == fe));
assert_eq!(p.diagrams[&d].nodes.len(), 4);
assert_eq!(p.diagrams[&d].view_options.hidden_edges, vec![fe]);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
p.diagrams.get_mut(&d).unwrap().view_options.relation_kinds = RelationKinds::Only(vec![]);
assert!(
+208 -42
View File
@@ -4,8 +4,12 @@ pub(crate) mod architecture_authoring;
mod architecture_authoring_tests;
#[cfg(test)]
mod behavior_removal_tests;
#[cfg(test)]
mod canvas_selection_tests;
mod checkpoints;
#[cfg(test)]
mod connection_begin_regressions;
#[cfg(test)]
mod context_help_tests;
#[cfg(test)]
mod diagram_focus_tests;
@@ -21,6 +25,8 @@ mod hosted;
pub(crate) mod multiselect;
#[cfg(test)]
mod multiselect_tests;
#[cfg(test)]
mod placement_relocation_regressions;
mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
@@ -387,6 +393,7 @@ pub enum Message {
Behavior(crate::behavior::Event),
Layer(Layer),
Select(Id),
CanvasSelect(crate::route_edit::Context, Id),
EditProperties(Id),
SelectNode(Id),
DiagramSelection(multiselect::Event),
@@ -955,6 +962,16 @@ impl App {
return true;
}
let original = self.selected;
let canvas_surface = self.architecture_surface();
let placement_owner = self.draft.as_ref().and_then(|draft| match draft.kind {
crate::forms::RecordKind::Node(owner) => self
.project()
.diagrams
.get(&owner)
.filter(|diagram| diagram.nodes.contains_key(&draft.original))
.map(|diagram| diagram.id),
_ => None,
});
let result = self
.draft
.as_ref()
@@ -1009,13 +1026,16 @@ impl App {
if let Some(layer) = self.project().layer_of(id) {
self.layer = layer;
}
if let Some((diagram, layer, node)) = self
let selected_surface = self
.project()
.diagrams
.values()
.find(|d| d.id == id || d.nodes.contains_key(&id))
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)))
{
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)));
let relocated_placement = placement_owner
.zip(selected_surface.and_then(|(diagram, _, node)| node.map(|_| diagram)))
.is_some_and(|(before, after)| before != after);
if let Some((diagram, layer, node)) = selected_surface {
self.layer = layer;
if self.page == Page::Behavior
&& let Some(machine) =
@@ -1029,6 +1049,20 @@ impl App {
self.selected_node = node;
}
}
// Ordinary Properties edits retain their saved architecture view.
// Follow an explicitly edited diagram identity/layer, never an
// inferred replacement for an unrelated queued canvas click.
// An accepted placement move explicitly chooses its new view.
// Same-owner edits (including a new UUID) still retain inspection.
if !relocated_placement && let Some((diagram, _, _)) = canvas_surface {
let diagram = if diagram == original { id } else { diagram };
if let Some(view) = self.project().diagrams.get(&diagram)
&& view.projection.is_architecture()
{
self.layer = view.layer;
self.active_diagram = Some(diagram);
}
}
self.sync();
self.status = "Properties applied".into();
true
@@ -1179,7 +1213,13 @@ impl App {
if id != self.selected {
self.selected_attachment = None;
}
if self.selected == id && !process_preview {
if self.selected == id
&& !process_preview
&& self
.project()
.layer_of(id)
.is_none_or(|layer| layer == self.layer)
{
return;
}
if !self.apply() {
@@ -1225,6 +1265,59 @@ impl App {
}
self.refresh_draft();
}
/// The saved architecture surface, independent of the Inspector's object.
fn architecture_surface(&self) -> Option<(Id, Layer, ProjectionKind)> {
if self.page != Page::Diagram {
return None;
}
let diagram = self.project().diagrams.get(&self.active_diagram?)?;
diagram.projection.is_architecture().then_some((
diagram.id,
diagram.layer,
diagram.projection,
))
}
fn canvas_target_visible(&self, diagram: Id, id: Id) -> bool {
let view = project_view(self.project(), diagram);
view.nodes
.iter()
.any(|node| node.id == id || node.element == id)
|| view.ports.iter().any(|port| port.element == id)
|| view.edges.iter().any(|edge| edge.id == id)
}
/// Inspect a contextual target without following its model layer. An edit
/// may invalidate a previously visible occurrence; accept the edit but
/// refuse that old hit. Invisible Show-in-view targets remain intentional.
fn inspect_canvas_target(&mut self, id: Id) -> bool {
if self.selected == id {
return true;
}
let surface = self.architecture_surface();
let accepted_draft = self.draft_dirty();
let was_visible = accepted_draft
&& surface.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id));
if !self.project().contains(id) || !self.apply() {
return false;
}
if !self.project().contains(id)
|| (was_visible
&& (self.architecture_surface() != surface
|| !surface
.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id))))
{
if accepted_draft {
self.status =
"Properties applied. The view changed; select a visible target again.".into();
}
return false;
}
self.selected = id;
self.selected_node = None;
self.selected_attachment = None;
self.selected_bend = None;
self.refresh_draft();
true
}
fn reveal_explorer_selection(&mut self) {
if let Some(group) = self.explorer_index.group(self.selected) {
self.explorer_groups_collapsed.remove(&group);
@@ -1425,6 +1518,11 @@ impl App {
{
return Task::none();
}
if let Message::CanvasSelect(captured, _) = &message
&& (self.page != Page::Diagram || self.route_context() != Some(*captured))
{
return Task::none();
}
// Selection can change without advancing the captured canvas context.
if let Message::Route(
crate::route_edit::Event::Commit(captured, changed, ..)
@@ -2185,6 +2283,7 @@ impl App {
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
Message::Projection(projection) => active || projection.scenario().is_some(),
Message::SelectNode(_)
| Message::CanvasSelect(..)
| Message::PlaceSelected
| Message::HideSelected
| Message::ToggleCollapse
@@ -2785,10 +2884,26 @@ impl App {
e.kind() != Kind::Port && d.projection.includes(e.kind())
})
});
if show_in_mixed_view {
self.selected = menu.request.object;
self.selected_attachment = None;
self.refresh_draft();
// Draw acts on the visible current view even when opened from
// Explorer; explicit Select/Open actions still navigate.
let draw_in_current_view = match &*action {
Message::Connection(crate::connection_edit::Event::Begin(tool, source)) => {
*source == menu.request.object
&& self.active_diagram.is_some_and(|diagram| {
crate::connection_edit::can_begin(
self.project(),
diagram,
*tool,
*source,
)
})
}
_ => false,
};
if show_in_mixed_view || draw_in_current_view || menu.request.canvas.is_some() {
if !self.inspect_canvas_target(menu.request.object) {
return Task::none();
}
} else {
self.select(menu.request.object);
}
@@ -2800,7 +2915,15 @@ impl App {
}
self.reconcile_multiselect();
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) => {
self.create_kind = kind;
@@ -2985,6 +3108,14 @@ impl App {
self.status = "Property draft reverted".into();
}
Message::Select(id) => self.select(id),
Message::CanvasSelect(context, id) => {
let view = project_view(self.project(), context.diagram);
if view.ports.iter().any(|port| port.element == id)
|| view.edges.iter().any(|edge| edge.id == id)
{
self.inspect_canvas_target(id);
}
}
Message::DiagramSelection(event) => self.multiselect_event(event),
Message::EditProperties(id) => {
self.select(id);
@@ -3004,10 +3135,23 @@ impl App {
return Task::none();
}
let original = self.selected;
let surface = self.architecture_surface();
let accepted_draft = self.draft_dirty();
if !self.apply() {
return Task::none();
}
let id = if id == original { self.selected } else { id };
if let Some((diagram, _, _)) = surface
&& (self.architecture_surface() != surface
|| !self.canvas_target_visible(diagram, id))
{
if accepted_draft {
self.status =
"Properties applied. The view changed; select a visible target again."
.into();
}
return Task::none();
}
if let Some(d) = self
.active_diagram
.and_then(|d| self.project().diagrams.get(&d))
@@ -3432,7 +3576,7 @@ impl App {
self.workflow = Some(crate::workflow::Workflow::relationship_kind(
self.project(),
self.selected,
self.layer,
self.project().layer_of(self.selected).unwrap_or(self.layer),
kind,
incoming,
));
@@ -4458,13 +4602,18 @@ impl App {
self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into();
return;
}
let creation_layer = if child {
self.project().layer_of(self.selected).unwrap_or(self.layer)
} else {
self.layer
};
let allocated = child
&& self.create_kind.is_work()
&& self
.project()
.elements
.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 {
Some(self.selected)
} else {
@@ -4472,7 +4621,7 @@ impl App {
};
let Some(e) = self.project().make_element(
self.create_kind,
self.layer,
creation_layer,
&format!("New {}", self.create_kind.label().to_lowercase()),
parent,
) else {
@@ -4501,16 +4650,16 @@ impl App {
.project()
.diagrams
.values()
.find(|d| d.layer == self.layer && d.projection == preferred)
.find(|d| d.layer == creation_layer && d.projection == preferred)
.map(|d| d.id);
if diagram_target.is_none() {
let view = Id::new_v4();
commands.push(Command::CreateDiagram(Diagram {
id: view,
alias: self.project().fresh_alias("engineering_view", "view", view),
name: format!("{} {preferred}", self.layer),
name: format!("{} {preferred}", creation_layer),
description: String::new(),
layer: self.layer,
layer: creation_layer,
projection: preferred,
layout: None,
routes: Vec::new(),
@@ -4643,7 +4792,13 @@ impl App {
));
}
if self.execute(Command::Batch(commands)) {
let changed_view = 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.inspector_tab = crate::navigation::InspectorTab::Properties;
self.refresh_draft();
@@ -4657,7 +4812,7 @@ impl App {
self.workflow = Some(crate::workflow::Workflow::relationship(
self.project(),
self.selected,
self.layer,
self.project().layer_of(self.selected).unwrap_or(self.layer),
));
}
fn add_trace(&mut self) {
@@ -5078,8 +5233,30 @@ impl App {
epoch: self.canvas_epoch,
})
}
// Admission belongs to the caller: palette choice, contextual source, or
// an already admitted session whose complete context is still current.
fn arm_connection(
&mut self,
context: crate::route_edit::Context,
tool: crate::connection_tool::Tool,
) -> crate::route_edit::Context {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let context = crate::route_edit::Context {
epoch: self.canvas_epoch,
..context
};
self.connection = Some(crate::connection_edit::Session::new(
self.project(),
&project_view(self.project(), context.diagram),
context,
tool,
));
self.selected_bend = None;
self.status = tool.hint().into();
context
}
fn edit_connection(&mut self, event: crate::connection_edit::Event) {
use crate::connection_edit::{Event, Session};
use crate::connection_edit::Event;
let Some(context) = self.route_context() else {
return;
};
@@ -5095,10 +5272,13 @@ impl App {
}
match event {
Event::Begin(tool, source) => {
self.edit_connection(Event::Choose(Some(tool)));
if let Some(context) = self.route_context() {
self.edit_connection(Event::Endpoint(context, source));
if !crate::connection_edit::can_begin(self.project(), context.diagram, tool, source)
{
self.status = "Choose a visible compatible source in this diagram.".into();
return;
}
let context = self.arm_connection(context, tool);
self.edit_connection(Event::Endpoint(context, source));
}
Event::Choose(Some(tool)) => {
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
@@ -5112,16 +5292,7 @@ impl App {
{
return;
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let context = self.route_context().unwrap();
self.connection = Some(Session::new(
self.project(),
&project_view(self.project(), context.diagram),
context,
tool,
));
self.selected_bend = None;
self.status = tool.hint().into();
self.arm_connection(context, tool);
}
Event::Endpoint(captured, id) => {
if captured != context {
@@ -5182,17 +5353,9 @@ impl App {
&project_view(self.project(), context.diagram),
id,
);
if session.tool == crate::connection_tool::Tool::Allocation
&& self.project().diagrams[&context.diagram].projection
== ProjectionKind::Traceability
{
// Mixed views can allocate either perspective. Choosing
// a visible source must not navigate to its preferred view.
self.selected = id;
self.selected_attachment = None;
self.refresh_draft();
} else {
self.select(id);
if !self.inspect_canvas_target(id) {
self.connection = Some(session);
return;
}
self.status = if session.candidates.is_empty() {
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
@@ -5208,8 +5371,11 @@ impl App {
Event::Restart(captured) => {
if captured == context
&& let Some(session) = &self.connection
&& session.context == context
{
self.edit_connection(Event::Choose(Some(session.tool)));
// A contextual mixed-layer tool need not be in this layer's
// palette. Its unchanged session context retains admission.
self.arm_connection(context, session.tool);
}
}
Event::Choose(None) => {}
@@ -89,7 +89,10 @@ async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field
);
assert!(!messages.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
Message::Select(_)
| Message::CanvasSelect(..)
| Message::SelectNode(_)
| Message::MoveNode(..)
)));
assert_eq!(f.app.document.bundle(), &before);
assert_eq!((f.app.selected, f.app.selected_node), selected);
@@ -237,7 +237,10 @@ fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
));
assert!(!events.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
Message::Select(_)
| Message::CanvasSelect(..)
| Message::SelectNode(_)
| Message::MoveNode(..)
)));
send(&mut f.app, events);
assert_eq!((f.app.selected, f.app.selected_node), selection);
@@ -1,5 +1,6 @@
//! Compact neutral tool palette. Tool state never changes its allocated width.
use super::*;
use crate::connection_tool::Tool;
use iced::{
Point, Rectangle, Size, Theme, mouse,
widget::{
@@ -11,7 +12,7 @@ use iced::{
#[derive(Clone, Copy)]
enum Icon {
Element(Kind),
Arrow,
Connection(Tool),
Existing,
Select,
Collapse(bool),
@@ -76,10 +77,59 @@ impl canvas::Program<Message> for Icon {
);
}
}
Self::Arrow => {
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
}
Self::Connection(connection) => match connection {
// The OA and SA work-flow tools share a visual family; they
// never coexist in a palette. Keep their small glyph legible.
Tool::Interaction | Tool::FunctionalExchange => {
for x in [1., 11.] {
f.stroke(
&Path::rounded_rectangle(
Point::new(x, 5.),
Size::new(4., 6.),
1.5.into(),
),
stroke,
);
}
f.stroke(&line(&[(5., 8.), (10., 8.)]), stroke);
f.stroke(&line(&[(7.5, 5.5), (10., 8.), (7.5, 10.5)]), stroke);
}
Tool::CommunicationMean => {
// Participant endpoints, without implying a fixed direction.
for x in [1., 11.] {
f.stroke(
&Path::rectangle(Point::new(x, 4.), Size::new(4., 8.)),
stroke,
);
}
f.stroke(&line(&[(5., 8.), (11., 8.)]), stroke);
}
Tool::BoundaryExchange => {
// Ports on facing participant boundaries. Standard exchanges
// are unoriented, so the generic tool has no arrowhead.
for x in [2., 14.] {
f.stroke(&line(&[(x, 2.), (x, 14.)]), stroke);
}
for x in [1., 12.] {
f.stroke(
&Path::rectangle(Point::new(x, 6.), Size::new(3., 4.)),
stroke,
);
}
f.stroke(&line(&[(4., 8.), (12., 8.)]), stroke);
}
Tool::Allocation => {
// Work enters a performer: allocation can appear as nesting
// rather than a relationship line in allocated-work views.
f.stroke(&line(&[(1., 2.), (1., 14.), (15., 14.), (15., 2.)]), stroke);
f.stroke(
&Path::rounded_rectangle(Point::new(5., 9.), Size::new(6., 3.), 1.into()),
stroke,
);
f.stroke(&line(&[(8., 1.), (8., 7.)]), stroke);
f.stroke(&line(&[(5., 4.), (8., 7.), (11., 4.)]), stroke);
}
},
Self::Existing => {
f.stroke(
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
@@ -234,7 +284,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
}
content = content.push(tool(
app,
Icon::Arrow,
Icon::Connection(*connection),
connection.label(),
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
app.connection
@@ -0,0 +1,904 @@
//! Canvas inspection retains the displayed mixed-layer surface; navigation follows the model.
use super::*;
use crate::diagram_usage::Target;
use crate::model_menu::{Event as MenuEvent, Request};
struct Fixture {
app: App,
diagram: Id,
process: Id,
placement: Id,
edge: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let process = p
.add_element(Kind::Process, Layer::Oa, "Operational process", None)
.unwrap();
let capability = p
.add_element(Kind::Capability, Layer::Oa, "Operational capability", None)
.unwrap();
let edge = p.relate(
RelationKind::ChainInvolvement,
"Enables",
process,
capability,
);
p.ensure_diagram(Layer::Oa);
let diagram = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let placement = p.place(diagram, process, Position { x: 0., y: 0. });
p.place(diagram, capability, Position { x: 500., y: 0. });
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Sa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
assert!(
project_view(app.project(), diagram)
.edges
.iter()
.any(|e| e.id == edge)
);
Fixture {
app,
diagram,
process,
placement,
edge,
}
}
fn assert_surface(f: &Fixture) {
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(f.app.layer, Layer::Sa);
assert_eq!(f.app.active_diagram, Some(f.diagram));
}
#[test]
fn mixed_projection_switch_restores_all_authored_content_and_one_step_history() {
let mut f = fixture();
let before = f.app.document.snapshot();
let mut expected = before.decoded.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Allocation;
let _ = f
.app
.update(Message::Projection(ProjectionKind::Allocation));
assert_surface(&f);
assert_eq!(f.app.project(), &expected);
assert!(project_view(f.app.project(), f.diagram).nodes.is_empty());
assert_eq!(f.app.project().diagrams[&f.diagram].nodes.len(), 2);
let switched = f.app.document.snapshot();
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());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &switched.decoded.project);
assert_eq!(f.app.document.bundle(), &switched.bundle);
let _ = f
.app
.update(Message::Projection(ProjectionKind::Traceability));
assert_surface(&f);
assert_eq!(f.app.project(), &before.decoded.project);
assert!(
project_view(f.app.project(), f.diagram)
.edges
.iter()
.any(|e| e.id == f.edge)
);
}
#[test]
fn mixed_canvas_edge_and_group_inspection_preserve_surface_and_history() {
for grouped in [false, true] {
let mut f = fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
if grouped {
let _ = f
.app
.update(Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Placement(f.placement),
multiselect::Choice::Replace,
)));
}
let message = if grouped {
Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Edge(f.edge),
multiselect::Choice::Add,
))
} else {
Message::CanvasSelect(f.app.route_context().unwrap(), f.edge)
};
let _ = f.app.update(message);
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Edge(f.edge))
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets().len(),
if grouped { 2 } else { 1 }
);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
}
}
fn invoke(f: &mut Fixture, object: Id, placement: Option<Id>, action: Message) {
let mut request = Request::object(object, iced::Point::ORIGIN);
request.canvas = f.app.route_context();
request.placement = placement;
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
let token = f.app.model_menu.as_ref().unwrap().token;
let _ = f
.app
.update(Message::Menu(MenuEvent::Invoke(token, Box::new(action))));
}
#[test]
fn mixed_canvas_context_properties_and_hide_keep_the_captured_surface() {
let mut f = fixture();
let edge = f.edge;
invoke(&mut f, edge, None, Message::EditProperties(edge));
assert_surface(&f);
assert_eq!(f.app.selected, edge);
assert!(!f.app.document.can_undo());
let before = f.app.document.snapshot();
let process = f.process;
let placement = f.placement;
invoke(&mut f, process, Some(placement), Message::HideSelected);
assert_surface(&f);
assert!(
!f.app.project().diagrams[&f.diagram]
.nodes
.contains_key(&placement)
);
assert!(f.app.project().elements.contains_key(&process));
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
#[test]
fn mixed_canvas_explicit_navigation_still_follows_the_target_layer() {
let mut f = fixture();
let process = f.process;
let edge = f.edge;
invoke(&mut f, edge, None, Message::Select(process));
assert_eq!(f.app.selected, process);
assert_eq!(f.app.layer, Layer::Oa);
assert_ne!(f.app.active_diagram, Some(f.diagram));
}
#[test]
fn explicit_navigation_to_the_inspected_cross_layer_object_follows_its_layer() {
let mut f = fixture();
let _ = f.app.update(Message::CanvasSelect(
f.app.route_context().unwrap(),
f.edge,
));
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
let before = f.app.document.snapshot();
let _ = f.app.update(Message::Select(f.edge));
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.layer, Layer::Oa);
assert_ne!(f.app.active_diagram, Some(f.diagram));
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_invalid_draft_and_stale_packets_preserve_selection() {
let mut f = fixture();
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::Field("/id".into(), "-".into()));
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
assert_eq!(f.app.selected, f.diagram);
assert_surface(&f);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
let _ = f
.app
.update(Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Edge(f.edge),
multiselect::Choice::Replace,
)));
assert_eq!(f.app.selected, f.diagram);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
let _ = f.app.update(Message::RevertProperties);
f.app.canvas_epoch += 1;
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
assert_eq!(f.app.selected, f.diagram);
assert!(!f.app.document.can_undo());
}
#[test]
fn cross_layer_port_inspection_and_connection_start_retain_the_view() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let port = Id::new_v4();
let element = make_port(
&p,
port,
owner,
"Output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap();
p.elements.insert(port, element);
p.ensure_diagram(Layer::Sa);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let placement = p.place(diagram, owner, Position { x: 0., y: 0. });
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
assert!(
project_view(app.project(), diagram)
.ports
.iter()
.any(|p| p.element == port)
);
let _ = app.update(Message::CanvasSelect(app.route_context().unwrap(), port));
assert_eq!(app.selected, port);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Port { port, placement })
);
// Reset inspection so the endpoint path cannot pass by selecting the same ID.
app.selected = diagram;
app.refresh_draft();
let context = app.route_context().unwrap();
// Mixed canvases can retain a tool from either perspective.
app.connection = Some(crate::connection_edit::Session::new(
app.project(),
&project_view(app.project(), diagram),
context,
crate::connection_tool::Tool::FunctionalExchange,
));
let _ = app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(context, port),
));
assert_eq!(app.selected, port);
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.connection.as_ref().unwrap().source, Some(port));
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
};
}
project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Capabilities;
f.app = App::with_project(project);
let _ = f.app.update(Message::Select(f.process));
let _ = f.app.update(Message::Diagram(Choice {
id: f.diagram,
label: "System capabilities".into(),
}));
assert_eq!(f.app.selected, f.process);
assert_eq!(f.app.layer, Layer::Sa);
assert_eq!(f.app.active_diagram, Some(f.diagram));
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);
}
}
// Regression scope: a contextual click may accept a valid property draft, but
// must not inherit the draft object's semantic-layer navigation. If acceptance
// changes the originating diagram identity or hides the clicked target, retain
// that one accepted edit and refuse the old click. Do not infer a new target or
// reinterpret an old surface packet on a replacement diagram.
fn draft_click_fixture() -> (Fixture, Id) {
let mut f = fixture();
let mut project = f.app.project().clone();
let capability = project
.add_element(Kind::Capability, Layer::Oa, "Second capability", None)
.unwrap();
let other_edge = project.relate(
RelationKind::ChainInvolvement,
"Also enables",
f.process,
capability,
);
project.place(f.diagram, capability, Position { x: 500., y: 300. });
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();
assert!(
project_view(f.app.project(), f.diagram)
.edges
.iter()
.any(|edge| edge.id == other_edge)
);
(f, other_edge)
}
fn expected_draft_commit(app: &App, command: Command) -> Document {
let mut expected = Document::from_bundle(
app.document.bundle().clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
expected.execute(command).unwrap();
expected
}
fn assert_accepted_draft(app: &App, expected: &Document, revision: u64) {
assert_eq!(app.project(), expected.project());
assert_eq!(app.document.bundle(), expected.bundle());
assert_eq!(app.document.revision(), revision + 1);
assert!(!app.draft_dirty());
assert!(app.document.can_undo());
assert!(!app.document.can_redo());
assert_source_pair(app);
}
fn assert_one_draft_history(
app: &mut App,
before: &oa_core::source_project::CompiledSourceProject,
expected: &Document,
) {
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
!app.document.can_undo(),
"the click must not add a second edit"
);
assert!(app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), expected.project());
assert_eq!(app.document.bundle(), expected.bundle());
assert!(!app.document.can_redo());
}
fn pending_edge_name(f: &mut Fixture) {
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
assert_surface(f);
assert_eq!(f.app.selected, f.edge);
let _ = f
.app
.update(Message::Field("/name".into(), "Reviewed enables".into()));
assert!(f.app.draft_dirty());
assert!(f.app.draft.as_ref().unwrap().command().is_ok());
}
#[test]
fn valid_cross_layer_draft_then_canvas_edge_retains_surface_and_one_edit() {
let (mut f, other_edge) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let expected =
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
pending_edge_name(&mut f);
// Capture after drafting, so this exercises successful draft application,
// not refusal of a packet made stale by the property edit itself.
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
assert_accepted_draft(&f.app, &expected, revision);
assert_surface(&f);
assert_eq!(f.app.selected, other_edge);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Edge(other_edge))
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn valid_cross_layer_draft_then_node_click_retains_surface_and_one_edit() {
let (mut f, _) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let expected =
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
pending_edge_name(&mut f);
let _ = f.app.update(Message::SelectNode(f.placement));
assert_accepted_draft(&f.app, &expected, revision);
assert_surface(&f);
assert_eq!(f.app.selected, f.process);
assert_eq!(f.app.selected_node, Some(f.placement));
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Placement(f.placement))
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn diagram_reidentity_draft_commits_but_refuses_the_old_surface_click() {
let (mut f, other_edge) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let replacement = Id::new_v4();
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
let _ = f
.app
.update(Message::Field("/id".into(), replacement.to_string()));
assert!(f.app.draft_dirty());
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
assert_accepted_draft(&f.app, &expected, revision);
assert!(!f.app.project().diagrams.contains_key(&context.diagram));
assert!(
f.app
.active_diagram
.is_some_and(|id| f.app.project().diagrams.contains_key(&id)),
"never restore a removed diagram identity"
);
assert_ne!(f.app.selected, other_edge, "refuse the old surface click");
assert!(
f.app
.multiselect_snapshot()
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
);
assert_eq!(
f.app.status,
"Properties applied. The view changed; select a visible target again."
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn projection_draft_commits_but_refuses_the_now_hidden_canvas_target() {
let (mut f, other_edge) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
diagram.projection = ProjectionKind::Allocation;
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
let _ = f.app.update(Message::FieldShape(
"/projection".into(),
serde_json::to_value(ProjectionKind::Allocation).unwrap(),
));
assert!(f.app.draft_dirty());
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
assert_accepted_draft(&f.app, &expected, revision);
assert_surface(&f);
assert!(
!project_view(f.app.project(), f.diagram)
.edges
.iter()
.any(|edge| edge.id == other_edge)
);
assert_ne!(
f.app.selected, other_edge,
"do not inspect a no-longer-visible hit"
);
assert!(
f.app
.multiselect_snapshot()
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
);
assert_eq!(
f.app.status,
"Properties applied. The view changed; select a visible target again."
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn invalid_cross_layer_draft_blocks_contextual_clicks_without_changing_history() {
for click in 0..3 {
let (mut f, other_edge) = draft_click_fixture();
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let _ = f
.app
.update(Message::Field("/id".into(), "not-a-uuid".into()));
assert!(f.app.draft_dirty());
let expected_error = f.app.draft.as_ref().unwrap().command().unwrap_err();
let draft =
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let selection = f.app.multiselect_snapshot().unwrap();
let message = match click {
0 => Message::CanvasSelect(f.app.route_context().unwrap(), other_edge),
1 => Message::SelectNode(f.placement),
_ => Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Edge(other_edge),
multiselect::Choice::Add,
)),
};
let _ = f.app.update(message);
if click < 2 {
assert_eq!(f.app.status, expected_error);
} else {
assert_eq!(
f.app.status,
"Finish or cancel the active edit before changing the diagram selection."
);
}
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
assert!(!f.app.document.can_redo());
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selection.primary()
);
if click == 0 {
// Pre-dispatch stale rejection applies no draft and must not emit
// either success feedback or a new validation message.
let stale = f.app.route_context().unwrap();
f.app.canvas_epoch += 1;
f.app.status = "Keep previous feedback".into();
let _ = f.app.update(Message::CanvasSelect(stale, other_edge));
assert_eq!(f.app.status, "Keep previous feedback");
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
}
}
}
#[test]
fn explicit_select_after_valid_cross_layer_draft_still_navigates_and_commits_once() {
let (mut f, other_edge) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let expected =
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
pending_edge_name(&mut f);
let _ = f.app.update(Message::Select(other_edge));
assert_accepted_draft(&f.app, &expected, revision);
assert_eq!(f.app.selected, other_edge);
assert_eq!(f.app.layer, Layer::Oa);
assert_ne!(f.app.active_diagram, Some(f.diagram));
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn properties_apply_on_cross_layer_edge_retains_surface_and_one_edit() {
let (mut f, _) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let expected =
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
pending_edge_name(&mut f);
let _ = f.app.update(Message::Apply);
assert_accepted_draft(&f.app, &expected, revision);
assert_eq!(f.app.status, "Properties applied");
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Edge(f.edge))
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn properties_apply_honors_an_intentional_active_diagram_layer_edit() {
let (mut f, _) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
diagram.layer = Layer::Oa;
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
let _ = f.app.update(Message::FieldShape(
"/layer".into(),
serde_json::to_value(Layer::Oa).unwrap(),
));
assert!(f.app.draft_dirty());
let _ = f.app.update(Message::Apply);
assert_accepted_draft(&f.app, &expected, revision);
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(
f.app.layer,
Layer::Oa,
"honor the edited diagram's own layer"
);
assert_eq!(f.app.active_diagram, Some(f.diagram));
assert_eq!(f.app.selected, f.diagram);
assert_eq!(f.app.draft.as_ref().unwrap().original, f.diagram);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn properties_apply_follows_the_known_active_diagram_reidentity_once() {
let (mut f, _) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let replacement = Id::new_v4();
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
let _ = f
.app
.update(Message::Field("/id".into(), replacement.to_string()));
assert!(f.app.draft_dirty());
let _ = f.app.update(Message::Apply);
assert_accepted_draft(&f.app, &expected, revision);
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(f.app.layer, Layer::Sa);
// This identity was explicitly authored in the diagram's own draft. No
// queued click is being remapped or inferred from semantic similarity.
assert_eq!(f.app.active_diagram, Some(replacement));
assert_eq!(f.app.selected, replacement);
assert_eq!(f.app.draft.as_ref().unwrap().original, replacement);
assert!(!f.app.project().diagrams.contains_key(&f.diagram));
assert_one_draft_history(&mut f.app, &before, &expected);
}
@@ -0,0 +1,366 @@
// Test-only proposal pinned to peer 6520a89dc042dd433454f6cc63cb23b0e6df7911.
// Independent App test module; real popup messages retain menu tokens.
use super::*;
use crate::model_menu::{Event as MenuEvent, Request};
struct BeginFixture {
app: App,
diagram: Id,
surface_layer: Layer,
source: Id,
target: Id,
wrong_layer: Id,
hidden: Id,
tool: crate::connection_tool::Tool,
}
fn begin_fixture(source_layer: Layer, allocation: bool, mixed: bool) -> BeginFixture {
use crate::connection_tool::Tool;
let mut p = Project::empty();
let (work, performer, exchange) = match source_layer {
Layer::Oa => (Kind::Activity, Kind::Entity, Tool::Interaction),
Layer::Sa => (Kind::Function, Kind::System, Tool::FunctionalExchange),
};
let opposite = if source_layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let surface_layer = if mixed { opposite } else { source_layer };
let source = p
.add_element(
if allocation { performer } else { work },
source_layer,
"Source",
None,
)
.unwrap();
let target = p.add_element(work, source_layer, "Target", None).unwrap();
let hidden = p
.add_element(work, source_layer, "Hidden target", None)
.unwrap();
let other_work = if opposite == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let wrong_layer = p
.add_element(other_work, opposite, "Wrong layer target", None)
.unwrap();
p.ensure_diagram(source_layer);
let diagram = p.ensure_diagram(surface_layer);
p.diagrams.get_mut(&diagram).unwrap().projection = if mixed {
ProjectionKind::Traceability
} else {
ProjectionKind::Capabilities
};
p.place(diagram, source, Position { x: 0., y: 0. });
p.place(diagram, target, Position { x: 400., y: 0. });
if mixed {
p.place(diagram, wrong_layer, Position { x: 800., y: 0. });
}
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = surface_layer;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
assert!(!app.document.can_undo());
BeginFixture {
app,
diagram,
surface_layer,
source,
target,
wrong_layer,
hidden,
tool: if allocation {
Tool::Allocation
} else {
exchange
},
}
}
fn begin_surface(f: &BeginFixture) {
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(f.app.layer, f.surface_layer);
assert_eq!(f.app.active_diagram, Some(f.diagram));
}
fn begin_popup_action(f: &mut BeginFixture) -> Option<Message> {
begin_popup_action_from(f, true)
}
fn begin_popup_action_from(f: &mut BeginFixture, canvas: bool) -> Option<Message> {
let mut request = Request::object(f.source, iced::Point::ORIGIN);
if canvas {
request.canvas = f.app.route_context();
request.placement = f.app.project().diagrams[&f.diagram]
.nodes
.values()
.find(|node| node.element == f.source)
.map(|node| node.id);
}
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
let popup = crate::model_menu::popup(&f.app).expect("real model popup");
let expected = format!("Draw {}", f.tool.label().to_lowercase());
for entry in popup.entries {
if let crate::context_menu::Entry::Action { label, message, .. } = entry
&& label == expected
{
if let Some(Message::Menu(MenuEvent::Invoke(token, _))) = &message {
assert_eq!(*token, popup.token);
} else {
assert!(message.is_none(), "Draw must use the menu token envelope");
}
return message;
}
}
None // Omission is also a valid unavailable-action policy.
}
fn begin_from_popup(f: &mut BeginFixture) -> Message {
begin_from_popup_origin(f, true)
}
fn begin_from_popup_origin(f: &mut BeginFixture, canvas: bool) -> Message {
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let action =
begin_popup_action_from(f, canvas).expect("visible semantic source offers enabled Draw");
let _ = f.app.update(action.clone());
begin_surface(f);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
let session = f
.app
.connection
.as_ref()
.expect("menu Draw must arm its tool");
assert_eq!(session.tool, f.tool);
assert_eq!(session.source, Some(f.source));
assert_eq!(session.context, f.app.route_context().unwrap());
assert!(session.candidates.contains(&f.target));
assert!(!session.candidates.contains(&f.wrong_layer));
assert!(!session.candidates.contains(&f.hidden));
action
}
fn begin_complete_and_check_history(f: &mut BeginFixture) {
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(context, f.target),
));
begin_surface(f);
assert!(f.app.connection.is_none());
assert_eq!(f.app.document.revision(), revision + 1);
let relations: Vec<_> = f
.app
.project()
.relations
.values()
.filter(|r| !before.decoded.project.relations.contains_key(&r.id))
.collect();
assert_eq!(relations.len(), 1);
let expected = match f.tool {
crate::connection_tool::Tool::Interaction => RelationKind::Interaction,
crate::connection_tool::Tool::FunctionalExchange => RelationKind::FunctionalExchange,
crate::connection_tool::Tool::Allocation => RelationKind::Allocation,
_ => unreachable!(),
};
assert_eq!(relations[0].kind(), expected);
assert_eq!(
(relations[0].source(), relations[0].target()),
(f.source, f.target)
);
// Random identities are accepted only for the precise command delta.
// Everything else, including traces, metadata and view options, stays exact.
let mut expected_project = before.decoded.project.clone();
let created = relations[0];
assert!(
expected_project
.relations
.insert(created.id, created.clone())
.is_none()
);
if let RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} = &created.body
{
assert_eq!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
assert!(items.is_empty());
assert_ne!(source_port, target_port);
let output = &f.app.project().elements[source_port];
let input = &f.app.project().elements[target_port];
assert_eq!(
(output.kind(), output.layer(), output.parent),
(Kind::Port, Layer::Sa, Some(f.source))
);
assert_eq!(
(input.kind(), input.layer(), input.parent),
(Kind::Port, Layer::Sa, Some(f.target))
);
assert!(
matches!(port_contract(output), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items.is_empty())
);
assert!(
matches!(port_contract(input), Some(PortContract::FunctionInput { incoming_items }) if incoming_items.is_empty())
);
assert!(!before.decoded.project.elements.contains_key(source_port));
assert!(!before.decoded.project.elements.contains_key(target_port));
assert_eq!(output.id, *source_port);
assert_eq!(input.id, *target_port);
expected_project
.elements
.insert(*source_port, output.clone());
expected_project
.elements
.insert(*target_port, input.clone());
assert_eq!(
f.app.project().elements.len(),
before.decoded.project.elements.len() + 2
);
} else {
assert_ne!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
assert_eq!(f.app.project().elements, before.decoded.project.elements);
}
assert_eq!(f.app.project(), &expected_project);
assert_eq!(f.app.project().diagrams, before.decoded.project.diagrams);
for (id, element) in &before.decoded.project.elements {
assert_eq!(&f.app.project().elements[id], element);
}
begin_assert_source_pair(&f.app);
let after = f.app.document.snapshot();
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(),
"creation is a single history step"
);
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &after.decoded.project);
assert_eq!(f.app.document.bundle(), &after.bundle);
begin_assert_source_pair(&f.app);
}
#[test]
fn contextual_begin_exchange_uses_actual_menu_in_both_mixed_directions() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, false, true);
begin_from_popup(&mut f);
begin_complete_and_check_history(&mut f);
}
}
#[test]
fn contextual_begin_cancel_restart_and_stale_menu_preserve_session_ownership() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, false, true);
let original = f.app.document.snapshot();
let stale_menu = begin_from_popup(&mut f);
let first = f.app.route_context().unwrap();
let _ = f
.app
.update(Message::Connection(crate::connection_edit::Event::Restart(
first,
)));
begin_surface(&f);
let restarted = f
.app
.connection
.as_ref()
.expect("restart retains contextual tool");
assert_eq!(restarted.tool, f.tool);
assert_eq!(restarted.source, None);
assert!(restarted.candidates.contains(&f.source));
let current = restarted.context;
assert_ne!(current, first);
let _ = f.app.update(stale_menu);
let _ = f.app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(first, f.target),
));
assert_eq!(f.app.connection.as_ref().unwrap().context, current);
assert_eq!(f.app.connection.as_ref().unwrap().source, None);
let _ = f.app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(current, f.source),
));
assert_eq!(f.app.connection.as_ref().unwrap().source, Some(f.source));
let _ = f.app.update(Message::Escape);
let _ = f.app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(current, f.target),
));
assert!(f.app.connection.is_none());
begin_surface(&f);
assert_eq!(f.app.project(), &original.decoded.project);
assert_eq!(f.app.document.bundle(), &original.bundle);
assert!(!f.app.document.can_undo());
}
}
#[test]
fn contextual_begin_allocation_uses_actual_menu_in_both_mixed_directions() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, true, true);
begin_from_popup(&mut f);
begin_complete_and_check_history(&mut f);
}
}
#[test]
fn contextual_begin_allocation_is_not_enabled_when_projection_hides_work() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, true, false);
let view = project_view(f.app.project(), f.diagram);
assert!(view.nodes.iter().any(|n| n.element == f.source));
assert!(!view.nodes.iter().any(|n| n.element == f.target));
assert!(!f.tool.available_in(layer, ProjectionKind::Capabilities));
let before = f.app.document.snapshot();
assert!(
begin_popup_action(&mut f).is_none(),
"Draw allocation must be disabled or omitted when work is not projected"
);
let _ = f.app.update(Message::Menu(MenuEvent::Close));
// Defensive handler refusal complements actual-menu enabledness above.
let _ = f
.app
.update(Message::Connection(crate::connection_edit::Event::Begin(
f.tool, f.source,
)));
assert!(f.app.connection.is_none());
begin_surface(&f);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
}
fn begin_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 contextual_begin_explorer_draw_retains_visible_mixed_diagram_in_both_directions() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, false, true);
// A real Explorer Request::object has no canvas/placement envelope,
// but its generated Draw action still targets the visible current view.
begin_from_popup_origin(&mut f, false);
begin_complete_and_check_history(&mut f);
}
}
@@ -0,0 +1,339 @@
// Proposed tests against c7065db76ecca3faa733ccb5f09b87fd0a0958be.
use super::*;
struct RelocationFixture {
app: App,
source: Id,
target: Id,
node: Id,
other_node: Id,
edge: Id,
}
fn relocation_fixture(source_layer: Layer, target_layer: Layer) -> RelocationFixture {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Observed work", None)
.unwrap();
let other = p
.add_element(Kind::Activity, Layer::Oa, "Other work", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Observed exchange", work, other);
let source = p.ensure_diagram(source_layer);
p.diagrams.get_mut(&source).unwrap().projection = ProjectionKind::Traceability;
let node = p.place(source, work, Position { x: 20., y: 20. });
let other_node = p.place(source, other, Position { x: 500., y: 20. });
let mut destination = p.diagrams[&source].clone();
destination.id = Id::new_v4();
destination.alias = "relocation_destination".into();
destination.name = "Relocation destination".into();
destination.layer = target_layer;
destination.nodes.clear();
let target = destination.id;
p.diagrams.insert(target, destination);
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = source_layer;
app.active_diagram = Some(source);
app.selected = node;
app.refresh_draft();
app.reconcile_multiselect();
assert!(
matches!(app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(d) if d == source)
);
assert!(!app.document.can_undo());
RelocationFixture {
app,
source,
target,
node,
other_node,
edge,
}
}
fn relocation_field(app: &mut App, path: &str, value: impl ToString) {
let _ = app.update(Message::Field(path.into(), value.to_string()));
}
fn relocation_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());
}
fn relocation_selection(app: &App, diagram: Id, placement: Id) {
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.layer, app.project().diagrams[&diagram].layer);
assert_eq!(app.selected, placement);
assert_eq!(app.selected_node, Some(placement));
let draft = app.draft.as_ref().unwrap();
assert_eq!(draft.original, placement);
assert!(matches!(draft.kind, crate::forms::RecordKind::Node(d) if d == diagram));
}
fn relocation_expected(app: &App, command: Command) -> Document {
let mut expected = Document::from_bundle(
app.document.bundle().clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
expected.execute(command).unwrap();
expected
}
fn relocation_history(
app: &mut App,
before: &oa_core::source_project::CompiledSourceProject,
expected: &Document,
) {
assert_eq!(app.project(), expected.project());
assert_eq!(app.document.bundle(), expected.bundle());
assert!(app.document.can_undo());
assert!(!app.document.can_redo());
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), expected.project());
assert_eq!(app.document.bundle(), expected.bundle());
assert!(!app.document.can_redo());
relocation_source_pair(app);
}
#[test]
fn explicit_placement_relocation_follows_actual_owner_with_optional_new_identity() {
for layer in [Layer::Oa, Layer::Sa] {
for reidentify in [false, true] {
let mut f = relocation_fixture(Layer::Oa, layer);
let before = f.app.document.snapshot();
let before_project = before.decoded.project.clone();
let revision = f.app.document.revision();
let id = if reidentify { Id::new_v4() } else { f.node };
let mut expected = before_project.clone();
let mut placement = expected
.diagrams
.get_mut(&f.source)
.unwrap()
.nodes
.remove(&f.node)
.unwrap();
placement.id = id;
expected
.diagrams
.get_mut(&f.target)
.unwrap()
.nodes
.insert(id, placement);
let expected_document = relocation_expected(
&f.app,
Command::Batch(vec![
Command::EditNode {
diagram: f.source,
original: f.node,
node: expected.diagrams[&f.target].nodes[&id].clone(),
},
Command::RelocateNode {
node: id,
diagram: f.target,
},
]),
);
assert_eq!(expected_document.project(), &expected);
relocation_field(&mut f.app, "/diagram", f.target);
if reidentify {
relocation_field(&mut f.app, "/id", id);
}
assert!(f.app.apply(), "{}", f.app.status);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.revision(), revision + 1);
relocation_selection(&f.app, f.target, id);
relocation_source_pair(&f.app);
assert_eq!(f.app.document.bundle(), expected_document.bundle());
relocation_history(&mut f.app, &before, &expected_document);
}
}
}
#[test]
fn same_owner_placement_position_alias_and_identity_keep_mixed_surface() {
for different_current_view in [false, true] {
for field in ["/position/x", "/alias", "/id"] {
let mut f = relocation_fixture(Layer::Sa, Layer::Oa);
// Ordinary Select plus the diagram picker can retain an independent
// placement Inspector while showing a different saved view. This is
// an App-handler workflow, not a claim of native menu execution.
let _ = f.app.update(Message::Select(f.app.project().id));
let _ = f.app.update(Message::Select(f.node));
let current = if different_current_view {
f.target
} else {
f.source
};
if different_current_view {
let _ = f.app.update(Message::Diagram(Choice {
id: current,
label: "Other current view".into(),
}));
}
assert_eq!(f.app.selected, f.node);
assert_eq!(f.app.active_diagram, Some(current));
assert!(
matches!(f.app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(d) if d == f.source)
);
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut expected = before.decoded.project.clone();
let mut placement = expected
.diagrams
.get_mut(&f.source)
.unwrap()
.nodes
.remove(&f.node)
.unwrap();
let value = match field {
"/position/x" => {
placement.position.x = 123.;
"123".to_owned()
}
"/alias" => {
placement.alias = "renamed_placement".into();
placement.alias.clone()
}
_ => {
placement.id = Id::new_v4();
placement.id.to_string()
}
};
let id = placement.id;
let expected_document = relocation_expected(
&f.app,
Command::EditNode {
diagram: f.source,
original: f.node,
node: placement.clone(),
},
);
expected
.diagrams
.get_mut(&f.source)
.unwrap()
.nodes
.insert(id, placement);
assert_eq!(expected_document.project(), &expected);
relocation_field(&mut f.app, field, value);
assert!(f.app.apply(), "{}", f.app.status);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), expected_document.bundle());
assert_eq!(f.app.document.revision(), revision + 1);
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(f.app.active_diagram, Some(current));
assert_eq!(f.app.layer, f.app.project().diagrams[&current].layer);
assert_eq!(f.app.selected, id);
assert_eq!(
f.app.selected_node,
if different_current_view {
None
} else {
Some(id)
}
);
let draft = f.app.draft.as_ref().unwrap();
assert_eq!(draft.original, id);
assert!(matches!(draft.kind, crate::forms::RecordKind::Node(d) if d == f.source));
relocation_source_pair(&f.app);
relocation_history(&mut f.app, &before, &expected_document);
}
}
}
#[test]
fn accepted_relocation_refuses_queued_old_canvas_target_with_feedback() {
for edge_hit in [false, true] {
let mut f = relocation_fixture(Layer::Oa, Layer::Sa);
let before = f.app.document.snapshot();
let expected_document = relocation_expected(
&f.app,
Command::Batch(vec![
Command::EditNode {
diagram: f.source,
original: f.node,
node: f.app.project().diagrams[&f.source].nodes[&f.node].clone(),
},
Command::RelocateNode {
node: f.node,
diagram: f.target,
},
]),
);
let mut expected = before.decoded.project.clone();
let placement = expected
.diagrams
.get_mut(&f.source)
.unwrap()
.nodes
.remove(&f.node)
.unwrap();
expected
.diagrams
.get_mut(&f.target)
.unwrap()
.nodes
.insert(f.node, placement);
let revision = f.app.document.revision();
let captured = f.app.route_context().unwrap();
relocation_field(&mut f.app, "/diagram", f.target);
let message = if edge_hit {
Message::CanvasSelect(captured, f.edge)
} else {
Message::SelectNode(f.other_node)
};
let _ = f.app.update(message);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.revision(), revision + 1);
relocation_selection(&f.app, f.target, f.node);
assert_eq!(
f.app.status,
"Properties applied. The view changed; select a visible target again."
);
assert!(
f.app.multiselect_snapshot().is_none_or(|snapshot| {
!snapshot.targets().iter().any(|target| {
matches!(target, crate::diagram_usage::Target::Placement(id) if *id == f.other_node)
|| matches!(target, crate::diagram_usage::Target::Edge(id) if *id == f.edge)
})
}),
"the rejected old hit must not enter the destination selection"
);
assert_eq!(expected_document.project(), &expected);
assert_eq!(f.app.document.bundle(), expected_document.bundle());
relocation_source_pair(&f.app);
relocation_history(&mut f.app, &before, &expected_document);
}
}
#[test]
fn rejected_placement_destination_preserves_owner_model_source_and_draft() {
let mut f = relocation_fixture(Layer::Oa, Layer::Sa);
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
relocation_field(&mut f.app, "/diagram", Id::new_v4());
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(!f.app.apply());
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
relocation_selection(&f.app, f.source, f.node);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
}
+18
View File
@@ -39,6 +39,24 @@ impl Session {
}
}
/// Contextual drawing follows the visible semantic source, not the palette layer.
/// The caller separately validates the menu/context lifetime and pending drafts.
pub fn can_begin(project: &Project, diagram: Id, tool: Tool, source: Id) -> bool {
let Some(diagram) = project.diagrams.get(&diagram) else {
return false;
};
let Some(element) = project.elements.get(&source) else {
return false;
};
diagram.projection.is_architecture()
&& (element.layer() == diagram.layer
|| diagram.projection == oa_core::ProjectionKind::Traceability)
&& Tool::for_layer(element.layer()).contains(&tool)
&& tool.can_start(project, source)
&& (tool != Tool::Allocation || tool.available_in(element.layer(), diagram.projection))
&& visible(&oa_core::projection::project_view(project, diagram.id)).any(|id| id == source)
}
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
view.nodes
.iter()
+9 -6
View File
@@ -993,7 +993,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
world,
));
}
return Some(Action::publish(Message::Select(port.element)).and_capture());
return Some(
Action::publish(Message::CanvasSelect(self.context, port.element))
.and_capture(),
);
}
if let Some(id) = self.hit(view, p) {
let position = self
@@ -1010,7 +1013,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
.hit_label(&scene.routed, p)
.or_else(|| self.hit_association_label(&scene.routed, p))
{
Some(Action::publish(Message::Select(id)).and_capture())
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
if id == self.selected
&& self.editable
@@ -1026,7 +1029,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
self.begin_route(state, world, Handle::Bend(index), route);
Some(Action::capture())
} else {
Some(Action::publish(Message::Select(id)).and_capture())
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
}
} else {
state.pan_start = Some((mouse::Button::Left, p));
@@ -2441,7 +2444,7 @@ mod tests {
.unwrap()
.into_inner()
.0;
assert!(matches!(selected,Some(Message::Select(id)) if id==port.element));
assert!(matches!(selected,Some(Message::CanvasSelect(_, id)) if id==port.element));
let request = canvas.context_request(
&state,
click,
@@ -3054,7 +3057,7 @@ mod tests {
let mut state = State::default();
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::CanvasSelect(_, id)) if id==edge)
);
assert!(
state.route_drag.is_none(),
@@ -3104,7 +3107,7 @@ mod tests {
let center = scene.routed.edges[&edge].label.center;
let screen = point(center) + canvas.pan;
assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::Select(id)) if id==edge)
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::CanvasSelect(_, id)) if id==edge)
);
canvas.selected = edge;
pointer(
@@ -179,7 +179,7 @@ fn coalesced_port_drag_commits_once_through_app_and_source_history() {
zoom,
);
assert_eq!(messages.len(), 2);
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
assert!(matches!(&messages[0],Message::CanvasSelect(_, id)if *id==output));
assert!(
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
);
@@ -251,7 +251,7 @@ fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
b,
);
let picked = match &press {
Message::Select(id) => Some(*id),
Message::CanvasSelect(_, id) => Some(*id),
_ => None,
};
let release_debug = format!("{release:?}");
@@ -300,7 +300,7 @@ fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
.filter_map(|e| event(&c, &mut state, e, b))
.collect();
assert!(
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
matches!(messages.first(),Some(Message::CanvasSelect(_, id)) if *id==output),
"Stationary source was lost between batches: {messages:?}"
);
assert!(
@@ -165,7 +165,7 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
.unwrap()
.into_inner()
.0;
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
assert!(matches!(press, Some(Message::CanvasSelect(_, id)) if id == port.element));
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
let menu = canvas.context_request(&state, at, bounds);
assert_eq!(menu.object, port.element);
+1 -1
View File
@@ -375,7 +375,7 @@ mod tests {
messages.extend(message);
}
}
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
assert!(matches!(messages.first(), Some(Message::CanvasSelect(_, id)) if *id == port));
assert!(
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
);
+2
View File
@@ -327,6 +327,8 @@ fn article(topic: Topic) -> Article {
"Show a correspondence between OA and SA",
&[
"Choose OA ↔ SA traceability in View content. Changing this setting does not create counterparts or add their placements, so an existing single-layer diagram can initially look unchanged.",
"Switching back to Architecture or another single-layer projection shows only that view's layer. The other layer's placements, routing and saved visibility remain stored; return to OA ↔ SA traceability to show them again, subject to the view's filters. Selecting a displayed card, port or line and opening its Properties keeps the current view and perspective. Applying ordinary Properties edits keeps that saved architecture view open. Changing a placement's Diagram explicitly moves it and follows its destination. Changes you make to the view's own perspective or identity still take effect. If the app asks you to select again, choose a visible target.",
"In a mixed traceability view, use Draw from a visible source's canvas menu or the Model explorer sidebar on the Architecture page to connect from that source's OA or SA layer without leaving the view. Choose a highlighted compatible visible target; Escape cancels. The palette offers tools for the view's perspective. Opening an endpoint or selecting a record in the Model explorer sidebar follows that record's perspective. Contextual relationship forms keep the view while opening; applying the reviewed relationship can navigate to its result.",
"Use Review layer traces to inspect named Operational and System endpoints and the meaning of their link. This opens the model's layer-trace list with no search restriction; traces between relationships are reviewed here rather than drawn between element cards.",
"In the chosen architecture view, use Add existing elements. The chooser offers compatible elements from both layers. Select an existing endpoint and click its position in the canvas; repeat for the other end if needed. This adds a placement, not a new semantic element or trace. For port correspondences, place the ports' owners.",
"The view shows correspondence lines alongside ordinary relationships. The shown-line count reflects the current projection and visibility settings, not trace coverage. If a line is hidden, use View visibility and inspect Saved view visibility; placing an endpoint does not remove explicit hides or filters.",
+1 -4
View File
@@ -557,10 +557,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
&format!("Draw {}", tool.label().to_lowercase()),
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
app.active_diagram.is_some_and(|diagram| {
crate::connection_edit::visible(&projection::project_view(
p, diagram,
))
.any(|element| element == id)
crate::connection_edit::can_begin(p, diagram, *tool, id)
}),
));
}
@@ -372,3 +372,150 @@ async fn source6_http_explicit_communication_ports_preview_apply_and_restart_pre
assert_pair(&reopened);
server.stop().await;
}
#[tokio::test]
async fn mixed_traceability_projection_roundtrip_preserves_dormant_http_state() {
let f = fixture();
let mut model = compile_bundle(&f.bundle, ProjectLimits::default())
.unwrap()
.decoded
.project;
let view = model.diagrams.get_mut(&f.diagram).unwrap();
view.layer = Layer::Sa;
view.projection = ProjectionKind::Traceability;
// The OA placements and category are intentionally opposite the view's layer.
view.view_options.hidden_edges = vec![f.mean];
for (index, element) in f.sa.into_iter().enumerate() {
model.place(
f.diagram,
element,
Position {
x: index as f32 * 400.,
y: 300.,
},
);
}
let mut bundle = f.bundle.clone();
bundle.files.insert(
bundle.entry.clone(),
format!(
"// Authored source-6 engineering rationale\n{}",
oa_core::language::format(&model).replacen(
"{\n",
"{\n include \"notes.arcadia\";\n",
1
)
),
);
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &bundle).await;
assert_pair(&original);
let resource = original.resource_id;
let mixed = oa_core::projection::project_view(&original.model, f.diagram);
assert!(
f.oa.iter()
.chain(&f.sa)
.all(|id| mixed.nodes.iter().any(|n| n.element == *id))
);
let mut ordinary = original.model.diagrams[&f.diagram].clone();
ordinary.projection = ProjectionKind::Allocation;
let response = transact(
&server,
&original,
vec![Command::EditDiagram(f.diagram, ordinary.clone())],
)
.await;
let status = response.status();
let etag = response
.headers()
.get("etag")
.map(|v| v.to_str().unwrap().to_owned());
let result: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["changed"], true);
assert_eq!(result["dry_run"], false);
assert_eq!(result["revision"], original.revision + 1);
let changed = server.get(resource).await;
assert_eq!(etag.as_deref(), Some(changed.etag().as_str()));
let mut expected = original.model.clone();
expected.diagrams.insert(f.diagram, ordinary.clone());
assert_eq!(
changed.model, expected,
"only the projection changes; dormant state is retained"
);
assert_pair(&changed);
assert_eq!(
changed.source_project.files["notes.arcadia"],
bundle.files["notes.arcadia"]
);
assert!(
changed.source_project.files[&bundle.entry]
.starts_with("// Authored source-6 engineering rationale\n")
);
let projected = oa_core::projection::project_view(&changed.model, f.diagram);
assert!(projected.nodes.iter().all(|n| !f.oa.contains(&n.element)));
assert!(
f.sa.iter()
.all(|id| projected.nodes.iter().any(|n| n.element == *id))
);
// A dormant option still must name an existing category. Rejection must not
// advance the ETag or rewrite either side of the persisted model/source pair.
let disk = directory.path().join(format!("{resource}.json"));
let accepted_bytes = std::fs::read(&disk).unwrap();
ordinary.view_options.participant_categories = CategorySelection::Any(vec![Id::new_v4()]);
let rejected = transact(
&server,
&changed,
vec![Command::EditDiagram(f.diagram, ordinary)],
)
.await;
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let retained = server.get(resource).await;
assert_eq!(retained.etag(), changed.etag());
assert_eq!(
serde_json::to_value(&retained).unwrap(),
serde_json::to_value(&changed).unwrap()
);
assert_eq!(std::fs::read(&disk).unwrap(), accepted_bytes);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(resource).await;
assert_eq!(
serde_json::to_value(&reopened).unwrap(),
serde_json::to_value(&changed).unwrap()
);
assert_pair(&reopened);
let response = transact(
&server,
&reopened,
vec![Command::EditDiagram(
f.diagram,
original.model.diagrams[&f.diagram].clone(),
)],
)
.await;
let status = response.status();
let result: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["changed"], true);
assert_eq!(result["revision"], changed.revision + 1);
let restored = server.get(resource).await;
assert_eq!(restored.model, original.model);
assert_pair(&restored);
assert_eq!(
restored.source_project.files["notes.arcadia"],
bundle.files["notes.arcadia"]
);
let projected = oa_core::projection::project_view(&restored.model, f.diagram);
assert!(
f.oa.iter()
.chain(&f.sa)
.all(|id| projected.nodes.iter().any(|n| n.element == *id))
);
assert!(!projected.edges.iter().any(|edge| edge.id == f.mean));
server.stop().await;
}
+1 -1
View File
@@ -147,7 +147,7 @@ schemas["ViewOptions"] = obj({
"coverage": {**ref("Coverage"), "default": "All"},
"work_contexts": {**ref("ContextSelection"), "default": "All"},
"exchange_labels": {**ref("ExchangeLabels"), "default": "Name"}}, [])
schemas["ViewOptions"]["description"] = "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. Traceability permits both-layer hidden/category/context references; ordinary projections require the diagram layer. Filtering preserves unfiltered automatic port positions."
schemas["ViewOptions"]["description"] = "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. String projections preserve both-layer hidden/category/context references and latent placements; only Traceability renders both layers. Typed StateMachine/Scenario views retain layer-scoped admission. Filtering preserves unfiltered automatic port positions."
schemas["Diagram"]["properties"]["view_options"] = {**ref("ViewOptions"), "default": {}}
def upsert_variant(variants, name, value):