Add state diagram form and representation contract parity
This commit is contained in:
+203
-13
@@ -337,6 +337,8 @@ fn friendly(name: &str) -> String {
|
||||
"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(),
|
||||
@@ -408,6 +410,7 @@ fn reference(path: &str) -> bool {
|
||||
| "sa"
|
||||
| "Named"
|
||||
| "diagram"
|
||||
| "machine"
|
||||
) || matches!(
|
||||
parent,
|
||||
"items"
|
||||
@@ -542,7 +545,12 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let layer = element
|
||||
.as_ref()
|
||||
.map(Element::layer)
|
||||
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())));
|
||||
.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
|
||||
@@ -567,6 +575,8 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<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" {
|
||||
@@ -688,18 +698,16 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| id.parse().ok())
|
||||
.unwrap_or(diagram);
|
||||
return project.diagrams.get(&diagram).is_some_and(|d| {
|
||||
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
|
||||
&& !d
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.id != draft.original && n.element == candidate.id)
|
||||
});
|
||||
return project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.is_some_and(|d| placement_allowed(project, d, candidate, draft.original));
|
||||
}
|
||||
if !same_layer(candidate) {
|
||||
return false;
|
||||
}
|
||||
match field {
|
||||
"machine" => candidate.kind() == Kind::StateMachine,
|
||||
"parent" => element.as_ref().is_some_and(|child| {
|
||||
if candidate.id == draft.original
|
||||
|| candidate.id == child.id
|
||||
@@ -1002,6 +1010,18 @@ fn group_label(path: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
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!(
|
||||
@@ -1013,17 +1033,31 @@ fn unit_body(path: &str, value: &Value) -> bool {
|
||||
/// 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();
|
||||
let words: &[&str] = match field {
|
||||
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"projection" => &[
|
||||
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"],
|
||||
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
|
||||
"viewpoint" if path.contains("/Oa/") => &[
|
||||
"OperationalActivity",
|
||||
@@ -1515,6 +1549,162 @@ mod tests {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user