Integrate typed port authoring and reviewable exchange workflows

This commit is contained in:
2026-09-05 20:00:21 +03:00
parent 2dd17aa693
commit fa096cefd3
21 changed files with 6449 additions and 2154 deletions
+199 -29
View File
@@ -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)]
+189 -32
View File
@@ -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
View File
@@ -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]
);
}
+820
View File
@@ -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)
)
);
}
}
+2
View File
@@ -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)]
+958
View File
@@ -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."]
);
}
}
+921
View File
@@ -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);
}
}
+68 -5
View File
@@ -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);
+49 -1
View File
@@ -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(
+477 -37
View File
@@ -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]));
}
}
}