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
+6 -1
View File
@@ -654,7 +654,12 @@ pub fn plan_function_port_items(
));
}
}
finish(p, vec![Command::EditElement(port, e)])
let commands = if p.elements.get(&port) == Some(&e) {
vec![]
} else {
vec![Command::EditElement(port, e)]
};
finish(p, commands)
}
/// Whether removing these allocation facts breaks a typed exchange mapping.
+22 -1
View File
@@ -2245,8 +2245,29 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option<Id
}),
}
}
/// Uses the same relation rules as committed edits, without cloning a project.
/// Sources for new interface roles; preserved legacy associations remain readable.
pub fn can_create_interface_role(p: &Project, source: Id) -> bool {
p.elements.get(&source).is_some_and(|e| {
e.kind().is_performer()
|| matches!(
crate::ports::port_contract(e),
Some(PortContract::Component { .. })
)
})
}
/// Feasibility of creating a relationship with the default authoring contract.
/// An FE builder creates its typed ports; validating an exact unbound FE here
/// would incorrectly hide every target. Exact records still require endpoints.
pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> bool {
if kind == RelationKind::FunctionalExchange {
return source != target
&& [source, target].iter().all(|id| {
p.elements
.get(id)
.is_some_and(|e| e.layer() == Layer::Sa && e.kind() == Kind::Function)
});
}
if kind == RelationKind::Allocation
&& p.relations
.values()
+7
View File
@@ -93,6 +93,13 @@ fn functional_creation_reuse_and_item_sync_are_atomic_and_identity_safe() {
wrong.source_port = PortChoice::Reuse(input);
assert!(plan_functional_exchange(&p, &wrong).is_err());
assert!(plan_function_port_items(&p, output, vec![]).is_err());
for port in [input, output] {
assert!(
plan_function_port_items(&p, port, vec![item])
.unwrap()
.is_empty()
);
}
assert_eq!(session.project(), &p);
assert!(ElementBody::new(Kind::Port, Layer::Sa).is_none());
}
+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]));
}
}
}
File diff suppressed because it is too large Load Diff
+1201 -1154
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -4,7 +4,7 @@
Deliver the first release of Arcadia Studio for system engineers: complete Operational Analysis and System Analysis/System Architecture workflows, equivalent graphical and textual models, clear artifact traceability and a deployable/programmatically accessible Rust service. Logical/Physical Architecture and later Arcadia layers are future releases, as the new objective explicitly states.
The authoritative objective is the updated attachment ending 20fca018-f8ce-4cc0-86e7-8ead5d325630/goal-objective.md. It retains the first-release scope and adds explicit modular source/include, drag-selection and directly editable behavior-diagram requirements. The accepted 0.2.0 prototype is the baseline, not proof of this release's completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and its existing binary archives remain in dist until replacement passes acceptance.
The authoritative objective is attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md. It retains first-release scope and source/include, drag-selection and direct behavior editing requirements, and strengthens domain coherence, alternative evaluation and target-user task evidence. TASK_LEDGER.md separates given/verified facts, assumptions and open candidates; STATE.md records progress. The accepted0.2.0 prototype is the baseline, not proof of release completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and existing archives remain in dist until replacement passes acceptance.
## Existing architecture and retained decisions
@@ -16,8 +16,12 @@ The current forms are a closed typed-record adapter, not a second metamodel. Exp
Current priority within milestone2: address the user's diagram/port correction using `release-diagram-port-correction.md`. Separate engineering views, actual owner-bound port geometry and full functional/component-port semantics precede further breadth. Existing R5 state authoring is preserved; it still needs native verification.
User's current routing correction adds a first-class diagram requirement: editable connection bend points with automatic obstacle-aware starting routes. Finish the active port native checkpoint, then prioritize one persisted routing slice (create/move/remove bend, reset automatic, undo/reopen, text/API/SVG equality) alongside source-authoring work. Route identity belongs to the diagram representation and must not alter semantic relation endpoints or ownership. Direct state gestures remain required and should reuse the resulting geometry/routing primitives only where semantics match.
Updated priority: fix drag selection and repeated selection immediately; complete the active isolated port/schema3 implementation. Then make behavior diagrams directly editable using shared persisted geometry and ordinary commands, and replace the single huge source editing experience with a modular project/include workflow. These are release requirements, not future limitations. Source projects must preserve identity, precise per-file diagnostics, Git-friendly ownership of declarations, cycle/missing/duplicate include failures, safe local include resolution and GUI/API parity. Behavior acceptance requires actual canvas movement and contextual creation/editing, not only selection plus external menus. Freeze concrete interfaces after read-only exploration before changing the shared schema.
Current uncertainty-reduction work: evaluate aggregate files/includes versus nested semantic syntax using three engineer text tasks and an independent critic; do not assume smaller files alone solve readability. Pure virtual-bundle compilation and declaration-preserving updates provide an experiment and reusable foundation. Direct behavior editing first tests a state-machine task (create in region, drag, draw/reconnect transition, edit guard, undo/reopen) before propagating the interaction design to scenarios/chains. Compare successful task completion, semantic clarity, mode switches and recoverability, not control counts.
1. **Release coverage audit and interfaces.** Compare actual core/UI/API with official Capella OA/SA semantics and the new objective. Freeze a coverage table and concrete variant/reference/transition contracts before editing shared interfaces. Distinguish unsupported later layers from missing OA/SA workflows; V2 exclusions are reconsidered. Native audit exercises onboarding/help/authoring at supported sizes. Independent audits have separate file ownership.
2. **Complete engineering semantics.** Fill verified gaps in capability relationships and mission involvement; operational process/functional-chain function/control topology; scenario/state/data constructs needed by OA/SA engineers. Add typed semantic-content drift and reviewed existing-counterpart reconciliation, including reference mapping and explicit dependency/ownership decisions. Every addition has a usable graphical authoring workflow, equivalent source/API representation, meaningful reject/undo/delete/reidentify/transition tests and a worked example. Exact additions and invariants are recorded in the coverage/interface artifact from milestone1, avoiding speculative LA/PA concepts.
3. **Approachable authoring and help.** Replace static help with searchable topic navigation, concise concepts, numbered OA→SA tutorials, related topics and direct workspace actions. Add a focused starting workspace, recent models and practical creation guidance. Improve source authoring with asynchronous versioned lint, semantic warnings, diagnostic navigation and find/go-to tools. Make selection/navigation and hidden/placed model distinctions clear. Add recoverable local checkpoints for unsaved accepted models and drafts; preserve explicit file-save/conflict behavior.
+50 -40
View File
@@ -1,69 +1,79 @@
# First release state — hierarchy and port correction
# Progress Ledger — first release
## Objective and evidence
## Objective and current phase
Active first-release goal: complete OA + SA/System Architecture, Rust/Iced Windows/Linux, equivalent graphical/source editing, reST contract, traceability, REST and built-in guidance. Objective attachment20fca018-f8ce-4cc0-86e7-8ead5d325630, read fully. V2 is the preserved baseline, not release acceptance. PLAN.md and release-semantics-audit.md retain the full scope.
Authoritative objective attachment07aee762-7d27-4cbe-81c3-2e82c7170711, read fully2026-09-05. Deliver coherent OA+SA/System Architecture for engineers with equivalent graphical/text/REST modeling, hierarchy, traceability, offline semantics/help and Windows/Linux release evidence. TASK_LEDGER.md distinguishes facts, assumptions and candidate designs; PLAN.md and release-semantics-audit.md retain scope. Goal is active, not complete.
User prioritizes Capella-like hierarchical architecture and correct ports. release-diagram-port-correction.md records the correction; release-port-contract.md is being finalized before implementation. No release completion claim.
Current phase: integrate and evaluate typed ports; test modular source-project foundation and compare human-editable language designs. Direct behavior canvas editing and remaining first-release work remain required.
## Implemented and verified
## Verified milestones
- R1 explicit process/chain occurrences and guarded control topology; R2 missions/capabilities; R4 typed scenarios/messages/reuse; R5 state/event semantics, nested regions and pseudostates. Exact contracts/reviews are in the corresponding release-*.md reports. R1/R2/R4 independent semantic findings closed before this checkpoint.
- Desktop contextual scenario/state/process authoring; typed property adapter; searchable offline guide; asynchronous bounded source lint with stale-result rejection; UTF-8 Find/Go to line. State authoring tests pass, but R4/R5 complete native authoring acceptance remains pending.
- Architecture, functional-dataflow and capability projections separated. Demo now has six focused views. Shared projection preserves allocation/containment, calculates owner-bound selectable ports, and feeds canvas, Fit, SVG and movement. Generic port metamodel is still pending correction.
- Git initialized on codex/release1-engineering; source baseline commit93ba83d. No remote publication. User authorizes worktrees. Current post-review changes are uncommitted.
- Baseline full locked core/desktop/server tests passed (desktop64; server1unit+17HTTP); strict workspace Clippy passed at baseline. Post-review core4 projection regressions pass, including missing work-parent fallback and direct-versus-inferred allocation distinction.
- Git branch codex/release1-engineering. Baseline93ba83d, port contractc20a207, hierarchyc7101b9, drag/native evidence2ef16dc, typed core/source/API merge757ab88. No remote publication.
- R1 process/chain topology, R2 missions/capabilities, R4 typed scenarios, R5 states/events and their contextual forms exist. Corresponding release reports retain exact validation/review evidence; complete native R4/R5 authoring acceptance remains pending.
- c7101b9 distinguishes allocation summaries from actual assignments, preserves nested geometry/folding/ports, focused architecture/dataflow/capability views, canvas/SVG labels, and reproducible demo generation. Full locked core+desktop+server checks passed then (desktop70, server1unit+17HTTP), strict workspace Clippy/build passed.
- Drag fix2ef16dc retains semantic selection after MoveNode and permits same-card reselection without losing identity drafts. Desktop71tests+strict desktop Clippy passed before port integration. Actual native drag of an initially unselected nested activity, same-card click and name edit/apply succeeded at1440x900.
- Typed port/schema3 commits0d14438+b9f88ce merged757ab88. Merge retained main footer/reflow/allocation_summary with new anchor sides. Frozen V2 readers preserve originals as Legacy; missing FE endpoints are deterministic/reportable. Port/source/API handoffs document scoped tests, not desktop acceptance.
- Root current focused desktop tests:3port workflow tests pass (FE items/endpoints; Standard component generation and allocation; undo/redo/idempotence; creation/stale preview; Legacy resolution),4diagram regressions pass, migration-report opening test passes. Forms worker17focused tests pass. These are narrower than full current release verification.
## Current changes and ownership
## Active implementation and independent review
- Root owns core projection/session/regressions, desktop views.rs, docs, integration and native CUA. Fixes: omitted work parent falls back to actual performer; port reveal unfolds owner ancestry; component-port footer and larger root corridors; inferred allocation summary metadata/inspector explanation.
- hierarchy_port_review owns ONLY desktop app.rs, diagram.rs, tests.rs for insertion/reflow fixes and canvas/SVG summary distinction. Earlier full desktop run67/68 failed: folding selects placement ID instead of semantic owner, causing subsequent child creation rejection. Worker fixes root cause and nested sibling reflow; do not claim current tests all pass. Other reviewed fixes: projected child drag preview, creation routing from capability views, unfolded insertion path, SVG labels/stereotypes.
- port_contract_design owns ONLY release-port-contract.md now; adopted design separates FunctionInput/Output item contracts, Component Standard/Flow and independent orientation, PortAllocation, required FE endpoint IDs, atomic generation/reuse and explicit lossless V2→3 migration. Isolated core implementation worktree follows contract freeze.
- capella_startup_diagnosis read-only complete: healthy original Capella pid14556/window1049956; redundant graphical splash pid7048/window395654 hung in native splash update. No deletion/config workaround indicated.
- Root owns desktop integration except completed forms worker changes: port_workflow.rs, workflow.rs, app.rs, views.rs, diagram.rs, main.rs, tests.rs. Contextual typed creation/generation/resolution/split/sync; FE item/endpoint selectors; independent glyph/orientation; load/source migration report. Generic Port creation removed from ordinary picker. Added component endpoint choices and SVG contract regression most recently; rerun tests after these edits. Formatting/strict full checks and native port acceptance pending.
- Core reviewer found alias reservation and same-Batch PortAllocation replacement deleting ExchangeAllocation. Fixes in ports.rs/references.rs/session.rs/release_ports.rs;62focused tests reported pass. Pending deletion IDs handle Reidentify/EditRelation and explicit deletion/recreation. Root reviewed diff and is independently running13port tests plus exact-file commit; inspect process8944 outcome before any restart/commit. Clippy was blocked by concurrent source refactor, not reported passed.
- Forms worker released forms.rs and forms/port_fields.rs after17tests, formatting and no forms Clippy findings. Invalid contract drafts/current references are retained; typed endpoint/role choices and retained legacy display added. Uncommitted for aggregate desktop milestone.
- hierarchy_port_review owns pure source_project.rs, language.rs seams, lib.rs export, source-project tests, docs/language.rst and handoff. SourceBundle has virtual paths/files; compile returns stable-ID bundle/model/index/located warnings. Aggregate limits wrap existing ParseLimits. Formatter/update implementation/tests underway; no desktop/filesystem/server bundle work yet. Temporary warnings must disappear with completion.
- Fresh source_usability_critic compares aggregate files, nested syntax and another alternative through engineering tasks, criteria and primary evidence. No edits/native input.
- Fresh port_workflow_review independently reviews desktop contracts/workflows/selection/glyphs/complexity. No edits/native input. Retrieve findings before milestone acceptance.
## Native verification and current pause
## Native evidence and pause
Computer Use through node_repl @oai/sky, skill26.901.41600. Root exclusively owns input. Most recent attempt reported physical Escape stop before inventory; CUA paused, no further native input without user continuation. Code work can proceed. Existing persistent handles must be revalidated on resume.
Native CUA is root-only via @oai/sky. Last call reported physical Escape and explicitly stopped native use. Automatic goal continuation resumes code/research/tests, not native input. Wait for user native continuation and re-inventory windows before acting.
Studio checkpoint D:/CodexBuilds/Capella2-release1-01a06e2e/ui-hierarchy-checkpoint/arcadia-studio.exe, SHA256 D6055F659754E9159232483EAF9FA595C1D7F53683F8FB618D855F04B9C931A5, last window723086,1440×900. Personally exercised OA/SA layer changes, nested participants/work, auto function/component port appearance and function-output selection. Found label overlap; footer/corridor/alias fixes are newer than this executable and need native recheck. No unsaved edits in this checkpoint.
Current last Studio checkpoint D:/CodexBuilds/Capella2-release1-01a06e2e/ui-hierarchy-reviewed/arcadia-studio.exe SHA25630FA95CA9C606B20EAA5B4FA08AE04EB47CCB1A2498C05C0C26F2B049F006BE5 (predates typed integration), last main527250 and Save As1706878. Unsaved moved/renamed activity may remain. Save was NOT verified: helper returned16x16 primary capture plus full related modal, failed UIA/bounds and later user-input/Escape guards. Do not discard/overwrite or claim drag-selection-ui.arcadia exists. Earlier Studio windows disappeared from inventory. Default layout verified; current minimum resize not verified.
Healthy original Capella has the disposable In-Flight Entertainment sample open. Root reached System Analysis workflow and expanded System Architecture diagrams; SAB port workflow not yet exercised. Previously observed six workflows are recorded in release-capella-workflow-benchmark.md. Root startup diagnosis now supersedes old unavailable-window notes.
Original Capella healthy app capellac.exe, last window527162, disposable workspace D:/CodexBuilds/Capella2-release1-01a06e2e/capella-workspace. SAB native FE creation automatically made FOP/FIP; component exchange made Flow/Out CP1. Standard/Flow matching, interface roles, allocated ports and function item declarations observed. Allocated-port chooser opened but no allocation committed; original sample remains unsaved. Detailed performed/observed distinction in release-capella-workflow-benchmark.md.
Preserved actual native authoring fixtures: ui-r2-checkpoint/capability-extension-ui.arcadia (conditional extension); ui-r1-r4-checkpoint/process-branch-ui.arcadia (five control links and guarded alternatives), both under the release build directory. Find/Enter/F3/CtrlG and1100×700 layout previously exercised. New process Fit, scenario/state authoring, source focus refinements and latest diagram geometry still require native verification.
Preserved earlier native fixtures: ui-r2-checkpoint/capability-extension-ui.arcadia; ui-r1-r4-checkpoint/process-branch-ui.arcadia. Prior source Find/Enter/F3/CtrlG and1100x700 verification apply to their earlier checkpoints, not current integration.
## Exact next actions
## Highest-value next actions
1. Finish nested insertion/reflow and summary display; run focused then full current tests/Clippy. Regenerate distributable examples with current focused diagrams, preserving historical V2 fixtures. Update docs and commit reviewed hierarchy milestone.
2. Freeze port contract and create isolated Git worktree for core/schema3 implementation. Root integrates typed desktop actions, forms, projection glyphs and API/source parity. Verify migration, deletion/undo, OA↔SA mapping and item/provider roles with independent review.
3. Resume native Capella SAB/port study and Studio default/minimum authoring verification when CUA is resumed; fix from observed evidence.
4. Continue remaining first-release requirements: R6 data, R3 semantic drift/mapped reconciliation, persistent filters/coverage, Home/recent/recovery, optional hosted ETag editing, updated examples/help/reST/API, independent final reviews, Windows/Linux checks and packages.
1. Poll live tests/agents; fix material review findings. Complete typed desktop full tests, formatting/strict Clippy, regenerate current examples (historical V2 goldens untouched), build fresh checkpoint and commit evidence-qualified milestone. Native acceptance remains pending until explicit resume.
2. Finish pure bundle experiment, evaluate human/AI editability alternatives, freeze source contract from evidence. Implement per-file editor/tree/diagnostics/undo, recoverable multi-file Save All/conflicts and REST virtual bundles. Test graph/text ownership/comment preservation, includes, IDs, limits and failures.
3. Adopt/test persisted behavior context and direct gestures: state region creation/move/connect/reconnect/guard, scenarios lifeline/message manipulation, process contribution topology. No UI-only durable geometry or menu-only substitute.
4. R6 data contracts, R3 semantic drift/reconciliation, persistent filters/coverage, Home/recent/recovery, optional hosted ETag workflows, examples/guide/reST/API and full independent cross-disciplinary release review remain. Follow scope, do not call missing functionality a limitation.
5. Final current Windows/Linux build/run/native/service tests, MSRV, packages/checksums and reproducible instructions; only then completion.
## Environment and commands
## Environment
Windows Rust1.98.1, target D:/CodexBuilds/Capella2-01a06e2e. Set CARGO_TARGET_DIR to that path, then cargo test --locked -p oa-core -p oa-desktop -p oa-server; cargo clippy --locked --workspace --all-targets -- -D warnings; cargo build --locked -p oa-desktop. Copy build to a checkpoint path before native launch to avoid linker locks. WSLUbuntu24.04 setup in verification-v2-linux.md; final MSRV/platform verification remains.
Windows Rust1.98.1; CARGO_TARGET_DIR=D:/CodexBuilds/Capella2-01a06e2e. Commands: cargo test --locked -p oa-core -p oa-desktop -p oa-server; cargo clippy --locked --workspace --all-targets -- -D warnings; cargo build --locked -p oa-desktop; cargo run --locked -p oa-core --example generate_demo. Copy executable to a checkpoint before native launch. WSLUbuntu24.04 setup in verification-v2-linux.md; current final platforms/MSRV remain unverified. Git worktree D:/CodexWorktrees/Capella2-typed-ports retains committed port baseline, no active writers there.
Prior recursive target/debug deletion was rejected: do not retry/bypass. No disk blocker. Old agents hit usage limits but current agents work; no usage reset authorized or used. Goal remains active; previous short turn stopped on explicit CUA Escape and made no material implementation progress.
Prior recursive target/debug deletion was rejected; do not retry/bypass. No disk blocker. Temporary agent capacity errors can be retried on same agent; inspect authoritative live state before restarting work. No usage reset authorized/used.
## Current integration checkpoint, 2026-09-05
Progress audit: previous turn made concrete committed/tested/native progress before Escape; current turn adds meaningful invariant evidence and comparative design evaluation. The important unknowns are usability/coherence and remaining scope, not merely compiler status. No blocked or completion claim.
User resumed CUA explicitly; it is working. Current original Capella window527162 maximized2560x1392. Root opened [SAB] High Level System Overview with some FCs, inspected function output port FOP5 properties (Outgoing Exchange Items, Realized Function Output Ports), created FunctionalExchange38 between Provide Navigation Data and Determine Operating Profiles by palette/source/target. It automatically created output FOP1 and input FIP3; Semantic Browser verifies ownership and actual endpoint identities. Allocation Management had no available component exchange; cancelled. Separate propagation/synchronization commands observed in Modeling Accelerators. Component-port creation/properties is next.
## 2026-09-05 resumed checkpoint
Hierarchy worker released app.rs/diagram.rs/tests.rs after fixing semantic selection lost on folding, nested sibling/ancestor reflow including folded hidden descendants, and canvas/SVG inferred-summary distinction. Root full locked core+desktop+server tests session6598 passed (desktop70, server1unit+17HTTP); strict workspace Clippy and desktop build session38990 passed. Demo source/JSON regenerated from a single model with matching IDs and successful load checks; old V2 fixtures untouched. New maintained core example generate_demo provides reproducible regeneration. Guide and README reflect focused view names/summary semantics.
User explicitly resumed Computer Use. Fresh inventory: Studio and prior Save As absent; old unsaved rename/drag fixture was never verified saved. Original Capella still open in its pending port-allocation chooser; native testing resumed with foreground activation and fresh captures. CUA returns owned modals outside main-window bounds, so reposition visible dialog before targeting off-window controls; the first UIA allocation action failed bounds and made no assignment.
Latest native checkpoint ready: D:/CodexBuilds/Capella2-release1-01a06e2e/ui-hierarchy-reviewed/arcadia-studio.exe SHA2563603AC957B6E16B4321F7FB7E759B3A2265357679DB8F27DD9ED5642B7E02179. Not launched yet. Old Studio window723086 has dirty marker, so inspect and preserve any unsaved changes before closing (do not infer it is safe from the earlier no-edit state).
Core alias/batch replacement fixes committed e41b8de after root13port regressions passed. Source bundle foundation committed06b3384; worker150core tests and strict core Clippy passed (20new regressions). Lossless token updates are now the next bounded source task; nested syntax remains a separate measured experiment.
Typed-port work isolated at D:/CodexWorktrees/Capella2-typed-ports, branch codex/typed-port-contracts, from c20a207. port_contract_design owns core model/ports/validation/references/session/transitions/demo/projection + non-language tests; capella_startup_diagnosis now owns persistence/migration/language/server/API/reST + source/migration tests. They coordinate shared schema but never edit the same files. Root main remains buildable during their implementation. Temporary model-capacity errors were retried on the same agents; both resumed. Full core port/schema3 acceptance pending.
Independent desktop review found six concrete issues: FE target/Apply eligibility, port owner-diagram navigation/unfolding, active-vs-preserved direction during item synchronization, incomplete semantic preview, Standard exchange default orientation, and legacy role eligibility. All six now have fixes. Root full desktop suite93tests passed; strict desktop Clippy and build passed. Logs desktop-port-preview-tests.log/desktop-port-clippy.log/desktop-port-build.log under D:/CodexBuilds/Capella2-release1-01a06e2e. Typed semantic preview wired from separately reviewed port_preview.rs (4embeddedtests).
## Updated objective and drag bug
Fresh native candidate: ui-typed-ports/arcadia-studio.exe SHA2565935489A66D445CEC2131FF3582BB96426BB3DFCE37DEC77E8189D8A2F2F44D5. Native acceptance not yet performed. Current examples regenerated by generate_demo and source/JSON equality verified; old migration goldens unchanged. Changes remain uncommitted pending native iteration.
Authoritative objective is now attachment20fca018-f8ce-4cc0-86e7-8ead5d325630 (read fully). It retains first-release scope and adds modular source projects/includes, stable drag/reselection, and directly editable behavior/scenario diagrams. Root fixes drag; hierarchy_port_review is read-only mapping direct behavior manipulation/persisted geometry interfaces. Active port work remains aligned.
Native typed-port trial now passed at1440x900 client: created Supplementary incident report function output carrying Digital distress report; selected port stayed in same allocation diagram; CtrlZ restored41objects/25links and CtrlShiftZ restored42objects/25links. Existing Distress reporting connection reuse shows explicit no-op and disables Apply. Generated a second implementation with Standard component ports: orientation defaults off; preview identifies both owners, item union, exact function-port/port-allocation/exchange-allocation references; apply yields44objects/29links, zero checks, empty-square Standard glyphs and no exchange arrow. Captures inspected directly. Preview's very long aliases approached/clipped the right edge; added bounded width and WordOrGlyph wrapping, increased preview text12→13; must rebuild/reverify this edit.
Root verified drag bug: MoveNode returned a placement identity, dropping semantic highlight; SelectNode returned early for that same placement. Handler now selects the moved semantic element and restores its property draft; same-card click from a clean placement inspector restores semantic selection. Dirty placement identity edits defer to gesture release so existing remap behavior survives. Focused repeated drag/reselection/property-edit/undo regression and existing placement-identity regression pass. Native build ui-hierarchy-reviewed replaced before first launch, current SHA25630FA95CA9C606B20EAA5B4FA08AE04EB47CCB1A2498C05C0C26F2B049F006BE5; native checking now.
Fresh focused review found two additional valid cases: unoriented Flow/In-source Boundary target filtering still used oriented=true; filtered-view stored port-owner placement could outrank a usable folded architecture. Reviewer owns workflow.rs/app.rs for those two fixes/regressions. Root does not edit these files until release.
Original Capella: created component exchange C1 between Aircraft and IFE System; it automatically made ComponentPorts. Source CP1 properties explicitly show provided/required interfaces, allocated/realized ports, Standard/Flow kind and Unset/In/Out/InOut direction; generated source is Flow/Out. Opened its allocated-port chooser, containing function ports under Aircraft-assigned functions. No port allocation committed yet; chooser remains open. Component Exchange tooltip requires same port kind; main contract amended to reject typed Standard/Flow mismatch, both workers notified. Unknown screenshot/foreground mismatch interrupted picker selection; no unchecked retry or guessed input. Capella later restored at1008×762 with modal partly outside main window; use full dialog screenshot only after fresh valid observation.
Save As for typed-port-authoring-ui.arcadia pending. Native helper returned16x16 primary plus full related dialog; UIA set_value failed cached-property retrieval, so save is not claimed. Current main5636752, SaveAs919908; reobserve before input. Main capture shows filename control at visible lower part of overhanging modal. User-input guard required a fresh capture. No overwrite/discard performed.
## Drag selection verified; typed-port integration next
Save recovery succeeded using the full related dialog screenshot (screenshots[1],1186×713), not the16×16primary. Clicked the actual filename, visually verified caret/selection, typed full dedicated path, clicked Save. Main now shows All changes saved and Project saved. Verified native artifact exists: D:/CodexBuilds/Capella2-release1-01a06e2e/ui-typed-ports/typed-port-authoring-ui.arcadia (35,699bytes), with supplementary output, Standard implementation ports, explicit two PortAllocations and ExchangeAllocation. User's intervening diagram moves were preserved. Native reopened-file verification still pending. This supersedes the pending-save note above; full-size related screenshot coordinate targeting is working in current helper, whereas some UIA fields still return wrong focus/cache errors.
Root full desktop71 tests and strict desktop all-targets Clippy passed (session60951). Reopened current SHA30FA95CA checkpoint through native CUA, fresh window527250. Dragged an initially unselected Report distress activity, clicked the same moved card, edited its name to Report distress at sea and applied: semantic highlight and properties remained correct throughout; containing participant expanded. Save As is pending, not verified: the helper targets a16×16 primary capture for modal1706878 alongside a1186×713 related screenshot and failed UIA set_value/coordinate operations; a later refresh detected user input. Do not claim the fixture exists, overwrite or discard unsaved changes. Both prior Studio windows were absent on fresh inventory, so old unsaved geometry is not assumed recoverable. Current default native layout verified; current minimum resize still pending.
## Routing requirement and additional native evidence
Port core0d14438 and migration/source/APIb9f88ce are committed on codex/typed-port-contracts, isolated worktree clean. Integrate together, retaining main hierarchy reflow/allocation_summary changes. Handoffs release-port-core.md and release-port-migration.md record actual tests and APIs. Core worker reported125 total core tests including9focused port tests; migration worker31focused core+20server tests and strict Clippy. Root integration checks, desktop typed controls/glyphs and native port acceptance remain pending. Migration worker now independently reviews core ports; hierarchy reviewer designs modular source projects. Direct behavior editing design reuses contextual Diagram placements; interface adoption/implementation remains ahead.
User explicitly requires customizable connection routing/bend points or sufficiently effective automatic routing to avoid overlap. PLAN/TASK_LEDGER updated; bounded route-design investigation now active before implementation. Port semantics remain the shared endpoint contract; routing must be per-diagram layout, with manual override, automatic starting route, source/API/SVG/history parity.
Minimum client1100x700 verified (capture1102x732): outermost right/bottom frame pixels permit resize, Fit brings nested diagram into canvas, inspector scrolls independently, port review modal and fixed Cancel/Apply fit. No clipping of controls; long alias wrapping fix still requires new build. Native no-op synchronization misleadingly enabled Apply; core planner now returns no commands for an unchanged contract.13core port regressions and focused desktop sync regression passed.
Fresh functions fixture was built with actual core (initial temporary fixture compile confused Option with Result, corrected; final run valid5elements/2allocations/noports). Native Open and SA selection succeeded atminimumsize. Actual +Relationship picker offered Receive incident despite no ports; choosing target enabled Apply; added Incident report item and applied. Result7objects/3links with2function ports and FE, zero checks. This directly verifies the UI eligibility defect beyond event tests. Undo/redo/save continuation follows. Opening SA-only source initially showed empty OA; root fixed initial perspective to populatedSA and added regression; full rerun pending.
Typed checkpoint verification: root observed complete locked workspace tests pass and desktop/server build finish (typed-port-workspace-tests.log, typed-port-final-build.log). Strict workspace Clippy previously passed. Updated executable still requires focused native recheck of preview wrapping, no-op synchronization and SA-only opening. Source-preserving core updates committed dbc8971 (160 core tests); isolated B writeback committed2dd17aa (23 experimental tests). Native routing trial in original Capella successfully inserted a bend by dragging an Oblique segment; benchmark records precise observed evidence. Routing implementation starts now, not yet accepted.
+49
View File
@@ -0,0 +1,49 @@
# Task Ledger — first release
Authoritative objective: attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md, read fully on2026-09-05. STATE.md records execution evidence; PLAN.md is the living strategy; DECISIONS.md and linked release contracts retain detailed rationale.
## Goal and success criteria
System engineers can develop a coherent OA→SA system model, including iterative changes and evidence, through equivalent graphical, textual and REST workflows. Rust/Iced Windows and Linux release, strict reST semantics, navigable offline concepts/tutorials, and reproducible build/run/evaluation instructions. Later Arcadia layers are future releases. Completion requires actual native workflows, automated invariants, and fresh cross-disciplinary acceptance, not compilation or a feature count.
## Given facts
- User prioritizes engineering usability and Capella domain fidelity, hierarchical containment/allocation displays, real function/component ports and intuitive transitions in both directions.
- Text-only model development must be practical; modular files/includes are the proposed answer to the current huge flat file.
- Drag selection must remain stable; behavior/scenario canvases must support direct editing consistent with architecture.
- Git and worktrees are authorized. Remote publication is not requested.
- Connection routes need explicit bend points/customization or sufficiently effective automatic routing. User observed straight connections prevent readable diagrams. Treat saved per-diagram routes and practical obstacle avoidance as a primary requirement, not visual decoration.
## Verified facts
- Native Capella SAB authoring creates function input/output endpoints with an FE. Component ports separately carry Standard/Flow kind, orientation, interface roles and explicit allocated function ports. Details and successfully performed versus merely observed operations are in release-capella-workflow-benchmark.md.
- Studio uses one typed Project and validated atomic commands across Rust core/desktop/server. Hierarchy projection distinguishes actual allocation from inferred parent summaries; IDs and placement are separate.
- Drag selection bug fixed and committed2ef16dc;71desktop tests and real moved-card selection/property editing passed before typed-port integration.
- Typed ports/schema3 migration merged757ab88. Existing port identities survive as Legacy and missing FE endpoints are generated deterministically; current migration and port tests provide scoped evidence in release-port-core.md/release-port-migration.md.
- Independent core review reproduced alias reservation and same-transaction allocation replacement defects; fixes have focused regression evidence. Desktop port integration has passing focused event/form tests; full checks and native acceptance are still pending.
- Current behavior diagrams derive geometry and only handle selection. Text editing currently flattens the accepted model. These remain product gaps regardless of underlying semantic coverage.
## Assumptions to test
- Aggregate-oriented source files will reduce cognitive load enough for routine engineering edits; file splitting alone may leave declaration syntax too flat.
- Behavior layout can reuse NodePlacement plus typed diagram context without duplicating semantic state; reconnection/reordering must preserve existing occurrence/relation identities.
- Editable per-diagram bends plus an automatic initial route can resolve realistic crossings; an automatic layout cannot guarantee an ideal readable arrangement for arbitrary models. Test manual control, nesting, ports and persisted/exported route parity.
- Source comments and ownership can survive graphical edits by replacing only changed declaration spans. Generated IDs must stabilize before subsequent text edits.
## Open questions
- Which combination of file organization, nesting syntax and navigation best supports realistic text-only engineering tasks? Independent candidate comparison is active.
- Can an engineer discover and complete typed exchange/allocation and legacy resolution workflows with acceptable effort at minimum window size? Native acceptance is pending after physical Escape.
- What remaining OA/SA gaps materially block target workflows? R3 semantic reconciliation, R6 data contracts, directly editable behavior, lifecycle/recovery and hosted editing remain in the release plan.
## Candidate designs and current decisions
- Source candidate A: retain explicit source3 declarations in semantic aggregate files with includes. Candidate B: nested owner/context syntax lowering to the same model. Candidate C: independent critic to propose a materially different alternative. Rank on domain clarity, task effort, identity/roundtrip safety, Git locality and grammar/implementation complexity. Current pure bundle implementation is a testable foundation, not proof that A alone satisfies usability.
- Behavior alternatives: independent UI-only coordinates, new behavior-layout schema, or existing diagram placements with explicit context. Prefer the last candidate provisionally: it preserves text/API parity and command history without a second geometry system. Validate on direct state-machine creation/move/reconnect before extending all behavior views.
- Ports: adopted typed separation based on official metamodel plus direct native evidence; rejected generic InOut/interface port conflation and inferred allocation facts. See release-port-contract.md. Revisit only with contradictory domain evidence or concrete usability failures.
## Current strategy and evaluators
Finish and independently review typed-port desktop integration; require creation/generation/split/undo/reopen invariants and native trials. In parallel, test pure source-bundle compilation/updates and independently evaluate text representations using representative engineering tasks. Then integrate per-file source editing, safe project saves and REST bundles against the selected design. Prototype persisted direct behavior gestures next. Continue remaining release semantics and cross-disciplinary acceptance; no subsystem checkpoint constitutes release completion.
Meaningful progress now: committed drag fix and native evidence, integrated typed core contracts, reproduced/fixed transaction defects, passing focused authoring tests, and a concrete source experiment. Highest uncertainty is source/behavior usability; more feature enumeration without task evidence would be a poor proxy.
@@ -107,3 +107,9 @@ Opened Send Audio Announcement's **FunctionOutputPort FOP5** properties: Outgoin
Created **C1** with Component Exchange between Aircraft and IFE System. Capella automatically created their ComponentPorts. The tool's tooltip requires matching port kinds and complementary Flow orientations. Source **CP1** properties show **Standard/Flow**, **Unset/In/Out/InOut**, Provided Interfaces, Required Interfaces, Allocated Ports and Realized Ports. The generated source is Flow/Out. Opened its Allocated Ports picker: available function ports belong to functions assigned to Aircraft (including the newly created Provide Navigation Data FOP1). No allocation was committed; the picker remains pending. Screenshot identity/foreground errors interrupted further input, so component-exchange generation accelerators and allocation completion are not claimed.
The adopted Studio port contract uses separate typed function and component variants, mandatory FE endpoints, independently oriented component exchanges, explicit port allocations and preserved Legacy migration. A typed Standard/Flow mismatch is rejected following the observed native restriction. This evidence does not itself establish Studio acceptance. Original sample changes remain unsaved in the disposable workspace; fresh inventory is required before continuing.
### Allocation completion after native resume (lead, 2026-09-05)
Resumed the existing CP1 chooser with fresh inventory/foreground capture. The native helper's UIA index selected **Provide Electrical Power / FOP1** (verified from the highlighted path) rather than the initially intended navigation output; this is a valid Aircraft-owned output and is the actual benchmark subject. Moved the overhanging dialog within the main window after a bounded-coordinate failure, transferred that output into the selected list, confirmed OK, inspected **Allocated Ports: FOP1**, and committed Finish. Flow/Out remained selected and both Provided/Required Interfaces stayed undefined. This successfully exercises explicit component-port allocation without deriving interface roles; no component-exchange allocation or accelerator is implied. Disposable Capella sample remains unsaved. Tool-captured screenshots inspected directly; no screenshot files stored.
2026-09-05 routing trial (native, disposable sample): selected Audio Signal FE, Semantic Browser confirmed Play Sound in Cabin/FOP1 to Listen to Audio Announcement/FIP1. Rectilinear corner drag produced no observed movement. Context menu exposes Remove Bend-points and Format > Line Style > Tree/Oblique/Rectilinear. Switched to Oblique, then dragged the diagonal segment (472,485) to (432,415); fresh capture showed a new explicit bend with unchanged semantic endpoints. Reference sample remains unsaved.
+190
View File
@@ -0,0 +1,190 @@
# Editable connection routing — first slice, 2026-09-05
Status: lead provisionally adopted the per-diagram route-list seam. This is a
design contract, not implemented or native-verified behavior. Typed-port work
is checkpointed before shared schema edits. State gesture design is preserved
separately in release-state-gesture-design.md.
## Problem, scope and chosen representation
Architecture canvas, hit testing and SVG independently regenerate a fixed cubic
between endpoints (`diagram.rs:149,616,670,712`). No edge geometry is stored.
Fit (`app.rs:1836`) and SVG bounds only include nodes. This prevents authored
detours and can conceal lines under cards/headers.
Use routing metadata keyed logically by (diagram, existing relation/trace ID).
One semantic edge is projected at most once per current diagram; the same edge
can have independent routing in different diagrams. A separately identified
EdgePlacement would add aliases, identity lifecycle and source declarations
without a current multiple-representation requirement. Reject desktop-only
coordinates because they lose text/API/save parity. A literal UUID-keyed map
needs a source reference-key grammar or special serializer; use a unique-keyed
list that fits existing typed records instead.
```rust
// All types derive Debug, Clone, PartialEq, Serialize, Deserialize.
// Records deny unknown fields; missing optional/default fields are accepted.
pub struct EdgeLabel {
pub fraction: f32, // [0, 1] of routed polyline arc length
pub offset: Position, // diagram-space displacement from that point
}
pub struct EdgeRoute {
pub edge: Id, // existing Relation or Trace, never a placement UUID
pub waypoints: Vec<Position>, // default []; pinned points in traversal order
pub label: Option<EdgeLabel>, // default None = automatic label placement
}
// Diagram gains: #[serde(default)] pub routes: Vec<EdgeRoute>;
Command::SetEdgeRoute { diagram: Id, route: EdgeRoute }
Command::RemoveEdgeRoute { diagram: Id, edge: Id }
```
Set upserts by edge, preserving unrelated route entries/order; Remove is
idempotent for an existing diagram/edge. Neither creates/reidentifies a semantic
object. An empty route with no label is equivalent to the automatic default;
UI uses Remove for Reset routing. Preserve explicit empty input during roundtrip.
List order has no routing meaning. Store only pinned points, not computed elbows,
endpoints, or generated label coordinates. Route edits remain one history entry.
```text
routes [
{ edge @report_exchange;
waypoints [ { x 420; y 260; }, { x 690; y 260; } ];
label { fraction 0.55; offset { x 0; y -16; } };
}
];
```
This is part of the ordinary diagram declaration. Add `edge` to language and
graphical reference catalogs; route points/label use existing typed numbers and
lists. Keep the diagram in its existing views source file. No second source
grammar or semantic relationship is introduced. Full Project JSON carries the
same list; existing POST `/api/v1/projects/{id}/transactions` accepts the two new
Command variants with existing conditional ETag behavior. Update OpenAPI and
normative reST. No new REST route is necessary.
## Shared deterministic projection and default router
Add a pure Rust core routing module, separate from semantic validation. It takes
Project, Diagram and the already projected nodes/ports/edges, returning for each
edge its full polyline, label anchor, effective pinned points and routing warnings.
Recommended seam: `route_diagram(p, d, view) -> RoutedView`, with RoutedEdge keyed
by edge ID and carrying `points: Vec<Position>`, `label: EdgeLabelGeometry` and
`warnings: Vec<RouteWarning>`. Return total bounds including paths/labels.
Desktop draw, edge/label hit tests, drag preview, Fit and SVG consume this result.
Delete the duplicate cubic and midpoint calculations after parity passes.
Default routing is orthogonal. Authored waypoints are hard points visited in
order; the router may add intermediate orthogonal elbows. Generated elbows are
not silently persisted. UI distinguishes pinned handles from computed elbows;
dragging an automatic segment/elbow pins the chosen point. Removing a pinned
point reroutes the adjacent spans; Reset removes all pins. Segment crossing and
shared runs are soft costs, node/header intersections are forbidden when a valid
route exists. Do not claim a guarantee of zero crossings for arbitrary graphs.
Use a bounded rectilinear visibility/grid search over endpoint stubs, obstacle
boundaries and pins, with Manhattan length plus bend penalty. Deterministic tie
breaking uses coordinates/direction and stable edge ID order, never random/hash
iteration. First try direct and one-/two-bend clear paths; search only as needed.
Reuse obstacle preparation within one routed view. Bound search work and report
a visible fallback warning instead of hanging or silently drawing through a card.
Cache by diagram/model revision and transient preview revision in the desktop;
pan/zoom alone does not change world geometry. Measure before adding a routing
framework or dependency.
## Endpoints, hierarchy and obstacles
Actual projected ports are hard endpoints. Use PortAnchorSide's outward normal
for a short orthogonal lead-out/lead-in (initial constants: 18 diagram units,
12 units clearance); a target is approached from outside along that normal.
Never move, invent or switch a function/component port to simplify routing.
Arrowheads use the final nonzero segment and existing `oriented` flag; Standard
unoriented exchanges stay unoriented. For non-port edges choose a clear side of
the projected owner rectangle, evaluating alternate sides if the cheapest is
blocked. Geometry never changes semantic direction or endpoint references.
Visible leaf cards and unrelated container interiors are obstacles. Ancestors of
either endpoint are traversable enclosures, but their header rectangles remain
obstacles; their children remain obstacles independently. The endpoint owner can
be crossed only by its own lead segment. A functional exchange may cross an
ancestor boundary without inventing a component port/allocation. Preserve the
current distinction between semantic containment and allocation projection.
If collapsing an ancestor promotes either endpoint to another visible node,
temporarily route that edge automatically. Preserve its complete stored route
and show that custom routing resumes on expansion; disable bend editing for
this promoted representation. This avoids old interior pins dragging a folded
edge through invisible child coordinates. Do not expose fictitious ports on the
collapsed container. Unrelated folding may reroute around the new obstacle but
does not suspend otherwise unchanged endpoint routes. An edge hidden because
both endpoints fold into one anchor retains its stored metadata.
## Movement, lifecycle and validation
Waypoints are absolute diagram coordinates. Moving one endpoint leaves pins in
place and reconnects the endpoint span automatically. When MoveNode moves a group
and both actual endpoint-owner placements belong to that group, translate all
its stored pins by the same delta, including hidden descendants. Label offset
stays a relative vector; arc-length anchoring moves the label with the route.
Use endpoint-owner placement lookup before visibility promotion, not the visible
collapsed anchor, to decide group membership. Other diagrams remain unchanged.
One shared pure placement-delta helper supplies this calculation to command
movement and desktop preview. Reflow currently emits multiple EditNode commands
(`diagram.rs:30`); append translated-route updates for edges whose endpoints move
by the same delta. Raw EditNode/text coordinate edits remain exact record edits
and do not implicitly move unrelated route points. Layout preserves pins; expose
Reset routing as an explicit separate action instead of erasing authored work.
Reconnect/port-contract edits preserve stored pins but compute fresh endpoints;
blocked pinned geometry is reported as a recoverable layout warning.
Deleting a relation/trace removes its routes from every diagram in the same
transaction. Reidentify remaps EdgeRoute.edge without changing coordinates.
Deleting a diagram naturally removes its route list. Hiding/relocating a node,
changing projection or folding does not delete route metadata; it resumes if the
same semantic edge is shown again. Source/API may store routes for currently
hidden edges, so visibility is not a validity requirement.
Structural validation rejects duplicate edge entries, references to nonexistent
or non-edge objects, nonfinite/out-of-range coordinates, invalid label fraction,
and excessive point counts. Use existing MAX_COORDINATE. Adopt 128 pinned points
per edge for this slice; count route records in admission totals and retain
existing aggregate payload/source limits. A point inside an obstacle or an
impossible path is a located layout warning, not semantic corruption or permission
to erase the point. Route defaults/missing fields must be the same in UI, source,
JSON and REST. V1/V2 historical readers/goldens stay frozen; migration produces
empty routes. Existing schema3 files without routes load with an empty list;
this is an additive extension of the unreleased schema3 model.
## UI slice, partition and acceptance
Select a connection to reveal route handles; double-click a segment or use a
visible Add bend action, drag a handle, select a handle and Remove bend, or Reset
routing. Drag the label separately. Endpoint handles are not bend handles and
cannot detach ports. Use fixed screen-space hit radii (approximately 8 px divided
by zoom), explicit nearest-hit precedence and segment distances from the actual
polyline. Node/label/handle hit ordering must work inside nested enclosures.
Capture the initiating diagram/revision; Escape cancels; release emits one command;
stale gestures or invalid source/property drafts cannot mutate a different model.
Core schema/lifecycle owns model.rs, session.rs, references.rs, validation.rs and
focused tests. Independent router owns new routing.rs plus geometry tests after
seam freeze. Source/API worker owns language/reST/OpenAPI and round-trip/HTTP
tests after schema freeze; coordinate with current source-project work. Lead owns
diagram.rs, app.rs, views/forms, actual CUA and integration. No concurrent writers
to shared files. Root checkpoints typed ports before this implementation begins.
Automated gates: independent routes for one edge in two views; add/move/remove and
one-step exact undo; deletion/reidentify; malformed/duplicate/huge inputs;
V1/V2/schema3-default loading; source/JSON/HTTP equivalence; obstacle clearance,
port normals, nested header avoidance, deterministic routing, promoted endpoints,
group movement and bounds. Test actual pointer gestures at 50%, 100% and 150%.
Test SVG polyline/arrow/label coordinates against shared routing output.
Native gate: a nested SA architecture with function and Standard/Flow component
ports, three participants and enough exchanges to force detours. Add/move/remove
pins, drag a label, move one endpoint and a whole container, fold/unfold, undo,
save/reopen and export SVG at 1440x900 and 1100x700. Inspect crossings, headers,
labels, handles and typed port attachment. Measure interactive latency on a
100-node/150-edge fixture before claiming smoothness; initial target is below
100 ms for settled routing and no multi-second stalls during drag. Record actual
times/results, not forecasts. None of these gates has been run for this design.
+87
View File
@@ -0,0 +1,87 @@
# Direct state editing — bounded design, 2026-09-05
Read-only investigation; implementation and native verification remain pending.
Connection routing became the user's next priority after this design handoff.
## Decision and first task
Reuse Diagram/NodePlacement and existing atomic commands. Prefer a typed
`ProjectionKind::StateMachine { machine: Id }` over desktop-only coordinates or
a second persisted behavior-layout store. Existing projection strings remain
valid. Proposed source: `projection StateMachine { machine @operating_modes; };`.
The context references a same-layer machine and scopes placements to its machine,
regions and vertices. Explicit creation initializes existing contents in one
transaction; rendering must not dirty a model. Missing placements remain legal
and discoverable through an elements-not-shown action.
Retain active behavior diagram identity independently of semantic selection;
selecting a root Event must not switch machines. Diagram context/layout are model
content; active tool, pointer and zoom remain editor state. Context participates
in reference remapping/deletion, language reference controls and OpenAPI.
The first native task is: create a region and Initial/State; drag a child and a
composite; draw a transition; reconnect its endpoint; edit its guard; undo/redo;
save/reopen and inspect the equivalent source/API records. This is the minimum
vertical slice, not acceptance of all behavior editing.
## Existing seams and implementation contract
- `behavior.rs:56,118,494`: selection-derived context, selection-only canvas.
- `behavior/state_scene.rs:24,163`: computed vertical geometry; retain its state
symbols, internal-transition rows and typed routing, but consume persisted
shared node geometry. Generic projection drops equal-anchored/self edges and
includes relationships unsuitable for a state diagram.
- `behavior/states.rs:73,191`: reuse existing typed transition controls and
candidate validation. Reconnection edits a clone of the original relation.
- `model.rs:1235,1247`, `projection.rs:104,184`, `session.rs:562`: reuse placement
identity, semantic nesting/folding and grouped movement.
Events carry diagram identity explicitly: CreateAt(diagram, parent, kind,
position), Move(diagram, node, position), Connect(diagram, source, target),
Reconnect(diagram, relation, endpoint, vertex), EditTransition(diagram, relation).
They lower to existing CreateElement+CreateNode Batch, MoveNode,
CreateRelation/EditRelation commands. No alternate mutation API is required.
Gesture state is Idle/Move/Connect/Reconnect with transient preview and a captured
revision. Release commits once; Escape, stale revision or context change cancels.
Allocate creation IDs once per accepted plan. Drawing/hit testing/preview consume
one scene. Extend app.rs:864's draft guard to every mutation gesture.
## Required invariants
Creation targets the explicitly highlighted semantic Region. Region creation
targets a machine or stable State/Mode. Dragging never reparents. Moving a
container moves all descendant placements once, including folded descendants,
only in that diagram. Child movement preserves ownership and siblings. Resize
constraints preserve child/header space and movement cannot misleadingly suggest
ownership by another region. Entry/Exit remain region-owned boundary symbols.
Reconnect preserves relation UUID, alias, name, prose, triggers/effects and
artifact references; validate the full replacement so the old relation is not
double-counted for cardinality. Do not silently change transition kind or erase
incompatible data. Internal remains a row/self contract; External self-loops and
Local edges remain distinct. Cross-immediate-region transitions can be valid;
cross-machine endpoints reject. Existing history, boundary and orthogonal rules
in state_validation.rs remain authoritative.
Reject invalid drafts without losing them. New tools must not discard unfinished
behavior drafts. Preserve semantic selection after movement and same-card
reselection. Context references remap with UUID changes; machine deletion removes
its dedicated views without deleting cross-layer counterparts. Source-created
unplaced elements remain discoverable; cross-machine reparenting must also handle
incompatible placements atomically or reject with an actionable diagnostic.
## Partition and evidence gates
Core/context owns model.rs, projection.rs, references.rs, validation.rs and focused
tests. After type freeze, representation owns language.rs, source-project reference
handling, reST/OpenAPI and round-trip/API tests. Desktop gestures own new
behavior/state_canvas.rs and state_scene.rs. Lead integrates behavior.rs,
states.rs, app.rs and diagram context controls. Avoid a general graph framework.
Test pointer events at non-100% zoom, one history entry per gesture, exact
identities, grouped movement limited to one view, atomic rejection, context
lifecycle and source/JSON/REST equality. Native trial at 1440x900 and 1100x700 uses
two regions, a nested composite and guarded transitions. Try invalid edges into
Initial, invalid history reconnection, Escape mid-drag, a folded composite,
root-Event selection and save/reopen. Record task completion, discoverability,
ownership clarity and recoverability; no such native result is claimed here.