Files
ARCADIA-Studio/crates/oa-desktop/src/forms.rs
T

2642 lines
98 KiB
Rust

//! 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 data_fields;
mod port_fields;
mod view_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,
}
#[derive(Clone, Default)]
pub struct ValueBrowser {
pub scope: Option<crate::data_values::FieldPathKey>,
pub page: usize,
}
#[derive(Clone, Default)]
pub struct ReferenceBrowser {
pub query: String,
pub page: usize,
}
pub struct Draft {
pub kind: RecordKind,
pub original: Id,
pub value: Value,
baseline: Value,
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
context: Option<Value>,
pub semantic_groups: bool,
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
pub reference_browsers: BTreeMap<String, ReferenceBrowser>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecoveryDraft {
original: Id,
value: Value,
exact_strings: bool,
inputs: BTreeMap<String, String>,
constructors: Vec<(String, String, Value, BTreeMap<String, String>)>,
ports: BTreeMap<String, Value>,
}
impl Draft {
pub fn recovery_snapshot(&self) -> RecoveryDraft {
let raw = |inputs: &BTreeMap<String, Input>| {
inputs
.iter()
.map(|(path, input)| (path.clone(), input.raw.clone()))
.collect()
};
RecoveryDraft {
original: self.original,
value: self.value.clone(),
exact_strings: self.exact_strings,
inputs: raw(&self.inputs),
constructors: self
.constructor_drafts
.iter()
.map(|((path, kind), (value, inputs))| {
(path.clone(), kind.clone(), value.clone(), raw(inputs))
})
.collect(),
ports: self.port_contract_drafts.clone(),
}
}
pub fn from_recovery(project: &Project, saved: RecoveryDraft) -> Result<Self, String> {
let input_count = saved.inputs.len().saturating_add(
saved
.constructors
.iter()
.map(|(_, _, _, inputs)| inputs.len())
.sum::<usize>(),
);
if input_count > crate::source_workspace::LIMITS.model.max_records
|| saved.constructors.len() > 1024
|| saved.ports.len() > 32
{
return Err("Recovered property buffers exceed the supported limit.".into());
}
let mut draft = Self::from_project(project, saved.original)
.ok_or("Recovered property owner no longer exists.")?;
draft.value = saved.value;
draft.exact_strings = saved.exact_strings;
draft.rebuild();
if !draft.inputs.keys().eq(saved.inputs.keys()) {
return Err("Recovered property fields no longer match their raw buffers.".into());
}
for (path, raw) in saved.inputs {
draft.input(&path, raw);
}
draft.constructor_drafts = saved
.constructors
.into_iter()
.map(|(path, kind, value, inputs)| {
(
(path, kind),
(
value,
inputs
.into_iter()
.map(|(path, raw)| {
let editor = text_editor::Content::with_text(&raw);
(path, Input { raw, editor })
})
.collect(),
),
)
})
.collect();
draft.port_contract_drafts = saved.ports;
Ok(draft)
}
pub fn from_project(project: &Project, selected: Id) -> Option<Self> {
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,
context: None,
semantic_groups: false,
constructor_drafts: BTreeMap::new(),
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
reference_browsers: BTreeMap::new(),
};
draft.rebuild();
Some(draft)
}
fn rebuild(&mut self) {
// Array/variant edits can change the meaning of a row path.
self.value_browsers.clear();
self.inputs.clear();
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
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<Value, String> {
self.materialize_inputs(|_| true)
}
/// Read-only context for dependent controls. Workflow proposals never replace
/// the authored value or its input buffers and are not used by command().
pub fn context_value(&self) -> Value {
self.context
.clone()
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
}
pub fn set_context(&mut self, context: Option<Value>) {
self.context = context;
}
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
self.materialize_inputs(|path| {
scopes
.iter()
.any(|root| path == root || path.starts_with(&format!("{root}/")))
})
}
fn materialize_inputs(&self, include: impl Fn(&str) -> bool) -> Result<Value, String> {
let mut value = self.value.clone();
for (path, input) in &self.inputs {
if !include(path) {
continue;
}
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(())
}
/// Replace a workflow-authorized subtree without parsing unrelated inputs,
/// including an invalid value which this replacement deliberately removes.
pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
*self
.value
.pointer_mut(path)
.ok_or("Property no longer exists.")? = value;
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
Ok(())
}
/// Preserve each selected constructor branch, including incomplete raw input
/// and editor state. No sibling fields are parsed or cleared by a switch.
pub fn variant_scoped(&mut self, path: &str, proposed: Value) -> Result<(), String> {
let current = self
.value
.pointer(path)
.ok_or("Property no longer exists.")?
.clone();
if same_variant(&current, &proposed) {
return Ok(());
}
let constructor = path.ends_with("/DataType/definition")
|| path.ends_with("/Port/contract")
|| path.ends_with("/Occurrence/body")
|| path.ends_with("/Event/body")
|| path.ends_with("/message_kind");
if !constructor {
return self.shape_scoped(path, proposed);
}
// Input/output direction changes carry the item declaration. Read those
// raw reference strings before stashing their editor buffers so an
// incomplete reference cannot silently fall back to its stored UUID.
let port_current = if path.ends_with("/Port/contract") {
self.materialize_scopes(&[path.to_owned()])?
.pointer(path)
.cloned()
} else {
None
};
let tag = |value: &Value| {
value
.as_str()
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
.unwrap_or("")
.to_owned()
};
let previous_key = (path.to_owned(), tag(&current));
let next_key = (path.to_owned(), tag(&proposed));
let mut inputs = BTreeMap::new();
for key in self
.inputs
.keys()
.filter(|key| within(key, path))
.cloned()
.collect::<Vec<_>>()
{
inputs.insert(key.clone(), self.inputs.remove(&key).unwrap());
}
self.constructor_drafts
.insert(previous_key, (current.clone(), inputs));
let saved = self.constructor_drafts.remove(&next_key);
let (value, restored) = if let Some(saved) = saved {
saved
} else {
let value = if path.ends_with("/Port/contract") {
port_fields::switch_contract(
port_current.as_ref().unwrap_or(&current),
proposed,
&mut BTreeMap::new(),
)
} else {
proposed
};
(value, BTreeMap::new())
};
self.shape_scoped(path, value)?;
self.inputs.extend(restored);
Ok(())
}
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
let previous = std::mem::take(&mut self.inputs);
self.rebuild();
for (path, input) in previous {
if let Some(path) = new_path(&path)
&& self.inputs.contains_key(&path)
{
self.inputs.insert(path, input);
}
}
}
pub fn array_scoped(
&mut self,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let mut candidate = self.value.clone();
let count = candidate
.pointer(path)
.and_then(Value::as_array)
.ok_or("List no longer exists.")?
.len();
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild_retaining(|old| {
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
return Some(old.to_owned());
};
let (number, rest) = tail.split_once('/').unwrap_or((tail, ""));
let position = number.parse::<usize>().ok()?;
let position = match index {
Some(index) if direction == 0 && position == index => return None,
Some(index) if direction == 0 && position > index => position - 1,
Some(index) if direction != 0 => {
let target = (index as i32 + direction).clamp(0, count as i32 - 1) as usize;
if position == index {
target
} else if position == target {
index
} else {
position
}
}
_ => position,
};
Some(if rest.is_empty() {
format!("{path}/{position}")
} else {
format!("{path}/{position}/{rest}")
})
});
Ok(())
}
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
self.value
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.rebuild_retaining(|path| Some(path.to_owned()));
Ok(())
}
pub fn value_path_scoped(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let row = self
.value
.pointer_mut(path)
.and_then(Value::as_object_mut)
.ok_or("Message value no longer exists.")?;
row.insert("path".into(), json!(key.path));
row.insert("field".into(), json!(key.field));
self.rebuild_retaining(|old| {
(!within(old, &format!("{path}/path")) && !within(old, &format!("{path}/field")))
.then(|| old.to_owned())
});
Ok(())
}
pub fn array(
&mut self,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild();
Ok(())
}
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
let mut candidate = self.materialize()?;
candidate
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.value = candidate;
self.rebuild();
Ok(())
}
pub fn value_path(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
let row = candidate
.pointer_mut(path)
.and_then(Value::as_object_mut)
.ok_or("Message value no longer exists.")?;
row.insert("path".into(), json!(key.path));
row.insert("field".into(), json!(key.field));
self.value = candidate;
self.rebuild();
Ok(())
}
pub fn command(&self) -> Result<Command, String> {
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<Id, String> {
self.inputs
.get("/id")
.ok_or("Missing identity.")?
.raw
.parse()
.map_err(|_| "Identity must be a UUID.".into())
}
}
fn within(path: &str, root: &str) -> bool {
path == root
|| path
.strip_prefix(root)
.is_some_and(|tail| tail.starts_with('/'))
}
fn edit_array(
value: &mut Value,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let items = value
.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!({"path": [], "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()),
}
Ok(())
}
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 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::<String>() + 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"
| "supertype"
) || 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"
| "supertype"
)
}
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" | "source_role" | "target_role")
)
|| path.split('/').rev().nth(1) == Some("literals")
|| (path.contains("/default/")
&& matches!(path.rsplit('/').next(), Some("String" | "Enumeration")))
}
// 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.
pub(crate) fn reference_choices(
draft: &Draft,
project: &Project,
path: &str,
current: Id,
) -> Vec<Choice> {
let identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
let compatible = reference_ids(draft, project, path);
let mut objects = compatible
.clone()
.into_iter()
.chain((!current.is_nil()).then_some(current))
.collect::<BTreeSet<_>>()
.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::<Vec<_>>();
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<Id> {
let value = draft.context_value();
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
let owner = value
.get("element")
.and_then(|id| serde_json::from_value::<Id>(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::<Element>(value.clone()).ok())
.flatten()
.or_else(|| project.elements.get(&draft.original).cloned());
let relation = (draft.kind == RecordKind::Relation)
.then(|| serde_json::from_value::<Relation>(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::<Layer>(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::<Vec<_>>();
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::CommunicationMean
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
),
}
})
.map(|r| r.id)
.collect();
}
if field == "field" {
let address_path = path
.strip_suffix("/field")
.and_then(|row| value.pointer(&format!("{row}/path")))
.and_then(|v| serde_json::from_value::<Vec<Id>>(v.clone()).ok())
.unwrap_or_default();
return element
.as_ref()
.and_then(|e| match e.shared()? {
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id),
message_kind,
..
},
..
} => project.relations.get(id).and_then(|r| {
let scope = address_path.split_last().map(|(&field, path)| {
crate::data_values::FieldPathKey {
path: path.to_vec(),
field,
}
});
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
}),
_ => None,
})
.unwrap_or_default()
.into_iter()
.filter(|choice| choice.key.path == address_path)
.map(|choice| choice.key.field)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
}
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::<usize>().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 {
"supertype" => element.as_ref().is_some_and(|owner| {
if candidate.id == owner.id
|| candidate.kind() != owner.kind()
|| (candidate.kind() == Kind::DataType
&& !data::is_record(project, candidate.id))
{
return false;
}
if let (
Some(SharedBody::ExchangeItem {
kind, mechanism, ..
}),
Some(SharedBody::ExchangeItem {
kind: base_kind,
mechanism: base_mechanism,
..
}),
) = (owner.shared(), candidate.shared())
&& (kind != base_kind
|| (*base_mechanism != ExchangeMechanism::Unspecified
&& mechanism != base_mechanism))
{
return false;
}
let mut next = Some(candidate.id);
let mut seen = BTreeSet::new();
while let Some(id) = next {
if id == owner.id || !seen.insert(id) {
return false;
}
next = project.elements.get(&id).and_then(data::supertype);
}
true
}),
"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::<Vec<_>>();
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"],
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
"storage" => &["Value", "Reference"],
"direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"],
"direction" if path.contains("DataField") => {
&["Unspecified", "In", "Out", "InOut", "Return"]
}
"transmission" => &[
"Unspecified",
"Synchronous",
"Asynchronous",
"Unicast",
"Multicast",
"Broadcast",
],
"acquisition" => &["Unspecified", "Read", "Accept"],
"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"],
"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":{"minimum":null,"maximum":null}}),
),
(
"Real".into(),
json!({"Real":{"minimum":null,"maximum":null}}),
),
(
"Text".into(),
json!({"String":{"minimum_length":0,"maximum_length":null}}),
),
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
("Record".into(), json!({"Record":{"supertype":null}})),
];
}
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::<ElementBody>(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",
"view_options",
"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()
}
/// Render one closed property subtree for a workflow-owned typed draft.
/// The caller routes messages to that draft and authorizes the selected scope.
pub fn scoped<'a>(
draft: &'a Draft,
project: &'a Project,
path: &str,
label: &str,
) -> View<'a, Message> {
match draft.value.pointer(path) {
Some(value) => property(draft, project, value, path, label, false),
None => text("This property is not applicable to the current target.")
.size(12)
.into(),
}
}
fn property<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
path: &str,
label: &str,
skip_variant: bool,
) -> View<'a, Message> {
if let Some(view) = view_fields::property(draft, project, value, path) {
return view;
}
if let Some(view) = data_fields::property(draft, project, value, path) {
return view;
}
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
if path.ends_with("/Port/legacy") && !draft.semantic_groups {
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();
}
if path.ends_with("/Port/legacy") && draft.semantic_groups && !skip_variant {
let mut content = column![
label_view(),
text(
"Retained generic association is part of this group. Change or clear it explicitly."
)
.size(11)
]
.spacing(5);
if value.is_null() {
content = content.push(button(text("Add generic association").size(11)).on_press(
Message::FieldShape(path.into(), json!({"direction":"InOut","interface":null})),
));
} else {
content = content
.push(property(draft, project, value, path, label, true))
.push(
button(text("Clear generic association").size(11))
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
}
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::<Vec<_>>();
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::<Id>().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::<Id>().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::*;
#[test]
fn semantic_port_group_choices_use_combined_owner_and_retain_invalid_transferred_items() {
let mut p = Project::empty();
let function = p
.add_element(Kind::Function, Layer::Sa, "Function", None)
.unwrap();
let component = p
.add_element(Kind::System, Layer::Sa, "Component", None)
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Item", None)
.unwrap();
let id = Id::new_v4();
p.elements.insert(
id,
ports::make_port(
&p,
id,
function,
"Port",
PortContract::FunctionInput {
incoming_items: vec![item],
},
)
.unwrap(),
);
let mut draft = Draft::from_project(&p, id).unwrap();
draft.semantic_groups = true;
let path = "/body/Sa/Shared/Port/contract";
let input = format!("{path}/FunctionInput/incoming_items/0");
draft.input(&input, "incomplete-id".into());
let cursor = draft.inputs[&input].editor.cursor();
draft
.variant_scoped(path, json!({"FunctionOutput":{"outgoing_items":[]}}))
.unwrap();
assert_eq!(
draft.inputs[&format!("{path}/FunctionOutput/outgoing_items/0")].raw,
"incomplete-id"
);
assert!(serde_json::from_value::<Element>(draft.materialize().unwrap()).is_err());
draft
.variant_scoped(path, json!({"FunctionInput":{"incoming_items":[]}}))
.unwrap();
assert_eq!(draft.inputs[&input].raw, "incomplete-id");
assert_eq!(draft.inputs[&input].editor.cursor(), cursor);
let choices =
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
assert!(
choices
.iter()
.any(|(_, v)| v.get("FunctionInput").is_some())
);
assert!(choices.iter().any(|(_, v)| *v == json!("Legacy")));
assert!(!choices.iter().any(|(_, v)| v.get("Component").is_some()));
let mut context = draft.value.clone();
context["parent"] = json!(component);
draft.set_context(Some(context));
let choices =
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
assert!(choices.iter().any(|(_, v)| v.get("Component").is_some()));
assert!(
!choices
.iter()
.any(|(_, v)| v.get("FunctionInput").is_some())
);
}
fn ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
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::<ProjectionKind>(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<_>>(),
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::<BTreeSet<_>>(),
project.diagrams.keys().copied().collect::<BTreeSet<_>>()
);
// 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(),
view_options: Default::default(),
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,
mechanism: ExchangeMechanism::Flow,
supertype: None,
},
);
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.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
assert!(draft.dirty());
assert_eq!(p.elements[&item].layer(), Layer::Oa);
}
}