Files
ARCADIA-Studio/crates/oa-desktop/src/port_workflow.rs
T

1061 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,
layout: None,
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);
}
}