2010 lines
82 KiB
Rust
2010 lines
82 KiB
Rust
//! Explicit, reviewable relationship and cross-perspective operations.
|
|
use crate::{
|
|
app::{Choice, Message},
|
|
style,
|
|
views::short,
|
|
};
|
|
use iced::{
|
|
Element, Fill,
|
|
widget::{
|
|
Space, button, checkbox, column, pick_list, row, scrollable, text, text_editor, text_input,
|
|
},
|
|
};
|
|
use oa_core::{transitions::*, *};
|
|
use std::collections::BTreeSet;
|
|
mod communication;
|
|
pub(crate) mod recovery;
|
|
#[cfg(test)]
|
|
mod transition_tests;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Semantic(Box<crate::semantic_review::Event>),
|
|
Port(crate::port_workflow::Event),
|
|
ExchangeItem(Id, bool),
|
|
Endpoint(bool, EndpointChoice),
|
|
Oriented(bool),
|
|
Cancel,
|
|
Apply,
|
|
RelationName(String),
|
|
RelationKind(RelationKind),
|
|
Condition(text_editor::Action),
|
|
Source(Choice),
|
|
Target(Choice),
|
|
Scope(String),
|
|
Destination(Choice),
|
|
MappingTarget(Id, Choice),
|
|
MappingKind(Id, Kind),
|
|
CommunicationPort(Id, bool, communication::PortMenuChoice),
|
|
OperationOmission(Id, bool),
|
|
OperationRationale(Id, String),
|
|
Resolve(Id, TransitionField, FieldDecision),
|
|
Preview,
|
|
}
|
|
impl Event {
|
|
pub fn semantic(event: crate::semantic_review::Event) -> Self {
|
|
Self::Semantic(Box::new(event))
|
|
}
|
|
}
|
|
pub enum Workflow {
|
|
Semantic(Box<crate::semantic_review::State>),
|
|
Ports(Box<crate::port_workflow::State>),
|
|
Relation(Box<Relationship>),
|
|
ManualTrace {
|
|
base_fingerprint: String,
|
|
name: String,
|
|
source: Choice,
|
|
target: Option<Choice>,
|
|
},
|
|
Transition(Box<Transition>),
|
|
}
|
|
pub struct Relationship {
|
|
base_fingerprint: String,
|
|
id: Id,
|
|
pub name: String,
|
|
pub kind: RelationKind,
|
|
pub source: Option<Choice>,
|
|
pub target: Option<Choice>,
|
|
pub condition: text_editor::Content,
|
|
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 {
|
|
fn accepts_source(&self, p: &Project, id: Id) -> bool {
|
|
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.get(&id).is_some_and(|r| {
|
|
matches!(
|
|
r.kind(),
|
|
RelationKind::BoundaryExchange | RelationKind::CommunicationMean
|
|
)
|
|
});
|
|
}
|
|
p.elements.get(&id).is_some_and(|e| {
|
|
Kind::ALL
|
|
.into_iter()
|
|
.any(|target| target.supports(self.layer) && self.kind.permits(e.kind(), target))
|
|
})
|
|
}
|
|
|
|
fn refresh_choices(&mut self, p: &Project) {
|
|
self.sources = p
|
|
.elements
|
|
.keys()
|
|
.chain(p.relations.keys())
|
|
.copied()
|
|
.filter(|id| self.accepts_source(p, *id))
|
|
.map(|id| choice(p, id))
|
|
.collect();
|
|
self.sources.sort_by(|a, b| a.label.cmp(&b.label));
|
|
self.targets = p
|
|
.elements
|
|
.keys()
|
|
.chain(p.relations.keys())
|
|
.copied()
|
|
.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;
|
|
};
|
|
if self.kind == RelationKind::BoundaryExchange {
|
|
validation::can_connect_boundary(p, source, target, self.oriented)
|
|
} else {
|
|
validation::can_connect(p, self.kind, source, target)
|
|
}
|
|
}
|
|
|
|
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
|
|
if !self.name.trim().is_empty() {
|
|
return self.name.clone();
|
|
}
|
|
let verb = match self.kind {
|
|
RelationKind::CapabilityInclude => "includes",
|
|
RelationKind::CapabilityExtend => "extends",
|
|
RelationKind::CapabilityGeneralization => "specializes",
|
|
RelationKind::MissionCapability => "exploits",
|
|
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
|
|
"participates in"
|
|
}
|
|
RelationKind::Allocation => "performs",
|
|
_ => return format!("{}: {} → {}", self.kind, p.named(source), p.named(target)),
|
|
};
|
|
format!("{} {verb} {}", p.named(source), p.named(target))
|
|
}
|
|
fn default_orientation(&mut self, p: &Project) {
|
|
if self.kind == RelationKind::CommunicationMean {
|
|
self.oriented = false;
|
|
return;
|
|
}
|
|
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, items, ..
|
|
}
|
|
| RelationBody::CommunicationMean {
|
|
oriented, items, ..
|
|
} = &mut body
|
|
{
|
|
*oriented = self.oriented;
|
|
*items = self.items.clone();
|
|
}
|
|
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,
|
|
pub plan: TransitionPlan,
|
|
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
|
}
|
|
impl Transition {
|
|
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
|
|
let mut candidate = p.clone();
|
|
for command in &self.plan.commands {
|
|
match command {
|
|
Command::CreateElement(e) | Command::EditElement(_, e) => {
|
|
candidate.elements.insert(e.id, e.clone());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let targets = self
|
|
.plan
|
|
.changes
|
|
.iter()
|
|
.map(|c| c.target)
|
|
.filter(|id| candidate.elements.contains_key(id))
|
|
.collect::<BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect();
|
|
(candidate, targets)
|
|
}
|
|
fn automatic_behavior(&self, p: &Project) -> bool {
|
|
self.request
|
|
.sources
|
|
.first()
|
|
.is_some_and(|id| crate::behavior::selected_behavior(p, *id).is_some())
|
|
}
|
|
fn new_counterpart(&self, p: &Project) -> bool {
|
|
self.request.sources.first().is_some_and(|source| {
|
|
self.plan
|
|
.changes
|
|
.iter()
|
|
.find(|c| c.source == *source)
|
|
.map_or_else(
|
|
|| {
|
|
!p.traces
|
|
.values()
|
|
.any(|t| t.oa == *source || t.sa == *source)
|
|
&& !self
|
|
.request
|
|
.mappings
|
|
.iter()
|
|
.any(|m| m.source == *source && m.target.is_some())
|
|
},
|
|
|c| c.action == TransitionAction::Create,
|
|
)
|
|
})
|
|
}
|
|
fn boundary_choice(&self, p: &Project, conflict: &TransitionConflict) -> bool {
|
|
conflict.field.is_none()
|
|
&& conflict.target.is_none()
|
|
&& conflict.message
|
|
== "Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred."
|
|
&& conflict
|
|
.source
|
|
.and_then(|id| p.elements.get(&id))
|
|
.is_some_and(|e| {
|
|
matches!(
|
|
e.kind(),
|
|
Kind::Entity | Kind::Actor | Kind::System | Kind::SystemActor
|
|
)
|
|
})
|
|
}
|
|
fn destination_error(&self, p: &Project) -> Option<String> {
|
|
let id = self.request.destination_diagram?;
|
|
let (candidate, targets) = self.presentation(p);
|
|
match candidate.diagrams.get(&id) {
|
|
None => Some("The chosen result diagram is missing. Your choice is retained; choose another diagram.".into()),
|
|
Some(view) if !result_view_compatible(&candidate, view, &targets, self.request.direction.target_layer()) =>
|
|
Some("The chosen result diagram cannot show every transitioned element. Choose another diagram; no model changes have been applied.".into()),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool {
|
|
(diagram.projection.is_architecture() || diagram.projection.state_machine().is_some())
|
|
&& (diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
|
|
&& (diagram.projection.state_machine().is_none() || !targets.is_empty())
|
|
&& targets.iter().all(|id| {
|
|
p.elements.get(id).is_some_and(|e| {
|
|
diagram.projection.includes(e.kind())
|
|
&& diagram.projection.state_machine().is_none_or(|machine| {
|
|
oa_core::projection::state_machine_contains(p, machine, *id)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
fn automatic_result_view(p: &Project, targets: &[Id], layer: Layer) -> Option<Id> {
|
|
p.diagrams
|
|
.values()
|
|
.filter(|d| d.layer == layer && result_view_compatible(p, d, targets, layer))
|
|
.filter_map(|d| {
|
|
Some((
|
|
match d.projection {
|
|
ProjectionKind::Allocation => 0,
|
|
ProjectionKind::Overview => 1,
|
|
ProjectionKind::Decomposition => 2,
|
|
_ => return None,
|
|
},
|
|
d.id,
|
|
))
|
|
})
|
|
.min()
|
|
.map(|(_, id)| id)
|
|
}
|
|
|
|
fn place_transition_results(
|
|
before: &Project,
|
|
candidate: &mut Project,
|
|
diagram: Id,
|
|
targets: &[Id],
|
|
) {
|
|
// Keep every accepted placement and route. Only newly introduced placements
|
|
// receive space below the accepted projected bounds, including tall/nested nodes.
|
|
let accepted_nodes = before.diagrams.get(&diagram).map(|d| &d.nodes);
|
|
let mut occupied = candidate.diagrams[&diagram].clone();
|
|
occupied
|
|
.nodes
|
|
.retain(|id, _| accepted_nodes.is_some_and(|nodes| nodes.contains_key(id)));
|
|
let projected = oa_core::projection::project_diagram(candidate, &occupied);
|
|
let mut bottom = projected
|
|
.nodes
|
|
.iter()
|
|
.map(|n| n.position.y + n.size.height)
|
|
.chain(
|
|
occupied
|
|
.nodes
|
|
.values()
|
|
.map(|n| n.position.y + n.size.height),
|
|
)
|
|
.fold(20.0_f32, f32::max)
|
|
+ 40.;
|
|
for target in targets {
|
|
if candidate.diagrams[&diagram].nodes.values().any(|n| {
|
|
n.element == *target && accepted_nodes.is_some_and(|nodes| nodes.contains_key(&n.id))
|
|
}) {
|
|
continue;
|
|
}
|
|
let node = candidate.place(diagram, *target, Position { x: 60., y: bottom });
|
|
let placement = candidate
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&node)
|
|
.unwrap();
|
|
placement.position = Position { x: 60., y: bottom };
|
|
bottom += placement.size.height + 40.;
|
|
}
|
|
}
|
|
impl Workflow {
|
|
pub fn manual_trace(p: &Project, selected: Id) -> Result<Self, String> {
|
|
if p.layer_of(selected).is_none()
|
|
|| !(p.elements.contains_key(&selected) || p.relations.contains_key(&selected))
|
|
{
|
|
return Err("Select an OA or SA element or relationship to trace.".into());
|
|
}
|
|
Ok(Self::ManualTrace {
|
|
base_fingerprint: project_fingerprint(p),
|
|
name: "Engineering correspondence".into(),
|
|
source: choice(p, selected),
|
|
target: None,
|
|
})
|
|
}
|
|
pub fn relationship(p: &Project, selected: Id, layer: Layer) -> Self {
|
|
if let Some(PortContract::Component { orientation, .. }) =
|
|
p.elements.get(&selected).and_then(port_contract)
|
|
{
|
|
return Self::relationship_kind(
|
|
p,
|
|
selected,
|
|
layer,
|
|
RelationKind::BoundaryExchange,
|
|
*orientation == ComponentPortOrientation::In,
|
|
);
|
|
}
|
|
let kind = match p.elements.get(&selected).map(oa_core::Element::kind) {
|
|
Some(Kind::OperationalMission | Kind::Mission) => RelationKind::MissionCapability,
|
|
Some(Kind::Capability | Kind::SystemCapability) => RelationKind::CapabilityInclude,
|
|
Some(Kind::Role) => RelationKind::RoleAssignment,
|
|
Some(Kind::Process | Kind::FunctionalChain) => RelationKind::ChainInvolvement,
|
|
Some(kind) if kind.is_performer() => RelationKind::Allocation,
|
|
Some(kind) if kind.is_state() => RelationKind::StateTransition,
|
|
_ if layer == Layer::Oa => RelationKind::Interaction,
|
|
_ => RelationKind::FunctionalExchange,
|
|
};
|
|
Self::relationship_kind(p, selected, layer, kind, false)
|
|
}
|
|
|
|
pub fn relationship_kind(
|
|
p: &Project,
|
|
selected: Id,
|
|
layer: Layer,
|
|
kind: RelationKind,
|
|
incoming: bool,
|
|
) -> Self {
|
|
let mut draft = Relationship {
|
|
base_fingerprint: project_fingerprint(p),
|
|
id: Id::new_v4(),
|
|
name: String::new(),
|
|
kind,
|
|
source: (!incoming).then(|| choice(p, selected)),
|
|
target: incoming.then(|| choice(p, selected)),
|
|
condition: text_editor::Content::new(),
|
|
layer,
|
|
sources: vec![],
|
|
targets: vec![],
|
|
items: vec![],
|
|
source_port: automatic_port(),
|
|
target_port: automatic_port(),
|
|
oriented: true,
|
|
};
|
|
if draft
|
|
.source
|
|
.as_ref()
|
|
.is_some_and(|s| !draft.accepts_source(p, s.id))
|
|
{
|
|
draft.source = None;
|
|
}
|
|
draft.default_orientation(p);
|
|
draft.refresh_choices(p);
|
|
Self::Relation(Box::new(draft))
|
|
}
|
|
pub fn transition(p: &Project, selected: Id) -> Result<Self, String> {
|
|
let layer = p
|
|
.layer_of(selected)
|
|
.ok_or("Select an element or relationship in OA or SA.")?;
|
|
let direction = if layer == Layer::Oa {
|
|
TransitionDirection::OaToSa
|
|
} else {
|
|
TransitionDirection::SaToOa
|
|
};
|
|
let mut request = TransitionRequest::new(direction, vec![selected]);
|
|
request.scope = DependencyScope::Required;
|
|
let plan = plan_transition(p, &request).map_err(|e| e.to_string())?;
|
|
Ok(Self::Transition(Box::new(Transition {
|
|
request,
|
|
plan,
|
|
communication_ports: Default::default(),
|
|
})))
|
|
}
|
|
/// Return a complete accepted candidate only on explicit Apply. Intermediate
|
|
/// choices and previews never mutate the Session.
|
|
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
|
|
match self {
|
|
Self::Semantic(_) => {
|
|
return Err(
|
|
"Semantic review commits its exact command batch through Document.".into(),
|
|
);
|
|
}
|
|
Self::Ports(state) => return state.update(p, event),
|
|
Self::ManualTrace {
|
|
name,
|
|
source,
|
|
target,
|
|
..
|
|
} => match event {
|
|
Event::RelationName(value) => *name = value,
|
|
Event::Target(value) => *target = Some(value),
|
|
Event::Apply => {
|
|
let target = target
|
|
.as_ref()
|
|
.ok_or("Choose a counterpart in the other perspective.")?
|
|
.id;
|
|
if name.trim().is_empty() {
|
|
return Err("Give the trace an engineering name.".into());
|
|
}
|
|
let (oa, sa) = if p.layer_of(source.id) == Some(Layer::Oa) {
|
|
(source.id, target)
|
|
} else {
|
|
(target, source.id)
|
|
};
|
|
let id = Id::new_v4();
|
|
let trace = Trace {
|
|
id,
|
|
alias: p.fresh_alias("correspondence", "trace", id),
|
|
name: name.clone(),
|
|
description: String::new(),
|
|
oa,
|
|
sa,
|
|
kind: TraceKind::Manual,
|
|
baseline: None,
|
|
};
|
|
return execute_batch(p, &[Command::CreateTrace(trace)])
|
|
.map(Some)
|
|
.map_err(|e| e.to_string());
|
|
}
|
|
_ => {}
|
|
},
|
|
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;
|
|
d.target = None;
|
|
if d.source
|
|
.as_ref()
|
|
.is_some_and(|s| !d.accepts_source(p, s.id))
|
|
{
|
|
d.source = None;
|
|
}
|
|
d.default_orientation(p);
|
|
d.refresh_choices(p);
|
|
}
|
|
Event::Source(source) => {
|
|
d.source = Some(source);
|
|
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);
|
|
}
|
|
Event::Target(target) => d.target = Some(target),
|
|
Event::Condition(action) => d.condition.perform(action),
|
|
Event::Apply => {
|
|
return execute_batch(p, &d.commands(p)?)
|
|
.map(Some)
|
|
.map_err(|e| e.to_string());
|
|
}
|
|
_ => {}
|
|
},
|
|
Self::Transition(d) => {
|
|
match &event {
|
|
Event::OperationOmission(source, enabled) => {
|
|
d.request
|
|
.operation_omissions
|
|
.retain(|v| v.source != *source);
|
|
if *enabled {
|
|
d.request.operation_omissions.push(OperationOmission {
|
|
source: *source,
|
|
rationale: String::new(),
|
|
});
|
|
}
|
|
}
|
|
Event::OperationRationale(source, raw) => {
|
|
if let Some(decision) = d
|
|
.request
|
|
.operation_omissions
|
|
.iter_mut()
|
|
.find(|v| v.source == *source)
|
|
{
|
|
decision.rationale = raw.clone();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
match event {
|
|
Event::Destination(choice) => {
|
|
d.request.destination_diagram = (!choice.id.is_nil()).then_some(choice.id);
|
|
}
|
|
Event::CommunicationPort(exchange, source, value) => {
|
|
d.choose_communication_port(p, exchange, source, value)?;
|
|
}
|
|
Event::Scope(scope) => {
|
|
d.request.scope = match scope.as_str() {
|
|
"Required references" => DependencyScope::Required,
|
|
"Whole subtree" => DependencyScope::Subtree,
|
|
_ => DependencyScope::SelectedOnly,
|
|
}
|
|
}
|
|
Event::MappingTarget(source, target) => {
|
|
let target = (!target.id.is_nil()).then_some(target.id);
|
|
let previous = d
|
|
.request
|
|
.mappings
|
|
.iter()
|
|
.find(|m| m.source == source)
|
|
.map(|m| m.target)
|
|
.unwrap_or_else(|| {
|
|
d.plan
|
|
.changes
|
|
.iter()
|
|
.find(|c| c.source == source)
|
|
.map(|c| c.target)
|
|
.or_else(|| {
|
|
d.plan
|
|
.conflicts
|
|
.iter()
|
|
.find(|c| c.source == Some(source))
|
|
.and_then(|c| c.target)
|
|
})
|
|
// Planned new identities are not an existing counterpart.
|
|
.filter(|id| p.contains(*id))
|
|
});
|
|
if previous != target {
|
|
d.request.resolutions.retain(|r| r.source != source);
|
|
}
|
|
if let Some(mapping) =
|
|
d.request.mappings.iter_mut().find(|m| m.source == source)
|
|
{
|
|
mapping.target = target;
|
|
} else {
|
|
d.request.mappings.push(TransitionMapping {
|
|
source,
|
|
target,
|
|
target_kind: None,
|
|
});
|
|
}
|
|
}
|
|
Event::MappingKind(source, kind) => {
|
|
let mapping = d.request.mappings.iter().find(|m| m.source == source);
|
|
let planned_target = d
|
|
.plan
|
|
.changes
|
|
.iter()
|
|
.find(|c| c.source == source)
|
|
.map(|c| c.target);
|
|
let previous = mapping
|
|
.and_then(|m| m.target_kind)
|
|
.or_else(|| {
|
|
planned_target.and_then(|target| {
|
|
p.elements.get(&target).map(|e| e.kind()).or_else(|| {
|
|
d.plan.commands.iter().find_map(|command| match command {
|
|
Command::CreateElement(e) if e.id == target => {
|
|
Some(e.kind())
|
|
}
|
|
_ => None,
|
|
})
|
|
})
|
|
})
|
|
})
|
|
.or_else(|| {
|
|
p.elements.get(&source).and_then(|e| {
|
|
let kinds = counterpart_kinds(e.kind(), d.request.direction);
|
|
(kinds.len() == 1).then(|| kinds[0])
|
|
})
|
|
});
|
|
// Adding a kind mapping explicitly requests creation. If
|
|
// it replaces an inferred existing target, that is also
|
|
// a new review context even when its kind is identical.
|
|
let replaces_existing = mapping.is_none()
|
|
&& planned_target.is_some_and(|target| p.contains(target));
|
|
if previous != Some(kind) || replaces_existing {
|
|
d.request.resolutions.retain(|r| r.source != source);
|
|
}
|
|
if let Some(mapping) =
|
|
d.request.mappings.iter_mut().find(|m| m.source == source)
|
|
{
|
|
mapping.target_kind = Some(kind);
|
|
} else {
|
|
d.request.mappings.push(TransitionMapping {
|
|
source,
|
|
target: None,
|
|
target_kind: Some(kind),
|
|
});
|
|
}
|
|
}
|
|
Event::Resolve(source, field, decision) => {
|
|
d.request
|
|
.resolutions
|
|
.retain(|r| r.source != source || r.field != field);
|
|
d.request.resolutions.push(FieldResolution {
|
|
source,
|
|
field,
|
|
decision,
|
|
});
|
|
}
|
|
Event::Apply => {
|
|
let mut candidate =
|
|
apply_transition(p, &d.plan).map_err(|e| e.to_string())?;
|
|
// Behavior is already usable in its dedicated workspace. An
|
|
// unrequested architecture placement would alter an unrelated
|
|
// view, including its routes, just to display this operation.
|
|
if d.request.destination_diagram.is_none()
|
|
&& d.request.sources.first().is_some_and(|source| {
|
|
crate::behavior::selected_behavior(p, *source).is_some()
|
|
})
|
|
{
|
|
return Ok(Some(candidate));
|
|
}
|
|
let layer = d.request.direction.target_layer();
|
|
let targets: Vec<_> = d
|
|
.plan
|
|
.changes
|
|
.iter()
|
|
.map(|c| c.target)
|
|
.filter(|id| candidate.elements.contains_key(id))
|
|
.collect();
|
|
let diagram = if let Some(id) = d.request.destination_diagram {
|
|
let view = candidate.diagrams.get(&id).ok_or("The chosen result diagram no longer exists. Choose another result diagram.")?;
|
|
if !result_view_compatible(&candidate, view, &targets, layer) {
|
|
return Err("The chosen result diagram cannot show every transitioned element. Choose a compatible result diagram.".into());
|
|
}
|
|
id
|
|
} else {
|
|
automatic_result_view(&candidate, &targets, layer)
|
|
.unwrap_or_else(|| candidate.ensure_diagram(layer))
|
|
};
|
|
// Explicit behavior destinations retain the core plan's
|
|
// placements; free-space placement is an architecture aid.
|
|
if !d.automatic_behavior(p) {
|
|
place_transition_results(p, &mut candidate, diagram, &targets);
|
|
}
|
|
return execute_batch(&candidate, &[])
|
|
.map(Some)
|
|
.map_err(|e| e.to_string());
|
|
}
|
|
_ => {}
|
|
}
|
|
d.plan = plan_transition(p, &d.request).map_err(|e| e.to_string())?;
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
pub fn title(&self) -> &'static str {
|
|
match self {
|
|
Self::Semantic(_) => "Review semantics",
|
|
Self::Ports(state) => state.title(),
|
|
Self::ManualTrace { .. } => "Link existing counterparts",
|
|
Self::Relation(_) => "Create relationship",
|
|
Self::Transition(t) => {
|
|
if t.request.direction == TransitionDirection::OaToSa {
|
|
"Operational → System"
|
|
} else {
|
|
"System → Operational"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
|
|
if let Self::Semantic(state) = self {
|
|
return state.view(p);
|
|
}
|
|
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(_) | Self::Semantic(_) => unreachable!("Dedicated dialog returned above"),
|
|
Self::ManualTrace {
|
|
name,
|
|
source,
|
|
target,
|
|
..
|
|
} => {
|
|
let mut choices = p
|
|
.elements
|
|
.keys()
|
|
.chain(p.relations.keys())
|
|
.filter(|id| {
|
|
p.layer_of(**id)
|
|
.is_some_and(|layer| Some(layer) != p.layer_of(source.id))
|
|
})
|
|
.map(|id| choice(p, *id))
|
|
.collect::<Vec<_>>();
|
|
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
|
content = content.push(text("Record an explicit engineering correspondence between existing records. This link preserves both records and does not propagate properties.").size(13).color(style::MUTED))
|
|
.push(text(format!("From: {}",source.label)).size(14))
|
|
.push(text_input("Trace name",name).padding(10).on_input(|v|Message::Workflow(Event::RelationName(v))))
|
|
.push(pick_list(choices,target.clone(),|v|Message::Workflow(Event::Target(v))).placeholder("Choose counterpart in the other perspective").width(Fill).padding(10).text_size(13));
|
|
}
|
|
Self::Relation(d) => {
|
|
let kinds = RelationKind::ALL
|
|
.into_iter()
|
|
.filter(|kind| {
|
|
if *kind == RelationKind::ContextualLink {
|
|
return false;
|
|
}
|
|
*kind == RelationKind::ExchangeAllocation
|
|
|| Kind::ALL.into_iter().any(|source| {
|
|
source.supports(d.layer)
|
|
&& Kind::ALL.into_iter().any(|target| {
|
|
target.supports(d.layer) && kind.permits(source, target)
|
|
})
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let (source_label, target_label) = match d.kind {
|
|
RelationKind::CapabilityInclude => {
|
|
("Including capability", "Included capability")
|
|
}
|
|
RelationKind::CapabilityExtend => {
|
|
("Extending capability", "Extended capability")
|
|
}
|
|
RelationKind::CapabilityGeneralization => {
|
|
("Specific capability", "General capability")
|
|
}
|
|
RelationKind::MissionCapability => ("Mission", "Exploited capability"),
|
|
RelationKind::MissionInvolvement => ("Participant", "Mission"),
|
|
RelationKind::CapabilityInvolvement => ("Participant or work", "Capability"),
|
|
_ => ("Source", "Target"),
|
|
};
|
|
content = content
|
|
.push(
|
|
text(if d.kind == RelationKind::BoundaryExchange {
|
|
"Component port ↔ component port"
|
|
} else {
|
|
d.kind.hint()
|
|
})
|
|
.size(13)
|
|
.color(style::MUTED),
|
|
)
|
|
.push(
|
|
pick_list(kinds, Some(d.kind), |v| {
|
|
Message::Workflow(Event::RelationKind(v))
|
|
})
|
|
.text_size(13)
|
|
.padding(9)
|
|
.width(Fill),
|
|
)
|
|
.push(text(source_label).size(12))
|
|
.push(
|
|
pick_list(d.sources.as_slice(), d.source.as_ref(), |v| {
|
|
Message::Workflow(Event::Source(v))
|
|
})
|
|
.placeholder("Choose source")
|
|
.text_size(13)
|
|
.padding(9)
|
|
.width(Fill),
|
|
)
|
|
.push(text(target_label).size(12))
|
|
.push(
|
|
pick_list(d.targets.as_slice(), d.target.as_ref(), |v| {
|
|
Message::Workflow(Event::Target(v))
|
|
})
|
|
.placeholder("Choose a compatible target")
|
|
.text_size(13)
|
|
.padding(9)
|
|
.width(Fill),
|
|
);
|
|
if d.source.is_some() && d.targets.is_empty() {
|
|
content = content.push(
|
|
text(if d.kind == RelationKind::BoundaryExchange {
|
|
"No compatible component port on another participant. Select the other system or actor, add a port of the same kind (Standard or Flow), then connect it here. Flow ports also need compatible input/output orientations."
|
|
} else {
|
|
"No compatible target yet. Create the needed model element, then connect it here."
|
|
})
|
|
.size(12)
|
|
.color(style::MUTED),
|
|
);
|
|
}
|
|
if d.kind == RelationKind::CapabilityExtend {
|
|
content = content.push(text("Extension condition").size(12)).push(
|
|
text_editor(&d.condition)
|
|
.placeholder("When does the extending capability add behavior?")
|
|
.on_action(|action| Message::Workflow(Event::Condition(action)))
|
|
.height(72)
|
|
.padding(9),
|
|
);
|
|
}
|
|
if matches!(
|
|
d.kind,
|
|
RelationKind::BoundaryExchange | RelationKind::CommunicationMean
|
|
) {
|
|
content = content.push(
|
|
checkbox(d.oriented)
|
|
.label("Directed source → target (unchecked: bidirectional)")
|
|
.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),
|
|
);
|
|
}
|
|
}
|
|
if matches!(
|
|
d.kind,
|
|
RelationKind::FunctionalExchange
|
|
| RelationKind::BoundaryExchange
|
|
| RelationKind::CommunicationMean
|
|
) {
|
|
content = content.push(text("Declared carried items").size(14));
|
|
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)))
|
|
.padding(10)
|
|
.size(13),
|
|
);
|
|
if let (Some(source), Some(target)) = (&d.source, &d.target) {
|
|
content = content.push(
|
|
text(d.engineering_name(p, source.id, target.id))
|
|
.size(13)
|
|
.color(style::TEAL),
|
|
);
|
|
}
|
|
}
|
|
Self::Transition(d) => {
|
|
content=content.push(text(if d.new_counterpart(p) {
|
|
"Create a linked counterpart in the other layer. The source element stays in its current layer."
|
|
} else {
|
|
"Review names and descriptions of existing traced counterparts. For structural changes, use Review counterpart changes…"
|
|
}).size(12).color(style::MUTED));
|
|
let scope = match d.request.scope {
|
|
DependencyScope::SelectedOnly => "Selected only",
|
|
DependencyScope::Required => "Required references",
|
|
DependencyScope::Subtree => "Whole subtree",
|
|
}
|
|
.to_string();
|
|
content = content.push(
|
|
row![
|
|
text("Include").size(12),
|
|
pick_list(
|
|
vec![
|
|
"Selected only".to_string(),
|
|
"Required references".to_string(),
|
|
"Whole subtree".to_string()
|
|
],
|
|
Some(scope),
|
|
|v| Message::Workflow(Event::Scope(v))
|
|
)
|
|
.text_size(12)
|
|
.padding(8),
|
|
Space::new().width(Fill),
|
|
button(text("Refresh preview").size(12))
|
|
.style(style::nav)
|
|
.on_press(Message::Workflow(Event::Preview))
|
|
]
|
|
.spacing(10)
|
|
.align_y(iced::Center),
|
|
);
|
|
let (presentation, targets) = d.presentation(p);
|
|
let target_layer = d.request.direction.target_layer();
|
|
let automatic = if d.automatic_behavior(p) {
|
|
"Automatic · open the counterpart in Behavior".into()
|
|
} else if let Some(id) =
|
|
automatic_result_view(&presentation, &targets, target_layer)
|
|
{
|
|
format!("Automatic · {}", p.named(id))
|
|
} else {
|
|
format!("Automatic · create {target_layer} overview")
|
|
};
|
|
let mut diagrams = vec![Choice {
|
|
id: Id::nil(),
|
|
label: automatic,
|
|
}];
|
|
diagrams.extend(
|
|
presentation
|
|
.diagrams
|
|
.values()
|
|
.filter(|view| {
|
|
result_view_compatible(&presentation, view, &targets, target_layer)
|
|
})
|
|
.map(|view| Choice {
|
|
id: view.id,
|
|
label: format!("{} · {}", view.name, view.projection),
|
|
}),
|
|
);
|
|
let selected = d.request.destination_diagram.unwrap_or(Id::nil());
|
|
if !diagrams.iter().any(|choice| choice.id == selected) {
|
|
diagrams.push(Choice {
|
|
id: selected,
|
|
label: format!("Unavailable result diagram · {}", p.named(selected)),
|
|
});
|
|
}
|
|
let selected = diagrams
|
|
.iter()
|
|
.find(|choice| choice.id == selected)
|
|
.cloned();
|
|
content = content.push(
|
|
column![
|
|
text("Show result in").size(12),
|
|
pick_list(diagrams, selected, |choice| Message::Workflow(
|
|
Event::Destination(choice)
|
|
))
|
|
.width(Fill)
|
|
.text_size(12)
|
|
.padding(8)
|
|
]
|
|
.spacing(5),
|
|
);
|
|
if let Some(error) = d.destination_error(p) {
|
|
content = content.push(
|
|
text(error)
|
|
.size(12)
|
|
.color(iced::Color::from_rgb8(178, 54, 58)),
|
|
);
|
|
}
|
|
let sources = d
|
|
.request
|
|
.sources
|
|
.iter()
|
|
.copied()
|
|
.chain(d.plan.changes.iter().map(|c| c.source))
|
|
.chain(d.plan.conflicts.iter().filter_map(|c| c.source))
|
|
.collect::<BTreeSet<_>>();
|
|
for source in sources {
|
|
if d.request.direction == TransitionDirection::SaToOa
|
|
&& matches!(
|
|
p.elements.get(&source).and_then(|e| e.shared()),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange {
|
|
operation: Some(_),
|
|
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
|
..
|
|
},
|
|
..
|
|
})
|
|
)
|
|
{
|
|
let omission = d
|
|
.request
|
|
.operation_omissions
|
|
.iter()
|
|
.find(|v| v.source == source);
|
|
content=content.push(text(format!("Technical operation · {}",p.named(source))).size(13))
|
|
.push(checkbox(omission.is_some()).label("Explicitly omit the interface operation in the OA counterpart").on_toggle(move|enabled|Message::Workflow(Event::OperationOmission(source,enabled))));
|
|
if let Some(omission) = omission {
|
|
content = content.push(
|
|
text_input("Required engineering rationale", &omission.rationale)
|
|
.on_input(move |raw| {
|
|
Message::Workflow(Event::OperationRationale(source, raw))
|
|
})
|
|
.padding(8)
|
|
.size(12),
|
|
);
|
|
}
|
|
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
|
|
}
|
|
let mapping = d.request.mappings.iter().find(|m| m.source == source);
|
|
let existing = p
|
|
.traces
|
|
.values()
|
|
.filter_map(|t| {
|
|
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
|
|
{
|
|
Some(t.sa)
|
|
} else if d.request.direction == TransitionDirection::SaToOa
|
|
&& t.sa == source
|
|
{
|
|
Some(t.oa)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
|
|
if existing.len() == 1 {
|
|
Some(existing[0])
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
let mut targets = vec![Choice {
|
|
id: Id::nil(),
|
|
label: "Create new counterpart".into(),
|
|
}];
|
|
targets.extend(
|
|
p.elements
|
|
.keys()
|
|
.chain(p.relations.keys())
|
|
.filter(|id| {
|
|
p.layer_of(**id) == Some(d.request.direction.target_layer())
|
|
&& p.elements.contains_key(&source)
|
|
== p.elements.contains_key(id)
|
|
})
|
|
.map(|id| choice(p, *id)),
|
|
);
|
|
let selected = targets
|
|
.iter()
|
|
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
|
|
.cloned();
|
|
let mut item = column![
|
|
text(format!(
|
|
"{} · {}",
|
|
short(p.named(source), 70),
|
|
p.elements
|
|
.get(&source)
|
|
.map(|e| e.kind().label())
|
|
.unwrap_or("Relationship")
|
|
))
|
|
.size(14),
|
|
pick_list(targets, selected, move |v| Message::Workflow(
|
|
Event::MappingTarget(source, v)
|
|
))
|
|
.text_size(12)
|
|
.padding(8)
|
|
.width(Fill)
|
|
]
|
|
.spacing(7);
|
|
if target.is_none()
|
|
&& let Some(e) = p.elements.get(&source)
|
|
{
|
|
let kinds = counterpart_kinds(e.kind(), d.request.direction);
|
|
if !kinds.is_empty() {
|
|
let selected = mapping.and_then(|m| m.target_kind).or_else(|| {
|
|
if kinds.len() == 1 {
|
|
Some(kinds[0])
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
item = item.push(
|
|
pick_list(kinds, selected, move |kind| {
|
|
Message::Workflow(Event::MappingKind(source, kind))
|
|
})
|
|
.placeholder("Choose counterpart kind / system boundary")
|
|
.text_size(12)
|
|
.padding(8)
|
|
.width(Fill),
|
|
);
|
|
if matches!(e.kind(), Kind::Entity | Kind::Actor)
|
|
&& d.request.direction == TransitionDirection::OaToSa
|
|
{
|
|
item = item.push(text("System is the system of interest being designed. System actor remains outside that boundary. Choose the intended role; it is never inferred from the name.").size(11));
|
|
}
|
|
}
|
|
}
|
|
if let Some(ports) = d.communication_view(p, source) {
|
|
item = item.push(ports);
|
|
}
|
|
content = content.push(
|
|
iced::widget::container(item)
|
|
.padding(12)
|
|
.style(style::subtle),
|
|
);
|
|
}
|
|
let mut changes = column![].spacing(8);
|
|
for change in &d.plan.changes {
|
|
changes = changes.push(
|
|
text(format!("{:?} · {}", change.action, p.named(change.source)))
|
|
.size(12)
|
|
.color(style::TEAL),
|
|
);
|
|
for field in &change.fields {
|
|
changes = changes.push(
|
|
text(format!(
|
|
"{:?}: {} → {}",
|
|
field.field,
|
|
short(&field.before, 90),
|
|
short(&field.after, 90)
|
|
))
|
|
.size(12),
|
|
);
|
|
}
|
|
}
|
|
content = content.push(changes);
|
|
let mut fields = d
|
|
.plan
|
|
.conflicts
|
|
.iter()
|
|
.filter_map(|c| Some((c.source?, c.field?)))
|
|
.chain(d.request.resolutions.iter().map(|r| (r.source, r.field)))
|
|
.fold(Vec::new(), |mut out, field| {
|
|
if !out.contains(&field) {
|
|
out.push(field);
|
|
}
|
|
out
|
|
});
|
|
// Keep property widgets in the same order while a resolution
|
|
// changes the preview, so continuous typing retains focus.
|
|
fields.sort_by_key(|(source, field)| (*source, *field as u8));
|
|
let mut general_conflicts = column![].spacing(8);
|
|
for conflict in &d.plan.conflicts {
|
|
if conflict.field.is_none() && !d.boundary_choice(p, conflict) {
|
|
general_conflicts = general_conflicts.push(
|
|
text(&conflict.message)
|
|
.size(12)
|
|
.color(iced::Color::from_rgb8(178, 54, 58)),
|
|
);
|
|
}
|
|
}
|
|
content = content.push(general_conflicts);
|
|
for (source, field) in fields {
|
|
let target = d
|
|
.plan
|
|
.conflicts
|
|
.iter()
|
|
.find(|c| c.source == Some(source) && c.field == Some(field))
|
|
.and_then(|c| c.target)
|
|
.or_else(|| {
|
|
d.request
|
|
.mappings
|
|
.iter()
|
|
.find(|m| m.source == source)
|
|
.and_then(|m| m.target)
|
|
})
|
|
.or_else(|| {
|
|
d.plan
|
|
.changes
|
|
.iter()
|
|
.find(|c| c.source == source)
|
|
.map(|c| c.target)
|
|
})
|
|
.or_else(|| {
|
|
p.traces.values().find_map(|t| {
|
|
if d.request.direction == TransitionDirection::OaToSa
|
|
&& t.oa == source
|
|
{
|
|
Some(t.sa)
|
|
} else if d.request.direction == TransitionDirection::SaToOa
|
|
&& t.sa == source
|
|
{
|
|
Some(t.oa)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
});
|
|
let value = |id| {
|
|
p.properties(id)
|
|
.map(|v| match field {
|
|
TransitionField::Name => v.name,
|
|
TransitionField::Description => v.description,
|
|
})
|
|
.unwrap_or_default()
|
|
};
|
|
let resolution = d
|
|
.request
|
|
.resolutions
|
|
.iter()
|
|
.find(|r| r.source == source && r.field == field);
|
|
let merged = resolution
|
|
.and_then(|r| {
|
|
if let FieldDecision::Value(v) = &r.decision {
|
|
Some(v.as_str())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or("");
|
|
let status = resolution
|
|
.map(|r| match &r.decision {
|
|
FieldDecision::KeepTarget => "Keep target",
|
|
FieldDecision::TakeSource => "Take source",
|
|
FieldDecision::Value(_) => "Use merged text",
|
|
})
|
|
.unwrap_or("Choose how to resolve this conflict");
|
|
let card = column![
|
|
text(format!(
|
|
"{:?} · {} → {}",
|
|
field,
|
|
p.named(source),
|
|
target.map(|id| p.named(id)).unwrap_or("New counterpart")
|
|
))
|
|
.size(14),
|
|
text(format!(
|
|
"Current target: {}",
|
|
target.map(value).unwrap_or_default()
|
|
))
|
|
.size(12),
|
|
text(format!("Proposed source: {}", value(source))).size(12),
|
|
text(status).size(12).color(style::TEAL),
|
|
row![
|
|
button(text("Keep target").size(12))
|
|
.padding(8)
|
|
.style(style::nav)
|
|
.on_press(Message::Workflow(Event::Resolve(
|
|
source,
|
|
field,
|
|
FieldDecision::KeepTarget
|
|
))),
|
|
button(text("Take source").size(12))
|
|
.padding(8)
|
|
.style(style::nav)
|
|
.on_press(Message::Workflow(Event::Resolve(
|
|
source,
|
|
field,
|
|
FieldDecision::TakeSource
|
|
)))
|
|
]
|
|
.spacing(8),
|
|
text_input("Or enter merged text", merged)
|
|
.padding(9)
|
|
.size(12)
|
|
.on_input(move |v| Message::Workflow(Event::Resolve(
|
|
source,
|
|
field,
|
|
FieldDecision::Value(v)
|
|
)))
|
|
]
|
|
.spacing(8);
|
|
content = content.push(
|
|
iced::widget::container(card)
|
|
.padding(12)
|
|
.style(style::subtle),
|
|
);
|
|
}
|
|
for warning in &d.plan.warnings {
|
|
content = content.push(
|
|
text(format!("Modeling warning · {warning}"))
|
|
.size(12)
|
|
.color(style::MUTED),
|
|
);
|
|
}
|
|
content = content.push(
|
|
text(format!(
|
|
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
|
|
d.plan.changes.len(),
|
|
d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(),
|
|
d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(),
|
|
d.plan.warnings.len()
|
|
))
|
|
.size(12),
|
|
);
|
|
}
|
|
}
|
|
let enabled = match self {
|
|
Self::Semantic(_) => false,
|
|
Self::Ports(_) => false,
|
|
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
|
|
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
|
|
Self::Transition(d) => d.plan.conflicts.is_empty() && d.destination_error(p).is_none(),
|
|
};
|
|
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.,
|
|
..Default::default()
|
|
}))
|
|
.height(480),
|
|
row![
|
|
Space::new().width(Fill),
|
|
button(text("Cancel").size(12))
|
|
.padding(10)
|
|
.style(style::nav)
|
|
.on_press(Message::Workflow(Event::Cancel)),
|
|
button(
|
|
text(
|
|
if matches!(self, Self::Transition(d) if d.new_counterpart(p)) {
|
|
"Create counterpart"
|
|
} else {
|
|
"Apply reviewed change"
|
|
}
|
|
)
|
|
.size(12)
|
|
)
|
|
.padding(10)
|
|
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
|
|
]
|
|
.spacing(10)
|
|
]
|
|
.spacing(18)
|
|
.into()
|
|
}
|
|
}
|
|
fn choice(p: &Project, id: Id) -> Choice {
|
|
if let Some(element) = p.elements.get(&id)
|
|
&& let Some(contract) = port_contract(element)
|
|
{
|
|
let contract = match contract {
|
|
PortContract::FunctionInput { .. } => "Input".into(),
|
|
PortContract::FunctionOutput { .. } => "Output".into(),
|
|
PortContract::Component {
|
|
kind: ComponentPortKind::Standard,
|
|
..
|
|
} => "Standard".into(),
|
|
PortContract::Component { orientation, .. } => format!("Flow {orientation:?}"),
|
|
PortContract::Legacy => "Legacy".into(),
|
|
};
|
|
return Choice {
|
|
id,
|
|
label: format!(
|
|
"{} / {} · {} · @{}",
|
|
short(element.parent.map_or("Unowned", |owner| p.named(owner)), 18),
|
|
short(&element.name, 20),
|
|
contract,
|
|
element.alias
|
|
),
|
|
};
|
|
}
|
|
Choice {
|
|
id,
|
|
label: format!(
|
|
"{} · {}",
|
|
p.named(id),
|
|
p.layer_of(id).map(|l| l.to_string()).unwrap_or_default()
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod port_authoring_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn boundary_picker_matches_exact_commands_across_contracts_and_ownership() {
|
|
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 function = p
|
|
.add_element(Kind::Function, Layer::Sa, "Internal work", None)
|
|
.unwrap();
|
|
let mut endpoints = vec![];
|
|
let mut standard = vec![];
|
|
for owner in [system, actor] {
|
|
for orientation in [
|
|
ComponentPortOrientation::Unset,
|
|
ComponentPortOrientation::In,
|
|
ComponentPortOrientation::Out,
|
|
ComponentPortOrientation::InOut,
|
|
] {
|
|
let id = Id::new_v4();
|
|
let port = make_port(
|
|
&p,
|
|
id,
|
|
owner,
|
|
"Flow",
|
|
PortContract::Component {
|
|
kind: ComponentPortKind::Flow,
|
|
orientation,
|
|
},
|
|
)
|
|
.unwrap();
|
|
p.elements.insert(id, port);
|
|
endpoints.push(id);
|
|
}
|
|
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);
|
|
endpoints.push(id);
|
|
standard.push(id);
|
|
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
|
|
let id = Id::new_v4();
|
|
let mut port = make_port(&p, id, owner, "Legacy", PortContract::Legacy).unwrap();
|
|
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
|
|
*legacy = Some(LegacyPortContract {
|
|
direction,
|
|
interface: None,
|
|
});
|
|
}
|
|
p.elements.insert(id, port);
|
|
endpoints.push(id);
|
|
}
|
|
}
|
|
for contract in [
|
|
PortContract::FunctionInput {
|
|
incoming_items: vec![],
|
|
},
|
|
PortContract::FunctionOutput {
|
|
outgoing_items: vec![],
|
|
},
|
|
] {
|
|
let id = Id::new_v4();
|
|
let port = make_port(&p, id, function, "Unrelated function port", contract).unwrap();
|
|
p.elements.insert(id, port);
|
|
endpoints.push(id);
|
|
}
|
|
let interface = p
|
|
.add_element(Kind::Interface, Layer::Sa, "Telemetry", None)
|
|
.unwrap();
|
|
p.relate(
|
|
RelationKind::InterfaceRequirement,
|
|
"Requires telemetry",
|
|
standard[0],
|
|
interface,
|
|
);
|
|
p.relate(
|
|
RelationKind::InterfaceProvision,
|
|
"Provides telemetry",
|
|
standard[1],
|
|
interface,
|
|
);
|
|
let missing_contract = Id::new_v4();
|
|
let port = make_port(
|
|
&p,
|
|
missing_contract,
|
|
actor,
|
|
"Missing provision",
|
|
PortContract::Component {
|
|
kind: ComponentPortKind::Standard,
|
|
orientation: ComponentPortOrientation::Unset,
|
|
},
|
|
)
|
|
.unwrap();
|
|
p.elements.insert(missing_contract, port);
|
|
endpoints.extend([missing_contract, system, function, Id::new_v4()]);
|
|
assert!(execute_batch(&p, &[]).is_ok());
|
|
for source in &endpoints {
|
|
let Workflow::Relation(mut draft) = Workflow::relationship_kind(
|
|
&p,
|
|
*source,
|
|
Layer::Sa,
|
|
RelationKind::BoundaryExchange,
|
|
false,
|
|
) else {
|
|
unreachable!()
|
|
};
|
|
// Include invalid sources as well as those shown by the UI.
|
|
draft.source = Some(choice(&p, *source));
|
|
for oriented in [false, true] {
|
|
draft.oriented = oriented;
|
|
for target in &endpoints {
|
|
let exact = Relation {
|
|
id: Id::new_v4(),
|
|
alias: "boundary_probe".into(),
|
|
name: "Boundary probe".into(),
|
|
description: String::new(),
|
|
body: RelationBody::BoundaryExchange {
|
|
source: *source,
|
|
target: *target,
|
|
oriented,
|
|
items: vec![],
|
|
},
|
|
};
|
|
let accepted = execute_batch(&p, &[Command::CreateRelation(exact)]).is_ok();
|
|
assert_eq!(
|
|
draft.accepts_target(&p, *target),
|
|
accepted,
|
|
"source {source}, target {target}, oriented {oriented}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn generic_component_port_connection_keeps_its_endpoint_and_direction() {
|
|
for (kind, orientation) in [
|
|
(ComponentPortKind::Flow, ComponentPortOrientation::In),
|
|
(ComponentPortKind::Flow, ComponentPortOrientation::Out),
|
|
(ComponentPortKind::Flow, ComponentPortOrientation::InOut),
|
|
(ComponentPortKind::Flow, ComponentPortOrientation::Unset),
|
|
(ComponentPortKind::Standard, ComponentPortOrientation::Unset),
|
|
] {
|
|
let mut p = Project::empty();
|
|
let actor = p
|
|
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
|
.unwrap();
|
|
let system = p
|
|
.add_element(Kind::System, Layer::Sa, "System", None)
|
|
.unwrap();
|
|
let incoming = orientation == ComponentPortOrientation::In;
|
|
let other_orientation = if kind == ComponentPortKind::Standard {
|
|
ComponentPortOrientation::Unset
|
|
} else if incoming {
|
|
ComponentPortOrientation::Out
|
|
} else {
|
|
ComponentPortOrientation::In
|
|
};
|
|
let mut ports = vec![];
|
|
for (owner, orientation) in [(actor, orientation), (system, other_orientation)] {
|
|
let id = Id::new_v4();
|
|
let port = make_port(
|
|
&p,
|
|
id,
|
|
owner,
|
|
"Data",
|
|
PortContract::Component { kind, orientation },
|
|
)
|
|
.unwrap();
|
|
p.elements.insert(id, port);
|
|
ports.push(id);
|
|
}
|
|
let before = p.clone();
|
|
let mut workflow = Workflow::relationship(&p, ports[0], Layer::Sa);
|
|
let Workflow::Relation(draft) = &workflow else {
|
|
unreachable!()
|
|
};
|
|
assert_eq!(draft.kind, RelationKind::BoundaryExchange);
|
|
if incoming {
|
|
assert!(draft.source.is_none());
|
|
assert_eq!(draft.target.as_ref().unwrap().id, ports[0]);
|
|
} else {
|
|
assert_eq!(draft.source.as_ref().unwrap().id, ports[0]);
|
|
assert!(draft.target.is_none());
|
|
}
|
|
assert_eq!(draft.oriented, kind == ComponentPortKind::Flow);
|
|
workflow
|
|
.update(
|
|
&p,
|
|
if incoming {
|
|
Event::Source(choice(&p, ports[1]))
|
|
} else {
|
|
Event::Target(choice(&p, ports[1]))
|
|
},
|
|
)
|
|
.unwrap();
|
|
let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap();
|
|
assert_eq!(p, before, "The modal never mutates its input model");
|
|
let (expected_source, expected_target) = if incoming {
|
|
(ports[1], ports[0])
|
|
} else {
|
|
(ports[0], ports[1])
|
|
};
|
|
assert!(accepted.relations.values().any(|relation| matches!(relation.body, RelationBody::BoundaryExchange { source, target, oriented, .. } if source == expected_source && target == expected_target && oriented == (kind == ComponentPortKind::Flow))));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_port_names_show_owner_contract_and_unique_alias() {
|
|
let mut p = Project::empty();
|
|
let actor = p
|
|
.add_element(Kind::SystemActor, Layer::Sa, "Operator", None)
|
|
.unwrap();
|
|
let system = p
|
|
.add_element(Kind::System, Layer::Sa, "Rescue", None)
|
|
.unwrap();
|
|
let mut labels = BTreeSet::new();
|
|
for owner in [actor, actor, system] {
|
|
let id = Id::new_v4();
|
|
let port = make_port(
|
|
&p,
|
|
id,
|
|
owner,
|
|
"Data",
|
|
PortContract::Component {
|
|
kind: ComponentPortKind::Flow,
|
|
orientation: ComponentPortOrientation::Out,
|
|
},
|
|
)
|
|
.unwrap();
|
|
p.elements.insert(id, port);
|
|
let label = choice(&p, id).label;
|
|
assert!(label.contains(&format!("{} / Data", p.named(owner))));
|
|
assert!(label.contains("Flow Out"));
|
|
assert!(label.contains(&format!("@{}", p.elements[&id].alias)));
|
|
assert!(
|
|
labels.insert(label),
|
|
"Duplicate names must still identify distinct ports"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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]));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod saved_scenario_destination_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn empty_transition_results_do_not_make_a_scenario_an_architecture_destination() {
|
|
let mut p = Project::empty();
|
|
let owner = p
|
|
.add_element(Kind::Scenario, Layer::Oa, "Scenario", None)
|
|
.unwrap();
|
|
let mut diagram = Diagram {
|
|
id: Id::new_v4(),
|
|
alias: "target_view".into(),
|
|
name: "Target".into(),
|
|
description: String::new(),
|
|
layer: Layer::Oa,
|
|
projection: ProjectionKind::Scenario { scenario: owner },
|
|
view_options: Default::default(),
|
|
nodes: Default::default(),
|
|
routes: vec![],
|
|
layout: None,
|
|
};
|
|
assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
|
let machine = p
|
|
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
|
|
.unwrap();
|
|
diagram.projection = ProjectionKind::StateMachine { machine };
|
|
assert!(result_view_compatible(&p, &diagram, &[machine], Layer::Oa));
|
|
assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
|
diagram.projection = ProjectionKind::Overview;
|
|
assert!(result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
|
}
|
|
}
|