Integrate typed port authoring and reviewable exchange workflows
This commit is contained in:
+199
-29
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
|
||||
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
|
||||
|
||||
type FileSnapshot = (PathBuf, Project, Arc<[u8]>);
|
||||
type LoadedSnapshot = (PathBuf, persistence::DecodeResult, Arc<[u8]>);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
@@ -64,6 +65,7 @@ pub enum Message {
|
||||
CreateKind(Kind),
|
||||
Add(bool),
|
||||
AddRelation,
|
||||
PortAction(crate::port_workflow::Action),
|
||||
AddRelationKind(RelationKind, bool),
|
||||
InspectorTab(crate::navigation::InspectorTab),
|
||||
RevealInDiagram(Id, Id),
|
||||
@@ -107,7 +109,7 @@ pub enum Message {
|
||||
SourceNavigation(crate::source_navigation::Event),
|
||||
Save(bool),
|
||||
Saved(Result<Option<FileSnapshot>, String>),
|
||||
Loaded(Result<Option<FileSnapshot>, String>),
|
||||
Loaded(Result<Option<LoadedSnapshot>, String>),
|
||||
Request(Pending),
|
||||
Discard,
|
||||
Cancel,
|
||||
@@ -157,6 +159,7 @@ pub struct App {
|
||||
pub source_jump: Option<text_editor::Cursor>,
|
||||
pub source_navigation: crate::source_navigation::State,
|
||||
pub model_issues: Vec<validation::Issue>,
|
||||
pub migration: Option<persistence::MigrationReport>,
|
||||
source_past: Vec<String>,
|
||||
source_future: Vec<String>,
|
||||
source_version: u64,
|
||||
@@ -176,10 +179,19 @@ impl App {
|
||||
let source = language::format(&project);
|
||||
let model_issues = validate(&project);
|
||||
let selected = project.id;
|
||||
let populated = |layer| {
|
||||
project.elements.values().any(|e| e.layer() == layer)
|
||||
|| project.diagrams.values().any(|d| d.layer == layer)
|
||||
};
|
||||
let layer = if !populated(Layer::Oa) && populated(Layer::Sa) {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let active_diagram = project
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == Layer::Oa)
|
||||
.filter(|d| d.layer == layer)
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
let draft = Draft::from_project(&project, selected);
|
||||
@@ -190,13 +202,17 @@ impl App {
|
||||
guide: crate::guide::State::default(),
|
||||
behavior: Default::default(),
|
||||
inspector_tab: Default::default(),
|
||||
layer: Layer::Oa,
|
||||
layer,
|
||||
selected,
|
||||
selected_node: None,
|
||||
draft,
|
||||
active_diagram,
|
||||
search: String::new(),
|
||||
create_kind: Kind::Activity,
|
||||
create_kind: if layer == Layer::Sa {
|
||||
Kind::Function
|
||||
} else {
|
||||
Kind::Activity
|
||||
},
|
||||
status: "Coastal rescue example · explore both engineering perspectives".into(),
|
||||
busy: false,
|
||||
pending: None,
|
||||
@@ -217,6 +233,7 @@ impl App {
|
||||
source_jump: None,
|
||||
source_navigation: crate::source_navigation::State::default(),
|
||||
model_issues,
|
||||
migration: None,
|
||||
source_past: vec![],
|
||||
source_future: vec![],
|
||||
source_version: 0,
|
||||
@@ -316,9 +333,12 @@ impl App {
|
||||
if !self.source_dirty() {
|
||||
return true;
|
||||
}
|
||||
match language::parse(&self.source_editor.text()) {
|
||||
Ok(project) => match self.session.execute(Command::Replace(project)) {
|
||||
match language::parse_with_report(&self.source_editor.text()) {
|
||||
Ok(decoded) => match self.session.execute(Command::Replace(decoded.project)) {
|
||||
Ok(_) => {
|
||||
if decoded.migration.is_some() {
|
||||
self.migration = decoded.migration;
|
||||
}
|
||||
self.sync();
|
||||
self.status = "Text applied as one model change".into();
|
||||
true
|
||||
@@ -423,22 +443,7 @@ impl App {
|
||||
self.fit();
|
||||
}
|
||||
fn diagram_for(&self, layer: Layer, element: Id) -> Option<Id> {
|
||||
self.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.min_by_key(|d| {
|
||||
(
|
||||
!self
|
||||
.project()
|
||||
.elements
|
||||
.get(&element)
|
||||
.is_none_or(|e| d.projection.includes(e.kind())),
|
||||
!d.nodes.values().any(|n| n.element == element),
|
||||
d.projection != ProjectionKind::Allocation,
|
||||
)
|
||||
})
|
||||
.map(|d| d.id)
|
||||
preferred_diagram(self.project(), self.active_diagram, layer, element)
|
||||
}
|
||||
fn select(&mut self, id: Id) {
|
||||
if self.selected == id {
|
||||
@@ -714,13 +719,14 @@ impl App {
|
||||
}
|
||||
if let Some(mut workflow) = self.workflow.take() {
|
||||
let transition_focus = match &workflow {
|
||||
crate::workflow::Workflow::Ports(state) => Some(state.focus()),
|
||||
crate::workflow::Workflow::Transition(t) => {
|
||||
t.plan.changes.first().map(|c| c.target)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match workflow.update(self.project(), event) {
|
||||
Ok(Some(candidate)) => {
|
||||
Ok(Some(mut candidate)) => {
|
||||
let focus = transition_focus
|
||||
.or_else(|| {
|
||||
candidate
|
||||
@@ -736,12 +742,21 @@ impl App {
|
||||
.find(|id| !self.project().traces.contains_key(id))
|
||||
.copied()
|
||||
});
|
||||
let destination = focus.and_then(|id| {
|
||||
candidate.layer_of(id).and_then(|layer| {
|
||||
preferred_diagram(&candidate, self.active_diagram, layer, id)
|
||||
})
|
||||
});
|
||||
if let (Some(id), Some(diagram)) = (focus, destination) {
|
||||
reveal_port_owner(&mut candidate, diagram, id);
|
||||
}
|
||||
if self.execute(Command::Replace(candidate)) {
|
||||
if let Some(id) = focus {
|
||||
self.selected = id;
|
||||
if let Some(layer) = self.project().layer_of(id) {
|
||||
self.layer = layer;
|
||||
self.active_diagram = self.diagram_for(layer, id);
|
||||
self.active_diagram =
|
||||
destination.or_else(|| self.diagram_for(layer, id));
|
||||
}
|
||||
self.refresh_draft();
|
||||
}
|
||||
@@ -837,6 +852,16 @@ impl App {
|
||||
self.add_relation();
|
||||
}
|
||||
}
|
||||
Message::PortAction(action) => {
|
||||
if self.apply() {
|
||||
match crate::port_workflow::State::new(self.project(), self.selected, action) {
|
||||
Ok(state) => {
|
||||
self.workflow = Some(crate::workflow::Workflow::Ports(Box::new(state)))
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::AddRelationKind(kind, incoming) => {
|
||||
if self.apply() {
|
||||
self.workflow = Some(crate::workflow::Workflow::relationship_kind(
|
||||
@@ -1363,8 +1388,13 @@ impl App {
|
||||
Message::Loaded(result) => {
|
||||
self.busy = false;
|
||||
match result {
|
||||
Ok(Some((path, p, bytes))) => {
|
||||
self.reset(p, Some(path));
|
||||
Ok(Some((path, decoded, bytes))) => {
|
||||
self.reset(decoded.project, Some(path));
|
||||
self.migration = decoded.migration;
|
||||
if self.migration.is_some() {
|
||||
self.status = "Imported earlier schema · review migration details in Model checks before saving".into();
|
||||
self.page = Page::Validation;
|
||||
}
|
||||
self.file_snapshot = Some(bytes);
|
||||
}
|
||||
Ok(None) => self.status = "Open cancelled".into(),
|
||||
@@ -1427,6 +1457,10 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
fn add_element(&mut self, child: bool) {
|
||||
if self.create_kind == Kind::Port {
|
||||
self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into();
|
||||
return;
|
||||
}
|
||||
let allocated = child
|
||||
&& self.create_kind.is_work()
|
||||
&& self
|
||||
@@ -1848,10 +1882,146 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_snapshot(path: PathBuf) -> Result<FileSnapshot, String> {
|
||||
fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id) -> Option<Id> {
|
||||
if let Some(owner) = p
|
||||
.elements
|
||||
.get(&element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.and_then(|e| e.parent)
|
||||
{
|
||||
return p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.min_by_key(|d| {
|
||||
let view = project_view(p, d.id);
|
||||
(
|
||||
!d.projection.includes(p.elements[&owner].kind()),
|
||||
!view.ports.iter().any(|port| port.element == element),
|
||||
!d.nodes.values().any(|node| node.element == owner),
|
||||
active != Some(d.id),
|
||||
)
|
||||
})
|
||||
.map(|d| d.id);
|
||||
}
|
||||
p.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.min_by_key(|d| {
|
||||
(
|
||||
!p.elements
|
||||
.get(&element)
|
||||
.is_none_or(|e| d.projection.includes(e.kind())),
|
||||
!d.nodes.values().any(|n| n.element == element),
|
||||
d.projection != ProjectionKind::Allocation,
|
||||
)
|
||||
})
|
||||
.map(|d| d.id)
|
||||
}
|
||||
fn reveal_port_owner(p: &mut Project, diagram: Id, element: Id) {
|
||||
let Some(owner) = p
|
||||
.elements
|
||||
.get(&element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.and_then(|e| e.parent)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(owner_node) = p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == owner)
|
||||
.map(|n| n.id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let unfold: Vec<_> = p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
n.collapsed && projection::descendant_nodes(p, diagram, n.id).contains(&owner_node)
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
for id in unfold {
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.collapsed = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||
let project = persistence::decode(&bytes).map_err(|e| e.to_string())?;
|
||||
Ok((path, project, Arc::from(bytes)))
|
||||
let decoded = persistence::decode_with_report(&bytes).map_err(|e| e.to_string())?;
|
||||
Ok((path, decoded, Arc::from(bytes)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod port_view_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filtered_active_view_cannot_hide_a_created_port_when_eligible_owner_view_is_folded() {
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let function = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Function", None)
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "Performs", system, function);
|
||||
let architecture = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
|
||||
let container = p.place(architecture, system, Position { x: 40., y: 40. });
|
||||
p.place(architecture, function, Position { x: 70., y: 130. });
|
||||
let capabilities = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
capabilities,
|
||||
Diagram {
|
||||
id: capabilities,
|
||||
alias: "filtered_view".into(),
|
||||
name: "Filtered view".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Capabilities,
|
||||
nodes: Default::default(),
|
||||
},
|
||||
);
|
||||
p.place(capabilities, system, Position { x: 40., y: 40. });
|
||||
p.place(capabilities, function, Position { x: 70., y: 130. });
|
||||
p.diagrams
|
||||
.get_mut(&architecture)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&container)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let mut app = App::with_project(p.clone());
|
||||
let _ = app.update(Message::Select(function));
|
||||
app.active_diagram = Some(capabilities);
|
||||
app.refresh_draft();
|
||||
let _ = app.update(Message::PortAction(crate::port_workflow::Action::Output));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert!(
|
||||
project_view(app.project(), architecture)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|port| port.element == app.selected)
|
||||
);
|
||||
assert!(!app.project().diagrams[&architecture].nodes[&container].collapsed);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&p,
|
||||
"Creation and the eligible owner reveal remain one transaction"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -356,14 +356,16 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let direction = direction / length;
|
||||
let tip1 = end - direction * 9. + Vector::new(-direction.y, direction.x) * 4.;
|
||||
let tip2 = end - direction * 9. - Vector::new(-direction.y, direction.x) * 4.;
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(tip1);
|
||||
p.line_to(end);
|
||||
p.line_to(tip2);
|
||||
}),
|
||||
Stroke::default().with_color(color).with_width(1.7),
|
||||
);
|
||||
if edge.oriented {
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(tip1);
|
||||
p.line_to(end);
|
||||
p.line_to(tip2);
|
||||
}),
|
||||
Stroke::default().with_color(color).with_width(1.7),
|
||||
);
|
||||
}
|
||||
if edge.id == self.selected
|
||||
|| capability_label.is_some()
|
||||
|| matches!(
|
||||
@@ -421,34 +423,54 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
)
|
||||
};
|
||||
let origin = Point::new(port.position.x - 7., port.position.y - 7.);
|
||||
let path = Path::rectangle(origin, Size::new(14., 14.));
|
||||
let glyph = port_glyph(&self.project.elements[&port.element]);
|
||||
let path = if glyph == PortGlyph::Function {
|
||||
Path::new(|p| {
|
||||
p.move_to(origin);
|
||||
p.line_to(origin + Vector::new(14., 7.));
|
||||
p.line_to(origin + Vector::new(0., 14.));
|
||||
p.close();
|
||||
})
|
||||
} else {
|
||||
Path::rectangle(origin, Size::new(14., 14.))
|
||||
};
|
||||
f.fill(&path, Color::WHITE);
|
||||
f.stroke(
|
||||
&path,
|
||||
let mut stroke =
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
.with_width(if self.selected == port.element {
|
||||
2.5
|
||||
} else {
|
||||
1.5
|
||||
}),
|
||||
);
|
||||
});
|
||||
if glyph == PortGlyph::Legacy {
|
||||
stroke.line_dash = canvas::LineDash {
|
||||
segments: &[3., 2.],
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
f.stroke(&path, stroke);
|
||||
let center = Point::new(port.position.x, port.position.y);
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(center - Vector::new(4., 0.));
|
||||
p.line_to(center + Vector::new(4., 0.));
|
||||
p.move_to(center + Vector::new(1., -3.));
|
||||
p.line_to(center + Vector::new(4., 0.));
|
||||
p.line_to(center + Vector::new(1., 3.));
|
||||
if port.direction == PortDirection::InOut {
|
||||
p.move_to(center + Vector::new(-1., -3.));
|
||||
p.line_to(center - Vector::new(4., 0.));
|
||||
p.line_to(center + Vector::new(-1., 3.));
|
||||
}
|
||||
}),
|
||||
Stroke::default().with_color(color),
|
||||
);
|
||||
if matches!(glyph, PortGlyph::Flow(_)) {
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(center - Vector::new(4., 0.));
|
||||
p.line_to(center + Vector::new(4., 0.));
|
||||
p.move_to(center + Vector::new(1., -3.));
|
||||
p.line_to(center + Vector::new(4., 0.));
|
||||
p.line_to(center + Vector::new(1., 3.));
|
||||
if glyph == PortGlyph::Flow(true) {
|
||||
p.move_to(center + Vector::new(-1., -3.));
|
||||
p.line_to(center - Vector::new(4., 0.));
|
||||
p.line_to(center + Vector::new(-1., 3.));
|
||||
}
|
||||
}),
|
||||
Stroke::default().with_color(color),
|
||||
);
|
||||
}
|
||||
if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) {
|
||||
f.fill(&Path::circle(center, 1.5), color);
|
||||
}
|
||||
if self.selected == port.element {
|
||||
f.fill_text(Text {
|
||||
content: short(self.project.named(port.element), 40),
|
||||
@@ -476,6 +498,35 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum PortGlyph {
|
||||
Function,
|
||||
Standard,
|
||||
Flow(bool),
|
||||
Unspecified,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
fn port_glyph(element: &oa_core::Element) -> PortGlyph {
|
||||
match port_contract(element) {
|
||||
Some(PortContract::FunctionInput { .. } | PortContract::FunctionOutput { .. }) => {
|
||||
PortGlyph::Function
|
||||
}
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
}) => PortGlyph::Standard,
|
||||
Some(PortContract::Component {
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
..
|
||||
}) => PortGlyph::Unspecified,
|
||||
Some(PortContract::Component { orientation, .. }) => {
|
||||
PortGlyph::Flow(*orientation == ComponentPortOrientation::InOut)
|
||||
}
|
||||
_ => PortGlyph::Legacy,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, container: bool) {
|
||||
let Some(e) = p.elements.get(&n.element) else {
|
||||
return;
|
||||
@@ -731,7 +782,12 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed} marker-end=\"url(#arrow)\"><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
|
||||
let arrow = if edge.oriented {
|
||||
" marker-end=\"url(#arrow)\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed}{arrow}><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
|
||||
if stereotype.is_some()
|
||||
|| matches!(
|
||||
p.relations.get(&edge.id).map(|r| r.kind()),
|
||||
@@ -754,10 +810,37 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
svg.push_str(&format!("<g><title>{}</title><rect x=\"{}\" y=\"{}\" width=\"14\" height=\"14\" fill=\"white\" stroke=\"#147d7b\"/><path d=\"M {} {} h 8 l -3 -3 m 3 3 l -3 3\" fill=\"none\" stroke=\"#147d7b\"/></g>",escape(p.named(port.element)),port.position.x-7.,port.position.y-7.,port.position.x-4.,port.position.y));
|
||||
if port.direction == PortDirection::InOut {
|
||||
svg.push_str(&format!("<path d=\"M {} {} l -3 -3 m 3 3 l -3 3\" transform=\"rotate(180 {} {})\" fill=\"none\" stroke=\"#147d7b\"/>",port.position.x+4.,port.position.y,port.position.x,port.position.y));
|
||||
let glyph = port_glyph(&p.elements[&port.element]);
|
||||
let (x, y) = (port.position.x, port.position.y);
|
||||
svg.push_str(&format!(
|
||||
"<g><title>{}</title>",
|
||||
escape(p.named(port.element))
|
||||
));
|
||||
if glyph == PortGlyph::Function {
|
||||
svg.push_str(&format!("<path data-port-kind=\"function\" d=\"M {} {} l 14 7 l -14 7 Z\" fill=\"white\" stroke=\"#147d7b\"/>", x-7., y-7.));
|
||||
} else {
|
||||
let dash = if glyph == PortGlyph::Legacy {
|
||||
" stroke-dasharray=\"3 2\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"14\" height=\"14\" fill=\"white\" stroke=\"#147d7b\"{dash}/>",x-7.,y-7.));
|
||||
if matches!(glyph, PortGlyph::Flow(_)) {
|
||||
svg.push_str(&format!("<path d=\"M {} {y} h 8 l -3 -3 m 3 3 l -3 3\" fill=\"none\" stroke=\"#147d7b\"/>",x-4.));
|
||||
if glyph == PortGlyph::Flow(true) {
|
||||
svg.push_str(&format!(
|
||||
"<path d=\"M {} {} l -3 3 l 3 3\" fill=\"none\" stroke=\"#147d7b\"/>",
|
||||
x - 1.,
|
||||
y - 3.
|
||||
));
|
||||
}
|
||||
} else if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) {
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{x}\" cy=\"{y}\" r=\"1.5\" fill=\"#147d7b\"/>"
|
||||
));
|
||||
}
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
@@ -767,6 +850,80 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn typed_function_glyphs_and_unoriented_standard_connections_have_distinct_svg() {
|
||||
let p = Project::demo();
|
||||
let boundary = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::BoundaryExchange)
|
||||
.unwrap();
|
||||
let mut commands = vec![];
|
||||
for id in [boundary.source(), boundary.target()] {
|
||||
let mut port = p.elements[&id].clone();
|
||||
let Some(SharedBody::Port { contract, .. }) = port.shared_mut() else {
|
||||
panic!("component port");
|
||||
};
|
||||
*contract = PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
};
|
||||
commands.push(Command::EditElement(id, port));
|
||||
}
|
||||
let mut connection = boundary.clone();
|
||||
let RelationBody::BoundaryExchange { oriented, .. } = &mut connection.body else {
|
||||
unreachable!()
|
||||
};
|
||||
*oriented = false;
|
||||
commands.push(Command::EditRelation(boundary.id, connection));
|
||||
let p = execute_batch(&p, &commands).unwrap();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let svg = export_svg(&p, diagram);
|
||||
let title = format!("<title>{}</title>", escape(&boundary.name));
|
||||
let edge_path = svg
|
||||
.split(&title)
|
||||
.next()
|
||||
.unwrap()
|
||||
.rsplit("<path ")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!edge_path.contains("marker-end"),
|
||||
"Unoriented Standard connection has no arrow"
|
||||
);
|
||||
let view = project_view(&p, diagram);
|
||||
for port in &view.ports {
|
||||
let title = format!("<g><title>{}</title>", escape(p.named(port.element)));
|
||||
let body = svg
|
||||
.split(&title)
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("</g>")
|
||||
.next()
|
||||
.unwrap();
|
||||
match port_contract(&p.elements[&port.element]) {
|
||||
Some(PortContract::FunctionInput { .. } | PortContract::FunctionOutput { .. }) => {
|
||||
assert!(body.contains("data-port-kind=\"function\""))
|
||||
}
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
}) => {
|
||||
assert!(body.contains("<rect "));
|
||||
assert!(
|
||||
!body.contains("<path "),
|
||||
"Standard port has no inferred Flow arrow"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn svg_distinguishes_inferred_work_allocation_from_direct_allocation() {
|
||||
for (layer, performer_kind, work_kind, header) in [
|
||||
(
|
||||
|
||||
+396
-137
@@ -14,6 +14,7 @@ use iced::{
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod port_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordKind {
|
||||
@@ -37,6 +38,7 @@ pub struct Draft {
|
||||
baseline: Value,
|
||||
pub inputs: BTreeMap<String, Input>,
|
||||
pub exact_strings: bool,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
@@ -72,6 +74,7 @@ impl Draft {
|
||||
value,
|
||||
inputs: BTreeMap::new(),
|
||||
exact_strings: false,
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
@@ -179,6 +182,17 @@ impl Draft {
|
||||
}
|
||||
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;
|
||||
@@ -301,6 +315,15 @@ fn friendly(name: &str) -> String {
|
||||
"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(),
|
||||
@@ -378,7 +401,33 @@ fn reference(path: &str) -> bool {
|
||||
| "diagram"
|
||||
) || matches!(
|
||||
parent,
|
||||
"items" | "steps" | "branches" | "occurrences" | "constrained" | "triggers" | "effects"
|
||||
"items"
|
||||
| "incoming_items"
|
||||
| "outgoing_items"
|
||||
| "steps"
|
||||
| "branches"
|
||||
| "occurrences"
|
||||
| "constrained"
|
||||
| "triggers"
|
||||
| "effects"
|
||||
)
|
||||
}
|
||||
fn optional_reference(path: &str) -> bool {
|
||||
matches!(
|
||||
path.rsplit('/').next().unwrap_or_default(),
|
||||
"parent"
|
||||
| "interface"
|
||||
| "context"
|
||||
| "represented"
|
||||
| "exchange"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
| "action"
|
||||
| "sequence"
|
||||
| "scenario"
|
||||
| "reply_to"
|
||||
| "lifeline"
|
||||
| "state"
|
||||
)
|
||||
}
|
||||
fn multiline(path: &str) -> bool {
|
||||
@@ -411,7 +460,9 @@ fn free_string(path: &str) -> bool {
|
||||
// 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 mut objects = reference_ids(draft, project, path)
|
||||
let compatible = reference_ids(draft, project, path);
|
||||
let mut objects = compatible
|
||||
.clone()
|
||||
.into_iter()
|
||||
.chain((!current.is_nil()).then_some(current))
|
||||
.collect::<BTreeSet<_>>()
|
||||
@@ -430,21 +481,31 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| project.named(id).to_owned());
|
||||
let label = identities
|
||||
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));
|
||||
objects.insert(
|
||||
0,
|
||||
Choice {
|
||||
id: Id::nil(),
|
||||
label: "Not assigned".into(),
|
||||
},
|
||||
);
|
||||
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
|
||||
}
|
||||
|
||||
@@ -500,6 +561,9 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
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();
|
||||
@@ -584,100 +648,257 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.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|
|
||||
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
|
||||
&& !d.nodes.values().any(|n| n.id != draft.original && n.element == candidate.id));
|
||||
}
|
||||
if !same_layer(candidate) { return false; }
|
||||
match field {
|
||||
"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;
|
||||
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| {
|
||||
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
|
||||
&& !d
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.id != draft.original && n.element == candidate.id)
|
||||
});
|
||||
}
|
||||
if !same_layer(candidate) {
|
||||
return false;
|
||||
}
|
||||
match field {
|
||||
"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
|
||||
)
|
||||
}
|
||||
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);
|
||||
"represented" => {
|
||||
candidate.kind().is_performer()
|
||||
|| candidate.kind().is_work()
|
||||
|| candidate.kind() == Kind::Role
|
||||
}
|
||||
match child.shared() {
|
||||
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 { .. }),
|
||||
},
|
||||
"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,
|
||||
}
|
||||
}),
|
||||
"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.parent == Some(if source { r.source() } else { r.target() })
|
||||
&& matches!(candidate.shared(), Some(SharedBody::Port { direction, .. })
|
||||
if if source { *direction != PortDirection::In } else { *direction != PortDirection::Out })
|
||||
}),
|
||||
_ => match list {
|
||||
"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()
|
||||
},
|
||||
}
|
||||
})
|
||||
.map(|e| e.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lifeline_matches(project: &Project, occurrence: &Element, line: &Element, field: &str) -> bool {
|
||||
@@ -774,7 +995,6 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
"FunctionalDataflow",
|
||||
"Capabilities",
|
||||
],
|
||||
"direction" => &["In", "Out", "InOut"],
|
||||
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
|
||||
"viewpoint" if path.contains("/Oa/") => &[
|
||||
"OperationalActivity",
|
||||
@@ -929,9 +1149,15 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
|
||||
&& 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([Layer::Oa, Layer::Sa], Some(body.layer()), move |layer|
|
||||
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.")
|
||||
@@ -985,11 +1211,37 @@ fn property<'a>(
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
|
||||
if path.ends_with("/Port/legacy") {
|
||||
let mut content = column![].spacing(4);
|
||||
if !value.is_null() {
|
||||
content = content.push(
|
||||
text("Preserved generic association")
|
||||
.size(12)
|
||||
.color(style::INK),
|
||||
);
|
||||
for line in port_fields::legacy_lines(value, project) {
|
||||
content = content.push(text(line).size(11).color(style::MUTED));
|
||||
}
|
||||
}
|
||||
return content.into();
|
||||
}
|
||||
let enum_choices = if skip_variant {
|
||||
vec![]
|
||||
} else {
|
||||
variants(path, value, draft.kind)
|
||||
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()
|
||||
@@ -1001,6 +1253,16 @@ fn property<'a>(
|
||||
.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(),
|
||||
@@ -1016,6 +1278,9 @@ fn property<'a>(
|
||||
.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));
|
||||
}
|
||||
@@ -1031,26 +1296,8 @@ fn property<'a>(
|
||||
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 = value.is_null()
|
||||
|| matches!(
|
||||
path.rsplit('/').next().unwrap_or_default(),
|
||||
"parent"
|
||||
| "interface"
|
||||
| "context"
|
||||
| "represented"
|
||||
| "exchange"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
| "action"
|
||||
| "sequence"
|
||||
| "scenario"
|
||||
| "reply_to"
|
||||
| "lifeline"
|
||||
| "state"
|
||||
| "source_port"
|
||||
| "target_port"
|
||||
);
|
||||
return column![
|
||||
let optional = optional_reference(path);
|
||||
let mut content = column![
|
||||
label_view(),
|
||||
pick_list(choices, selected, move |choice| Message::FieldShape(
|
||||
owned_path.clone(),
|
||||
@@ -1064,8 +1311,17 @@ fn property<'a>(
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(5)
|
||||
.into();
|
||||
.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) => {
|
||||
@@ -1251,21 +1507,20 @@ mod tests {
|
||||
let wrong_layer = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Operational item", None)
|
||||
.unwrap();
|
||||
let interface = p
|
||||
.add_element(Kind::Interface, Layer::Sa, "Contract", None)
|
||||
.unwrap();
|
||||
let output = p
|
||||
.add_element(Kind::Port, Layer::Sa, "Output", Some(function))
|
||||
.unwrap();
|
||||
let input = p
|
||||
.add_element(Kind::Port, Layer::Sa, "Input", Some(receiver))
|
||||
.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]);
|
||||
@@ -1289,8 +1544,12 @@ mod tests {
|
||||
);
|
||||
let port = Draft::from_project(&p, output).unwrap();
|
||||
assert_eq!(
|
||||
ids(&port, &p, "/body/Sa/Shared/Port/interface"),
|
||||
vec![interface]
|
||||
ids(
|
||||
&port,
|
||||
&p,
|
||||
"/body/Sa/Shared/Port/contract/FunctionOutput/outgoing_items/0"
|
||||
),
|
||||
vec![item]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
//! Contextual controls for the closed port model. Draft-only preservation never
|
||||
//! changes the persisted contract or substitutes for candidate validation.
|
||||
use super::*;
|
||||
|
||||
fn constructor(value: &Value) -> Option<&str> {
|
||||
value
|
||||
.as_str()
|
||||
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
|
||||
}
|
||||
|
||||
pub(super) fn switch_contract(
|
||||
current: &Value,
|
||||
proposed: Value,
|
||||
saved: &mut BTreeMap<String, Value>,
|
||||
) -> Value {
|
||||
let (Some(previous), Some(next)) = (constructor(current), constructor(&proposed)) else {
|
||||
return proposed;
|
||||
};
|
||||
if previous == next {
|
||||
return proposed;
|
||||
}
|
||||
saved.insert(previous.into(), current.clone());
|
||||
if let Some(existing) = saved.get(next) {
|
||||
return existing.clone();
|
||||
}
|
||||
// Direction changes retain the item declaration; switching constructors
|
||||
// must not silently replace an authored payload with an empty template.
|
||||
let items = current
|
||||
.pointer("/FunctionInput/incoming_items")
|
||||
.or_else(|| current.pointer("/FunctionOutput/outgoing_items"));
|
||||
match (next, items) {
|
||||
("FunctionInput", Some(items)) => json!({"FunctionInput":{"incoming_items":items}}),
|
||||
("FunctionOutput", Some(items)) => json!({"FunctionOutput":{"outgoing_items":items}}),
|
||||
_ => proposed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn variants(
|
||||
draft: &Draft,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
_value: &Value,
|
||||
) -> Option<Vec<(String, Value)>> {
|
||||
if path.ends_with("/Port/contract") {
|
||||
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let sa = current.pointer("/body/Sa").is_some();
|
||||
if !sa {
|
||||
return Some(vec![]);
|
||||
}
|
||||
let owner = current
|
||||
.get("parent")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| id.parse::<Id>().ok())
|
||||
.and_then(|id| project.elements.get(&id));
|
||||
let mut choices = vec![];
|
||||
if owner.is_none_or(|owner| owner.kind() == Kind::Function) {
|
||||
choices.push((
|
||||
"Function input".into(),
|
||||
json!({"FunctionInput":{"incoming_items":[]}}),
|
||||
));
|
||||
choices.push((
|
||||
"Function output".into(),
|
||||
json!({"FunctionOutput":{"outgoing_items":[]}}),
|
||||
));
|
||||
}
|
||||
if owner.is_none_or(|owner| matches!(owner.kind(), Kind::System | Kind::SystemActor)) {
|
||||
choices.push((
|
||||
"Component port".into(),
|
||||
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||
));
|
||||
}
|
||||
return Some(choices);
|
||||
}
|
||||
if path.ends_with("/Port/contract/Component/kind") {
|
||||
return Some(
|
||||
["Standard", "Flow"]
|
||||
.into_iter()
|
||||
.map(|kind| (kind.into(), json!(kind)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if path.ends_with("/Port/contract/Component/orientation") {
|
||||
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
|
||||
let orientations: &[&str] =
|
||||
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
|
||||
&["Unset"]
|
||||
} else {
|
||||
&["Unset", "In", "Out", "InOut"]
|
||||
};
|
||||
return Some(
|
||||
orientations
|
||||
.iter()
|
||||
.map(|orientation| {
|
||||
let label = if *orientation == "Unset" && orientations.len() == 1 {
|
||||
"Not applicable to Standard".into()
|
||||
} else {
|
||||
friendly(orientation)
|
||||
};
|
||||
(label, json!(orientation))
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn legacy_lines(value: &Value, project: &Project) -> Vec<String> {
|
||||
let direction = value
|
||||
.get("direction")
|
||||
.and_then(Value::as_str)
|
||||
.map(friendly)
|
||||
.unwrap_or_else(|| "Unspecified".into());
|
||||
let interface = value
|
||||
.get("interface")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| id.parse::<Id>().ok());
|
||||
let association = interface
|
||||
.map(|id| {
|
||||
project
|
||||
.elements
|
||||
.get(&id)
|
||||
.map(|e| format!("{} · @{}", e.name, e.alias))
|
||||
.unwrap_or_else(|| format!("Missing interface · {id}"))
|
||||
})
|
||||
.unwrap_or_else(|| "No interface was associated".into());
|
||||
vec![format!("Original direction: {direction}"), format!("Original interface: {association}"), "Retained from the generic model; this association does not declare a provided or required interface.".into()]
|
||||
}
|
||||
|
||||
fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
|
||||
let interfaces: Vec<_> = project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|role| {
|
||||
matches!(
|
||||
role.kind(),
|
||||
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
|
||||
) && role.source() == port
|
||||
})
|
||||
.map(Relation::target)
|
||||
.collect();
|
||||
if interfaces.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let declared: Vec<_> = interfaces
|
||||
.iter()
|
||||
.filter_map(|id| project.elements.get(id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect();
|
||||
items_satisfy(project, items, &declared)
|
||||
}
|
||||
fn boundary_pair(project: &Project, relation: &Relation, source: Id, target: Id) -> bool {
|
||||
let RelationBody::BoundaryExchange {
|
||||
oriented, items, ..
|
||||
} = &relation.body
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (Some(a), Some(b)) = (project.elements.get(&source), project.elements.get(&target)) else {
|
||||
return false;
|
||||
};
|
||||
if source == target
|
||||
|| a.layer() != b.layer()
|
||||
|| a.parent == b.parent
|
||||
|| [a, b].iter().any(|port| {
|
||||
!port
|
||||
.parent
|
||||
.and_then(|id| project.elements.get(&id))
|
||||
.is_some_and(|owner| owner.kind().is_performer())
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (Some(ac), Some(bc)) = (port_contract(a), port_contract(b)) else {
|
||||
return false;
|
||||
};
|
||||
let direction = |e: &Element, contract: &PortContract, outgoing| match contract {
|
||||
PortContract::Component { .. } => !*oriented || component_allows(contract, outgoing),
|
||||
PortContract::Legacy => {
|
||||
matches!(e.shared(), Some(SharedBody::Port { legacy: Some(LegacyPortContract { direction, .. }), .. }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out })
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !direction(a, ac, true) || !direction(b, bc, false) {
|
||||
return false;
|
||||
}
|
||||
if let (PortContract::Component { kind: ak, .. }, PortContract::Component { kind: bk, .. }) =
|
||||
(ac, bc)
|
||||
{
|
||||
if ak != bk {
|
||||
return false;
|
||||
}
|
||||
if !*oriented
|
||||
&& *ak == ComponentPortKind::Flow
|
||||
&& !((component_allows(ac, true) && component_allows(bc, false))
|
||||
|| (component_allows(ac, false) && component_allows(bc, true)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
component_items_fit(project, source, items) && component_items_fit(project, target, items)
|
||||
}
|
||||
|
||||
pub(super) fn relation_endpoints(
|
||||
project: &Project,
|
||||
relation: &Relation,
|
||||
field: &str,
|
||||
original: Id,
|
||||
) -> Option<Vec<Id>> {
|
||||
if !matches!(
|
||||
relation.kind(),
|
||||
RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::PortAllocation
|
||||
| RelationKind::InterfaceProvision
|
||||
| RelationKind::InterfaceRequirement
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let source_field = field == "source";
|
||||
let other = if source_field {
|
||||
relation.target()
|
||||
} else {
|
||||
relation.source()
|
||||
};
|
||||
if relation.kind() == RelationKind::FunctionalExchange {
|
||||
return Some(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Function && e.layer() == Layer::Sa && e.id != other)
|
||||
.map(|e| e.id)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let eligible = |e: &Element, source: bool| match relation.kind() {
|
||||
RelationKind::BoundaryExchange => {
|
||||
e.layer() == Layer::Sa
|
||||
&& matches!(port_contract(e), Some(PortContract::Component { .. }))
|
||||
}
|
||||
RelationKind::PortAllocation => {
|
||||
e.layer() == Layer::Sa
|
||||
&& if source {
|
||||
matches!(port_contract(e), Some(PortContract::Component { .. }))
|
||||
} else {
|
||||
port_contract(e).is_some_and(PortContract::is_function)
|
||||
}
|
||||
}
|
||||
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement => {
|
||||
if source {
|
||||
validation::can_create_interface_role(project, e.id)
|
||||
} else {
|
||||
e.kind() == Kind::Interface
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let mut context = project.clone();
|
||||
context.relations.remove(&original);
|
||||
let permits = |candidate: Id, peer: Id| {
|
||||
let (source, target) = if source_field {
|
||||
(candidate, peer)
|
||||
} else {
|
||||
(peer, candidate)
|
||||
};
|
||||
if relation.kind() == RelationKind::BoundaryExchange {
|
||||
boundary_pair(project, relation, source, target)
|
||||
} else {
|
||||
validation::can_connect(&context, relation.kind(), source, target)
|
||||
}
|
||||
};
|
||||
Some(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| eligible(e, source_field))
|
||||
.filter(|e| {
|
||||
if project.elements.contains_key(&other) {
|
||||
permits(e.id, other)
|
||||
} else {
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|peer| eligible(peer, !source_field))
|
||||
.any(|peer| permits(e.id, peer.id))
|
||||
}
|
||||
})
|
||||
.map(|e| e.id)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CONTRACT: &str = "/body/Sa/Shared/Port/contract";
|
||||
|
||||
fn add(project: &mut Project, kind: Kind, name: &str) -> Id {
|
||||
project.add_element(kind, Layer::Sa, name, None).unwrap()
|
||||
}
|
||||
|
||||
fn port(project: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let element = make_port(project, id, owner, name, contract).unwrap();
|
||||
project.elements.insert(id, element);
|
||||
id
|
||||
}
|
||||
|
||||
fn component(
|
||||
project: &mut Project,
|
||||
owner: Id,
|
||||
kind: ComponentPortKind,
|
||||
orientation: ComponentPortOrientation,
|
||||
) -> Id {
|
||||
port(
|
||||
project,
|
||||
owner,
|
||||
"Boundary port",
|
||||
PortContract::Component { kind, orientation },
|
||||
)
|
||||
}
|
||||
|
||||
fn function_port(project: &mut Project, owner: Id, output: bool, items: Vec<Id>) -> Id {
|
||||
port(
|
||||
project,
|
||||
owner,
|
||||
"Function port",
|
||||
if output {
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: items,
|
||||
}
|
||||
} else {
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: items,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn legacy(project: &mut Project, owner: Id, interface: Option<Id>) -> Id {
|
||||
let id = port(project, owner, "Preserved port", PortContract::Legacy);
|
||||
let layer = project.elements[&id].layer();
|
||||
project.elements.get_mut(&id).unwrap().body = ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface,
|
||||
}),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn choice_values(draft: &Draft, project: &Project, path: &str) -> Vec<Value> {
|
||||
variants(draft, project, path, draft.value.pointer(path).unwrap())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, value)| value)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_function_direction_preserves_invalid_items_and_each_authored_payload() {
|
||||
let mut project = Project::empty();
|
||||
let function = add(&mut project, Kind::Function, "Send");
|
||||
let item = add(&mut project, Kind::ExchangeItem, "Payload");
|
||||
let output = function_port(&mut project, function, true, vec![item]);
|
||||
let mut draft = Draft::from_project(&project, output).unwrap();
|
||||
let output_item = format!("{CONTRACT}/FunctionOutput/outgoing_items/0");
|
||||
let input_items = format!("{CONTRACT}/FunctionInput/incoming_items");
|
||||
draft.input(&output_item, "unfinished reference".into());
|
||||
draft
|
||||
.shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap().pointer(&input_items).unwrap(),
|
||||
&json!(["unfinished reference"])
|
||||
);
|
||||
assert!(draft.command().is_err());
|
||||
draft.shape(&input_items, json!([item])).unwrap();
|
||||
draft
|
||||
.shape(CONTRACT, json!({"FunctionOutput":{"outgoing_items":[]}}))
|
||||
.unwrap();
|
||||
assert_eq!(draft.inputs[&output_item].raw, "unfinished reference");
|
||||
draft
|
||||
.shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap().pointer(&input_items).unwrap(),
|
||||
&json!([item])
|
||||
);
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[draft.command().unwrap()])
|
||||
.unwrap()
|
||||
.elements[&output]
|
||||
.shared(),
|
||||
Some(&SharedBody::Port {
|
||||
contract: PortContract::FunctionInput {
|
||||
incoming_items: vec![item]
|
||||
},
|
||||
legacy: None,
|
||||
})
|
||||
);
|
||||
assert!(draft.dirty());
|
||||
assert!(
|
||||
port_contract(&project.elements[&output])
|
||||
.unwrap()
|
||||
.is_function()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_shape_keeps_incomplete_text_and_saved_contract_payloads() {
|
||||
let mut project = Project::empty();
|
||||
let function = add(&mut project, Kind::Function, "Send");
|
||||
let output = function_port(&mut project, function, true, vec![]);
|
||||
let mut draft = Draft::from_project(&project, output).unwrap();
|
||||
draft.set_exact(true).unwrap();
|
||||
draft.input("/description", "\"unfinished escape\\".into());
|
||||
let value = draft.value.clone();
|
||||
assert!(
|
||||
draft
|
||||
.shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}}))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(draft.value, value);
|
||||
assert_eq!(draft.inputs["/description"].raw, "\"unfinished escape\\");
|
||||
assert!(draft.port_contract_drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contract_choices_follow_owner_and_never_offer_legacy_or_oa_typed_ports() {
|
||||
let mut project = Project::empty();
|
||||
let function = add(&mut project, Kind::Function, "Send");
|
||||
let system = add(&mut project, Kind::System, "System");
|
||||
let output = function_port(&mut project, function, true, vec![]);
|
||||
let boundary = component(
|
||||
&mut project,
|
||||
system,
|
||||
ComponentPortKind::Standard,
|
||||
ComponentPortOrientation::Unset,
|
||||
);
|
||||
let activity = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operate", None)
|
||||
.unwrap();
|
||||
let retained = legacy(&mut project, activity, None);
|
||||
let function_draft = Draft::from_project(&project, output).unwrap();
|
||||
let component_draft = Draft::from_project(&project, boundary).unwrap();
|
||||
let choices = choice_values(&function_draft, &project, CONTRACT);
|
||||
assert_eq!(
|
||||
choices,
|
||||
vec![
|
||||
json!({"FunctionInput":{"incoming_items":[]}}),
|
||||
json!({"FunctionOutput":{"outgoing_items":[]}})
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
choice_values(&component_draft, &project, CONTRACT),
|
||||
vec![json!({"Component":{"kind":"Standard","orientation":"Unset"}})]
|
||||
);
|
||||
assert!(
|
||||
choice_values(
|
||||
&Draft::from_project(&project, retained).unwrap(),
|
||||
&project,
|
||||
"/body/Oa/Shared/Port/contract"
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
reference_ids(&function_draft, &project, "/parent"),
|
||||
vec![function]
|
||||
);
|
||||
assert_eq!(
|
||||
reference_ids(&component_draft, &project, "/parent"),
|
||||
vec![system]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_switch_retains_incompatible_orientation_until_explicit_repair() {
|
||||
let mut project = Project::empty();
|
||||
let system = add(&mut project, Kind::System, "System");
|
||||
let boundary = component(
|
||||
&mut project,
|
||||
system,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::Out,
|
||||
);
|
||||
let mut draft = Draft::from_project(&project, boundary).unwrap();
|
||||
let kind = format!("{CONTRACT}/Component/kind");
|
||||
let orientation = format!("{CONTRACT}/Component/orientation");
|
||||
assert!(choice_values(&draft, &project, &orientation).contains(&json!("InOut")));
|
||||
draft.shape(&kind, json!("Standard")).unwrap();
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap().pointer(&orientation),
|
||||
Some(&json!("Out"))
|
||||
);
|
||||
assert_eq!(
|
||||
choice_values(&draft, &project, &orientation),
|
||||
vec![json!("Unset")]
|
||||
);
|
||||
let mut session = Session::new(project.clone());
|
||||
assert!(session.execute(draft.command().unwrap()).is_err());
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(draft.dirty());
|
||||
draft.shape(&orientation, json!("Unset")).unwrap();
|
||||
session.execute(draft.command().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
port_contract(&session.project().elements[&boundary]),
|
||||
Some(&PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
})
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functional_endpoints_require_owner_direction_and_items_but_retain_bad_current_choice() {
|
||||
let mut project = Project::empty();
|
||||
let source = add(&mut project, Kind::Function, "Send");
|
||||
let target = add(&mut project, Kind::Function, "Receive");
|
||||
let alternative = add(&mut project, Kind::Function, "Alternative sender");
|
||||
let item = add(&mut project, Kind::ExchangeItem, "Message");
|
||||
let output = function_port(&mut project, source, true, vec![item]);
|
||||
let input = function_port(&mut project, target, false, vec![item]);
|
||||
let wrong_direction = function_port(&mut project, source, false, vec![item]);
|
||||
function_port(&mut project, source, true, vec![]);
|
||||
function_port(&mut project, target, true, vec![item]);
|
||||
let replacement = function_port(&mut project, alternative, true, vec![item]);
|
||||
let retained = legacy(&mut project, source, None);
|
||||
let exchange = project.relate(RelationKind::FunctionalExchange, "Message", source, target);
|
||||
project.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
source_port: output,
|
||||
target_port: input,
|
||||
items: vec![item],
|
||||
};
|
||||
let mut draft = Draft::from_project(&project, exchange).unwrap();
|
||||
let source_path = "/body/FunctionalExchange/source_port";
|
||||
assert_eq!(reference_ids(&draft, &project, source_path), vec![output]);
|
||||
assert_eq!(
|
||||
reference_ids(&draft, &project, "/body/FunctionalExchange/target_port"),
|
||||
vec![input]
|
||||
);
|
||||
assert!(
|
||||
!reference_choices(&draft, &project, source_path, output)
|
||||
.iter()
|
||||
.any(|choice| choice.id.is_nil())
|
||||
);
|
||||
for invalid in [wrong_direction, retained] {
|
||||
let choices = reference_choices(&draft, &project, source_path, invalid);
|
||||
assert!(choices.iter().any(|choice| choice.id == invalid
|
||||
&& choice.label.starts_with("Current choice needs review")));
|
||||
}
|
||||
let owners = reference_ids(&draft, &project, "/body/FunctionalExchange/source");
|
||||
assert!(owners.contains(&source) && owners.contains(&alternative));
|
||||
assert!(!owners.contains(&target));
|
||||
draft
|
||||
.shape("/body/FunctionalExchange/source", json!(alternative))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reference_ids(&draft, &project, source_path),
|
||||
vec![replacement]
|
||||
);
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap().pointer(source_path),
|
||||
Some(&json!(output))
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
draft.shape(source_path, json!(replacement)).unwrap();
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_allocation_choices_use_actual_owner_direction_and_ignore_edited_relation() {
|
||||
let mut project = Project::empty();
|
||||
let system = add(&mut project, Kind::System, "System");
|
||||
let actor = add(&mut project, Kind::SystemActor, "Partner");
|
||||
let function = add(&mut project, Kind::Function, "Send");
|
||||
let unallocated = add(&mut project, Kind::Function, "Unallocated");
|
||||
project.relate(RelationKind::Allocation, "Performs", system, function);
|
||||
assert_eq!(actual_performer(&project, unallocated), None);
|
||||
let output = function_port(&mut project, function, true, vec![]);
|
||||
function_port(&mut project, function, false, vec![]);
|
||||
function_port(&mut project, unallocated, true, vec![]);
|
||||
let first = component(
|
||||
&mut project,
|
||||
system,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::Out,
|
||||
);
|
||||
let second = component(
|
||||
&mut project,
|
||||
system,
|
||||
ComponentPortKind::Standard,
|
||||
ComponentPortOrientation::Unset,
|
||||
);
|
||||
component(
|
||||
&mut project,
|
||||
system,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::In,
|
||||
);
|
||||
component(
|
||||
&mut project,
|
||||
actor,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::Out,
|
||||
);
|
||||
legacy(&mut project, system, None);
|
||||
let allocation = project.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"Allocated port",
|
||||
first,
|
||||
output,
|
||||
);
|
||||
let draft = Draft::from_project(&project, allocation).unwrap();
|
||||
let sources = reference_ids(&draft, &project, "/body/PortAllocation/source");
|
||||
assert_eq!(
|
||||
sources.into_iter().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([first, second])
|
||||
);
|
||||
assert_eq!(
|
||||
reference_ids(&draft, &project, "/body/PortAllocation/target"),
|
||||
vec![output]
|
||||
);
|
||||
project.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"Other allocated port",
|
||||
second,
|
||||
output,
|
||||
);
|
||||
assert_eq!(
|
||||
reference_ids(&draft, &project, "/body/PortAllocation/source"),
|
||||
vec![first]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_choices_honor_draft_direction_same_kind_and_direct_interface_items() {
|
||||
let mut project = Project::empty();
|
||||
let system = add(&mut project, Kind::System, "System");
|
||||
let actor = add(&mut project, Kind::SystemActor, "Partner");
|
||||
let input = component(
|
||||
&mut project,
|
||||
system,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::In,
|
||||
);
|
||||
let output = component(
|
||||
&mut project,
|
||||
actor,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::Out,
|
||||
);
|
||||
component(
|
||||
&mut project,
|
||||
actor,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::In,
|
||||
);
|
||||
component(
|
||||
&mut project,
|
||||
actor,
|
||||
ComponentPortKind::Standard,
|
||||
ComponentPortOrientation::Unset,
|
||||
);
|
||||
legacy(&mut project, actor, None);
|
||||
let item = add(&mut project, Kind::ExchangeItem, "Message");
|
||||
let interface = add(&mut project, Kind::Interface, "Empty contract");
|
||||
// A participant declaration must not become the port's declaration.
|
||||
project.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Participant requires",
|
||||
actor,
|
||||
interface,
|
||||
);
|
||||
let exchange = project.relate(RelationKind::BoundaryExchange, "Boundary", input, output);
|
||||
project.relations.get_mut(&exchange).unwrap().body = RelationBody::BoundaryExchange {
|
||||
source: input,
|
||||
target: output,
|
||||
oriented: false,
|
||||
items: vec![item],
|
||||
};
|
||||
let mut draft = Draft::from_project(&project, exchange).unwrap();
|
||||
let target = "/body/BoundaryExchange/target";
|
||||
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
|
||||
draft
|
||||
.shape("/body/BoundaryExchange/oriented", json!(true))
|
||||
.unwrap();
|
||||
assert!(reference_ids(&draft, &project, target).is_empty());
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap().pointer(target),
|
||||
Some(&json!(output))
|
||||
);
|
||||
draft
|
||||
.shape("/body/BoundaryExchange/oriented", json!(false))
|
||||
.unwrap();
|
||||
project.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Port provides",
|
||||
output,
|
||||
interface,
|
||||
);
|
||||
assert!(reference_ids(&draft, &project, target).is_empty());
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
|
||||
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_roles_offer_participants_and_component_ports_independent_of_orientation() {
|
||||
let mut project = Project::empty();
|
||||
let system = add(&mut project, Kind::System, "System");
|
||||
let function = add(&mut project, Kind::Function, "Send");
|
||||
let function_port = function_port(&mut project, function, true, vec![]);
|
||||
let interface = add(&mut project, Kind::Interface, "Service");
|
||||
let input = component(
|
||||
&mut project,
|
||||
system,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::In,
|
||||
);
|
||||
let output = component(
|
||||
&mut project,
|
||||
system,
|
||||
ComponentPortKind::Flow,
|
||||
ComponentPortOrientation::Out,
|
||||
);
|
||||
let preserved = legacy(&mut project, system, Some(interface));
|
||||
for kind in [
|
||||
RelationKind::InterfaceProvision,
|
||||
RelationKind::InterfaceRequirement,
|
||||
] {
|
||||
let role = project.relate(kind, "Role", input, interface);
|
||||
let draft = Draft::from_project(&project, role).unwrap();
|
||||
let path = format!("/body/{kind:?}/source");
|
||||
let choices = reference_ids(&draft, &project, &path);
|
||||
assert_eq!(
|
||||
choices.into_iter().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([system, input, output])
|
||||
);
|
||||
for invalid in [function_port, preserved] {
|
||||
assert!(
|
||||
reference_choices(&draft, &project, &path, invalid)
|
||||
.iter()
|
||||
.any(|choice| choice.id == invalid
|
||||
&& choice.label.starts_with("Current choice needs review"))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
reference_ids(&draft, &project, &format!("/body/{kind:?}/target")),
|
||||
vec![interface]
|
||||
);
|
||||
}
|
||||
let draft = Draft::from_project(&project, preserved).unwrap();
|
||||
let lines = legacy_lines(
|
||||
draft.value.pointer("/body/Sa/Shared/Port/legacy").unwrap(),
|
||||
&project,
|
||||
);
|
||||
assert!(lines[0].contains("Input / output"));
|
||||
assert!(lines[1].contains("Service"));
|
||||
assert!(lines[2].contains("does not declare a provided or required interface"));
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[draft.command().unwrap()]).unwrap(),
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_demo_records_roundtrip_through_graphical_and_escaped_string_drafts() {
|
||||
let project = Project::demo();
|
||||
let mut kinds = BTreeSet::new();
|
||||
for (id, _) in project.identities() {
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
kinds.insert(
|
||||
format!("{:?}", draft.kind)
|
||||
.split('(')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned(),
|
||||
);
|
||||
assert!(!draft.dirty());
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[draft.command().unwrap()]).unwrap(),
|
||||
project,
|
||||
"Normal draft {id}"
|
||||
);
|
||||
draft.set_exact(true).unwrap();
|
||||
assert!(!draft.dirty());
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[draft.command().unwrap()]).unwrap(),
|
||||
project,
|
||||
"Escaped draft {id}"
|
||||
);
|
||||
draft.set_exact(false).unwrap();
|
||||
assert!(!draft.dirty());
|
||||
}
|
||||
assert_eq!(
|
||||
kinds,
|
||||
BTreeSet::from(
|
||||
[
|
||||
"Project", "Element", "Relation", "Diagram", "Node", "Artifact", "Trace"
|
||||
]
|
||||
.map(str::to_owned)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
mod navigation;
|
||||
mod port_preview;
|
||||
mod port_workflow;
|
||||
mod source_navigation;
|
||||
mod style;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
//! Engineering facts for reviewed port operations. This formats the concrete
|
||||
//! planner commands; it neither infers interface roles nor executes a second edit.
|
||||
use oa_core::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Explain a port plan, including endpoints reused by its concrete commands.
|
||||
/// An empty plan has no subject, so its existing context belongs in the caller.
|
||||
pub fn change_rows(before: &Project, commands: &[Command]) -> Vec<String> {
|
||||
let mut pending: Vec<_> = commands.iter().rev().collect();
|
||||
let mut flat = Vec::new();
|
||||
while let Some(command) = pending.pop() {
|
||||
if let Command::Batch(children) = command {
|
||||
pending.extend(children.iter().rev());
|
||||
} else {
|
||||
flat.push(command);
|
||||
}
|
||||
}
|
||||
if flat.is_empty() {
|
||||
return vec!["Already represented; no model changes required.".into()];
|
||||
}
|
||||
// Overlay borrowed records for names of newly created endpoints, without
|
||||
// cloning or validating the entire project on every view render.
|
||||
let mut context = Context::new(before);
|
||||
for command in &flat {
|
||||
match command {
|
||||
Command::CreateElement(e) | Command::EditElement(_, e) => {
|
||||
context.elements.insert(e.id, e);
|
||||
}
|
||||
Command::CreateRelation(r) | Command::EditRelation(_, r) => {
|
||||
context.relations.insert(r.id, r);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let original = Context::new(before);
|
||||
let mut rows = Vec::new();
|
||||
let mut reused_ports = BTreeSet::new();
|
||||
let mut reused_exchanges = BTreeSet::new();
|
||||
let mut reused_allocations = BTreeSet::new();
|
||||
for command in flat {
|
||||
match command {
|
||||
Command::CreateElement(e) => port_rows(&context, None, e, "Create", &mut rows),
|
||||
Command::EditElement(id, e) => {
|
||||
port_rows(&context, before.elements.get(id), e, "Update", &mut rows);
|
||||
}
|
||||
Command::CreateRelation(r) | Command::EditRelation(_, r) => {
|
||||
let old = if let Command::EditRelation(id, _) = command {
|
||||
before.relations.get(id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rows.push(format!(
|
||||
"{} {}: {}",
|
||||
if old.is_some() { "Update" } else { "Create" },
|
||||
r.kind(),
|
||||
context.label(r.id)
|
||||
));
|
||||
relation_rows(&context, old, r, &mut rows);
|
||||
for id in references::relation_references(r) {
|
||||
if context.element(id).is_some_and(|e| e.kind() == Kind::Port) {
|
||||
reused_ports.insert(id);
|
||||
}
|
||||
}
|
||||
if let RelationBody::ExchangeAllocation { source, target } = r.body {
|
||||
// Existing facts omitted by an idempotent planner still
|
||||
// matter to the reviewed correspondence.
|
||||
for exchange in [source, target] {
|
||||
reused_exchanges.insert(exchange);
|
||||
if let Some(exchange) = context.relation(exchange) {
|
||||
for id in references::relation_references(exchange) {
|
||||
if context.element(id).is_some_and(|e| e.kind() == Kind::Port) {
|
||||
reused_ports.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(boundary), Some(functional)) =
|
||||
(context.relation(source), context.relation(target))
|
||||
&& let (
|
||||
RelationBody::BoundaryExchange {
|
||||
source: a,
|
||||
target: b,
|
||||
..
|
||||
},
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
},
|
||||
) = (&boundary.body, &functional.body)
|
||||
{
|
||||
for allocation in context.all_relations().filter(|allocation| {
|
||||
matches!(allocation.body, RelationBody::PortAllocation { source, target }
|
||||
if [*a, *b].contains(&source)
|
||||
&& [*source_port, *target_port].contains(&target))
|
||||
}) {
|
||||
reused_allocations.insert(allocation.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => rows.push(
|
||||
"Additional command requires separate review; it is outside this port preview."
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
for id in reused_exchanges {
|
||||
if !context.relations.contains_key(&id)
|
||||
&& let Some(exchange) = before.relations.get(&id)
|
||||
{
|
||||
rows.push(format!("Reuse {}: {}", exchange.kind(), original.label(id)));
|
||||
relation_rows(&original, None, exchange, &mut rows);
|
||||
}
|
||||
}
|
||||
for id in reused_ports {
|
||||
if !context.elements.contains_key(&id)
|
||||
&& let Some(port) = before.elements.get(&id)
|
||||
{
|
||||
port_rows(&original, None, port, "Reuse", &mut rows);
|
||||
}
|
||||
}
|
||||
for id in reused_allocations {
|
||||
if !context.relations.contains_key(&id)
|
||||
&& let Some(allocation) = before.relations.get(&id)
|
||||
{
|
||||
rows.push(format!("Reuse port allocation: {}", original.label(id)));
|
||||
relation_rows(&original, None, allocation, &mut rows);
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Explain current item origins and every incident FE before resolution, split,
|
||||
/// or synchronization. Retained uses refer to the original primary identity.
|
||||
pub fn port_context_rows(project: &Project, port: Id) -> Vec<String> {
|
||||
let context = Context::new(project);
|
||||
let Some(element) = project.elements.get(&port) else {
|
||||
return vec![format!("Missing port: {port}")];
|
||||
};
|
||||
let Some(SharedBody::Port { contract, legacy }) = element.shared() else {
|
||||
return vec![];
|
||||
};
|
||||
let mut rows = vec![format!("Current port: {}", context.endpoint(port))];
|
||||
let mut origins: BTreeMap<Id, Vec<String>> = BTreeMap::new();
|
||||
if let Some((side, items)) = function_items(contract) {
|
||||
rows.push(format!(
|
||||
"Current {side} declaration: {}",
|
||||
context.items(items)
|
||||
));
|
||||
for item in items {
|
||||
origins
|
||||
.entry(*item)
|
||||
.or_default()
|
||||
.push(format!("declared {side}"));
|
||||
}
|
||||
}
|
||||
if let Some(legacy) = legacy {
|
||||
rows.push(legacy_line(&context, legacy));
|
||||
if let Some(interface) = legacy.interface
|
||||
&& let Some(SharedBody::Interface { items }) =
|
||||
context.element(interface).and_then(Element::shared)
|
||||
{
|
||||
for item in items {
|
||||
origins.entry(*item).or_default().push(format!(
|
||||
"preserved interface {} (suggestion only; not an active typed contract)",
|
||||
context.label(interface)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for relation in project.relations.values() {
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} = &relation.body
|
||||
{
|
||||
for (endpoint, role) in [(*source_port, "output"), (*target_port, "input")] {
|
||||
if endpoint == port {
|
||||
rows.push(format!(
|
||||
"Affected functional exchange: {} · {role} endpoint {} · carried items: {}",
|
||||
context.label(relation.id),
|
||||
context.label(port),
|
||||
context.items(items)
|
||||
));
|
||||
for item in items {
|
||||
origins.entry(*item).or_default().push(format!(
|
||||
"carried by {} at this {role} endpoint",
|
||||
context.label(relation.id)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if references::relation_references(relation).contains(&port) {
|
||||
rows.push(format!(
|
||||
"Retained {}: {}",
|
||||
relation.kind(),
|
||||
context.label(relation.id)
|
||||
));
|
||||
relation_rows(&context, None, relation, &mut rows);
|
||||
}
|
||||
}
|
||||
for (item, sources) in origins {
|
||||
rows.push(format!(
|
||||
"Item origin · {}: {}",
|
||||
context.label(item),
|
||||
sources.join("; ")
|
||||
));
|
||||
}
|
||||
for user in project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.id != port && references::element_references(e).contains(&port))
|
||||
{
|
||||
rows.push(format!(
|
||||
"Retained {} reference: {} → {}",
|
||||
user.kind(),
|
||||
context.label(user.id),
|
||||
context.label(port)
|
||||
));
|
||||
}
|
||||
for artifact in project.artifact_links.values().filter(|a| a.owner == port) {
|
||||
rows.push(format!(
|
||||
"Retained artifact: {} · @{} · {:?} · {} · owner {}",
|
||||
artifact.name,
|
||||
artifact.alias,
|
||||
artifact.relation,
|
||||
artifact.uri,
|
||||
context.label(port)
|
||||
));
|
||||
}
|
||||
for trace in project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == port || t.sa == port)
|
||||
{
|
||||
rows.push(format!(
|
||||
"Retained trace: {} · @{} · OA {} → SA {}",
|
||||
trace.name,
|
||||
trace.alias,
|
||||
context.label(trace.oa),
|
||||
context.label(trace.sa)
|
||||
));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
struct Context<'a> {
|
||||
before: &'a Project,
|
||||
elements: BTreeMap<Id, &'a Element>,
|
||||
relations: BTreeMap<Id, &'a Relation>,
|
||||
}
|
||||
impl<'a> Context<'a> {
|
||||
fn new(before: &'a Project) -> Self {
|
||||
Self {
|
||||
before,
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
fn element(&self, id: Id) -> Option<&Element> {
|
||||
self.elements
|
||||
.get(&id)
|
||||
.copied()
|
||||
.or_else(|| self.before.elements.get(&id))
|
||||
}
|
||||
fn relation(&self, id: Id) -> Option<&Relation> {
|
||||
self.relations
|
||||
.get(&id)
|
||||
.copied()
|
||||
.or_else(|| self.before.relations.get(&id))
|
||||
}
|
||||
fn all_relations(&self) -> impl Iterator<Item = &Relation> {
|
||||
self.before
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| !self.relations.contains_key(&r.id))
|
||||
.chain(self.relations.values().copied())
|
||||
}
|
||||
fn label(&self, id: Id) -> String {
|
||||
if let Some(e) = self.element(id) {
|
||||
format!("{} · @{}", e.name, e.alias)
|
||||
} else if let Some(r) = self.relation(id) {
|
||||
format!("{} · @{}", r.name, r.alias)
|
||||
} else {
|
||||
format!("{} · {id}", self.before.named(id))
|
||||
}
|
||||
}
|
||||
fn endpoint(&self, id: Id) -> String {
|
||||
let mut label = self.label(id);
|
||||
if let Some(port) = self.element(id).filter(|e| e.kind() == Kind::Port)
|
||||
&& let Some(owner) = port.parent
|
||||
{
|
||||
label.push_str(&format!(" (owner {})", self.label(owner)));
|
||||
}
|
||||
label
|
||||
}
|
||||
fn items(&self, items: &[Id]) -> String {
|
||||
if items.is_empty() {
|
||||
"none (empty contract)".into()
|
||||
} else {
|
||||
items
|
||||
.iter()
|
||||
.map(|id| self.label(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn function_items(contract: &PortContract) -> Option<(&'static str, &[Id])> {
|
||||
match contract {
|
||||
PortContract::FunctionInput { incoming_items } => Some(("incoming", incoming_items)),
|
||||
PortContract::FunctionOutput { outgoing_items } => Some(("outgoing", outgoing_items)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn contract_name(contract: &PortContract) -> String {
|
||||
match contract {
|
||||
PortContract::FunctionInput { .. } => "Function input".into(),
|
||||
PortContract::FunctionOutput { .. } => "Function output".into(),
|
||||
PortContract::Component { kind, orientation } => {
|
||||
if *kind == ComponentPortKind::Standard {
|
||||
"Component Standard (no Flow orientation)".into()
|
||||
} else {
|
||||
format!("Component Flow · {orientation:?}")
|
||||
}
|
||||
}
|
||||
PortContract::Legacy => "Legacy (unresolved)".into(),
|
||||
}
|
||||
}
|
||||
fn legacy_line(context: &Context<'_>, legacy: &LegacyPortContract) -> String {
|
||||
format!(
|
||||
"Preserved generic association: {:?}; interface {}. This does not declare a provided or required role.",
|
||||
legacy.direction,
|
||||
legacy
|
||||
.interface
|
||||
.map(|id| context.label(id))
|
||||
.unwrap_or_else(|| "none".into())
|
||||
)
|
||||
}
|
||||
fn role_qualifier(context: &Context<'_>, source: Id) -> &'static str {
|
||||
match context.element(source).and_then(Element::shared) {
|
||||
Some(SharedBody::Port { contract, legacy })
|
||||
if *contract == PortContract::Legacy
|
||||
|| (contract.is_function() && legacy.is_some()) =>
|
||||
{
|
||||
" (preserved compatibility declaration)"
|
||||
}
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
fn item_change_rows(
|
||||
context: &Context<'_>,
|
||||
label: &str,
|
||||
old: Option<&[Id]>,
|
||||
new: &[Id],
|
||||
rows: &mut Vec<String>,
|
||||
) {
|
||||
match old {
|
||||
Some(old) if old != new => rows.push(format!(
|
||||
"{label}: {} → {}",
|
||||
Context::new(context.before).items(old),
|
||||
context.items(new)
|
||||
)),
|
||||
_ => rows.push(format!("{label}: {}", context.items(new))),
|
||||
}
|
||||
}
|
||||
fn port_rows(
|
||||
context: &Context<'_>,
|
||||
old: Option<&Element>,
|
||||
port: &Element,
|
||||
verb: &str,
|
||||
rows: &mut Vec<String>,
|
||||
) {
|
||||
let Some(SharedBody::Port { contract, legacy }) = port.shared() else {
|
||||
rows.push(format!(
|
||||
"{verb} {}: {}",
|
||||
port.kind(),
|
||||
context.label(port.id)
|
||||
));
|
||||
return;
|
||||
};
|
||||
rows.push(format!("{verb} port: {}", context.endpoint(port.id)));
|
||||
let previous = old.and_then(port_contract);
|
||||
if let Some(previous) =
|
||||
previous.filter(|previous| contract_name(previous) != contract_name(contract))
|
||||
{
|
||||
rows.push(format!(
|
||||
"Port contract: {} → {}",
|
||||
contract_name(previous),
|
||||
contract_name(contract)
|
||||
));
|
||||
} else {
|
||||
rows.push(format!("Port contract: {}", contract_name(contract)));
|
||||
}
|
||||
if let Some((side, items)) = function_items(contract) {
|
||||
item_change_rows(
|
||||
context,
|
||||
&format!("{side} items"),
|
||||
previous.and_then(function_items).map(|(_, items)| items),
|
||||
items,
|
||||
rows,
|
||||
);
|
||||
}
|
||||
if let Some(legacy) = legacy {
|
||||
rows.push(legacy_line(context, legacy));
|
||||
}
|
||||
for role in context.all_relations().filter(|r| {
|
||||
r.source() == port.id
|
||||
&& matches!(
|
||||
r.kind(),
|
||||
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
|
||||
)
|
||||
}) {
|
||||
rows.push(format!(
|
||||
"{} interface {} · declaration {}{}",
|
||||
if role.kind() == RelationKind::InterfaceProvision {
|
||||
"Provides"
|
||||
} else {
|
||||
"Requires"
|
||||
},
|
||||
context.label(role.target()),
|
||||
context.label(role.id),
|
||||
role_qualifier(context, port.id)
|
||||
));
|
||||
}
|
||||
}
|
||||
fn relation_rows(
|
||||
context: &Context<'_>,
|
||||
old: Option<&Relation>,
|
||||
relation: &Relation,
|
||||
rows: &mut Vec<String>,
|
||||
) {
|
||||
let original = Context::new(context.before);
|
||||
match &relation.body {
|
||||
RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
} => {
|
||||
rows.push(format!(
|
||||
"Functions: {} → {}",
|
||||
context.label(*source),
|
||||
context.label(*target)
|
||||
));
|
||||
let previous = old.and_then(|r| match r.body {
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => Some((source_port, target_port)),
|
||||
_ => None,
|
||||
});
|
||||
for (label, port, prior) in [
|
||||
("Output endpoint", *source_port, previous.map(|p| p.0)),
|
||||
("Input endpoint", *target_port, previous.map(|p| p.1)),
|
||||
] {
|
||||
rows.push(if let Some(prior) = prior.filter(|prior| *prior != port) {
|
||||
format!(
|
||||
"{label}: {} → {}",
|
||||
original.endpoint(prior),
|
||||
context.endpoint(port)
|
||||
)
|
||||
} else {
|
||||
format!("{label}: {}", context.endpoint(port))
|
||||
});
|
||||
}
|
||||
item_change_rows(
|
||||
context,
|
||||
"Carried items",
|
||||
old.map(|r| r.body.items()),
|
||||
items,
|
||||
rows,
|
||||
);
|
||||
}
|
||||
RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented,
|
||||
items,
|
||||
} => {
|
||||
rows.push(format!(
|
||||
"Component endpoints: {} {} {}",
|
||||
context.endpoint(*source),
|
||||
if *oriented { "→" } else { "—" },
|
||||
context.endpoint(*target)
|
||||
));
|
||||
let orientation = |directed| if directed { "directed" } else { "unoriented" };
|
||||
let previous = old.and_then(|r| match r.body {
|
||||
RelationBody::BoundaryExchange { oriented, .. } => Some(oriented),
|
||||
_ => None,
|
||||
});
|
||||
rows.push(
|
||||
if let Some(previous) = previous.filter(|previous| previous != oriented) {
|
||||
format!(
|
||||
"Exchange orientation: {} → {}",
|
||||
orientation(previous),
|
||||
orientation(*oriented)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Exchange orientation: {} (independent of interface roles)",
|
||||
orientation(*oriented)
|
||||
)
|
||||
},
|
||||
);
|
||||
item_change_rows(
|
||||
context,
|
||||
"Boundary carried items",
|
||||
old.map(|r| r.body.items()),
|
||||
items,
|
||||
rows,
|
||||
);
|
||||
}
|
||||
RelationBody::PortAllocation { source, target } => {
|
||||
rows.push(format!(
|
||||
"Component port {} allocates function port {}",
|
||||
context.endpoint(*source),
|
||||
context.endpoint(*target)
|
||||
));
|
||||
if let (Some(component), Some(function)) = (
|
||||
context.element(*source).and_then(|port| port.parent),
|
||||
context.element(*target).and_then(|port| port.parent),
|
||||
) {
|
||||
for allocation in context.all_relations().filter(|r| {
|
||||
r.kind() == RelationKind::Allocation
|
||||
&& r.source() == component
|
||||
&& r.target() == function
|
||||
}) {
|
||||
rows.push(format!(
|
||||
"Actual work allocation: {} performs {} · {}",
|
||||
context.label(component),
|
||||
context.label(function),
|
||||
context.label(allocation.id)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
RelationBody::ExchangeAllocation { source, target } => {
|
||||
rows.push(format!(
|
||||
"Component exchange {} implements functional exchange {}",
|
||||
context.label(*source),
|
||||
context.label(*target)
|
||||
));
|
||||
}
|
||||
RelationBody::InterfaceProvision { source, target }
|
||||
| RelationBody::InterfaceRequirement { source, target } => {
|
||||
rows.push(format!(
|
||||
"{} {} interface {}{}",
|
||||
context.endpoint(*source),
|
||||
if relation.kind() == RelationKind::InterfaceProvision {
|
||||
"provides"
|
||||
} else {
|
||||
"requires"
|
||||
},
|
||||
context.label(*target),
|
||||
role_qualifier(context, *source)
|
||||
));
|
||||
}
|
||||
_ => rows.push(format!(
|
||||
"{} → {}",
|
||||
context.label(relation.source()),
|
||||
context.label(relation.target())
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, name: &str) -> Id {
|
||||
p.add_element(kind, Layer::Sa, name, None).unwrap()
|
||||
}
|
||||
fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let element = make_port(p, id, owner, name, contract).unwrap();
|
||||
p.elements.insert(id, element);
|
||||
id
|
||||
}
|
||||
fn exchange(p: &mut Project, source: Id, target: Id, items: Vec<Id>) -> (Id, Vec<Command>) {
|
||||
let id = Id::new_v4();
|
||||
let commands = plan_functional_exchange(
|
||||
p,
|
||||
&FunctionalExchangeRequest {
|
||||
id,
|
||||
alias: p.fresh_alias("report", "exchange", id),
|
||||
name: "Transfer report".into(),
|
||||
description: String::new(),
|
||||
source,
|
||||
target,
|
||||
source_port: PortChoice::Auto,
|
||||
target_port: PortChoice::Auto,
|
||||
items,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
(id, commands)
|
||||
}
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
source: Id,
|
||||
target: Id,
|
||||
first: Id,
|
||||
second: Id,
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let source = add(&mut p, Kind::Function, "Report incident");
|
||||
let target = add(&mut p, Kind::Function, "Assess incident");
|
||||
let actor = add(&mut p, Kind::SystemActor, "Operator");
|
||||
let system = add(&mut p, Kind::System, "Rescue system");
|
||||
let first = add(&mut p, Kind::ExchangeItem, "Incident report");
|
||||
let second = add(&mut p, Kind::ExchangeItem, "Assessment request");
|
||||
p.relate(RelationKind::Allocation, "Reports", actor, source);
|
||||
p.relate(RelationKind::Allocation, "Assesses", system, target);
|
||||
port(
|
||||
&mut p,
|
||||
source,
|
||||
"Report output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![first, second],
|
||||
},
|
||||
);
|
||||
port(
|
||||
&mut p,
|
||||
target,
|
||||
"Report input",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![first, second],
|
||||
},
|
||||
);
|
||||
Fixture {
|
||||
project: p,
|
||||
source,
|
||||
target,
|
||||
first,
|
||||
second,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functional_creation_identifies_new_and_reused_endpoints_and_owners() {
|
||||
let Fixture {
|
||||
mut project,
|
||||
source,
|
||||
target,
|
||||
first,
|
||||
..
|
||||
} = fixture();
|
||||
let removed = project
|
||||
.children(target)
|
||||
.find(|e| e.kind() == Kind::Port)
|
||||
.unwrap()
|
||||
.id;
|
||||
project.elements.remove(&removed);
|
||||
let (_, commands) = exchange(&mut project, source, target, vec![first]);
|
||||
let before = project.clone();
|
||||
let rows = change_rows(&project, &commands);
|
||||
let text = rows.join("\n");
|
||||
assert!(text.contains("Create port:"));
|
||||
assert!(text.contains(
|
||||
"Reuse port: Report output · @report_output (owner Report incident · @report_incident)"
|
||||
));
|
||||
assert!(text.contains("Output endpoint: Report output · @report_output"));
|
||||
assert!(
|
||||
text.contains("Input endpoint:")
|
||||
&& text.contains("owner Assess incident · @assess_incident")
|
||||
);
|
||||
assert!(text.contains("Carried items: Incident report · @incident_report"));
|
||||
assert!(text.contains("Port contract: Function input"));
|
||||
assert_eq!(project, before, "Preview must not apply a plan");
|
||||
assert!(execute_batch(&project, &commands).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_boundary_shows_item_union_and_existing_allocating_facts() {
|
||||
let Fixture {
|
||||
mut project,
|
||||
source,
|
||||
target,
|
||||
first,
|
||||
second,
|
||||
} = fixture();
|
||||
let (fe, commands) = exchange(&mut project, source, target, vec![first]);
|
||||
project = execute_batch(&project, &commands).unwrap();
|
||||
let boundary = Id::new_v4();
|
||||
let generated = plan_component_exchange(
|
||||
&project,
|
||||
&ComponentExchangeRequest {
|
||||
functional_exchange: fe,
|
||||
boundary: ComponentExchangeChoice::Create {
|
||||
id: boundary,
|
||||
alias: "implementation".into(),
|
||||
name: "Incident interface".into(),
|
||||
description: "Keep this prose".into(),
|
||||
port_kind: ComponentPortKind::Standard,
|
||||
oriented: false,
|
||||
source_port: PortChoice::Auto,
|
||||
target_port: PortChoice::Auto,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project = execute_batch(&project, &generated).unwrap();
|
||||
let (other_fe, commands) = exchange(&mut project, source, target, vec![second]);
|
||||
project = execute_batch(&project, &commands).unwrap();
|
||||
let interface = add(&mut project, Kind::Interface, "Incident service");
|
||||
project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Interface {
|
||||
items: vec![first, second],
|
||||
},
|
||||
);
|
||||
let connection = &project.relations[&boundary];
|
||||
let (a, b) = (connection.source(), connection.target());
|
||||
project.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Client requirement",
|
||||
a,
|
||||
interface,
|
||||
);
|
||||
project.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Service provision",
|
||||
b,
|
||||
interface,
|
||||
);
|
||||
let commands = plan_component_exchange(
|
||||
&project,
|
||||
&ComponentExchangeRequest {
|
||||
functional_exchange: other_fe,
|
||||
boundary: ComponentExchangeChoice::Reuse(boundary),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let rows = change_rows(&project, &commands);
|
||||
let text = rows.join("\n");
|
||||
assert!(text.contains("Boundary carried items: Incident report · @incident_report → Incident report · @incident_report, Assessment request · @assessment_request"));
|
||||
assert!(text.contains("Exchange orientation: unoriented"));
|
||||
assert!(text.contains("Port contract: Component Standard (no Flow orientation)"));
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.filter(|row| row.starts_with("Reuse port allocation:"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(text.contains("allocates function port Report output · @report_output (owner Report incident · @report_incident)"));
|
||||
assert!(text.contains("implements functional exchange Transfer report"));
|
||||
assert!(text.contains("Actual work allocation: Operator · @operator performs Report incident · @report_incident"));
|
||||
assert!(text.contains("Requires interface Incident service · @incident_service · declaration Client requirement"));
|
||||
assert!(text.contains("Provides interface Incident service · @incident_service · declaration Service provision"));
|
||||
assert_eq!(project.relations[&boundary].description, "Keep this prose");
|
||||
project = execute_batch(&project, &commands).unwrap();
|
||||
let (third_fe, commands) = exchange(&mut project, source, target, vec![first]);
|
||||
project = execute_batch(&project, &commands).unwrap();
|
||||
let commands = plan_component_exchange(
|
||||
&project,
|
||||
&ComponentExchangeRequest {
|
||||
functional_exchange: third_fe,
|
||||
boundary: ComponentExchangeChoice::Reuse(boundary),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let rows = change_rows(&project, &commands);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.any(|row| row == "Reuse Boundary exchange: Incident interface · @implementation")
|
||||
);
|
||||
assert!(
|
||||
!rows
|
||||
.iter()
|
||||
.any(|row| row.starts_with("Create Boundary exchange:"))
|
||||
);
|
||||
assert_eq!(change_rows(&project, &[Command::Batch(commands)]), rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_split_discloses_endpoint_routing_item_origins_and_retained_evidence() {
|
||||
let Fixture {
|
||||
mut project,
|
||||
source,
|
||||
target,
|
||||
first,
|
||||
..
|
||||
} = fixture();
|
||||
let interface = add(&mut project, Kind::Interface, "Preserved contract");
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] });
|
||||
let legacy = port(
|
||||
&mut project,
|
||||
source,
|
||||
"Shared legacy endpoint",
|
||||
PortContract::Legacy,
|
||||
);
|
||||
project.elements.get_mut(&legacy).unwrap().body = ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: Some(interface),
|
||||
}),
|
||||
},
|
||||
);
|
||||
let input = project
|
||||
.children(target)
|
||||
.find(|e| matches!(port_contract(e), Some(PortContract::FunctionInput { .. })))
|
||||
.unwrap()
|
||||
.id;
|
||||
let output = port(
|
||||
&mut project,
|
||||
target,
|
||||
"Reply output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![first],
|
||||
},
|
||||
);
|
||||
let outgoing = project.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Outgoing report",
|
||||
source,
|
||||
target,
|
||||
);
|
||||
project.relations.get_mut(&outgoing).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
source_port: legacy,
|
||||
target_port: input,
|
||||
items: vec![first],
|
||||
};
|
||||
let incoming = project.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Incoming report",
|
||||
target,
|
||||
source,
|
||||
);
|
||||
project.relations.get_mut(&incoming).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: target,
|
||||
target: source,
|
||||
source_port: output,
|
||||
target_port: legacy,
|
||||
items: vec![first],
|
||||
};
|
||||
let constraint = add(&mut project, Kind::Constraint, "Bandwidth budget");
|
||||
project.elements.get_mut(&constraint).unwrap().body = ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Constraint {
|
||||
expression: "Bandwidth is bounded".into(),
|
||||
constrained: vec![legacy],
|
||||
},
|
||||
);
|
||||
let artifact = Id::new_v4();
|
||||
project.artifact_links.insert(
|
||||
artifact,
|
||||
ArtifactLink {
|
||||
id: artifact,
|
||||
alias: "schematic".into(),
|
||||
name: "Electrical schematic".into(),
|
||||
description: String::new(),
|
||||
owner: legacy,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "./hardware/board.kicad_sch".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let activity = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational reporting", None)
|
||||
.unwrap();
|
||||
let trace = Id::new_v4();
|
||||
project.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "reporting_trace".into(),
|
||||
name: "Reporting correspondence".into(),
|
||||
description: String::new(),
|
||||
oa: activity,
|
||||
sa: legacy,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let context = port_context_rows(&project, legacy).join("\n");
|
||||
assert!(context.contains("Affected functional exchange: Outgoing report"));
|
||||
assert!(context.contains("Affected functional exchange: Incoming report"));
|
||||
assert!(
|
||||
context.contains("Item origin · Incident report · @incident_report:")
|
||||
&& context.contains("preserved interface Preserved contract")
|
||||
);
|
||||
assert!(context.contains("suggestion only; not an active typed contract"));
|
||||
assert!(context.contains("Retained Constraint reference: Bandwidth budget"));
|
||||
assert!(context.contains("Retained artifact: Electrical schematic · @schematic"));
|
||||
assert!(context.contains("Retained trace: Reporting correspondence · @reporting_trace"));
|
||||
let commands = plan_legacy_work_port_split(
|
||||
&project,
|
||||
&LegacyWorkPortSplitRequest {
|
||||
port: legacy,
|
||||
primary: FunctionPortSide::Output,
|
||||
incoming_items: vec![first],
|
||||
outgoing_items: vec![first],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let text = change_rows(&project, &commands).join("\n");
|
||||
let opposite = Id::new_v5(&legacy, b"split-opposite-port");
|
||||
let candidate = execute_batch(&project, &commands).unwrap();
|
||||
assert!(text.contains("Port contract: Legacy (unresolved) → Function output"));
|
||||
assert!(text.contains(&format!(
|
||||
"Input endpoint: {} → {}",
|
||||
Context::new(&project).endpoint(legacy),
|
||||
Context::new(&candidate).endpoint(opposite)
|
||||
)));
|
||||
assert!(text.contains("Create port:") && text.contains("Port contract: Function input"));
|
||||
assert_eq!(candidate.artifact_links[&artifact].owner, legacy);
|
||||
assert_eq!(candidate.traces[&trace].sa, legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronization_preview_exposes_contract_shrinkage_and_current_declaration_origin() {
|
||||
let Fixture {
|
||||
project,
|
||||
source,
|
||||
first,
|
||||
second,
|
||||
..
|
||||
} = fixture();
|
||||
let output = project
|
||||
.children(source)
|
||||
.find(|e| e.kind() == Kind::Port)
|
||||
.unwrap()
|
||||
.id;
|
||||
let commands = plan_function_port_items(&project, output, vec![first]).unwrap();
|
||||
let context = port_context_rows(&project, output).join("\n");
|
||||
assert!(
|
||||
context.contains(
|
||||
"Item origin · Assessment request · @assessment_request: declared outgoing"
|
||||
)
|
||||
);
|
||||
let text = change_rows(&project, &commands).join("\n");
|
||||
assert!(text.contains("outgoing items: Incident report · @incident_report, Assessment request · @assessment_request → Incident report · @incident_report"));
|
||||
let candidate = execute_batch(&project, &commands).unwrap();
|
||||
assert!(
|
||||
matches!(port_contract(&candidate.elements[&output]), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items == &[first])
|
||||
);
|
||||
assert!(project.elements.contains_key(&second));
|
||||
assert_eq!(
|
||||
change_rows(&project, &[]),
|
||||
["Already represented; no model changes required."]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
//! Contextual port authoring. Plans remain ordinary core commands and commit once.
|
||||
use crate::{
|
||||
app::{Choice, Message},
|
||||
port_preview, style, workflow,
|
||||
};
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, scrollable, text, text_input},
|
||||
};
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Input,
|
||||
Output,
|
||||
Flow,
|
||||
Standard,
|
||||
Generate,
|
||||
Resolve,
|
||||
Split,
|
||||
Synchronize,
|
||||
}
|
||||
impl Action {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Input => "Create function input port",
|
||||
Self::Output => "Create function output port",
|
||||
Self::Flow => "Create Flow component port",
|
||||
Self::Standard => "Create Standard component port",
|
||||
Self::Generate => "Generate component exchange",
|
||||
Self::Resolve => "Resolve legacy port contract",
|
||||
Self::Split => "Split legacy input/output port",
|
||||
Self::Synchronize => "Synchronize port items",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for Action {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
pub fn actions(p: &Project, id: Id) -> Vec<Action> {
|
||||
if p.layer_of(id) != Some(Layer::Sa) {
|
||||
return vec![];
|
||||
}
|
||||
if p.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| r.kind() == RelationKind::FunctionalExchange)
|
||||
{
|
||||
return vec![Action::Generate];
|
||||
}
|
||||
let Some(e) = p.elements.get(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
match e.kind() {
|
||||
Kind::Function => vec![Action::Input, Action::Output],
|
||||
Kind::System | Kind::SystemActor => vec![Action::Flow, Action::Standard],
|
||||
Kind::Port => match e.shared() {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(old),
|
||||
}) => {
|
||||
let mut out = vec![Action::Resolve];
|
||||
if old.direction == PortDirection::InOut
|
||||
&& e.parent
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| e.kind() == Kind::Function)
|
||||
{
|
||||
out.push(Action::Split);
|
||||
}
|
||||
out
|
||||
}
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { .. } | PortContract::FunctionOutput { .. },
|
||||
..
|
||||
}) => vec![Action::Synchronize],
|
||||
_ => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContractKind {
|
||||
Input,
|
||||
Output,
|
||||
Flow,
|
||||
Standard,
|
||||
}
|
||||
impl std::fmt::Display for ContractKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Input => "Function input",
|
||||
Self::Output => "Function output",
|
||||
Self::Flow => "Component · Flow",
|
||||
Self::Standard => "Component · Standard",
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Name(String),
|
||||
Kind(ContractKind),
|
||||
Orientation(String),
|
||||
Oriented(bool),
|
||||
Boundary(Choice),
|
||||
Item(Id, bool, bool),
|
||||
Primary(bool),
|
||||
Endpoint(bool, workflow::EndpointChoice),
|
||||
}
|
||||
pub struct State {
|
||||
action: Action,
|
||||
subject: Id,
|
||||
id: Id,
|
||||
baseline: Project,
|
||||
name: String,
|
||||
kind: ContractKind,
|
||||
orientation: ComponentPortOrientation,
|
||||
oriented: bool,
|
||||
boundary: Option<Choice>,
|
||||
source_port: workflow::EndpointChoice,
|
||||
target_port: workflow::EndpointChoice,
|
||||
incoming: BTreeSet<Id>,
|
||||
outgoing: BTreeSet<Id>,
|
||||
primary_input: bool,
|
||||
preview: Result<Vec<Command>, String>,
|
||||
}
|
||||
impl State {
|
||||
pub fn new(p: &Project, subject: Id, action: Action) -> Result<Self, String> {
|
||||
if !actions(p, subject).contains(&action) {
|
||||
return Err("This port action is unavailable for the selected element.".into());
|
||||
}
|
||||
let e = p.elements.get(&subject);
|
||||
let old = e.and_then(|e| e.shared()).and_then(|body| match body {
|
||||
SharedBody::Port { legacy, .. } => legacy.as_ref(),
|
||||
_ => None,
|
||||
});
|
||||
let owner = e.and_then(|e| e.parent).and_then(|id| p.elements.get(&id));
|
||||
let kind = match action {
|
||||
Action::Input => ContractKind::Input,
|
||||
Action::Output => ContractKind::Output,
|
||||
Action::Standard => ContractKind::Standard,
|
||||
Action::Resolve | Action::Split | Action::Synchronize
|
||||
if owner.is_some_and(|e| e.kind() == Kind::Function) =>
|
||||
{
|
||||
if matches!(
|
||||
e.and_then(port_contract),
|
||||
Some(PortContract::FunctionInput { .. })
|
||||
) || (matches!(e.and_then(port_contract), Some(PortContract::Legacy))
|
||||
&& old.is_some_and(|old| old.direction == PortDirection::In))
|
||||
{
|
||||
ContractKind::Input
|
||||
} else {
|
||||
ContractKind::Output
|
||||
}
|
||||
}
|
||||
_ => ContractKind::Flow,
|
||||
};
|
||||
let mut incoming = BTreeSet::new();
|
||||
let mut outgoing = BTreeSet::new();
|
||||
if let Some(contract) = e.and_then(port_contract) {
|
||||
match contract {
|
||||
PortContract::FunctionInput { incoming_items } => incoming.extend(incoming_items),
|
||||
PortContract::FunctionOutput { outgoing_items } => outgoing.extend(outgoing_items),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(SharedBody::Interface { items }) = old
|
||||
.and_then(|old| old.interface)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(|e| e.shared())
|
||||
{
|
||||
incoming.extend(items);
|
||||
outgoing.extend(items);
|
||||
}
|
||||
for r in p.relations.values() {
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} = &r.body
|
||||
{
|
||||
if *source_port == subject {
|
||||
outgoing.extend(items);
|
||||
}
|
||||
if *target_port == subject {
|
||||
incoming.extend(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut state = Self {
|
||||
action,
|
||||
subject,
|
||||
id: Id::new_v4(),
|
||||
baseline: p.clone(),
|
||||
name: if action == Action::Generate {
|
||||
format!("{} implementation", p.named(subject))
|
||||
} else {
|
||||
format!("{kind} port")
|
||||
},
|
||||
kind,
|
||||
orientation: old.map_or(ComponentPortOrientation::Unset, |old| match old.direction {
|
||||
PortDirection::In => ComponentPortOrientation::In,
|
||||
PortDirection::Out => ComponentPortOrientation::Out,
|
||||
PortDirection::InOut => ComponentPortOrientation::InOut,
|
||||
}),
|
||||
oriented: true,
|
||||
boundary: None,
|
||||
source_port: workflow::automatic_port(),
|
||||
target_port: workflow::automatic_port(),
|
||||
incoming,
|
||||
outgoing,
|
||||
primary_input: true,
|
||||
preview: Ok(vec![]),
|
||||
};
|
||||
state.preview = state.plan(p);
|
||||
Ok(state)
|
||||
}
|
||||
pub fn title(&self) -> &'static str {
|
||||
self.action.label()
|
||||
}
|
||||
pub fn focus(&self) -> Id {
|
||||
match self.action {
|
||||
Action::Generate => self
|
||||
.boundary
|
||||
.as_ref()
|
||||
.filter(|v| !v.id.is_nil())
|
||||
.map_or(self.id, |v| v.id),
|
||||
Action::Resolve | Action::Split | Action::Synchronize => self.subject,
|
||||
_ => self.id,
|
||||
}
|
||||
}
|
||||
fn contract(&self) -> PortContract {
|
||||
match self.kind {
|
||||
ContractKind::Input => PortContract::FunctionInput {
|
||||
incoming_items: self.incoming.iter().copied().collect(),
|
||||
},
|
||||
ContractKind::Output => PortContract::FunctionOutput {
|
||||
outgoing_items: self.outgoing.iter().copied().collect(),
|
||||
},
|
||||
ContractKind::Flow => PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: self.orientation,
|
||||
},
|
||||
ContractKind::Standard => PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn plan(&self, p: &Project) -> Result<Vec<Command>, String> {
|
||||
let result = match self.action {
|
||||
Action::Generate => plan_component_exchange(
|
||||
p,
|
||||
&ComponentExchangeRequest {
|
||||
functional_exchange: self.subject,
|
||||
boundary: if let Some(existing) = &self.boundary
|
||||
&& !existing.id.is_nil()
|
||||
{
|
||||
ComponentExchangeChoice::Reuse(existing.id)
|
||||
} else {
|
||||
ComponentExchangeChoice::Create {
|
||||
id: self.id,
|
||||
alias: p.fresh_alias(&self.name, "component_exchange", self.id),
|
||||
name: self.name.clone(),
|
||||
description: String::new(),
|
||||
port_kind: if self.kind == ContractKind::Standard {
|
||||
ComponentPortKind::Standard
|
||||
} else {
|
||||
ComponentPortKind::Flow
|
||||
},
|
||||
oriented: self.oriented,
|
||||
source_port: self.source_port.choice,
|
||||
target_port: self.target_port.choice,
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
Action::Resolve => plan_legacy_port_resolution(
|
||||
p,
|
||||
&LegacyPortResolution {
|
||||
port: self.subject,
|
||||
contract: self.contract(),
|
||||
},
|
||||
),
|
||||
Action::Split => plan_legacy_work_port_split(
|
||||
p,
|
||||
&LegacyWorkPortSplitRequest {
|
||||
port: self.subject,
|
||||
primary: if self.primary_input {
|
||||
FunctionPortSide::Input
|
||||
} else {
|
||||
FunctionPortSide::Output
|
||||
},
|
||||
incoming_items: self.incoming.iter().copied().collect(),
|
||||
outgoing_items: self.outgoing.iter().copied().collect(),
|
||||
},
|
||||
),
|
||||
Action::Synchronize => plan_function_port_items(
|
||||
p,
|
||||
self.subject,
|
||||
if self.kind == ContractKind::Input {
|
||||
&self.incoming
|
||||
} else {
|
||||
&self.outgoing
|
||||
}
|
||||
.iter()
|
||||
.copied()
|
||||
.collect(),
|
||||
),
|
||||
_ => make_port(p, self.id, self.subject, &self.name, self.contract()).and_then(|e| {
|
||||
let commands = vec![Command::CreateElement(e)];
|
||||
execute_batch(p, &commands).map(|_| commands)
|
||||
}),
|
||||
};
|
||||
result.map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
event: workflow::Event,
|
||||
) -> Result<Option<Project>, String> {
|
||||
if p != &self.baseline {
|
||||
return Err("The model changed after this preview. Reopen the port action to review current facts.".into());
|
||||
}
|
||||
match event {
|
||||
workflow::Event::Apply => {
|
||||
return execute_batch(p, self.preview.as_ref().map_err(Clone::clone)?)
|
||||
.map(Some)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
workflow::Event::Port(event) => match event {
|
||||
Event::Name(v) => self.name = v,
|
||||
Event::Kind(v) => {
|
||||
self.kind = v;
|
||||
if v == ContractKind::Standard {
|
||||
self.oriented = false;
|
||||
}
|
||||
}
|
||||
Event::Orientation(v) => {
|
||||
self.orientation = match v.as_str() {
|
||||
"In" => ComponentPortOrientation::In,
|
||||
"Out" => ComponentPortOrientation::Out,
|
||||
"InOut" => ComponentPortOrientation::InOut,
|
||||
_ => ComponentPortOrientation::Unset,
|
||||
}
|
||||
}
|
||||
Event::Oriented(v) => self.oriented = v,
|
||||
Event::Boundary(v) => self.boundary = Some(v),
|
||||
Event::Primary(v) => self.primary_input = v,
|
||||
Event::Endpoint(source, choice) => {
|
||||
if source {
|
||||
self.source_port = choice;
|
||||
} else {
|
||||
self.target_port = choice;
|
||||
}
|
||||
}
|
||||
Event::Item(id, input, enabled) => {
|
||||
let items = if input {
|
||||
&mut self.incoming
|
||||
} else {
|
||||
&mut self.outgoing
|
||||
};
|
||||
if enabled {
|
||||
items.insert(id);
|
||||
} else {
|
||||
items.remove(&id);
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
self.preview = self.plan(p);
|
||||
Ok(None)
|
||||
}
|
||||
pub fn view<'a>(&'a self, p: &'a Project) -> View<'a, Message> {
|
||||
let reusing = self.action == Action::Generate
|
||||
&& self.boundary.as_ref().is_some_and(|v| !v.id.is_nil());
|
||||
let mut body = column![
|
||||
text(self.title()).size(23),
|
||||
text(p.named(self.subject)).size(15)
|
||||
]
|
||||
.spacing(12)
|
||||
.width(Fill);
|
||||
if matches!(
|
||||
self.action,
|
||||
Action::Input | Action::Output | Action::Flow | Action::Standard | Action::Generate
|
||||
) && !reusing
|
||||
{
|
||||
body = body.push(
|
||||
text_input("Port or exchange name", &self.name)
|
||||
.padding(9)
|
||||
.on_input(|v| event(Event::Name(v))),
|
||||
);
|
||||
}
|
||||
if matches!(self.action, Action::Resolve | Action::Generate) && !reusing {
|
||||
let kinds = if self.kind == ContractKind::Input || self.kind == ContractKind::Output {
|
||||
vec![ContractKind::Input, ContractKind::Output]
|
||||
} else {
|
||||
vec![ContractKind::Standard, ContractKind::Flow]
|
||||
};
|
||||
body = body.push(
|
||||
pick_list(kinds, Some(self.kind), |v| event(Event::Kind(v)))
|
||||
.padding(9)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if self.kind == ContractKind::Flow && self.action != Action::Generate {
|
||||
body = body.push(text("Flow orientation").size(12)).push(
|
||||
pick_list(
|
||||
vec![
|
||||
"Unset".to_string(),
|
||||
"In".into(),
|
||||
"Out".into(),
|
||||
"InOut".into(),
|
||||
],
|
||||
Some(format!("{:?}", self.orientation)),
|
||||
|v| event(Event::Orientation(v)),
|
||||
)
|
||||
.padding(9),
|
||||
);
|
||||
}
|
||||
if self.action == Action::Generate {
|
||||
let mut choices = vec![Choice {
|
||||
id: Id::nil(),
|
||||
label: "Create a component exchange".into(),
|
||||
}];
|
||||
choices.extend(
|
||||
p.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::BoundaryExchange
|
||||
&& p.layer_of(r.id) == Some(Layer::Sa)
|
||||
})
|
||||
.map(|r| Choice {
|
||||
id: r.id,
|
||||
label: format!("Reuse {}", r.name),
|
||||
}),
|
||||
);
|
||||
body = body.push(pick_list(choices, self.boundary.clone(), |v| event(Event::Boundary(v))).placeholder("Create a component exchange").padding(9).width(Fill))
|
||||
.push(text("Uses actual function allocations. Compatible ports are reused only when unique; otherwise new ports are created. The preview includes both port allocations and exchange allocation.").size(12));
|
||||
if !reusing {
|
||||
body = body.push(
|
||||
checkbox(self.oriented)
|
||||
.label("Orient the new component exchange")
|
||||
.on_toggle(|v| event(Event::Oriented(v))),
|
||||
);
|
||||
for source in [true, false] {
|
||||
let selected = if source {
|
||||
&self.source_port
|
||||
} else {
|
||||
&self.target_port
|
||||
};
|
||||
let mut choices = self.component_port_choices(p, source);
|
||||
if !choices.contains(selected) {
|
||||
choices.push(selected.clone());
|
||||
}
|
||||
body = body
|
||||
.push(
|
||||
text(if source {
|
||||
"Source component port"
|
||||
} else {
|
||||
"Target component port"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
pick_list(choices, Some(selected.clone()), move |v| {
|
||||
event(Event::Endpoint(source, v))
|
||||
})
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
self.action,
|
||||
Action::Resolve | Action::Split | Action::Synchronize
|
||||
) {
|
||||
body = body.push(text("Item suggestions combine existing declarations, incident exchanges and any preserved interface membership. Review the effective contract below.").size(12));
|
||||
}
|
||||
if self.action == Action::Split {
|
||||
body = body.push(
|
||||
checkbox(self.primary_input)
|
||||
.label("Keep the original identity as the input port")
|
||||
.on_toggle(|v| event(Event::Primary(v))),
|
||||
);
|
||||
}
|
||||
for input in [true, false] {
|
||||
if self.action != Action::Split
|
||||
&& (input != (self.kind == ContractKind::Input)
|
||||
|| !matches!(self.kind, ContractKind::Input | ContractKind::Output))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let selected = if input {
|
||||
&self.incoming
|
||||
} else {
|
||||
&self.outgoing
|
||||
};
|
||||
body = body.push(
|
||||
text(if input {
|
||||
"Incoming exchange items"
|
||||
} else {
|
||||
"Outgoing exchange items"
|
||||
})
|
||||
.size(14),
|
||||
);
|
||||
let items: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == Layer::Sa && e.kind() == Kind::ExchangeItem)
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
body = body.push(
|
||||
text("No exchange items yet. An empty contract carries no items.").size(12),
|
||||
);
|
||||
}
|
||||
for item in items {
|
||||
let id = item.id;
|
||||
body = body.push(
|
||||
checkbox(selected.contains(&id))
|
||||
.label(format!("{} · @{}", item.name, item.alias))
|
||||
.on_toggle(move |enabled| event(Event::Item(id, input, enabled))),
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(self.action, Action::Resolve | Action::Split) {
|
||||
body = body.push(text("The original identity, prose, artifacts, constraints, traces and preserved legacy association stay on the primary port. Only functional-exchange endpoints are rerouted by splitting.").size(12));
|
||||
}
|
||||
if matches!(
|
||||
self.action,
|
||||
Action::Resolve | Action::Split | Action::Synchronize
|
||||
) {
|
||||
for description in port_preview::port_context_rows(p, self.subject) {
|
||||
body = body.push(
|
||||
text(description)
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(text::Wrapping::WordOrGlyph),
|
||||
);
|
||||
}
|
||||
}
|
||||
body = body.push(text("Proposed model changes").size(15));
|
||||
match &self.preview {
|
||||
Ok(commands) => {
|
||||
for description in port_preview::change_rows(p, commands) {
|
||||
body = body.push(
|
||||
text(description)
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(text::Wrapping::WordOrGlyph),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
body = body.push(
|
||||
text(error)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
)
|
||||
}
|
||||
}
|
||||
column![
|
||||
scrollable(body).height(480),
|
||||
row![
|
||||
button(text("Cancel"))
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(workflow::Event::Cancel)),
|
||||
button(text("Apply reviewed change")).on_press_maybe(
|
||||
self.preview
|
||||
.as_ref()
|
||||
.is_ok_and(|v| !v.is_empty())
|
||||
.then_some(Message::Workflow(workflow::Event::Apply))
|
||||
)
|
||||
]
|
||||
.spacing(10)
|
||||
]
|
||||
.spacing(16)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn component_port_choices(&self, p: &Project, source: bool) -> Vec<workflow::EndpointChoice> {
|
||||
let mut choices = vec![
|
||||
workflow::automatic_port(),
|
||||
workflow::EndpointChoice {
|
||||
choice: PortChoice::Create,
|
||||
label: "Create a new component port".into(),
|
||||
},
|
||||
];
|
||||
let Some(fe) = p.relations.get(&self.subject) else {
|
||||
return choices;
|
||||
};
|
||||
let owner = actual_performer(p, if source { fe.source() } else { fe.target() });
|
||||
let desired = if self.kind == ContractKind::Standard {
|
||||
ComponentPortKind::Standard
|
||||
} else {
|
||||
ComponentPortKind::Flow
|
||||
};
|
||||
choices.extend(p.elements.values().filter(|e| e.parent == owner && e.layer() == Layer::Sa).filter(|e| matches!(port_contract(e), Some(contract @ PortContract::Component { kind, .. }) if *kind == desired && component_allows(contract, source))).map(|e| workflow::EndpointChoice { choice: PortChoice::Reuse(e.id), label: format!("Reuse {} · @{}", e.name, e.alias) }));
|
||||
choices
|
||||
}
|
||||
}
|
||||
fn event(event: Event) -> Message {
|
||||
Message::Workflow(workflow::Event::Port(event))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::App;
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "Rescue system", None)
|
||||
.unwrap();
|
||||
let actor = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Operator", None)
|
||||
.unwrap();
|
||||
let output = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Report incident", None)
|
||||
.unwrap();
|
||||
let input = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Assess incident", None)
|
||||
.unwrap();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Sa, "Incident report", None)
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "Reports", actor, output);
|
||||
p.relate(RelationKind::Allocation, "Assesses", system, input);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
for id in [actor, system, output, input] {
|
||||
p.place(diagram, id, Position { x: 60., y: 60. });
|
||||
}
|
||||
let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
(p, output, input, item)
|
||||
}
|
||||
#[test]
|
||||
fn port_creation_keeps_owner_diagram_and_unfolds_in_the_same_undo() {
|
||||
for folded in [false, true] {
|
||||
let (mut p, output, _, _) = fixture();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa)
|
||||
.unwrap()
|
||||
.id;
|
||||
let performer = actual_performer(&p, output).unwrap();
|
||||
let container = p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == performer)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&container)
|
||||
.unwrap()
|
||||
.collapsed = folded;
|
||||
let other = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
other,
|
||||
Diagram {
|
||||
id: other,
|
||||
alias: "empty_overview".into(),
|
||||
name: "Empty overview".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Overview,
|
||||
nodes: Default::default(),
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(p.clone());
|
||||
send(&mut app, Message::Select(output));
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
send(&mut app, Message::PortAction(Action::Output));
|
||||
send(&mut app, Message::Workflow(workflow::Event::Apply));
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert!(
|
||||
projection::project_view(app.project(), diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|port| port.element == app.selected)
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&p,
|
||||
"Port creation and owner reveal are one undo step"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn synchronization_uses_active_contract_instead_of_preserved_direction() {
|
||||
let (mut p, output, _, item) = fixture();
|
||||
let id = Id::new_v4();
|
||||
let mut port = make_port(
|
||||
&p,
|
||||
id,
|
||||
output,
|
||||
"Resolved output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
|
||||
*legacy = Some(LegacyPortContract {
|
||||
direction: PortDirection::In,
|
||||
interface: None,
|
||||
});
|
||||
}
|
||||
p.elements.insert(id, port);
|
||||
let mut state = State::new(&p, id, Action::Synchronize).unwrap();
|
||||
assert_eq!(state.kind, ContractKind::Output);
|
||||
assert_eq!(state.outgoing, BTreeSet::from([item]));
|
||||
assert!(
|
||||
state.preview.as_ref().unwrap().is_empty(),
|
||||
"Unchanged sync disables Apply"
|
||||
);
|
||||
let candidate = state.update(&p, workflow::Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
candidate, p,
|
||||
"Opening and applying sync must not erase the active contract"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn graphical_function_exchange_and_component_generation_commit_and_undo_whole_contracts() {
|
||||
let (p, output, input, item) = fixture();
|
||||
let mut app = App::with_project(p.clone());
|
||||
send(&mut app, Message::Select(output));
|
||||
send(
|
||||
&mut app,
|
||||
Message::AddRelationKind(RelationKind::FunctionalExchange, false),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Workflow(workflow::Event::Target(Choice {
|
||||
id: input,
|
||||
label: "Assess incident".into(),
|
||||
})),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Workflow(workflow::Event::ExchangeItem(item, true)),
|
||||
);
|
||||
assert_eq!(app.project(), &p);
|
||||
send(&mut app, Message::Workflow(workflow::Event::Apply));
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
let exchange = app.selected;
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} = &app.project().relations[&exchange].body
|
||||
else {
|
||||
panic!("FE selected");
|
||||
};
|
||||
assert_eq!(items, &[item]);
|
||||
assert_eq!(app.project().elements[source_port].parent, Some(output));
|
||||
assert_eq!(app.project().elements[target_port].parent, Some(input));
|
||||
assert_eq!(
|
||||
port_contract(&app.project().elements[source_port]),
|
||||
Some(&PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item]
|
||||
})
|
||||
);
|
||||
let before_generation = app.project().clone();
|
||||
send(&mut app, Message::PortAction(Action::Generate));
|
||||
send(&mut app, event(Event::Kind(ContractKind::Standard)));
|
||||
assert_eq!(app.project(), &before_generation);
|
||||
send(&mut app, Message::Workflow(workflow::Event::Apply));
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
let boundary = app.selected;
|
||||
assert!(matches!(
|
||||
app.project().relations[&boundary].body,
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::PortAllocation)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::ExchangeAllocation)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(app.project())).unwrap(),
|
||||
*app.project()
|
||||
);
|
||||
let generated = app.project().clone();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before_generation);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &p);
|
||||
send(&mut app, Message::Redo);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &generated);
|
||||
send(&mut app, Message::Select(exchange));
|
||||
send(&mut app, Message::PortAction(Action::Generate));
|
||||
send(
|
||||
&mut app,
|
||||
event(Event::Boundary(Choice {
|
||||
id: boundary,
|
||||
label: "Reuse implementation".into(),
|
||||
})),
|
||||
);
|
||||
send(&mut app, Message::Workflow(workflow::Event::Apply));
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&generated,
|
||||
"Repeated generation duplicates no facts"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn creating_typed_port_and_rejecting_stale_preview_preserve_drafts_and_identity() {
|
||||
let (p, output, _, item) = fixture();
|
||||
let mut app = App::with_project(p.clone());
|
||||
send(&mut app, Message::Select(output));
|
||||
send(&mut app, Message::PortAction(Action::Output));
|
||||
send(&mut app, event(Event::Name("Incident output".into())));
|
||||
send(&mut app, event(Event::Item(item, false, true)));
|
||||
send(&mut app, Message::Workflow(workflow::Event::Apply));
|
||||
let port = app.selected;
|
||||
assert_eq!(app.project().elements[&port].parent, Some(output));
|
||||
assert_eq!(
|
||||
port_contract(&app.project().elements[&port]),
|
||||
Some(&PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item]
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
app.draft.as_ref().unwrap().kind,
|
||||
crate::forms::RecordKind::Element
|
||||
));
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &p);
|
||||
let mut draft = State::new(&p, output, Action::Output).unwrap();
|
||||
let changed =
|
||||
execute_batch(&p, &[Command::Rename(output, "Changed elsewhere".into())]).unwrap();
|
||||
assert!(
|
||||
draft
|
||||
.update(&changed, workflow::Event::Apply)
|
||||
.unwrap_err()
|
||||
.contains("model changed")
|
||||
);
|
||||
assert!(!changed.elements.contains_key(&draft.focus()));
|
||||
assert!(actions(&p, p.id).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn legacy_resolution_preserves_original_record_and_applies_as_one_undo() {
|
||||
let decoded = persistence::decode_with_report(include_bytes!(
|
||||
"../../oa-core/tests/fixtures/v2-coastal-rescue.json"
|
||||
))
|
||||
.unwrap();
|
||||
let original = decoded.project;
|
||||
let port = original
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| {
|
||||
e.layer() == Layer::Sa
|
||||
&& actions(&original, e.id).contains(&Action::Resolve)
|
||||
&& e.parent
|
||||
.and_then(|id| original.elements.get(&id))
|
||||
.is_some_and(|owner| owner.kind() == Kind::Function)
|
||||
})
|
||||
.unwrap()
|
||||
.id;
|
||||
let old = original.elements[&port].clone();
|
||||
let mut app = App::with_project(original.clone());
|
||||
send(&mut app, Message::Select(port));
|
||||
send(&mut app, Message::PortAction(Action::Resolve));
|
||||
send(&mut app, Message::Workflow(workflow::Event::Apply));
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
let resolved = &app.project().elements[&port];
|
||||
assert_ne!(port_contract(resolved), Some(&PortContract::Legacy));
|
||||
assert_eq!(
|
||||
(
|
||||
&resolved.alias,
|
||||
&resolved.name,
|
||||
&resolved.description,
|
||||
resolved.parent
|
||||
),
|
||||
(&old.alias, &old.name, &old.description, old.parent)
|
||||
);
|
||||
let legacy = |e: &oa_core::Element| match e.shared().unwrap() {
|
||||
SharedBody::Port { legacy, .. } => legacy.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(legacy(resolved), legacy(&old));
|
||||
assert_eq!(
|
||||
language::parse(&language::format(app.project())).unwrap(),
|
||||
*app.project()
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &original);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,19 @@ fn fixture() -> Project {
|
||||
p.relate(RelationKind::Allocation, "Performs", owner, work);
|
||||
p
|
||||
}
|
||||
#[test]
|
||||
fn system_only_models_open_the_populated_perspective() {
|
||||
let mut p = Project::empty();
|
||||
p.add_element(Kind::Function, Layer::Sa, "Receive report", None)
|
||||
.unwrap();
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
let app = App::with_project(p);
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.create_kind, Kind::Function);
|
||||
assert_eq!(App::with_project(Project::empty()).layer, Layer::Oa);
|
||||
assert_eq!(App::with_project(Project::demo()).layer, Layer::Oa);
|
||||
}
|
||||
fn source(app: &mut App, text: &str) {
|
||||
send(app, Message::Page(Page::Text));
|
||||
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
@@ -42,7 +55,10 @@ fn opening_and_creating_models_preserve_the_actual_window_size_for_fit() {
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some((
|
||||
std::path::PathBuf::from("resized-model.arcadia"),
|
||||
project.clone(),
|
||||
persistence::DecodeResult {
|
||||
project: project.clone(),
|
||||
migration: None,
|
||||
},
|
||||
std::sync::Arc::from(language::format(&project).into_bytes()),
|
||||
)))),
|
||||
);
|
||||
@@ -75,6 +91,31 @@ fn every_demo_record_has_a_lossless_graphical_draft() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
|
||||
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(
|
||||
include_bytes!("../../oa-core/tests/fixtures/v2-coastal-rescue.json").as_slice(),
|
||||
);
|
||||
let decoded = persistence::decode_with_report(&bytes).unwrap();
|
||||
let original = decoded.project.clone();
|
||||
let report = decoded.migration.clone().unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
send(
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some((
|
||||
std::path::PathBuf::from("legacy.json"),
|
||||
decoded,
|
||||
bytes.clone(),
|
||||
)))),
|
||||
);
|
||||
assert_eq!(app.migration, Some(report));
|
||||
assert_eq!(app.page, Page::Validation);
|
||||
assert_eq!(app.project(), &original);
|
||||
assert!(!app.dirty());
|
||||
assert!(app.source_snapshot.starts_with("arcadia 3;"));
|
||||
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_uses_measured_canvas_bounds_and_preserves_them_on_open() {
|
||||
let p = fixture();
|
||||
@@ -85,7 +126,10 @@ fn fit_uses_measured_canvas_bounds_and_preserves_them_on_open() {
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some((
|
||||
std::path::PathBuf::from("measured.arcadia"),
|
||||
p.clone(),
|
||||
persistence::DecodeResult {
|
||||
project: p.clone(),
|
||||
migration: None,
|
||||
},
|
||||
std::sync::Arc::from(language::format(&p).into_bytes()),
|
||||
)))),
|
||||
);
|
||||
@@ -1135,9 +1179,28 @@ fn nested_allocation_insertion_reflows_sibling_subtrees_and_preserves_fold_state
|
||||
let b_work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "B work", None)
|
||||
.unwrap();
|
||||
let b_port = p
|
||||
.add_element(Kind::Port, Layer::Oa, "B boundary", Some(b))
|
||||
.unwrap();
|
||||
// A preserved OA boundary remains explicit Legacy content in schema3.
|
||||
let b_port = Id::new_v4();
|
||||
p.elements.insert(
|
||||
b_port,
|
||||
oa_core::Element {
|
||||
id: b_port,
|
||||
alias: "b_boundary".into(),
|
||||
name: "B boundary".into(),
|
||||
description: String::new(),
|
||||
parent: Some(b),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Oa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
p.relate(RelationKind::Allocation, "A performs", a, a_work);
|
||||
p.relate(RelationKind::Allocation, "B performs", b, b_work);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
|
||||
@@ -133,7 +133,7 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
let mut creation = column![label("CREATE ELEMENT")].spacing(8);
|
||||
let kinds = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.supports(app.layer))
|
||||
.filter(|k| k.supports(app.layer) && *k != Kind::Port)
|
||||
.collect::<Vec<_>>();
|
||||
creation = creation.push(
|
||||
pick_list(kinds, Some(app.create_kind), Message::CreateKind)
|
||||
@@ -646,6 +646,20 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
}
|
||||
let kind = p.elements.get(&app.selected).map(Element::kind);
|
||||
let port_actions = crate::port_workflow::actions(p, app.selected);
|
||||
if !port_actions.is_empty() {
|
||||
body = body.push(
|
||||
pick_list(
|
||||
port_actions,
|
||||
None::<crate::port_workflow::Action>,
|
||||
Message::PortAction,
|
||||
)
|
||||
.placeholder("Port and exchange actions…")
|
||||
.text_size(12)
|
||||
.padding(9)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
let contextual: &[(&str, RelationKind, bool)] = match kind {
|
||||
Some(Kind::OperationalMission | Kind::Mission) => &[
|
||||
(
|
||||
@@ -1122,6 +1136,40 @@ fn checks(app: &App) -> View<'_, Message> {
|
||||
"Structural validity and engineering completeness, with direct navigation."
|
||||
)]
|
||||
.spacing(15);
|
||||
if let Some(report) = &app.migration {
|
||||
let mut card = column![text(format!("Imported schema {} → {}", report.from_schema, report.to_schema)).size(19),
|
||||
label("Import preserves the original file. Saving explicitly writes the current schema; legacy associations remain available for review.")].spacing(8);
|
||||
for port in &report.preserved_ports {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!("Preserved port · @{}", port.alias),
|
||||
Message::Select(port.id),
|
||||
))
|
||||
.push(label(&port.required_resolution));
|
||||
}
|
||||
for endpoint in &report.generated_endpoints {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!("Created {} endpoint · @{}", endpoint.side, endpoint.alias),
|
||||
Message::Select(endpoint.port),
|
||||
))
|
||||
.push(label(format!(
|
||||
"Owner: {} · exchange: {}",
|
||||
app.project().named(endpoint.owner),
|
||||
app.project().named(endpoint.exchange)
|
||||
)));
|
||||
}
|
||||
for boundary in &report.orientation_defaults {
|
||||
card = card.push(action(
|
||||
&format!(
|
||||
"Preserved directed exchange · {}",
|
||||
app.project().named(boundary.exchange)
|
||||
),
|
||||
Message::Select(boundary.exchange),
|
||||
));
|
||||
}
|
||||
out = out.push(container(card).padding(18).style(style::card));
|
||||
}
|
||||
if issues.is_empty() {
|
||||
out = out.push(
|
||||
container(
|
||||
|
||||
@@ -6,7 +6,9 @@ use crate::{
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{Space, button, column, pick_list, row, scrollable, text, text_editor, text_input},
|
||||
widget::{
|
||||
Space, button, checkbox, column, pick_list, row, scrollable, text, text_editor, text_input,
|
||||
},
|
||||
};
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
@@ -25,6 +27,10 @@ impl std::fmt::Display for RelationAction {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Port(crate::port_workflow::Event),
|
||||
ExchangeItem(Id, bool),
|
||||
Endpoint(bool, EndpointChoice),
|
||||
Oriented(bool),
|
||||
Cancel,
|
||||
Apply,
|
||||
RelationName(String),
|
||||
@@ -39,7 +45,8 @@ pub enum Event {
|
||||
Preview,
|
||||
}
|
||||
pub enum Workflow {
|
||||
Relation(Relationship),
|
||||
Ports(Box<crate::port_workflow::State>),
|
||||
Relation(Box<Relationship>),
|
||||
ManualTrace {
|
||||
name: String,
|
||||
source: Choice,
|
||||
@@ -48,6 +55,7 @@ pub enum Workflow {
|
||||
Transition(Box<Transition>),
|
||||
}
|
||||
pub struct Relationship {
|
||||
id: Id,
|
||||
pub name: String,
|
||||
pub kind: RelationKind,
|
||||
pub source: Option<Choice>,
|
||||
@@ -56,6 +64,60 @@ pub struct Relationship {
|
||||
layer: Layer,
|
||||
sources: Vec<Choice>,
|
||||
targets: Vec<Choice>,
|
||||
items: Vec<Id>,
|
||||
source_port: EndpointChoice,
|
||||
target_port: EndpointChoice,
|
||||
oriented: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EndpointChoice {
|
||||
pub choice: PortChoice,
|
||||
pub label: String,
|
||||
}
|
||||
impl std::fmt::Display for EndpointChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
pub(crate) fn automatic_port() -> EndpointChoice {
|
||||
EndpointChoice {
|
||||
choice: PortChoice::Auto,
|
||||
label: "Automatic · reuse only if unique".into(),
|
||||
}
|
||||
}
|
||||
fn endpoint_choices(
|
||||
p: &Project,
|
||||
owner: Option<Id>,
|
||||
outgoing: bool,
|
||||
items: &[Id],
|
||||
) -> Vec<EndpointChoice> {
|
||||
let mut choices = vec![
|
||||
automatic_port(),
|
||||
EndpointChoice {
|
||||
choice: PortChoice::Create,
|
||||
label: "Create a new port".into(),
|
||||
},
|
||||
];
|
||||
choices.extend(
|
||||
p.elements
|
||||
.values()
|
||||
.filter(|e| e.parent == owner && e.layer() == Layer::Sa)
|
||||
.filter(|e| match port_contract(e) {
|
||||
Some(PortContract::FunctionInput { incoming_items }) if !outgoing => {
|
||||
items_satisfy(p, items, incoming_items)
|
||||
}
|
||||
Some(PortContract::FunctionOutput { outgoing_items }) if outgoing => {
|
||||
items_satisfy(p, items, outgoing_items)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.map(|e| EndpointChoice {
|
||||
choice: PortChoice::Reuse(e.id),
|
||||
label: format!("Reuse {} · @{}", e.name, e.alias),
|
||||
}),
|
||||
);
|
||||
choices
|
||||
}
|
||||
|
||||
impl Relationship {
|
||||
@@ -63,6 +125,12 @@ impl Relationship {
|
||||
if p.layer_of(id) != Some(self.layer) {
|
||||
return false;
|
||||
}
|
||||
if matches!(
|
||||
self.kind,
|
||||
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
|
||||
) {
|
||||
return validation::can_create_interface_role(p, id);
|
||||
}
|
||||
if self.kind == RelationKind::ExchangeAllocation {
|
||||
return p
|
||||
.relations
|
||||
@@ -91,16 +159,45 @@ impl Relationship {
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
self.source
|
||||
.as_ref()
|
||||
.is_some_and(|source| validation::can_connect(p, self.kind, source.id, *id))
|
||||
})
|
||||
.filter(|id| self.accepts_target(p, *id))
|
||||
.map(|id| choice(p, id))
|
||||
.collect();
|
||||
self.targets.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
}
|
||||
|
||||
fn accepts_target(&self, p: &Project, target: Id) -> bool {
|
||||
let Some(source) = self.source.as_ref().map(|source| source.id) else {
|
||||
return false;
|
||||
};
|
||||
let directed = validation::can_connect(p, self.kind, source, target);
|
||||
if self.kind != RelationKind::BoundaryExchange || self.oriented || directed {
|
||||
return directed;
|
||||
}
|
||||
if ![source, target].iter().all(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|element| element.kind() == Kind::Port)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
// An unoriented connection may use the reverse stored endpoint order.
|
||||
// Ask the exact core rules, including preserved Legacy restrictions;
|
||||
// reversing can_connect alone would incorrectly relax those rules.
|
||||
let candidate = Relation {
|
||||
id: self.id,
|
||||
alias: p.fresh_alias("connection", "relation", self.id),
|
||||
name: "Connection".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
},
|
||||
};
|
||||
execute_batch(p, &[Command::CreateRelation(candidate)]).is_ok()
|
||||
}
|
||||
|
||||
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
|
||||
if !self.name.trim().is_empty() {
|
||||
return self.name.clone();
|
||||
@@ -118,6 +215,64 @@ impl Relationship {
|
||||
};
|
||||
format!("{} {verb} {}", p.named(source), p.named(target))
|
||||
}
|
||||
fn default_orientation(&mut self, p: &Project) {
|
||||
self.oriented = !self
|
||||
.source
|
||||
.as_ref()
|
||||
.and_then(|v| p.elements.get(&v.id))
|
||||
.is_some_and(|e| {
|
||||
matches!(
|
||||
port_contract(e),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
)
|
||||
});
|
||||
}
|
||||
fn commands(&self, p: &Project) -> Result<Vec<Command>, String> {
|
||||
let a = self.source.as_ref().ok_or("Choose a source.")?.id;
|
||||
let b = self.target.as_ref().ok_or("Choose a target.")?.id;
|
||||
if !self.accepts_source(p, a) {
|
||||
return Err("Choose an eligible source for this relationship.".into());
|
||||
}
|
||||
let id = self.id;
|
||||
let name = self.engineering_name(p, a, b);
|
||||
if self.kind == RelationKind::FunctionalExchange {
|
||||
return plan_functional_exchange(
|
||||
p,
|
||||
&FunctionalExchangeRequest {
|
||||
id,
|
||||
alias: p.fresh_alias(&name, "exchange", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
source: a,
|
||||
target: b,
|
||||
source_port: self.source_port.choice,
|
||||
target_port: self.target_port.choice,
|
||||
items: self.items.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
let mut body = RelationBody::new(self.kind, a, b);
|
||||
if let RelationBody::CapabilityExtend { condition, .. } = &mut body {
|
||||
*condition = self.condition.text();
|
||||
}
|
||||
if let RelationBody::BoundaryExchange { oriented, .. } = &mut body {
|
||||
*oriented = self.oriented;
|
||||
}
|
||||
let commands = vec![Command::CreateRelation(Relation {
|
||||
id,
|
||||
alias: p.fresh_alias(&name, "relation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body,
|
||||
})];
|
||||
execute_batch(p, &commands)
|
||||
.map(|_| commands)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
pub struct Transition {
|
||||
pub request: TransitionRequest,
|
||||
@@ -158,6 +313,7 @@ impl Workflow {
|
||||
incoming: bool,
|
||||
) -> Self {
|
||||
let mut draft = Relationship {
|
||||
id: Id::new_v4(),
|
||||
name: String::new(),
|
||||
kind,
|
||||
source: (!incoming).then(|| choice(p, selected)),
|
||||
@@ -166,6 +322,10 @@ impl Workflow {
|
||||
layer,
|
||||
sources: vec![],
|
||||
targets: vec![],
|
||||
items: vec![],
|
||||
source_port: automatic_port(),
|
||||
target_port: automatic_port(),
|
||||
oriented: true,
|
||||
};
|
||||
if draft
|
||||
.source
|
||||
@@ -174,8 +334,9 @@ impl Workflow {
|
||||
{
|
||||
draft.source = None;
|
||||
}
|
||||
draft.default_orientation(p);
|
||||
draft.refresh_choices(p);
|
||||
Self::Relation(draft)
|
||||
Self::Relation(Box::new(draft))
|
||||
}
|
||||
pub fn transition(p: &Project, selected: Id) -> Result<Self, String> {
|
||||
let layer = p
|
||||
@@ -195,6 +356,7 @@ impl Workflow {
|
||||
/// choices and previews never mutate the Session.
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
|
||||
match self {
|
||||
Self::Ports(state) => return state.update(p, event),
|
||||
Self::ManualTrace {
|
||||
name,
|
||||
source,
|
||||
@@ -233,6 +395,25 @@ impl Workflow {
|
||||
_ => {}
|
||||
},
|
||||
Self::Relation(d) => match event {
|
||||
Event::Oriented(value) => {
|
||||
d.oriented = value;
|
||||
d.refresh_choices(p);
|
||||
}
|
||||
Event::ExchangeItem(id, enabled) => {
|
||||
if enabled && !d.items.contains(&id) {
|
||||
d.items.push(id);
|
||||
}
|
||||
if !enabled {
|
||||
d.items.retain(|item| *item != id);
|
||||
}
|
||||
}
|
||||
Event::Endpoint(source, choice) => {
|
||||
if source {
|
||||
d.source_port = choice;
|
||||
} else {
|
||||
d.target_port = choice;
|
||||
}
|
||||
}
|
||||
Event::RelationName(name) => d.name = name,
|
||||
Event::RelationKind(kind) => {
|
||||
d.kind = kind;
|
||||
@@ -243,18 +424,16 @@ impl Workflow {
|
||||
{
|
||||
d.source = None;
|
||||
}
|
||||
d.default_orientation(p);
|
||||
d.refresh_choices(p);
|
||||
}
|
||||
Event::Source(source) => {
|
||||
d.source = Some(source);
|
||||
if d.target.as_ref().is_some_and(|target| {
|
||||
!validation::can_connect(
|
||||
p,
|
||||
d.kind,
|
||||
d.source.as_ref().unwrap().id,
|
||||
target.id,
|
||||
)
|
||||
}) {
|
||||
d.default_orientation(p);
|
||||
if d.target
|
||||
.as_ref()
|
||||
.is_some_and(|target| !d.accepts_target(p, target.id))
|
||||
{
|
||||
d.target = None;
|
||||
}
|
||||
d.refresh_choices(p);
|
||||
@@ -262,22 +441,7 @@ impl Workflow {
|
||||
Event::Target(target) => d.target = Some(target),
|
||||
Event::Condition(action) => d.condition.perform(action),
|
||||
Event::Apply => {
|
||||
let a = d.source.as_ref().ok_or("Choose a source.")?.id;
|
||||
let b = d.target.as_ref().ok_or("Choose a target.")?.id;
|
||||
let id = Id::new_v4();
|
||||
let name = d.engineering_name(p, a, b);
|
||||
let mut body = RelationBody::new(d.kind, a, b);
|
||||
if let RelationBody::CapabilityExtend { condition, .. } = &mut body {
|
||||
*condition = d.condition.text();
|
||||
}
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: p.fresh_alias(&name, "relation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body,
|
||||
};
|
||||
return execute_batch(p, &[Command::CreateRelation(relation)])
|
||||
return execute_batch(p, &d.commands(p)?)
|
||||
.map(Some)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
@@ -430,6 +594,7 @@ impl Workflow {
|
||||
}
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ports(state) => state.title(),
|
||||
Self::ManualTrace { .. } => "Link existing counterparts",
|
||||
Self::Relation(_) => "Create relationship",
|
||||
Self::Transition(t) => {
|
||||
@@ -442,8 +607,17 @@ impl Workflow {
|
||||
}
|
||||
}
|
||||
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
|
||||
if let Self::Ports(state) = self {
|
||||
return state.view(p);
|
||||
}
|
||||
let mut content = column![text(self.title()).size(23)].spacing(14);
|
||||
let relation_plan = if let Self::Relation(d) = self {
|
||||
Some(d.commands(p))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match self {
|
||||
Self::Ports(_) => unreachable!("Port dialog returned above"),
|
||||
Self::ManualTrace {
|
||||
name,
|
||||
source,
|
||||
@@ -535,6 +709,54 @@ impl Workflow {
|
||||
.padding(9),
|
||||
);
|
||||
}
|
||||
if d.kind == RelationKind::BoundaryExchange {
|
||||
content = content.push(
|
||||
checkbox(d.oriented)
|
||||
.label("Oriented component exchange")
|
||||
.on_toggle(|v| Message::Workflow(Event::Oriented(v))),
|
||||
);
|
||||
}
|
||||
if d.kind == RelationKind::FunctionalExchange {
|
||||
content = content.push(text("Function ports and carried items").size(14))
|
||||
.push(text("Automatic uses one compatible typed port, or creates a new one. Creating this exchange and its ports is one undoable change.").size(12));
|
||||
for source in [true, false] {
|
||||
let owner = if source { &d.source } else { &d.target };
|
||||
let selected = if source {
|
||||
&d.source_port
|
||||
} else {
|
||||
&d.target_port
|
||||
};
|
||||
let mut options =
|
||||
endpoint_choices(p, owner.as_ref().map(|v| v.id), source, &d.items);
|
||||
if !options.contains(selected) {
|
||||
options.push(selected.clone());
|
||||
}
|
||||
content = content
|
||||
.push(text(if source { "Output port" } else { "Input port" }).size(12))
|
||||
.push(
|
||||
pick_list(options, Some(selected.clone()), move |v| {
|
||||
Message::Workflow(Event::Endpoint(source, v))
|
||||
})
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
for item in p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == d.layer && e.kind() == Kind::ExchangeItem)
|
||||
{
|
||||
let id = item.id;
|
||||
content = content.push(
|
||||
checkbox(d.items.contains(&id))
|
||||
.label(format!("{} · @{}", item.name, item.alias))
|
||||
.on_toggle(move |enabled| {
|
||||
Message::Workflow(Event::ExchangeItem(id, enabled))
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
content = content.push(
|
||||
text_input("Name (generated from endpoints when blank)", &d.name)
|
||||
.on_input(|v| Message::Workflow(Event::RelationName(v)))
|
||||
@@ -858,14 +1080,18 @@ impl Workflow {
|
||||
}
|
||||
}
|
||||
let enabled = match self {
|
||||
Self::Ports(_) => false,
|
||||
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
|
||||
Self::Relation(d) => d
|
||||
.source
|
||||
.as_ref()
|
||||
.zip(d.target.as_ref())
|
||||
.is_some_and(|(a, b)| validation::can_connect(p, d.kind, a.id, b.id)),
|
||||
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
|
||||
Self::Transition(d) => d.plan.conflicts.is_empty(),
|
||||
};
|
||||
if let Some(Err(error)) = relation_plan {
|
||||
content = content.push(
|
||||
text(error)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
column![
|
||||
scrollable(content.padding(iced::Padding {
|
||||
right: 14.,
|
||||
@@ -898,3 +1124,217 @@ fn choice(p: &Project, id: Id) -> Choice {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod port_authoring_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn boundary_picker_recomputes_for_unoriented_reverse_endpoints_without_relaxing_contracts() {
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let actor = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let mut create = |owner, name, contract| {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(&p, id, owner, name, contract).unwrap();
|
||||
p.elements.insert(id, port);
|
||||
id
|
||||
};
|
||||
let flow = |orientation| PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
};
|
||||
let input = create(system, "Receive", flow(ComponentPortOrientation::In));
|
||||
let output = create(actor, "Send", flow(ComponentPortOrientation::Out));
|
||||
let incompatible = create(actor, "Other input", flow(ComponentPortOrientation::In));
|
||||
let same_owner = create(system, "Local output", flow(ComponentPortOrientation::Out));
|
||||
let standard = create(
|
||||
actor,
|
||||
"Standard",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
);
|
||||
let legacy = create(actor, "Preserved output", PortContract::Legacy);
|
||||
if let Some(SharedBody::Port { legacy, .. }) =
|
||||
p.elements.get_mut(&legacy).unwrap().shared_mut()
|
||||
{
|
||||
*legacy = Some(LegacyPortContract {
|
||||
direction: PortDirection::Out,
|
||||
interface: None,
|
||||
});
|
||||
}
|
||||
let before = p.clone();
|
||||
let mut workflow = Workflow::relationship_kind(
|
||||
&p,
|
||||
input,
|
||||
Layer::Sa,
|
||||
RelationKind::BoundaryExchange,
|
||||
false,
|
||||
);
|
||||
let Workflow::Relation(draft) = &workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(!draft.targets.iter().any(|choice| choice.id == output));
|
||||
workflow.update(&p, Event::Oriented(false)).unwrap();
|
||||
let Workflow::Relation(draft) = &workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(draft.targets.iter().any(|choice| choice.id == output));
|
||||
for invalid in [input, incompatible, same_owner, standard, legacy] {
|
||||
assert!(
|
||||
!draft.targets.iter().any(|choice| choice.id == invalid),
|
||||
"Rejected endpoint {invalid}"
|
||||
);
|
||||
}
|
||||
workflow
|
||||
.update(&p, Event::Target(choice(&p, output)))
|
||||
.unwrap();
|
||||
let Workflow::Relation(draft) = &workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(draft.commands(&p).is_ok());
|
||||
workflow.update(&p, Event::Oriented(true)).unwrap();
|
||||
let Workflow::Relation(draft) = &workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
draft.target.as_ref().unwrap().id,
|
||||
output,
|
||||
"Keep the selected identity available for correction"
|
||||
);
|
||||
assert!(!draft.targets.iter().any(|choice| choice.id == output));
|
||||
assert!(
|
||||
draft.commands(&p).is_err(),
|
||||
"The actual Apply gate rejects the incompatible draft"
|
||||
);
|
||||
assert!(workflow.update(&p, Event::Apply).is_err());
|
||||
assert_eq!(p, before);
|
||||
workflow.update(&p, Event::Oriented(false)).unwrap();
|
||||
let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert!(accepted.relations.values().any(|r| matches!(r.body, RelationBody::BoundaryExchange { source, target, oriented: false, .. } if source == input && target == output)));
|
||||
}
|
||||
#[test]
|
||||
fn fresh_functions_are_actual_picker_targets_and_plan_enables_apply() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Source", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Target", None)
|
||||
.unwrap();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Outside layer", None)
|
||||
.unwrap();
|
||||
let mut w =
|
||||
Workflow::relationship_kind(&p, a, Layer::Sa, RelationKind::FunctionalExchange, false);
|
||||
let Workflow::Relation(d) = &w else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(d.targets.iter().any(|v| v.id == b));
|
||||
assert!(!d.targets.iter().any(|v| v.id == a || v.id == oa));
|
||||
assert!(d.commands(&p).is_err());
|
||||
w.update(&p, Event::Target(choice(&p, b))).unwrap();
|
||||
let Workflow::Relation(d) = &w else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
d.commands(&p).is_ok(),
|
||||
"The same plan enables the actual Apply control"
|
||||
);
|
||||
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
accepted
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let invalid = Relation {
|
||||
id: Id::new_v4(),
|
||||
alias: "unbound".into(),
|
||||
name: "Unbound".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::FunctionalExchange, a, b),
|
||||
};
|
||||
assert!(
|
||||
execute_batch(&p, &[Command::CreateRelation(invalid)]).is_err(),
|
||||
"Exact records remain strict"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn ordinary_standard_connections_default_unoriented_and_new_roles_exclude_legacy() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let f = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Function", None)
|
||||
.unwrap();
|
||||
let interface = p
|
||||
.add_element(Kind::Interface, Layer::Sa, "Interface", None)
|
||||
.unwrap();
|
||||
let mut ids = vec![];
|
||||
for owner in [a, b] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Standard",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
ids.push(id);
|
||||
}
|
||||
let legacy = Id::new_v4();
|
||||
let mut port = make_port(&p, legacy, f, "Preserved", PortContract::Legacy).unwrap();
|
||||
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
|
||||
*legacy = Some(LegacyPortContract {
|
||||
direction: PortDirection::In,
|
||||
interface: Some(interface),
|
||||
});
|
||||
}
|
||||
p.elements.insert(legacy, port);
|
||||
let mut w = Workflow::relationship_kind(
|
||||
&p,
|
||||
ids[0],
|
||||
Layer::Sa,
|
||||
RelationKind::BoundaryExchange,
|
||||
false,
|
||||
);
|
||||
w.update(&p, Event::Target(choice(&p, ids[1]))).unwrap();
|
||||
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert!(accepted.relations.values().any(|r| matches!(
|
||||
r.body,
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
)));
|
||||
for kind in [
|
||||
RelationKind::InterfaceProvision,
|
||||
RelationKind::InterfaceRequirement,
|
||||
] {
|
||||
let w = Workflow::relationship_kind(&p, legacy, Layer::Sa, kind, false);
|
||||
let Workflow::Relation(d) = w else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(d.source.is_none());
|
||||
assert!(!d.sources.iter().any(|v| v.id == legacy));
|
||||
assert!(d.sources.iter().any(|v| v.id == ids[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user