2543 lines
95 KiB
Rust
2543 lines
95 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;
|
|
|
|
#[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,
|
|
}
|
|
|
|
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>,
|
|
}
|
|
|
|
impl 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(),
|
|
};
|
|
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(¤t, &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(¤t));
|
|
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(¤t),
|
|
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 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::<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.
|
|
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::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",
|
|
"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) = 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(),
|
|
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);
|
|
}
|
|
}
|