diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index f04e9552..c2829512 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -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, +) -> 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 Result usize { let mut offset = 0; @@ -1449,7 +1462,10 @@ impl Parser<'_> { }, } } - fn document(&mut self) -> Result { + fn document( + &mut self, + migration_issue: &mut Option, + ) -> Result { 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, limits: ParseLimits, + migration_issue: &mut Option, ) -> Result { 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 { diff --git a/crates/oa-core/src/migration_v4.rs b/crates/oa-core/src/migration_v4.rs index 6127f724..527d593d 100644 --- a/crates/oa-core/src/migration_v4.rs +++ b/crates/oa-core/src/migration_v4.rs @@ -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 { + // 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; diff --git a/crates/oa-core/src/persistence.rs b/crates/oa-core/src/persistence.rs index 39f496d7..fc063187 100644 --- a/crates/oa-core/src/persistence.rs +++ b/crates/oa-core/src/persistence.rs @@ -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; diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs index ce4b2930..cd85398e 100644 --- a/crates/oa-core/src/projection.rs +++ b/crates/oa-core/src/projection.rs @@ -85,6 +85,7 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap> { .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() diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index c9f65c7d..098db652 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -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) diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index aa713f67..a4936644 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -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() diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index e38b9a3a..723fa97c 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -620,11 +620,14 @@ pub fn validate(p: &Project) -> Vec { 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 { ); } 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, diff --git a/crates/oa-core/src/view_options.rs b/crates/oa-core/src/view_options.rs index e57d4f33..f020ed75 100644 --- a/crates/oa-core/src/view_options.rs +++ b/crates/oa-core/src/view_options.rs @@ -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 { validate_options_in(p, None, options) } diff --git a/crates/oa-core/tests/legacy_diagram_admission.rs b/crates/oa-core/tests/legacy_diagram_admission.rs new file mode 100644 index 00000000..19b94d7e --- /dev/null +++ b/crates/oa-core/tests/legacy_diagram_admission.rs @@ -0,0 +1,203 @@ +//! Current latent presentation must not broaden frozen source/JSON admission. +use oa_core::{view_options::*, *}; + +fn legacy_json(project: &Project, version: u32) -> Vec { + 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() { + for version in 3..=6 { + let source = format!( + "arcadia {version}; project compat \"Compatibility\" {{\n\ + sa_function work \"Counterpart\" {{}}\n\ + diagram view \"OA concern\" {{ layer Oa; projection Traceability; }}\n\ + node occurrence {{ 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; + if version >= 4 { + let diagnostic = errors + .iter() + .find(|error| error.code == "diagram_layer") + .unwrap(); + assert_eq!(diagnostic.object_id, Some(node)); + 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)); + 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::new_v4(); + 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(¤t_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; + } +} diff --git a/crates/oa-core/tests/projection_switching.rs b/crates/oa-core/tests/projection_switching.rs new file mode 100644 index 00000000..04183004 --- /dev/null +++ b/crates/oa-core/tests/projection_switching.rs @@ -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()); +} diff --git a/crates/oa-core/tests/source6_view_contract.rs b/crates/oa-core/tests/source6_view_contract.rs index ac25c8fe..c5d96b1f 100644 --- a/crates/oa-core/tests/source6_view_contract.rs +++ b/crates/oa-core/tests/source6_view_contract.rs @@ -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!( diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 2e65a5be..ed0cf391 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -4,6 +4,8 @@ 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 diagram_focus_tests; @@ -381,6 +383,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), @@ -1217,6 +1220,24 @@ impl App { } self.refresh_draft(); } + /// Inspect an already validated canvas target without following its model layer. + fn inspect_canvas_target(&mut self, id: Id) -> bool { + if self.selected == id { + return true; + } + if !self.project().contains(id) || !self.apply() { + return false; + } + if !self.project().contains(id) { + 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); @@ -1363,6 +1384,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, ..) @@ -2118,6 +2144,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 @@ -2718,10 +2745,10 @@ 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(); + if show_in_mixed_view || menu.request.canvas.is_some() { + if !self.inspect_canvas_target(menu.request.object) { + return Task::none(); + } } else { self.select(menu.request.object); } @@ -2889,6 +2916,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); @@ -5086,17 +5121,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() diff --git a/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs b/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs index e445ac69..7d74bfa7 100644 --- a/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs @@ -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); diff --git a/crates/oa-desktop/src/app/allocation_drag_tests.rs b/crates/oa-desktop/src/app/allocation_drag_tests.rs index 46be13a0..7bcb19db 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests.rs @@ -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); diff --git a/crates/oa-desktop/src/app/canvas_selection_tests.rs b/crates/oa-desktop/src/app/canvas_selection_tests.rs new file mode 100644 index 00000000..fd2cd6ad --- /dev/null +++ b/crates/oa-desktop/src/app/canvas_selection_tests.rs @@ -0,0 +1,278 @@ +//! 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, 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 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()); +} diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 464c085b..70936268 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -993,7 +993,10 @@ impl canvas::Program 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 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 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( diff --git a/crates/oa-desktop/src/diagram/pointer_tests.rs b/crates/oa-desktop/src/diagram/pointer_tests.rs index 746fd5db..03902a5d 100644 --- a/crates/oa-desktop/src/diagram/pointer_tests.rs +++ b/crates/oa-desktop/src/diagram/pointer_tests.rs @@ -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!( diff --git a/crates/oa-desktop/src/diagram/port_hit_tests.rs b/crates/oa-desktop/src/diagram/port_hit_tests.rs index dc312fd1..77edaa3f 100644 --- a/crates/oa-desktop/src/diagram/port_hit_tests.rs +++ b/crates/oa-desktop/src/diagram/port_hit_tests.rs @@ -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); diff --git a/crates/oa-desktop/src/diagram/port_labels.rs b/crates/oa-desktop/src/diagram/port_labels.rs index 0e6a03cb..2b1e4e1c 100644 --- a/crates/oa-desktop/src/diagram/port_labels.rs +++ b/crates/oa-desktop/src/diagram/port_labels.rs @@ -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) ); diff --git a/crates/oa-server/tests/semantic_schema.py b/crates/oa-server/tests/semantic_schema.py index 861b83c0..80b45976 100644 --- a/crates/oa-server/tests/semantic_schema.py +++ b/crates/oa-server/tests/semantic_schema.py @@ -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): diff --git a/docs/api.rst b/docs/api.rst index ed7617cb..87453fb6 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -570,8 +570,15 @@ retain their identities and contracts; migration does not silently replace them. ``coverage`` and ``work_contexts``, and ``Name`` for ``exchange_labels``. ``{"Only":[]}`` hides all relation and trace lines; ``{"Any":[]}`` preserves an explicit empty category/context selection. Category members union within -each family, and independent filters intersect. ``Traceability`` permits -references from both layers; ordinary projections require the diagram's layer. +each family, and independent filters intersect. String projections permit saved +references and placements from both layers. Only ``Traceability`` renders both; +other string projections filter to the diagram's declared layer. ``EditDiagram`` +may change between these projections without deleting latent placements, routes +or visibility choices. Returning to Traceability restores their applicability. +Typed StateMachine/Scenario views retain their strict membership and layer checks. +Missing objects, wrong reference kinds/families and duplicate selections remain +invalid. Frozen versions 1–6 keep their prior layer admission before migration; +older version-7 implementations may reject these newly accepted ordinary views. These presentation choices preserve semantic records, routes, placements and the automatic port positions derived from unfiltered eligible exchanges. diff --git a/docs/language.rst b/docs/language.rst index 028db93b..ca1151f4 100644 --- a/docs/language.rst +++ b/docs/language.rst @@ -409,8 +409,12 @@ filters rather than widening them to All. Traceability views retain their established two-layer projection: default options preserve eligible OA and SA lines. Their hidden-edge, category and context -references may therefore select either layer. All ordinary projections require -these references in the declared diagram layer. A filter never hides the other +references may therefore select either layer. Ordinary string projections retain +these references from either layer as saved presentation choices; they still +render only their declared layer. Switching projections MUST preserve those +choices, including explicit empty selections. Existing-object, reference-kind, +family and uniqueness constraints still apply. Typed StateMachine and Scenario +views retain layer-scoped reference admission. A filter never hides the other layer merely because the Traceability diagram has an OA or SA layer label. Versions 1–5 reject the new relation, category constructor, diagram field (even @@ -1073,6 +1077,20 @@ capabilities with their involvement, exploitation, include, extend and generalization links. Decomposition and Overview retain general containment; Overview also exposes all relationships. These string projections hide inapplicable placements without deleting them or changing semantic records. +This includes opposite-layer placements retained after leaving Traceability. +Changing between string projections changes only the projection value; placement +identities, positions, sizes, collapsed state, port anchors, routes and visibility +choices remain authored. Returning to Traceability restores their applicability. +Ordinary views MUST NOT render opposite-layer nodes, parents, ports or lines. +This current-version admission correction does not broaden frozen source/JSON +versions 1–6: their layer-scoped placement and visibility checks remain required +before migration. Earlier version-7 implementations can reject ordinary views +containing these retained opposite-layer records. + +The explicit RelocateNode operation moves a placement to a different diagram +only when its element belongs to the destination layer or the destination is +Traceability. Retaining an existing hidden placement through a projection change +does not authorize a relocation that immediately hides it in another layer. A StateMachine projection MUST reference an existing StateMachine in the diagram's layer. Its placements MUST refer to that machine, its Regions, or diff --git a/docs/openapi.json b/docs/openapi.json index b4feebe5..db5f5052 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -12490,7 +12490,7 @@ }, "required": [], "additionalProperties": false, - "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." + "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." }, "CommunicationPortChoice": { "oneOf": [ diff --git a/harness/projection-navigation-20260925.md b/harness/projection-navigation-20260925.md new file mode 100644 index 00000000..db12c024 --- /dev/null +++ b/harness/projection-navigation-20260925.md @@ -0,0 +1,52 @@ +# Projection switching and canvas inspection + +Branch contribution for [#42](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/42) +and [#43](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/43), +based on published preview `715036e132677dd2bc2fb37ed316d93dc9987aec`. +Owner: Codex-ytr83r, session 01a0d706. This is a candidate, not integrated +acceptance or a replacement release. Live status belongs to the linked issues. + +## Decision and alternatives + +String projections are presentation filters over one authored diagram. Preserve +opposite-layer placements, routes and visibility selections when switching away +from Traceability; ordinary projections render only their declared layer. This +extends the existing documented preservation of inapplicable element kinds. +Deleting incompatible placements loses authored arrangements; automatically +creating a second view fragments the user's work and makes repeated switching +ambiguous. Neither alternative satisfies reversible view switching as directly. + +Missing identities and wrong reference kinds/families remain errors. StateMachine +and Scenario keep strict typed membership. Frozen versions 1–6 retain their old +layer admission before migration. The current version-7 correction adds no fields +or wire shapes; older version-7 readers may reject newly admitted latent records. +That compatibility limit must remain explicit until coordinated version adoption. +The explicit RelocateNode operation continues to require a compatible destination +layer or Traceability view; retaining latent records is distinct from moving a +placement into an incompatible destination. + +Canvas inspection carries the originating route context and preserves view/layer. +Explicit model navigation continues to follow the target layer. Draft validation +and stale-context rejection apply before inspection. Existing production group +selection already retained the canvas; fallback edge/port selection and canvas +context actions require the correction. Actual native reproduction must establish +which path accounts for the user's reported click; source diagnosis alone does not. + +## Acceptance + +- OA and SA switches through all ordinary projections preserve the complete model + except projection, source/JSON/bundle round trips, and one-step Undo/Redo. +- Returning to Traceability restores applicable placements; parent, port and edge + projection never leaks an opposite-layer record into an ordinary view. +- Invalid references and State/Scenario membership still reject; source and JSON + versions 3–6 retain their old layer checks and located diagnostic identities. +- Actual canvas clicks, context Properties/Hide and explicit navigation exercise + the intended selection distinction; stale packets and invalid drafts preserve + selection and source/history. +- Windows native selection, projection switching, Save, Undo/Redo and fresh reopen + are checked against predeclared full-model/source expectations. Compilation and + automated handler tests do not substitute for this acceptance. + +Linux remains excluded by the existing user pause. Independent design critique +identified saved visibility selectors and frozen migration as additional admission +paths; both are included. Automated and native outcomes are not yet recorded.