1060 lines
40 KiB
Rust
1060 lines
40 KiB
Rust
//! 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, serde::Serialize, serde::Deserialize)]
|
|
pub enum Action {
|
|
Component,
|
|
Input,
|
|
Output,
|
|
Flow,
|
|
Standard,
|
|
Generate,
|
|
Resolve,
|
|
Split,
|
|
Synchronize,
|
|
}
|
|
impl Action {
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Component => "Add component port",
|
|
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::Component, 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, serde::Serialize, serde::Deserialize)]
|
|
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>,
|
|
}
|
|
#[derive(serde::Serialize, serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RecoveryState {
|
|
version: u32,
|
|
base: String,
|
|
action: Action,
|
|
subject: Id,
|
|
id: Id,
|
|
name: String,
|
|
kind: ContractKind,
|
|
orientation: ComponentPortOrientation,
|
|
oriented: bool,
|
|
boundary: Option<Id>,
|
|
source_port: PortChoice,
|
|
target_port: PortChoice,
|
|
incoming: BTreeSet<Id>,
|
|
outgoing: BTreeSet<Id>,
|
|
primary_input: bool,
|
|
}
|
|
#[cfg(test)]
|
|
mod recovery_tests {
|
|
use super::*;
|
|
#[test]
|
|
fn port_recovery_retains_authored_contract_and_identity_but_rebuilds_preview() {
|
|
let mut p = Project::empty();
|
|
let owner = p
|
|
.add_element(Kind::Function, Layer::Sa, "Receiver", None)
|
|
.unwrap();
|
|
let item = p
|
|
.add_element(Kind::ExchangeItem, Layer::Sa, "Payload", None)
|
|
.unwrap();
|
|
let mut state = State::new(&p, owner, Action::Input).unwrap();
|
|
state.name = " exact pending port ".into();
|
|
state.incoming.insert(item);
|
|
state.primary_input = false;
|
|
state.preview = Err("An old transient preview must not be restored".into());
|
|
let saved = state.recovery_snapshot().unwrap();
|
|
assert!(saved.get("preview").is_none());
|
|
let restored = State::from_recovery(&p, saved.clone()).unwrap();
|
|
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
|
assert_eq!(restored.id, state.id);
|
|
assert!(restored.preview.is_ok());
|
|
p.name.push('!');
|
|
assert!(State::from_recovery(&p, saved).is_err());
|
|
}
|
|
}
|
|
impl State {
|
|
pub fn recovery_snapshot(&self) -> Result<serde_json::Value, String> {
|
|
serde_json::to_value(RecoveryState {
|
|
version: 1,
|
|
base: transitions::project_fingerprint(&self.baseline),
|
|
action: self.action,
|
|
subject: self.subject,
|
|
id: self.id,
|
|
name: self.name.clone(),
|
|
kind: self.kind,
|
|
orientation: self.orientation,
|
|
oriented: self.oriented,
|
|
boundary: self.boundary.as_ref().map(|c| c.id),
|
|
source_port: self.source_port.choice,
|
|
target_port: self.target_port.choice,
|
|
incoming: self.incoming.clone(),
|
|
outgoing: self.outgoing.clone(),
|
|
primary_input: self.primary_input,
|
|
})
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
pub fn from_recovery(p: &Project, value: serde_json::Value) -> Result<Self, String> {
|
|
let saved: RecoveryState = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
|
if saved.version != 1 || saved.base != transitions::project_fingerprint(p) {
|
|
return Err(
|
|
"Port workflow recovery does not match the accepted model identity and revision."
|
|
.into(),
|
|
);
|
|
}
|
|
if saved.incoming.len().saturating_add(saved.outgoing.len())
|
|
> crate::source_workspace::LIMITS.model.max_records
|
|
{
|
|
return Err("Recovered port draft exceeds item limits.".into());
|
|
}
|
|
let mut state = Self::new(p, saved.subject, saved.action)?;
|
|
state.id = saved.id;
|
|
state.name = saved.name;
|
|
state.kind = saved.kind;
|
|
state.orientation = saved.orientation;
|
|
state.oriented = saved.oriented;
|
|
state.boundary = saved.boundary.map(|id| Choice {
|
|
id,
|
|
label: p.named(id).into(),
|
|
});
|
|
state.source_port = workflow::recovery::endpoint(p, saved.source_port);
|
|
state.target_port = workflow::recovery::endpoint(p, saved.target_port);
|
|
state.incoming = saved.incoming;
|
|
state.outgoing = saved.outgoing;
|
|
state.primary_input = saved.primary_input;
|
|
state.preview = state.plan(p);
|
|
Ok(state)
|
|
}
|
|
pub(crate) fn from_recovery_after_source5_upgrade(
|
|
p: &Project,
|
|
legacy_base: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<Self, String> {
|
|
let mut saved: RecoveryState = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
|
if saved.version != 1 || saved.base != legacy_base {
|
|
return Err("Port workflow recovery does not match the accepted source-5 model identity and revision.".into());
|
|
}
|
|
saved.base = transitions::project_fingerprint(p);
|
|
Self::from_recovery(p, serde_json::to_value(saved).map_err(|e| e.to_string())?)
|
|
}
|
|
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::Component | 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(interface) = old.and_then(|old| old.interface) {
|
|
let items = data::interface_items(p, interface);
|
|
incoming.extend(items.iter().copied());
|
|
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::Component {
|
|
"Boundary port".into()
|
|
} else 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::Component
|
|
| 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::Component | 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.action == Action::Component {
|
|
body = body.push(text("Standard ports describe provided or required interfaces. Flow ports describe directional input/output. Both can be endpoints of a boundary exchange.").size(13));
|
|
}
|
|
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(
|
|
if matches!(
|
|
self.action,
|
|
Action::Component
|
|
| Action::Flow
|
|
| Action::Standard
|
|
| Action::Input
|
|
| Action::Output
|
|
) {
|
|
"Create port"
|
|
} else {
|
|
"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,
|
|
routes: Vec::new(),
|
|
view_options: Default::default(),
|
|
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);
|
|
}
|
|
}
|