Integrate typed port authoring and reviewable exchange workflows
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user