//! A small property-form adapter over the closed core records. The model remains //! typed; temporary strings belong only to drafts so incomplete numeric edits //! never enter accepted state. References are displayed as named choices. use crate::{ app::{Choice, Message}, style, }; use iced::{ Element as View, Fill, widget::{ self, button, checkbox, column, container, pick_list, row, text, text_editor, text_input, }, }; use oa_core::*; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; mod port_fields; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RecordKind { Project, Element, Relation, Diagram, Node(Id), Artifact, Trace, } pub struct Input { pub raw: String, pub editor: text_editor::Content, } pub struct Draft { pub kind: RecordKind, pub original: Id, pub value: Value, baseline: Value, pub inputs: BTreeMap, pub exact_strings: bool, port_contract_drafts: BTreeMap, } impl Draft { pub fn from_project(project: &Project, selected: Id) -> Option { let (kind, value) = if selected == project.id { ( RecordKind::Project, json!({"id":project.id,"alias":project.alias,"name":project.name,"description":project.description}), ) } else if let Some(v) = project.elements.get(&selected) { (RecordKind::Element, json!(v)) } else if let Some(v) = project.relations.get(&selected) { (RecordKind::Relation, json!(v)) } else if let Some(v) = project.diagrams.get(&selected) { (RecordKind::Diagram, json!(v)) } else if let Some(v) = project.artifact_links.get(&selected) { (RecordKind::Artifact, json!(v)) } else if let Some(v) = project.traces.get(&selected) { (RecordKind::Trace, json!(v)) } else { let (diagram, node) = project .diagrams .values() .find_map(|d| d.nodes.get(&selected).map(|n| (d.id, n)))?; let mut value = json!(node); value["port_anchors"] = json!(node.port_anchors); value["diagram"] = json!(diagram); (RecordKind::Node(diagram), value) }; let mut draft = Self { kind, original: selected, baseline: value.clone(), value, inputs: BTreeMap::new(), exact_strings: false, port_contract_drafts: BTreeMap::new(), }; draft.rebuild(); Some(draft) } fn rebuild(&mut self) { self.inputs.clear(); fn visit(value: &Value, path: &str, out: &mut BTreeMap) { match value { Value::Object(map) => { for (key, value) in map { if key != "nodes" { visit(value, &format!("{path}/{key}"), out); } } } Value::Array(items) => { for (index, value) in items.iter().enumerate() { visit(value, &format!("{path}/{index}"), out); } } Value::String(s) => { out.insert( path.into(), Input { raw: s.clone(), editor: text_editor::Content::with_text(s), }, ); } Value::Number(n) => { let s = n.to_string(); out.insert( path.into(), Input { raw: s.clone(), editor: text_editor::Content::with_text(&s), }, ); } _ => {} } } visit(&self.value, "", &mut self.inputs); if self.exact_strings { for (path, input) in &mut self.inputs { if free_string(path) { input.raw = serde_json::to_string(&input.raw).expect("String serialization"); input.editor = text_editor::Content::with_text(&input.raw); } } } } pub fn materialize(&self) -> Result { let mut value = self.value.clone(); for (path, input) in &self.inputs { let Some(target) = value.pointer_mut(path) else { continue; }; if target.is_number() { let parsed: Value = serde_json::from_str(&input.raw).map_err(|_| { format!( "{} must be a finite number.", friendly(path.rsplit('/').next().unwrap_or("Value")) ) })?; if !parsed.is_number() { return Err("Enter a finite number.".into()); } *target = parsed; } else if self.exact_strings && free_string(path) { let decoded: String = serde_json::from_str(&input.raw).map_err(|_| { format!( "{} must be a quoted JSON string while escaped strings are enabled.", friendly(path.rsplit('/').next().unwrap_or("Text")) ) })?; *target = Value::String(decoded); } else { *target = Value::String(input.raw.clone()); } } Ok(value) } pub fn dirty(&self) -> bool { self.materialize() .map_or(true, |value| value != self.baseline) } pub fn set_exact(&mut self, enabled: bool) -> Result<(), String> { self.value = self.materialize()?; self.exact_strings = enabled; self.rebuild(); Ok(()) } pub fn input(&mut self, path: &str, value: String) { if let Some(input) = self.inputs.get_mut(path) { input.editor = text_editor::Content::with_text(&value); input.raw = value; } } pub fn edit_text(&mut self, path: &str, action: text_editor::Action) { if let Some(input) = self.inputs.get_mut(path) { input.editor.perform(action); input.raw = input.editor.text(); } } pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> { let mut candidate = self.materialize()?; let value = if path.ends_with("/Port/contract") { port_fields::switch_contract( candidate .pointer(path) .ok_or("Property no longer exists.")?, value, &mut self.port_contract_drafts, ) } else { value }; *candidate .pointer_mut(path) .ok_or("Property no longer exists.")? = value; self.value = candidate; self.rebuild(); Ok(()) } pub fn array( &mut self, path: &str, index: Option, direction: i32, ) -> Result<(), String> { let mut candidate = self.materialize()?; let items = candidate .pointer_mut(path) .and_then(Value::as_array_mut) .ok_or("List no longer exists.")?; match index { None => items.push(if path.ends_with("literals") { json!("New value") } else if path.ends_with("bindings") { json!({"referenced": Id::nil(), "local": Id::nil()}) } else if path.ends_with("values") { json!({"field": Id::nil(), "expression": ""}) } else if path.ends_with("routes") { json!({"edge": Id::nil(), "waypoints": [], "label": null}) } else if path.ends_with("waypoints") { json!({"x": 0.0, "y": 0.0}) } else if path.ends_with("port_anchors") { json!({"port": Id::nil(), "side": "Right", "offset": 0.5}) } else { json!(Id::nil()) }), Some(i) if i < items.len() => { if direction == 0 { items.remove(i); } else { let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize; items.swap(i, target); } } _ => return Err("List item no longer exists.".into()), } self.value = candidate; self.rebuild(); Ok(()) } pub fn command(&self) -> Result { let mut value = self.materialize()?; let decode = |e: serde_json::Error| e.to_string(); Ok(match self.kind { RecordKind::Project => Command::EditProject { id: serde_json::from_value(value["id"].clone()).map_err(decode)?, alias: value["alias"].as_str().unwrap_or_default().into(), name: value["name"].as_str().unwrap_or_default().into(), description: value["description"].as_str().unwrap_or_default().into(), }, RecordKind::Element => Command::EditElement( self.original, serde_json::from_value(value).map_err(decode)?, ), RecordKind::Relation => Command::EditRelation( self.original, serde_json::from_value(value).map_err(decode)?, ), RecordKind::Diagram => Command::EditDiagram( self.original, serde_json::from_value(value).map_err(decode)?, ), RecordKind::Node(diagram) => { let destination: Id = serde_json::from_value( value .as_object_mut() .ok_or("Invalid placement draft.")? .remove("diagram") .ok_or("Choose a diagram.")?, ) .map_err(decode)?; let node: NodePlacement = serde_json::from_value(value).map_err(decode)?; let id = node.id; let edit = Command::EditNode { diagram, original: self.original, node, }; if destination == diagram { edit } else { Command::Batch(vec![ edit, Command::RelocateNode { node: id, diagram: destination, }, ]) } } RecordKind::Artifact => Command::EditArtifact( self.original, serde_json::from_value(value).map_err(decode)?, ), RecordKind::Trace => Command::EditTrace( self.original, serde_json::from_value(value).map_err(decode)?, ), }) } pub fn resulting_id(&self) -> Result { self.inputs .get("/id") .ok_or("Missing identity.")? .raw .parse() .map_err(|_| "Identity must be a UUID.".into()) } } fn friendly(name: &str) -> String { match name { "id" => "UUID identity".into(), "alias" => "Stable source alias".into(), "parent" => "Semantic parent".into(), "oa" => "Operational counterpart".into(), "sa" => "System counterpart".into(), "do_activity" => "Do behavior (description)".into(), "source_port" => "Output port".into(), "target_port" => "Input port".into(), "contract" => "Port contract".into(), "FunctionInput" => "Function input".into(), "FunctionOutput" => "Function output".into(), "Component" => "Component port".into(), "incoming_items" => "Incoming exchange items".into(), "outgoing_items" => "Outgoing exchange items".into(), "orientation" => "Flow orientation".into(), "oriented" => "Directed component exchange".into(), "Unset" => "Not specified".into(), "minimum" => "Minimum count".into(), "maximum" => "Maximum count".into(), "type_ref" => "Value type".into(), "Named" => "Data type".into(), "body" => "Occurrence type".into(), "diagram" => "Diagram".into(), "StateMachine" => "State machine".into(), "machine" => "State machine context".into(), "uri" => "Artifact URI".into(), "definition" => "Data type definition".into(), "steps" | "occurrences" => "Ordered occurrences".into(), "represented" => "Represents".into(), "reply_to" => "Reply to call".into(), "referenced" => "Referenced scenario lifeline".into(), "local" => "Local lifeline".into(), "field" => "Carried data field".into(), "OperationalActivity" => "Operational activities".into(), "ParticipantInteraction" => "Operational participants".into(), "Functional" => "System functions".into(), "BoundaryInterface" => "System boundary / interfaces".into(), "MixedLegacy" => "Mixed viewpoints (imported model)".into(), "ShallowHistory" => "Shallow history".into(), "DeepHistory" => "Deep history".into(), "EntryPoint" => "Entry point".into(), "ExitPoint" => "Exit point".into(), "triggers" => "Trigger events / work".into(), "effects" => "Effect work".into(), "trigger" => "Trigger notes".into(), "effect" => "Effect notes".into(), "Oa" => "Operational Analysis".into(), "Sa" => "System Analysis".into(), "InOut" => "Input / output".into(), "In" => "Input".into(), "Out" => "Output".into(), "DerivedFrom" => "Derived from".into(), _ => { let text = name.replace('_', " "); let mut chars = text.chars(); chars .next() .map(|c| c.to_uppercase().collect::() + chars.as_str()) .unwrap_or_default() } } } fn reference(path: &str) -> bool { let parts: Vec<_> = path.split('/').collect(); let field = parts.last().copied().unwrap_or_default(); let parent = parts.iter().rev().nth(1).copied().unwrap_or_default(); matches!( field, "parent" | "source" | "target" | "source_port" | "target_port" | "interface" | "context" | "represented" | "exchange" | "edge" | "port" | "sender" | "receiver" | "action" | "sequence" | "scenario" | "reply_to" | "referenced" | "local" | "field" | "lifeline" | "state" | "element" | "owner" | "oa" | "sa" | "Named" | "diagram" | "machine" ) || matches!( parent, "items" | "incoming_items" | "outgoing_items" | "steps" | "branches" | "occurrences" | "constrained" | "triggers" | "effects" ) } fn optional_reference(path: &str) -> bool { matches!( path.rsplit('/').next().unwrap_or_default(), "parent" | "interface" | "context" | "represented" | "exchange" | "sender" | "receiver" | "action" | "sequence" | "scenario" | "reply_to" | "lifeline" | "state" ) } fn multiline(path: &str) -> bool { matches!( path.rsplit('/').next().unwrap_or_default(), "description" | "precondition" | "postcondition" | "expression" | "entry" | "exit" | "do_activity" | "trigger" | "guard" | "condition" | "effect" ) } fn free_string(path: &str) -> bool { multiline(path) || matches!( path.rsplit('/').next(), Some("name" | "uri" | "revision" | "fragment" | "unit") ) || path.split('/').rev().nth(1) == Some("literals") } // These are choices for the closed model, not a second validation boundary. // A current reference is always retained, even while other draft fields make it // incompatible. Applying the complete draft remains the core validator's job. fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id) -> Vec { let identities = project.identities().into_iter().collect::>(); let compatible = reference_ids(draft, project, path); let mut objects = compatible .clone() .into_iter() .chain((!current.is_nil()).then_some(current)) .collect::>() .into_iter() .map(|id| { let name = project .diagrams .values() .find_map(|diagram| { diagram.nodes.get(&id).map(|node| { format!( "{} · placement in {}", project.named(node.element), diagram.name ) }) }) .unwrap_or_else(|| project.named(id).to_owned()); let mut label = identities .get(&id) .map(|alias| format!("{name} · @{alias}")) .unwrap_or_else(|| format!("Missing reference · {id}")); if identities.contains_key(&id) && !compatible.contains(&id) { label = format!("Current choice needs review · {label}"); } Choice { id, label } }) .collect::>(); objects.sort_by(|a, b| a.label.cmp(&b.label)); if optional_reference(path) || current.is_nil() { objects.insert( 0, Choice { id: Id::nil(), label: if optional_reference(path) { "Not assigned" } else { "Choose a required reference" } .into(), }, ); } objects } fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec { let value = draft.materialize().unwrap_or_else(|_| draft.value.clone()); if path.starts_with("/port_anchors/") && path.ends_with("/port") { let owner = value .get("element") .and_then(|id| serde_json::from_value::(id.clone()).ok()); return project .elements .values() .filter(|e| e.kind() == Kind::Port && e.parent == owner) .map(|e| e.id) .collect(); } let element = (draft.kind == RecordKind::Element) .then(|| serde_json::from_value::(value.clone()).ok()) .flatten() .or_else(|| project.elements.get(&draft.original).cloned()); let relation = (draft.kind == RecordKind::Relation) .then(|| serde_json::from_value::(value.clone()).ok()) .flatten() .or_else(|| project.relations.get(&draft.original).cloned()); let layer = element .as_ref() .map(Element::layer) .or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source()))) .or_else(|| { (draft.kind == RecordKind::Diagram) .then(|| serde_json::from_value::(value["layer"].clone()).ok()) .flatten() }); let field = path.rsplit('/').next().unwrap_or_default(); if field == "edge" { return project .relations .keys() .chain(project.traces.keys()) .copied() .collect(); } let list = path.split('/').rev().nth(1).unwrap_or_default(); let semantic = |id| { project .layer_of(id) .is_some_and(|l| layer.is_none_or(|own| own == l)) }; let same_layer = |e: &Element| layer.is_none_or(|l| e.layer() == l); let owned = |e: &Element| { e.parent == Some(draft.original) || element .as_ref() .is_some_and(|owner| e.parent == Some(owner.id)) }; if field == "diagram" { // Keep destinations selectable before retargeting the element. The // element picker below uses the draft destination's membership rules. return project.diagrams.keys().copied().collect(); } if field == "owner" || list == "constrained" { return project .identities() .into_iter() .map(|(id, _)| id) .filter(|id| field == "owner" || semantic(*id)) .collect(); } if matches!(field, "oa" | "sa") && draft.kind == RecordKind::Trace { let target = if field == "oa" { Layer::Oa } else { Layer::Sa }; return project .identities() .into_iter() .map(|(id, _)| id) .filter(|id| project.layer_of(*id) == Some(target)) .collect(); } if matches!(field, "source" | "target") && let Some(r) = &relation { if let Some(choices) = port_fields::relation_endpoints(project, r, field, draft.original) { return choices; } // can_connect treats allocations/initial exits as new links, so exclude // the relation being edited before asking it for endpoint choices. let mut context = project.clone(); context.relations.remove(&draft.original); let other = if field == "source" { r.target() } else { r.source() }; let other_exists = context.contains(other); let identities = project .identities() .into_iter() .map(|(id, _)| id) .collect::>(); let permits = |id, other| { let (source, target) = if field == "source" { (id, other) } else { (other, id) }; validation::can_connect(&context, r.kind(), source, target) }; return identities .iter() .copied() .filter(|id| { if other_exists { permits(*id, other) } else { identities.iter().any(|other| permits(*id, *other)) } }) .collect(); } if field == "exchange" { let owner = element .as_ref() .and_then(|e| e.parent) .and_then(|id| project.elements.get(&id)); return project .relations .values() .filter(|r| { semantic(r.id) && match owner.map(Element::kind) { Some(Kind::Process) => r.kind() == RelationKind::Interaction, Some(Kind::FunctionalChain) => r.kind() == RelationKind::FunctionalExchange, _ => matches!( r.kind(), RelationKind::Interaction | RelationKind::FunctionalExchange | RelationKind::BoundaryExchange ), } }) .map(|r| r.id) .collect(); } if field == "field" { return element .as_ref() .and_then(|e| match e.shared()? { SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange: Some(id), .. }, .. } => project.relations.get(id), _ => None, }) .map(|r| validation::carried_fields(project, r).into_iter().collect()) .unwrap_or_default(); } let scenario = element .as_ref() .and_then(|e| e.parent) .and_then(|id| validation::ancestor_of_kind(project, id, Kind::Scenario)); let binding_row = path .split('/') .rev() .nth(1) .and_then(|s| s.parse::().ok()); project .elements .values() .filter(|candidate| { if field == "element" && let RecordKind::Node(diagram) = draft.kind { let diagram = value .get("diagram") .and_then(Value::as_str) .and_then(|id| id.parse().ok()) .unwrap_or(diagram); return project .diagrams .get(&diagram) .is_some_and(|d| placement_allowed(project, d, candidate, draft.original)); } if !same_layer(candidate) { return false; } match field { "machine" => candidate.kind() == Kind::StateMachine, "parent" => element.as_ref().is_some_and(|child| { if candidate.id == draft.original || candidate.id == child.id || !validation::can_parent( project, child.kind(), child.layer(), Some(candidate.id), ) { return false; } let mut parent = candidate.parent; let mut seen = BTreeSet::new(); while let Some(id) = parent { if id == draft.original || id == child.id || !seen.insert(id) { return false; } parent = project.elements.get(&id).and_then(|e| e.parent); } match child.shared() { Some(SharedBody::Port { contract, .. }) => match contract { PortContract::FunctionInput { .. } | PortContract::FunctionOutput { .. } => { child.layer() == Layer::Sa && candidate.kind() == Kind::Function } PortContract::Component { .. } => { child.layer() == Layer::Sa && matches!(candidate.kind(), Kind::System | Kind::SystemActor) } PortContract::Legacy => { candidate.kind().is_work() || candidate.kind().is_performer() } }, Some(SharedBody::Occurrence { body, .. }) => match candidate.shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { .. }, .. }) => matches!(body, OccurrenceBody::Branch { .. }), Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) => !matches!(body, OccurrenceBody::Branch { .. }), _ if matches!( candidate.kind(), Kind::Process | Kind::FunctionalChain ) => { matches!( body, OccurrenceBody::Exchange { .. } | OccurrenceBody::Action { .. } | OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. } ) } _ => !matches!(body, OccurrenceBody::Branch { .. }), }, _ => true, } }), "Named" => candidate.kind() == Kind::DataType, "interface" => candidate.kind() == Kind::Interface, "context" => { candidate.kind().is_sequence() || candidate.kind().is_work() || candidate.kind().is_performer() || matches!( candidate.kind(), Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission ) } "represented" => { candidate.kind().is_performer() || candidate.kind().is_work() || candidate.kind() == Kind::Role } "action" => candidate.kind().is_work(), "sequence" => element .as_ref() .and_then(|e| e.parent) .and_then(|id| project.elements.get(&id)) .is_some_and(|owner| { candidate.kind() == owner.kind() && candidate.id != owner.id }), "scenario" => { candidate.kind() == Kind::Scenario && scenario.is_some_and(|owner| { let viewpoint = |id| match project.elements.get(&id).and_then(Element::shared) { Some(SharedBody::Scenario { viewpoint, .. }) => { Some(*viewpoint) } _ => None, }; let a = viewpoint(owner); let b = viewpoint(candidate.id); (a == b || a == Some(ScenarioViewpoint::MixedLegacy) || b == Some(ScenarioViewpoint::MixedLegacy)) && !scenario_reaches(project, candidate.id, owner) }) } "reply_to" => { candidate.id != draft.original && scenario.is_some() && validation::ancestor_of_kind(project, candidate.id, Kind::Scenario) == scenario && matches!( candidate.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Call, .. }, .. }) ) } "referenced" | "local" => element.as_ref().is_some_and(|e| { let Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: target, bindings, }, .. }) = e.shared() else { return false; }; if candidate.kind() != Kind::Lifeline || candidate.parent != if field == "referenced" { *target } else { scenario } || candidate.parent.is_none() { return false; } if bindings.iter().enumerate().any(|(i, b)| { Some(i) != binding_row && candidate.id == if field == "referenced" { b.referenced } else { b.local } }) { return false; } let other = binding_row.and_then(|i| bindings.get(i)).map(|b| { if field == "referenced" { b.local } else { b.referenced } }); let represented = |id| match project.elements.get(&id).and_then(Element::shared) { Some(SharedBody::Lifeline { represented }) => *represented, _ => None, }; match (represented(candidate.id), other.and_then(represented)) { (Some(a), Some(b)) => { validation::semantic_representation_compatible( project, if field == "local" { a } else { b }, if field == "local" { b } else { a }, ) != Some(false) } _ => true, } }), "state" => matches!(candidate.kind(), Kind::State | Kind::Mode), "sender" | "receiver" | "lifeline" => element.as_ref().is_some_and(|e| { let scenario = e .parent .and_then(|id| validation::ancestor_of_kind(project, id, Kind::Scenario)); candidate.kind() == Kind::Lifeline && candidate.parent == scenario && lifeline_matches(project, e, candidate, field) }), "source_port" | "target_port" => relation.as_ref().is_some_and(|r| { let source = field == "source_port"; candidate.layer() == Layer::Sa && candidate.parent == Some(if source { r.source() } else { r.target() }) && match port_contract(candidate) { Some(PortContract::FunctionOutput { outgoing_items }) if source => { items_satisfy(project, r.body.items(), outgoing_items) } Some(PortContract::FunctionInput { incoming_items }) if !source => { items_satisfy(project, r.body.items(), incoming_items) } _ => false, } }), _ => match list { "items" | "incoming_items" | "outgoing_items" => { candidate.kind() == Kind::ExchangeItem } "triggers" => candidate.kind().is_work() || candidate.kind() == Kind::Event, "effects" => candidate.kind().is_work(), "branches" => { owned(candidate) && matches!( candidate.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) ) } "steps" | "occurrences" => { owned(candidate) && candidate.kind() == Kind::Occurrence } _ => true, }, } }) .map(|e| e.id) .collect() } fn lifeline_matches(project: &Project, occurrence: &Element, line: &Element, field: &str) -> bool { let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared() else { return true; }; let endpoint = match body { OccurrenceBody::Exchange { .. } => validation::message_endpoints(project, body) .map(|(source, target)| if field == "sender" { source } else { target }), OccurrenceBody::Action { action, .. } => *action, OccurrenceBody::State { state: Some(state), .. } => { let owner = validation::ancestor_of_kind(project, *state, Kind::StateMachine) .and_then(|id| project.elements.get(&id)) .and_then(|e| e.parent); return owner.is_none_or(|owner| match line.shared() { Some(SharedBody::Lifeline { represented: Some(represented), }) => { *represented == owner || validation::effective_performer(project, *represented) == Some(owner) } _ => true, }); } _ => None, }; let Some(endpoint) = endpoint else { return true; }; validation::lifeline_matches_endpoint(project, line.id, endpoint) != Some(false) } // A selector must not offer a recursive scenario use. This only traverses // existing references; committing a complete draft still uses core validation. fn scenario_reaches(project: &Project, from: Id, target: Id) -> bool { let mut pending = vec![from]; let mut seen = BTreeSet::new(); while let Some(id) = pending.pop() { if id == target { return true; } if !seen.insert(id) { continue; } for e in project.elements.values() { if let Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: Some(next), .. }, .. }) = e.shared() && validation::ancestor_of_kind(project, e.id, Kind::Scenario) == Some(id) { pending.push(*next); } } } false } fn group_label(path: &str) -> Option<&'static str> { match path { "/position" => Some("Canvas position"), "/size" => Some("Box size"), "/baseline" => Some("Reviewed properties"), "/baseline/oa" => Some("Operational baseline"), "/baseline/sa" => Some("System baseline"), _ => None, } } fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool { (diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer()) && diagram .projection .state_machine() .is_none_or(|machine| projection::state_machine_contains(project, machine, element.id)) && !diagram .nodes .values() .any(|node| node.id != current && node.element == element.id) } fn unit_body(path: &str, value: &Value) -> bool { value.is_string() && matches!( path, "/body/Oa" | "/body/Sa" | "/body/Oa/Shared" | "/body/Sa/Shared" ) } /// Closed choices for the concrete model enums. No runtime schema/plugin layer. fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> { let field = path.rsplit('/').next().unwrap_or_default(); if path == "/projection" && kind == RecordKind::Diagram { let mut choices = [ "Overview", "Decomposition", "Allocation", "Traceability", "FunctionalDataflow", "Capabilities", ] .into_iter() .map(|name| (friendly(name), json!(name))) .collect::>(); choices.push(( "State machine".into(), if value.get("StateMachine").is_some() { value.clone() } else { json!({"StateMachine": {"machine": Id::nil()}}) }, )); return choices; } let words: &[&str] = match field { "side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"], "layer" => &["Oa", "Sa"], "operator" => &["Alternative", "Parallel", "Loop", "Optional"], "viewpoint" if path.contains("/Oa/") => &[ "OperationalActivity", "ParticipantInteraction", "MixedLegacy", ], "viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"], "phase" => &["Entry", "Do", "Exit"], "kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"], "kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"], "kind" if path.contains("State") => &[ "State", "Mode", "Initial", "Final", "Choice", "Junction", "Fork", "Join", "ShallowHistory", "DeepHistory", "EntryPoint", "ExitPoint", "Terminate", ], "kind" if path.contains("/Time/") => &["After", "At"], "transition_kind" => &["External", "Local", "Internal"], "kind" if kind == RecordKind::Trace => &[ "Refinement", "Participant", "Capability", "Mission", "Exchange", "Sequence", "Item", "Scenario", "State", "Manual", ], "relation" if kind == RecordKind::Artifact => &[ "References", "Satisfies", "Verifies", "Implements", "DerivedFrom", ], _ => &[], }; if !words.is_empty() { return words.iter().map(|s| (friendly(s), json!(s))).collect(); } if field == "body" && path.contains("/Event/") { return vec![ ("Signal event".into(), json!({"Signal":{"items":[]}})), ( "Time event".into(), json!({"Time":{"kind":"After","expression":""}}), ), ("Change event".into(), json!({"Change":{"expression":""}})), ]; } if field == "message_kind" { return vec![ ("Asynchronous message".into(), json!("Asynchronous")), ("Synchronous call".into(), json!("Call")), ("Reply".into(), json!("Reply")), ("Create lifeline".into(), json!("Create")), ("Delete lifeline".into(), json!("Delete")), ("Timer".into(), json!({"Timer":{"expression":""}})), ]; } if field == "definition" { return vec![ ("Boolean".into(), json!("Boolean")), ("Integer".into(), json!("Integer")), ("Real".into(), json!("Real")), ("Text".into(), json!("String")), ("Enumeration".into(), json!({"Enumeration":{"literals":[]}})), ("Record".into(), json!("Record")), ]; } if field == "type_ref" { return vec![ ("Boolean".into(), json!("Boolean")), ("Integer".into(), json!("Integer")), ("Real".into(), json!("Real")), ("Text".into(), json!("String")), ("Named data type".into(), json!({"Named":Id::nil()})), ]; } if field == "body" && path.contains("Occurrence") { return vec![ ( "Referenced scenario".into(), json!({"ScenarioUse":{"scenario":null,"bindings":[]}}), ), ( "Exchange".into(), json!({"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}), ), ( "Action".into(), json!({"Action":{"action":null,"lifeline":null}}), ), ( "Process control".into(), json!({"Control":{"kind":"Choice"}}), ), ( "Reusable process / chain".into(), json!({"SequenceReference":{"sequence":null}}), ), ( "State observation".into(), json!({"State":{"state":null,"lifeline":null}}), ), ( "Fragment".into(), json!({"Fragment":{"operator":"Alternative","branches":[]}}), ), ( "Branch".into(), json!({"Branch":{"guard":"","occurrences":[]}}), ), ]; } let _ = value; vec![] } pub fn same_variant(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Object(a), Value::Object(b)) => a.keys().next() == b.keys().next(), _ => a == b, } } pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> { let mut content = column![ checkbox(draft.exact_strings) .label("Escaped strings (advanced)") .text_size(11) .on_toggle(Message::ExactStrings) ] .spacing(12); if draft.kind == RecordKind::Element && let Some(body) = draft .materialize() .unwrap_or_else(|_| draft.value.clone()) .get("body") && let Ok(body) = serde_json::from_value::(body.clone()) && let Some(shared) = body.shared() { let shared = shared.clone(); let layers = if matches!(&shared, SharedBody::Port { contract, .. } if *contract != PortContract::Legacy) { vec![Layer::Sa] } else { vec![Layer::Oa, Layer::Sa] }; content = content.push(column![ text("Engineering perspective").size(11).color(style::MUTED), pick_list(layers, Some(body.layer()), move |layer| Message::FieldVariant("/body".into(), json!(ElementBody::shared_in(layer, shared.clone())))) .text_size(12).padding(8).width(Fill), text("Changing perspective keeps references; incompatible references must be updated before Apply.") .size(10).color(style::MUTED), ].spacing(5)); } for key in [ "name", "alias", "description", "parent", "body", "owner", "relation", "uri", "revision", "fragment", "oa", "sa", "kind", "baseline", "layer", "projection", "element", "diagram", "position", "size", "collapsed", "id", ] { if let Some(value) = draft.value.get(key) { content = content.push(property( draft, project, value, &format!("/{key}"), &friendly(key), false, )); } } content.into() } fn property<'a>( draft: &'a Draft, project: &'a Project, value: &'a Value, path: &str, label: &str, skip_variant: bool, ) -> View<'a, Message> { let label_view = || text(label.to_owned()).size(11).color(style::MUTED); if path.ends_with("/Port/legacy") { let mut content = column![].spacing(4); if !value.is_null() { content = content.push( text("Preserved generic association") .size(12) .color(style::INK), ); for line in port_fields::legacy_lines(value, project) { content = content.push(text(line).size(11).color(style::MUTED)); } } return content.into(); } let enum_choices = if skip_variant { vec![] } else { port_fields::variants(draft, project, path, value) .unwrap_or_else(|| variants(path, value, draft.kind)) }; if path.ends_with("/Port/contract") && !skip_variant && enum_choices.is_empty() { let mut content = column![ label_view(), text("Choose a System Analysis function or component owner to edit this port's typed contract. The current contract remains in the draft.") .size(11).color(style::MUTED) ].spacing(5); if value.is_object() { content = content.push(property(draft, project, value, path, "", true)); } return content.into(); } if !enum_choices.is_empty() { let labels = enum_choices .iter() .map(|(name, _)| name.clone()) .collect::>(); let selected = enum_choices .iter() .find(|(_, v)| same_variant(v, value)) .map(|(name, _)| name.clone()); let owned_path = path.to_string(); let choices = enum_choices.clone(); let retained = selected.is_none().then(|| { format!( "Current value retained for review: {}", value .as_str() .or_else(|| value.as_object()?.keys().next().map(String::as_str)) .map(friendly) .unwrap_or_else(|| "Unrecognized value".into()) ) }); let picker = pick_list(labels, selected, move |selected| { Message::FieldVariant( owned_path.clone(), choices .iter() .find(|(name, _)| *name == selected) .unwrap() .1 .clone(), ) }) .text_size(12) .padding(8) .width(Fill); let mut out = column![label_view(), picker].spacing(5); if let Some(retained) = retained { out = out.push(text(retained).size(11).color(style::MUTED)); } if value.is_object() { out = out.push(property(draft, project, value, path, "", true)); } return out.into(); } if reference(path) && (value.is_string() || value.is_null()) { let raw = draft .inputs .get(path) .map(|v| v.raw.as_str()) .or_else(|| value.as_str()); let current = raw.and_then(|s| s.parse::().ok()).unwrap_or(Id::nil()); let choices = reference_choices(draft, project, path, current); let selected = choices.iter().find(|c| c.id == current).cloned(); let owned_path = path.to_string(); let optional = optional_reference(path); let mut content = column![ label_view(), pick_list(choices, selected, move |choice| Message::FieldShape( owned_path.clone(), if choice.id.is_nil() && optional { Value::Null } else { json!(choice.id) } )) .text_size(12) .padding(8) .width(Fill) ] .spacing(5); if let Some(raw) = raw && raw.parse::().is_err() { content = content.push( text(format!("Invalid reference retained in the draft: {raw}")) .size(11) .color(style::MUTED), ); } return content.into(); } match value { Value::Object(map) => { let mut out = column![].spacing(10); if let Some(group) = group_label(path) { out = out.push(text(group).size(12).color(style::INK)); } if path.ends_with("/baseline") { out = out.push( button(text("Clear review baseline").size(11)) .style(style::nav) .on_press(Message::FieldShape(path.into(), Value::Null)), ); } if path.starts_with("/routes/") && path.ends_with("/label") { out = out.push( button(text("Automatic label position").size(11)) .style(style::nav) .on_press(Message::FieldShape(path.into(), Value::Null)), ); } for (key, child) in map { if key == "nodes" { continue; } let child_path = format!("{path}/{key}"); if unit_body(&child_path, child) { continue; } out = out.push(property( draft, project, child, &child_path, &friendly(key), false, )); } out.into() } Value::Array(items) => { let owned_path = path.to_string(); let mut out = column![ row![ label_view(), widget::Space::new().width(Fill), button(text("+ Add").size(11)) .style(style::nav) .on_press(Message::FieldArray(owned_path, None, 0)) ] .align_y(iced::Center) ] .spacing(6); for (index, item) in items.iter().enumerate() { out = out.push( container( column![ property( draft, project, item, &format!("{path}/{index}"), &format!("{} {}", label, index + 1), false ), row![ button(text("↑").size(12)) .style(style::nav) .on_press(Message::FieldArray(path.into(), Some(index), -1)), button(text("↓").size(12)) .style(style::nav) .on_press(Message::FieldArray(path.into(), Some(index), 1)), button(text("Remove").size(11)) .style(style::nav) .on_press(Message::FieldArray(path.into(), Some(index), 0)) ] .spacing(4) ] .spacing(3), ) .padding(8) .style(style::subtle), ); } out.into() } Value::Bool(checked) => { let path = path.to_owned(); checkbox(*checked) .label(label.to_owned()) .on_toggle(move |v| Message::FieldShape(path.clone(), json!(v))) .text_size(12) .into() } Value::Null => { let initial = if path.ends_with("maximum") { json!(1) } else if path.ends_with("baseline") { json!({"oa":{"name":"","description":""},"sa":{"name":"","description":""}}) } else if path.starts_with("/routes/") && path.ends_with("/label") { json!({"fraction":0.5,"offset":{"x":0.0,"y":-18.0}}) } else { json!("") }; column![ label_view(), button( text(if path.ends_with("maximum") { "Unlimited · set maximum" } else { "Not set · add value" }) .size(12) ) .style(style::nav) .on_press(Message::FieldShape(path.into(), initial)) ] .spacing(4) .into() } _ if unit_body(path, value) => text("").into(), _ => { let Some(input) = draft.inputs.get(path) else { return text("").into(); }; let owned_path = path.to_string(); let field: View<'_, Message> = if multiline(path) && !draft.exact_strings { text_editor(&input.editor) .on_action(move |a| Message::FieldText(owned_path.clone(), a)) .size(12) .height(100) .padding(8) .into() } else { text_input("", &input.raw) .on_input(move |v| Message::Field(owned_path.clone(), v)) .size(12) .padding(8) .into() }; let mut out = column![label_view(), field].spacing(5); if matches!( path.rsplit('/').next().unwrap_or_default(), "revision" | "fragment" | "maximum" ) { out = out.push( button(text("Clear optional value").size(10)) .style(style::nav) .on_press(Message::FieldShape(path.into(), Value::Null)), ); } out.into() } } } #[cfg(test)] mod tests { use super::*; fn ids(draft: &Draft, project: &Project, path: &str) -> Vec { reference_ids(draft, project, path) } fn state_diagram_form_fixture() -> Project { language::parse( r#"arcadia 3; project state_forms "State forms" { oa_entity crew "Crew" {} oa_state_machine modes "Modes" { parent @crew; } oa_region main "Main" { parent @modes; } oa_state ready "Ready" { parent @main; } oa_region nested "Nested" { parent @ready; } oa_state leaf "Leaf" { parent @nested; } oa_state_machine other "Other modes" { parent @crew; } oa_region other_region "Other region" { parent @other; } oa_state foreign "Foreign" { parent @other_region; } sa_system system "System" {} sa_state_machine sa_modes "System modes" { parent @system; } diagram overview "Overview" {} diagram states "States" { projection { StateMachine { machine @modes; }; }; } diagram other_states "Other states" { projection { StateMachine { machine @other; }; }; } diagram duplicate "Duplicate" { projection { StateMachine { machine @modes; }; }; } node ready_view { diagram @states; element @ready; } node region_view { diagram @states; element @main; } node duplicate_view { diagram @duplicate; element @ready; } }"#, ) .unwrap() } fn alias_id(project: &Project, alias: &str) -> Id { project .identities() .into_iter() .find(|(_, candidate)| *candidate == alias) .unwrap() .0 } #[test] fn state_projection_form_keeps_string_variants_and_requires_a_typed_context() { let project = state_diagram_form_fixture(); let diagram = alias_id(&project, "overview"); let machine = alias_id(&project, "modes"); let mut draft = Draft::from_project(&project, diagram).unwrap(); let choices = variants("/projection", &draft.value["projection"], draft.kind); assert_eq!(choices.len(), 7); for (_, value) in &choices[..6] { let decoded = serde_json::from_value::(value.clone()).unwrap(); assert!(value.is_string()); assert_eq!(json!(decoded), *value); } draft.shape("/projection", choices[6].1.clone()).unwrap(); assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err()); let path = "/projection/StateMachine/machine"; assert!(reference(path)); assert!(!optional_reference(path)); assert_eq!( ids(&draft, &project, path) .into_iter() .collect::>(), BTreeSet::from([machine, alias_id(&project, "other")]) ); draft.shape(path, json!(machine)).unwrap(); let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap(); assert_eq!( accepted.diagrams[&diagram].projection.state_machine(), Some(machine) ); assert_eq!(accepted.elements, project.elements); assert!(accepted.diagrams[&diagram].nodes.is_empty()); assert_eq!( variants("/projection", &draft.value["projection"], draft.kind)[6].1, json!({"StateMachine": {"machine": machine}}) ); draft.shape("/layer", json!("Sa")).unwrap(); assert_eq!( ids(&draft, &project, path), vec![alias_id(&project, "sa_modes")] ); assert!( reference_choices(&draft, &project, path, machine) .iter() .any(|choice| { choice.id == machine && choice.label.starts_with("Current choice needs review") }) ); assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err()); assert_eq!( draft.materialize().unwrap().pointer(path), Some(&json!(machine)) ); } #[test] fn state_placement_form_uses_draft_destination_membership_without_changing_other_views() { let project = state_diagram_form_fixture(); let node = alias_id(&project, "ready_view"); let mut draft = Draft::from_project(&project, node).unwrap(); let offered = ids(&draft, &project, "/element"); for alias in ["modes", "ready", "nested", "leaf"] { assert!(offered.contains(&alias_id(&project, alias)), "{alias}"); } for alias in [ "main", "other", "other_region", "foreign", "crew", "sa_modes", ] { assert!(!offered.contains(&alias_id(&project, alias)), "{alias}"); } assert_eq!( ids(&draft, &project, "/diagram") .into_iter() .collect::>(), project.diagrams.keys().copied().collect::>() ); // A destination must remain selectable before its compatible element; // the current element is retained visibly until the draft is complete. draft .shape("/diagram", json!(alias_id(&project, "other_states"))) .unwrap(); let ready = alias_id(&project, "ready"); assert!(!ids(&draft, &project, "/element").contains(&ready)); assert!( reference_choices(&draft, &project, "/element", ready) .iter() .any(|choice| { choice.id == ready && choice.label.starts_with("Current choice needs review") }) ); assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err()); draft .shape("/element", json!(alias_id(&project, "foreign"))) .unwrap(); let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap(); assert!( accepted.diagrams[&alias_id(&project, "other_states")] .nodes .contains_key(&node) ); draft .shape("/diagram", json!(alias_id(&project, "overview"))) .unwrap(); assert!(ids(&draft, &project, "/element").contains(&alias_id(&project, "crew"))); assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok()); } #[test] fn editing_allocation_offers_other_performers_without_counting_itself_twice() { let mut p = Project::empty(); let first = p.add(Kind::Entity, "First team", 0., 0.); let second = p.add(Kind::Entity, "Second team", 300., 0.); let work = p.add(Kind::Activity, "Coordinate", 600., 0.); let allocation = p.relate(RelationKind::Allocation, "Performs", first, work); let draft = Draft::from_project(&p, allocation).unwrap(); let choices = ids(&draft, &p, "/body/Allocation/source"); assert!(choices.contains(&first)); assert!(choices.contains(&second)); assert!(!choices.contains(&work)); assert!(!choices.contains(&p.id)); } #[test] fn data_and_port_choices_are_typed_but_retain_invalid_current_values() { let mut p = Project::empty(); let function = p .add_element(Kind::Function, Layer::Sa, "Send", None) .unwrap(); let receiver = p .add_element(Kind::Function, Layer::Sa, "Receive", None) .unwrap(); let item = p .add_element(Kind::ExchangeItem, Layer::Sa, "Message", None) .unwrap(); let wrong_layer = p .add_element(Kind::ExchangeItem, Layer::Oa, "Operational item", None) .unwrap(); let exchange = p.relate( RelationKind::FunctionalExchange, "Message", function, receiver, ); let RelationBody::FunctionalExchange { source_port: output, target_port: input, .. } = p.relations[&exchange].body else { panic!("Expected functional exchange"); }; let draft = Draft::from_project(&p, exchange).unwrap(); let choices = ids(&draft, &p, "/body/FunctionalExchange/items/0"); assert_eq!(choices, vec![item]); assert!(!choices.contains(&wrong_layer)); assert_eq!( ids(&draft, &p, "/body/FunctionalExchange/source_port"), vec![output] ); assert_eq!( ids(&draft, &p, "/body/FunctionalExchange/target_port"), vec![input] ); let retained = reference_choices(&draft, &p, "/body/FunctionalExchange/items/0", wrong_layer); assert!(retained.iter().any(|c| c.id == wrong_layer)); let missing = Id::new_v4(); assert!( reference_choices(&draft, &p, "/body/FunctionalExchange/items/0", missing) .iter() .any(|c| c.id == missing && c.label.starts_with("Missing reference")) ); let port = Draft::from_project(&p, output).unwrap(); assert_eq!( ids( &port, &p, "/body/Sa/Shared/Port/contract/FunctionOutput/outgoing_items/0" ), vec![item] ); } #[test] fn scenario_choices_respect_owner_and_exchange_layer() { let mut p = Project::empty(); let scenario = p .add_element(Kind::Scenario, Layer::Sa, "Main scenario", None) .unwrap(); let other = p .add_element(Kind::Scenario, Layer::Sa, "Other scenario", None) .unwrap(); let line = p .add_element(Kind::Lifeline, Layer::Sa, "Participant", Some(scenario)) .unwrap(); p.add_element(Kind::Lifeline, Layer::Sa, "Other participant", Some(other)) .unwrap(); let occurrence = p .add_element(Kind::Occurrence, Layer::Sa, "Message", Some(scenario)) .unwrap(); let wrong_owner = p .add_element(Kind::Occurrence, Layer::Sa, "Other message", Some(other)) .unwrap(); let draft = Draft::from_project(&p, occurrence).unwrap(); assert_eq!( ids( &draft, &p, "/body/Sa/Shared/Occurrence/body/Exchange/sender" ), vec![line] ); let scenario_draft = Draft::from_project(&p, scenario).unwrap(); assert_eq!( ids(&scenario_draft, &p, "/body/Sa/Shared/Scenario/steps/0"), vec![occurrence] ); assert!( !ids(&scenario_draft, &p, "/body/Sa/Shared/Scenario/steps/0").contains(&wrong_owner) ); } #[test] fn reply_and_reuse_forms_offer_semantically_compatible_references() { let mut p = Project::empty(); let send = p .add_element(Kind::Activity, Layer::Oa, "Request", None) .unwrap(); let receive = p .add_element(Kind::Activity, Layer::Oa, "Respond", None) .unwrap(); let exchange = p.relate(RelationKind::Interaction, "Request data", send, receive); let item = p .add_element(Kind::ExchangeItem, Layer::Oa, "Request payload", None) .unwrap(); let field = p .add_element(Kind::DataField, Layer::Oa, "Request number", Some(item)) .unwrap(); if let RelationBody::Interaction { items, .. } = &mut p.relations.get_mut(&exchange).unwrap().body { items.push(item); } let scenario = p .add_element(Kind::Scenario, Layer::Oa, "Request flow", None) .unwrap(); let reused = p .add_element(Kind::Scenario, Layer::Oa, "Authentication", None) .unwrap(); let caller = p .add_element(Kind::Lifeline, Layer::Oa, "Caller", Some(scenario)) .unwrap(); let callee = p .add_element(Kind::Lifeline, Layer::Oa, "Callee", Some(scenario)) .unwrap(); let ref_line = p .add_element(Kind::Lifeline, Layer::Oa, "Reused caller", Some(reused)) .unwrap(); for (id, represented) in [(caller, send), (callee, receive), (ref_line, send)] { p.elements.get_mut(&id).unwrap().body = ElementBody::shared_in( Layer::Oa, SharedBody::Lifeline { represented: Some(represented), }, ); } let call = p .add_element(Kind::Occurrence, Layer::Oa, "Call", Some(scenario)) .unwrap(); let reply = p .add_element(Kind::Occurrence, Layer::Oa, "Reply", Some(scenario)) .unwrap(); for (id, sender, receiver, message_kind, reply_to) in [ (call, caller, callee, MessageKind::Call, None), (reply, callee, caller, MessageKind::Reply, Some(call)), ] { p.elements.get_mut(&id).unwrap().body = ElementBody::shared_in( Layer::Oa, SharedBody::Occurrence { precondition: String::new(), postcondition: String::new(), body: OccurrenceBody::Exchange { exchange: Some(exchange), sender: Some(sender), receiver: Some(receiver), message_kind, reply_to, values: vec![], }, }, ); } let draft = Draft::from_project(&p, reply).unwrap(); let prefix = "/body/Oa/Shared/Occurrence/body/Exchange"; assert_eq!(ids(&draft, &p, &format!("{prefix}/sender")), vec![callee]); assert_eq!(ids(&draft, &p, &format!("{prefix}/receiver")), vec![caller]); assert_eq!(ids(&draft, &p, &format!("{prefix}/reply_to")), vec![call]); assert_eq!( ids(&draft, &p, &format!("{prefix}/values/0/field")), vec![field] ); let use_id = p .add_element( Kind::Occurrence, Layer::Oa, "Reuse authentication", Some(scenario), ) .unwrap(); *p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap() = vec![call, reply, use_id]; let mut draft = Draft::from_project(&p, use_id).unwrap(); draft.shape("/body/Oa/Shared/Occurrence/body", json!({"ScenarioUse":{"scenario":reused,"bindings":[{"referenced":ref_line,"local":caller}]}})).unwrap(); let prefix = "/body/Oa/Shared/Occurrence/body/ScenarioUse"; assert_eq!(ids(&draft, &p, &format!("{prefix}/scenario")), vec![reused]); assert_eq!( ids(&draft, &p, &format!("{prefix}/bindings/0/referenced")), vec![ref_line] ); assert_eq!( ids(&draft, &p, &format!("{prefix}/bindings/0/local")), vec![caller] ); let mut session = Session::new(p.clone()); session.execute(draft.command().unwrap()).unwrap(); assert_eq!( language::parse(&language::format(session.project())).unwrap(), *session.project() ); session.undo(); assert_eq!(*session.project(), p); } #[test] fn parent_choices_reject_cycles_without_removing_clear_or_current_choice() { let mut p = Project::empty(); let parent = p .add_element(Kind::Activity, Layer::Oa, "Parent", None) .unwrap(); let child = p .add_element(Kind::Activity, Layer::Oa, "Child", Some(parent)) .unwrap(); let draft = Draft::from_project(&p, parent).unwrap(); assert!(!ids(&draft, &p, "/parent").contains(&child)); assert!(!ids(&draft, &p, "/parent").contains(&parent)); let selected = reference_choices(&draft, &p, "/parent", child); assert!(selected.iter().any(|choice| choice.id == child)); assert!(selected.iter().any(|choice| choice.id.is_nil())); let machine = p .add_element(Kind::StateMachine, Layer::Oa, "Modes", None) .unwrap(); let machine_draft = Draft::from_project(&p, machine).unwrap(); assert!( reference_choices(&machine_draft, &p, "/parent", Id::nil()) .iter() .any(|c| c.id.is_nil()) ); assert!(unit_body("/body/Oa/Shared", &json!("StateMachine"))); assert!(unit_body("/body/Sa/Shared", &json!("Region"))); assert!(!unit_body( "/body/Sa/Shared/DataType/definition", &json!("Record") )); } #[test] fn state_observation_picker_keeps_work_lifelines_allocated_to_machine_owner() { let mut p = Project::empty(); let system = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let work = p .add_element(Kind::Function, Layer::Sa, "Monitor", None) .unwrap(); p.relate(RelationKind::Allocation, "Performs", system, work); let machine = p .add_element( Kind::StateMachine, Layer::Sa, "Operating modes", Some(system), ) .unwrap(); let region = p .add_element(Kind::Region, Layer::Sa, "Main", Some(machine)) .unwrap(); let state = p .add_element(Kind::State, Layer::Sa, "Ready", Some(region)) .unwrap(); let scenario = p .add_element(Kind::Scenario, Layer::Sa, "Monitoring", None) .unwrap(); let line = p .add_element(Kind::Lifeline, Layer::Sa, "Monitor", Some(scenario)) .unwrap(); p.elements.get_mut(&line).unwrap().body = ElementBody::shared_in( Layer::Sa, SharedBody::Lifeline { represented: Some(work), }, ); let occurrence = p .add_element(Kind::Occurrence, Layer::Sa, "Ready check", Some(scenario)) .unwrap(); p.elements.get_mut(&occurrence).unwrap().body = ElementBody::shared_in( Layer::Sa, SharedBody::Occurrence { precondition: String::new(), postcondition: String::new(), body: OccurrenceBody::State { state: Some(state), lifeline: Some(line), }, }, ); let draft = Draft::from_project(&p, occurrence).unwrap(); assert_eq!( ids(&draft, &p, "/body/Sa/Shared/Occurrence/body/State/lifeline"), vec![line] ); assert!(execute_batch(&p, &[draft.command().unwrap()]).is_ok()); } #[test] fn moving_placement_and_changing_identity_is_one_lossless_undoable_edit() { let mut p = Project::empty(); let work = p.add(Kind::Activity, "Observe", 120., 240.); let original_diagram = p.diagrams.values().next().unwrap().id; let original_node = p.diagrams[&original_diagram] .nodes .values() .next() .unwrap() .id; let target = Id::new_v4(); p.diagrams.insert( target, Diagram { id: target, alias: "second_view".into(), name: "Second view".into(), description: String::new(), layer: Layer::Oa, projection: ProjectionKind::Overview, routes: Vec::new(), nodes: BTreeMap::new(), }, ); let mut draft = Draft::from_project(&p, original_node).unwrap(); assert!(!draft.dirty()); let replacement = Id::new_v4(); draft.input("/id", replacement.to_string()); draft.shape("/diagram", json!(target)).unwrap(); let mut session = Session::new(p.clone()); session.execute(draft.command().unwrap()).unwrap(); assert!( session.project().diagrams[&original_diagram] .nodes .is_empty() ); let node = &session.project().diagrams[&target].nodes[&replacement]; assert_eq!(node.element, work); assert_eq!(node.position, Position { x: 120., y: 240. }); assert!(!json!(node).as_object().unwrap().contains_key("diagram")); session.undo(); assert_eq!(*session.project(), p); } #[test] fn shared_perspective_change_keeps_payload_and_rejects_incompatible_dependencies() { let mut p = Project::empty(); let item = p .add_element(Kind::ExchangeItem, Layer::Oa, "Power", None) .unwrap(); p.elements.get_mut(&item).unwrap().body = ElementBody::shared_in( Layer::Oa, SharedBody::ExchangeItem { kind: ExchangeItemKind::Energy, }, ); let mut draft = Draft::from_project(&p, item).unwrap(); let shared = p.elements[&item].shared().unwrap().clone(); draft .shape( "/body", json!(ElementBody::shared_in(Layer::Sa, shared.clone())), ) .unwrap(); let moved = execute_batch(&p, &[draft.command().unwrap()]).unwrap(); assert_eq!(moved.elements[&item].layer(), Layer::Sa); assert_eq!(moved.elements[&item].shared(), Some(&shared)); let interface = p .add_element(Kind::Interface, Layer::Oa, "Power contract", None) .unwrap(); p.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] }); assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err()); assert!(draft.dirty()); assert_eq!(p.elements[&item].layer(), Layer::Oa); } }