Compare commits

...
33 changed files with 1568 additions and 91 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,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!(
+76 -24
View File
@@ -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),
@@ -1171,7 +1174,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() {
@@ -1217,6 +1226,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 +1390,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 +2150,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 +2751,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);
}
@@ -2733,7 +2766,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;
@@ -2889,6 +2930,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);
@@ -3336,7 +3385,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,
));
@@ -4362,13 +4411,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 {
@@ -4376,7 +4430,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 {
@@ -4405,16 +4459,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(),
@@ -4547,7 +4601,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();
@@ -4561,7 +4621,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) {
@@ -5086,17 +5146,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()
@@ -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,6 +1,7 @@
use super::*;
use crate::app::multiselect::{self, Choice};
use crate::diagram_usage::Target as SelectionTarget;
use iced::widget::text_editor;
#[path = "group/connection_lifetime.rs"]
mod connection_lifetime;
@@ -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,544 @@
//! 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);
}
}
@@ -10,6 +10,11 @@ async fn press_escape(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
assert_eq!(
statuses,
[iced::event::Status::Captured],
"the mounted picker must capture Escape before App subscriptions"
);
crate::app::escape::tests::subscriptions(app, input, statuses[0], &mut messages);
for message in messages.iter().cloned() {
let task = app.update(message);
@@ -41,17 +46,11 @@ async fn mounted_state_missing_picker_escape_preserves_exact_document_then_reope
send(&mut app, Message::BehaviorDiagram(choice));
let mut mounted = Mounted::new(&mut app).await;
assert_eq!(state_edit::unplaced(app.project(), diagram), vec![f.b]);
let focus_id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, canvas, _) = mounted
.geometry(&app)
.focused
.into_iter()
.find(|(id, _, _)| id.as_ref() == Some(&focus_id))
.unwrap();
// The existing picker is immediately above the measured canvas, separated
// by 8px. Its 12px text and 8px padding give a 31.6px row.
let picker = iced::Point::new(canvas.center_x(), canvas.y - 24.);
let option = iced::Point::new(picker.x, canvas.y + 8.);
// Locate the stable picker slot itself: selection controls may sit between
// it and the canvas. The single menu option opens below this slot.
let bounds = mounted.geometry(&app).bounds("state-missing-picker");
let picker = bounds.center();
let option = iced::Point::new(picker.x, bounds.y + bounds.height * 1.5);
let before = app.document.snapshot();
let original = app.project().clone();
let source = app.document.bundle().clone();
+1 -1
View File
@@ -187,7 +187,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
.into();
out = out.push(
column![
missing_controls,
container(missing_controls).id("state-missing-picker"),
selection_controls(app),
crate::diagram::focus::wrap(
canvas.map(event),
+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)
);
+1
View File
@@ -322,6 +322,7 @@ 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.",
"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.",
@@ -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):
+9 -2
View File
@@ -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.
+20 -2
View File
@@ -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
+1 -1
View File
@@ -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": [
+12 -2
View File
@@ -9,7 +9,8 @@ excluded by the user's explicit request, including after networking recovers.**
## Assembled source and acceptance boundary
Combined code **4deced53b98cd4d7649ba7aa60fa4b9b30d2f3b8** adds the reviewed
Combined code through **4deced53b98cd4d7649ba7aa60fa4b9b30d2f3b8**, plus the
test-only import correction from **7c2b06b63f8a0fe14b3f001996bdf965b36a4e85**, adds the reviewed
Connection/Route early-admission correction to the assembled Scenario/Text,
ordinary/State/Process/Allocation selection, direct drawing, Traceability and
picker changes. The [source handoff](prerelease-integrated-source-20260924.md)
@@ -21,7 +22,16 @@ claim combined Cargo, native, packaged or release acceptance. The independent
further material source defect in its stated scope. A subsequent peer compile
found a missing `projection::` qualification in the new Route test helper;
the current code includes that one-line correction. The original compile failed
before running tests. Combined execution remains pending.
before running tests. The first combined compile at `897d9f04` subsequently
found a missing `text_editor` import in the Allocation Text Apply regression;
the current source includes that one-line correction too. Both failures remain
recorded in the source handoff. The subsequent full test run at `8ddfadf4`
failed the mounted State picker fixture: added selection controls invalidated
its canvas-relative clicks. Source successor `a9cd364eacbbffa0693bab05b5a2b65f9d105535`
measures an identified fixed-height picker slot and checks Escape capture,
retaining the exact preservation/reopen/Place/Undo assertions. The source
handoff links the preserved failed and interrupted attempts. Corrected combined
Windows execution and package acceptance remain pending.
Live build, final-source and publication results belong in
[issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37).
+11
View File
@@ -0,0 +1,11 @@
# Connection tool icons
Branch candidate for [#41](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/41), based on published Windows preview source `715036e132677dd2bc2fb37ed316d93dc9987aec`. This is not integrated or native acceptance.
The reported collapsed palette repeats one arrow for three different tools. Extend the existing Rust/Iced canvas glyphs at their actual 16×16 size: work endpoints plus an arrow for Interaction/Functional exchange, neutral participant endpoints for Communication means, facing component ports for Boundary exchange, and work entering a performer for Allocation. The first pair never coexist in one perspective. Unoriented generic tools must not imply a fixed direction.
Letter badges were rejected as language-dependent and cramped. Different arrow/dash styles risk confusing tool identity with relation direction. Native endpoint/container shapes fit the existing icon system without font or asset dependencies. Preserve button dimensions, explicit labels/tooltips, ordering, messages, availability and selection rules.
Independent source critique found no blocker and confirmed the tool semantics and Iced API use. Actual-size legibility remains unverified, particularly the exchange arrow, port details and possible download/import interpretation of Allocation. Verify enabled, disabled, hovered and selected palettes in both perspectives, expansion/collapse, tool activation, representative relation creation and cancellation. Existing keyboard/accessibility behavior must be checked, not inferred from hover labels. Reuse semantic connection tests; geometry hashes alone are not an identification or usability evaluator.
Linux remains excluded by the existing user pause. Live ownership and integration status belong to the linked issue.
@@ -80,6 +80,30 @@ actionable defect in the stated boundary. These do not substitute for the final
combined execution. The vendor patch-description correction is incorporated at
`17eaa03fd69741dff746f21d150dc04c341411b3`.
## Mounted State picker correction
The combined Windows test run at `8ddfadf440b5c208b1adb6b211a97ba0c99cff04`
failed the mounted State missing-picker Escape regression. The test located the
picker relative to the canvas, but the integrated selection toolbar adds 56px
plus an 8px gap between them. Both the opening and option clicks were stale.
Parent and independent source inspection agreed on this fixture defect; they
found no reason to change the production Escape policy from this failure.
Successor `a9cd364eacbbffa0693bab05b5a2b65f9d105535` adds a zero-padding
identified container around the existing fixed-height picker slot. The fixture
measures that slot, checks Escape capture before App subscriptions, and retains
zero messages, exact document/source/selection/camera/revision/history,
reopen-and-Place, and exact source Undo checks. The option coordinate assumes
this fixture's single downward-opening menu at 1800 by 1100; it is not a general
overlay geometry evaluator. No production Escape handler changes.
The peer records 2,199 passes, one failure and eight ignores for that failed
full run, with lint and product builds not run. The earlier final-02 attempt
was interrupted during harness listing before test bodies; neither attempt is
a passed gate. [Preserved attempts and v2 recipe](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/184c1307d1ae3d49007480020fa1c080c94ececa/harness/prerelease37-windows-gates-20260924/attempts-8dd)
retain their distinct boundaries. The corrected source requires current
combined Windows gates and packaged native acceptance.
## Combined gates and package handoff
The first peer compilation exposed E0425 in the new Route fixture: its
@@ -90,6 +114,16 @@ single test line. Production code is unchanged. This concrete finding supersedes
the earlier `9e99618c` build handoff; the failed compile ran no tests and remains
part of the peer's evidence. Source review and formatting did not catch it.
The first combined Windows attempt on `897d9f04` then found E0433 in the
Allocation Text Apply test added at `542b10ac`: the test's module lacked
`iced::widget::text_editor`. The current successor incorporates peer
`7c2b06b63f8a0fe14b3f001996bdf965b36a4e85`, adding that import only. Production
code remains unchanged. The failed combined compile ran no tests, lint or
product build; its 3,709 source inputs stayed unchanged and formatting passed.
[Preserved failure evidence](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/2bd0e615204be728b5c2b77cc7e28504c18a2277/harness/prerelease37-windows-gates-20260924/README.md)
records that distinct failure. The corrected successor requires fresh combined
execution; no earlier pass is substituted.
Codex-ytr83r owns the exact-candidate Windows build/package on its separate
machine. Codex-9e8r6t owns independent source/seam review and a downloaded-package
check. Codex-956atf remains the sole integration, tag and release publication owner.
+87
View File
@@ -0,0 +1,87 @@
# 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. The published Windows baseline reproduces both reported failure states;
source diagnosis and those baseline results do not establish patched acceptance.
Contextual child/relationship creation must derive the semantic layer from the
selected object, while root creation continues to use the active perspective.
## Acceptance
- 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. At published candidate `603a59c1785dbcca324dae063ecc3405d9519a30`, 41 targeted core checks pass: legacy admission (2), projection switching (3), review regressions (15), source-6 view contract (8), and state diagrams (13). Tracked inputs were unchanged across the run. Whole-workspace formatting also passed at that commit. The first `0341bca` core attempt failed a generated-UUID test oracle; explicit fixture identities corrected the evaluator, without relaxing the admission assertion.
The desktop attempt at `603a59c` was deliberately interrupted before test execution after independent source review found canvas Properties redispatch and contextual relationship/child semantic-layer defects. Its source inputs remained unchanged; no desktop passing result is claimed. The Properties issue arose from fixing explicit navigation to an already-inspected opposite-layer object. Keep both regressions: contextual Properties stays in the view, explicit navigation follows the object's layer.
Published-preview executable SHA256 `2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399` reproduces edge selection changing SA to OA, and Architecture switching refused with `diagram_layer`. The isolated valid fixture remained byte-for-byte unchanged after the trial. An earlier fixture used reserved alias `capability` and was rejected; it was corrected before either reproduction. Initial native-dialog capture/input failures also remain separate from application acceptance. Independent evidence review confirmed the resulting screenshot states and persisted byte equality; screenshots alone do not capture the complete action sequence or transient state history.
The frozen full-workspace attempt at `3a7221b80cfa7924921b3b77f2dd35550247f595`
closed with unchanged tracked inputs and Cargo exit101: core/CLI groups passed616
with1 existing ignore; desktop passed1538, failed1 and retained7 existing ignores.
The sole failure was the new preferred-view test's setup, before child creation:
`Message::Projection` edits and selects the diagram, so the test's expectation
that the parent remained selected was false. An exact isolated rerun and source
review confirmed this. The correction initializes the projection in the fixture,
then uses explicit parent selection and the ordinary diagram picker to establish
independent semantic inspection. Production navigation is unchanged. All other
new canvas tests passed, including Properties, explicit navigation, relationship
Apply/history, child/root semantic layer, and projection/source/history checks.
The command remains a failed workspace run; server tests and doctests were not
reached. Its planned Clippy/build stages were consequently not launched.
The same source's formatting check found one multiline icon call requiring a
single line. The follow-up applies that formatting correction and adds in-app
help describing preserved cross-layer content and contextual inspection. Those
are the only production-file changes after the full suite; the remaining change
corrects the test setup. Follow-up canvas/server/doctest, formatting, strict
Clippy and native-build results must be recorded separately, with their exact
source pin. Reuse successful unchanged core/desktop evidence within these limits;
do not turn the failed workspace command into a pass.
Candidate Windows GUI save/history/reopen, HTTP regression, the corrected test
and final checks remain outstanding. Raw local evidence is not yet a
peer-consumable handoff; publish the consolidated evidence before integration.
Scope boundary: contextual relationship forms retain the canvas while opening; successful Apply retains the existing explicit post-review navigation. The generic child-creation handler is tested for containment and allocation, but the separate architecture palette/performer workflow still offers creation kinds for the diagram layer. Thus an OA performer displayed in an SA Traceability view has no Add activity menu entry; this inherited limitation is not claimed repaired here.