1654 lines
59 KiB
Rust
1654 lines
59 KiB
Rust
//! Process/chain authoring and projection use contextual occurrences and explicit
|
|
//! SequenceLinks. Presentation order never invents control-flow relationships.
|
|
use super::*;
|
|
use iced::widget::{Column, text_input};
|
|
use std::collections::BTreeSet;
|
|
mod direct;
|
|
mod view;
|
|
use direct::Input;
|
|
pub(crate) use direct::{
|
|
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
|
|
};
|
|
#[cfg(test)]
|
|
pub(crate) mod direct_tests;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Tool {
|
|
Work,
|
|
Exchange,
|
|
Reuse,
|
|
Control,
|
|
Connect,
|
|
Branch,
|
|
}
|
|
impl Tool {
|
|
fn title(self) -> &'static str {
|
|
match self {
|
|
Self::Work => "Involve work",
|
|
Self::Exchange => "Involve exchange",
|
|
Self::Reuse => "Reuse process / chain",
|
|
Self::Control => "Add control",
|
|
Self::Connect => "Connect contributions",
|
|
Self::Branch => "Insert two paths",
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Start(Id, Tool, Option<Id>),
|
|
Cancel,
|
|
Submit,
|
|
Name(String),
|
|
Target(Choice),
|
|
Alternate(Choice),
|
|
Before(Choice),
|
|
After(Choice),
|
|
ClearBefore,
|
|
ClearAfter,
|
|
Guard(String),
|
|
AlternateGuard(String),
|
|
Control(ControlChoice),
|
|
Search(usize, String),
|
|
Page(usize, usize),
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct ControlChoice(pub ControlKind);
|
|
impl std::fmt::Display for ControlChoice {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self.0 {
|
|
ControlKind::Choice => "OR · alternative paths",
|
|
ControlKind::Merge => "OR · merge alternatives",
|
|
ControlKind::Fork => "AND · parallel paths",
|
|
ControlKind::Join => "AND · join parallel paths",
|
|
ControlKind::Loop => "IT · repeat / exit",
|
|
})
|
|
}
|
|
}
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
identity: Option<(Id, Id)>,
|
|
tool: CanvasTool,
|
|
expanded: BTreeSet<Id>,
|
|
scroll_offsets: [iced::Vector; 2],
|
|
return_to: Vec<(Id, Id)>,
|
|
draft: Option<Draft>,
|
|
viewport: Option<Size>,
|
|
zoom: Option<f32>,
|
|
fit: bool,
|
|
}
|
|
#[derive(Default)]
|
|
struct Browser {
|
|
search: String,
|
|
page: usize,
|
|
}
|
|
struct Draft {
|
|
insertion: Insertion,
|
|
fixed: bool,
|
|
browsers: [Browser; 4],
|
|
owner: Id,
|
|
tool: Tool,
|
|
name: String,
|
|
target: Option<Id>,
|
|
alternate: Option<Id>,
|
|
before: Option<Id>,
|
|
after: Option<Id>,
|
|
guard: String,
|
|
alternate_guard: String,
|
|
control: ControlKind,
|
|
}
|
|
impl State {
|
|
pub(super) fn pending_owner(&self) -> Option<Id> {
|
|
self.draft.as_ref().map(|d| d.owner)
|
|
}
|
|
|
|
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
|
let command = self
|
|
.draft
|
|
.as_ref()
|
|
.ok_or("Choose a contribution tool first.")?
|
|
.command(p)?;
|
|
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
|
Ok(command)
|
|
}
|
|
|
|
pub(super) fn acknowledge_commit(&mut self) {
|
|
self.draft = None;
|
|
}
|
|
|
|
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
|
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
|
return Err(PENDING_DRAFT.into());
|
|
}
|
|
match event {
|
|
Event::Start(owner, tool, selected) => {
|
|
let e = p
|
|
.elements
|
|
.get(&owner)
|
|
.ok_or("This process or chain no longer exists.")?;
|
|
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
|
return Err("Choose an operational process or system functional chain.".into());
|
|
}
|
|
let before = selected.filter(|id| is_contribution(p, owner, *id));
|
|
let after = before.and_then(|id| {
|
|
let outgoing: Vec<_> = sequence_links(p, owner)
|
|
.into_iter()
|
|
.filter(|r| r.source() == id)
|
|
.map(Relation::target)
|
|
.collect();
|
|
if outgoing.len() == 1 {
|
|
outgoing.first().copied()
|
|
} else {
|
|
legacy_order(p, owner)
|
|
.windows(2)
|
|
.find(|pair| pair[0] == id)
|
|
.map(|pair| pair[1])
|
|
}
|
|
});
|
|
self.draft = Some(Draft {
|
|
insertion: Insertion::default(),
|
|
fixed: false,
|
|
browsers: Default::default(),
|
|
owner,
|
|
tool,
|
|
name: String::new(),
|
|
target: None,
|
|
alternate: None,
|
|
before,
|
|
after,
|
|
guard: String::new(),
|
|
alternate_guard: "else".into(),
|
|
control: ControlKind::Choice,
|
|
});
|
|
}
|
|
Event::Cancel => self.acknowledge_commit(),
|
|
Event::Submit => return self.prepare_pending(p).map(Some),
|
|
other => {
|
|
if let Some(d) = &mut self.draft {
|
|
match other {
|
|
Event::Name(value) => d.name = value,
|
|
Event::Target(value) => d.target = Some(value.id),
|
|
Event::Alternate(value) => d.alternate = Some(value.id),
|
|
Event::Before(value) => d.before = Some(value.id),
|
|
Event::After(value) => d.after = Some(value.id),
|
|
Event::ClearBefore => d.before = None,
|
|
Event::ClearAfter => d.after = None,
|
|
Event::Guard(value) => d.guard = value,
|
|
Event::AlternateGuard(value) => d.alternate_guard = value,
|
|
Event::Control(value) => d.control = value.0,
|
|
Event::Search(index, value) if index < 4 => {
|
|
d.browsers[index].search = value;
|
|
d.browsers[index].page = 0;
|
|
}
|
|
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
|
|
if !self.fit {
|
|
return self.zoom.unwrap_or(1.);
|
|
}
|
|
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
|
((width - 24.).max(1.) / graph.width.max(1.))
|
|
.min(450. / graph.height.max(1.))
|
|
.clamp(0.05, 1.)
|
|
}
|
|
}
|
|
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
|
|
p.elements.get(&id).is_some_and(|e| {
|
|
e.parent == Some(owner)
|
|
&& matches!(
|
|
e.shared(),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Action { .. }
|
|
| OccurrenceBody::Exchange { .. }
|
|
| OccurrenceBody::Control { .. }
|
|
| OccurrenceBody::SequenceReference { .. },
|
|
..
|
|
})
|
|
)
|
|
})
|
|
}
|
|
fn reference_reaches(p: &Project, from: Id, to: Id) -> bool {
|
|
let mut pending = vec![from];
|
|
let mut seen = BTreeSet::new();
|
|
while let Some(id) = pending.pop() {
|
|
if id == to {
|
|
return true;
|
|
}
|
|
if seen.insert(id) {
|
|
pending.extend(p.children(id).filter_map(|e| match e.shared() {
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::SequenceReference { sequence },
|
|
..
|
|
}) => *sequence,
|
|
_ => None,
|
|
}));
|
|
}
|
|
}
|
|
false
|
|
}
|
|
fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
|
|
let Some(e) = p.elements.get(&owner) else {
|
|
return vec![];
|
|
};
|
|
let mut choices: Vec<_> = if tool == Tool::Exchange {
|
|
p.relations
|
|
.values()
|
|
.filter(|r| {
|
|
p.layer_of(r.id) == Some(e.layer())
|
|
&& r.kind()
|
|
== if e.layer() == Layer::Oa {
|
|
RelationKind::Interaction
|
|
} else {
|
|
RelationKind::FunctionalExchange
|
|
}
|
|
})
|
|
.map(|r| Choice {
|
|
id: r.id,
|
|
label: format!(
|
|
"{} · {} → {}",
|
|
r.name,
|
|
p.named(r.source()),
|
|
p.named(r.target())
|
|
),
|
|
})
|
|
.collect()
|
|
} else {
|
|
p.elements
|
|
.values()
|
|
.filter(|candidate| {
|
|
candidate.layer() == e.layer()
|
|
&& match tool {
|
|
Tool::Work | Tool::Branch => candidate.kind().is_work(),
|
|
Tool::Reuse => {
|
|
candidate.kind() == e.kind()
|
|
&& !reference_reaches(p, candidate.id, owner)
|
|
}
|
|
_ => false,
|
|
}
|
|
})
|
|
.map(|e| Choice {
|
|
id: e.id,
|
|
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
|
|
})
|
|
.collect()
|
|
};
|
|
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
|
choices
|
|
}
|
|
fn semantic_path(p: &Project, id: Id) -> String {
|
|
let mut path = Vec::new();
|
|
let mut current = Some(id);
|
|
while let Some(id) = current {
|
|
let Some(e) = p.elements.get(&id) else { break };
|
|
path.push(e.alias.as_str());
|
|
current = e.parent;
|
|
}
|
|
path.reverse();
|
|
path.join(" / ")
|
|
}
|
|
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
|
|
p.elements
|
|
.get(&owner)
|
|
.and_then(|e| e.steps())
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|id| is_contribution(p, owner, **id))
|
|
.enumerate()
|
|
.map(|(i, id)| Choice {
|
|
id: *id,
|
|
label: format!("{}. {}", i + 1, p.named(*id)),
|
|
})
|
|
.collect()
|
|
}
|
|
fn legacy_order(p: &Project, owner: Id) -> Vec<Id> {
|
|
if uses_explicit_topology(p, owner) {
|
|
return vec![];
|
|
}
|
|
let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) else {
|
|
return vec![];
|
|
};
|
|
if steps.iter().all(|id| {
|
|
matches!(
|
|
p.elements.get(id).and_then(|e| e.shared()),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange { .. },
|
|
..
|
|
})
|
|
)
|
|
}) {
|
|
steps.clone()
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
fn preserved_order(p: &Project, owner: Id, replace: Option<(Id, Id)>) -> Vec<Command> {
|
|
legacy_order(p, owner)
|
|
.windows(2)
|
|
.filter(|pair| replace != Some((pair[0], pair[1])))
|
|
.map(|pair| link(p, pair[0], pair[1], ""))
|
|
.collect()
|
|
}
|
|
fn contribution_work(p: &Project, id: Id) -> Option<Id> {
|
|
match p.elements.get(&id)?.shared()? {
|
|
SharedBody::Occurrence {
|
|
body: OccurrenceBody::Action { action, .. },
|
|
..
|
|
} => *action,
|
|
_ => None,
|
|
}
|
|
}
|
|
fn contribution_exchange(p: &Project, id: Id) -> Option<&Relation> {
|
|
match p.elements.get(&id)?.shared()? {
|
|
SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange { exchange, .. },
|
|
..
|
|
} => exchange.and_then(|id| p.relations.get(&id)),
|
|
_ => None,
|
|
}
|
|
}
|
|
fn compatible_source(p: &Project, draft: &Draft, id: Id) -> bool {
|
|
match draft.tool {
|
|
Tool::Work => {
|
|
draft
|
|
.before
|
|
.and_then(|id| contribution_exchange(p, id))
|
|
.is_none_or(|r| r.target() == id)
|
|
&& draft
|
|
.after
|
|
.and_then(|id| contribution_exchange(p, id))
|
|
.is_none_or(|r| r.source() == id)
|
|
}
|
|
Tool::Exchange => {
|
|
let Some(exchange) = p.relations.get(&id) else {
|
|
return false;
|
|
};
|
|
let preceding = draft.before.and_then(|id| {
|
|
contribution_work(p, id)
|
|
.or_else(|| contribution_exchange(p, id).map(Relation::target))
|
|
});
|
|
let following = draft.after.and_then(|id| {
|
|
contribution_work(p, id)
|
|
.or_else(|| contribution_exchange(p, id).map(Relation::source))
|
|
});
|
|
preceding.is_none_or(|id| exchange.source() == id)
|
|
&& following.is_none_or(|id| exchange.target() == id)
|
|
}
|
|
_ => true,
|
|
}
|
|
}
|
|
pub(super) fn occurrence(
|
|
p: &Project,
|
|
owner: Id,
|
|
name: &str,
|
|
body: OccurrenceBody,
|
|
) -> oa_core::Element {
|
|
let mut e = p
|
|
.make_element(
|
|
Kind::Occurrence,
|
|
p.elements[&owner].layer(),
|
|
name,
|
|
Some(owner),
|
|
)
|
|
.expect("Occurrence layer");
|
|
if let Some(SharedBody::Occurrence { body: value, .. }) = e.shared_mut() {
|
|
*value = body;
|
|
}
|
|
e
|
|
}
|
|
fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
|
|
let id = Id::new_v4();
|
|
Command::CreateRelation(Relation {
|
|
id,
|
|
alias: p.fresh_alias("sequence_link", "relation", id),
|
|
name: "Sequence link".into(),
|
|
description: String::new(),
|
|
body: RelationBody::SequenceLink {
|
|
source,
|
|
target,
|
|
guard: guard.to_owned(),
|
|
},
|
|
})
|
|
}
|
|
impl Draft {
|
|
fn command(&self, p: &Project) -> Result<Command, String> {
|
|
let owner = p
|
|
.elements
|
|
.get(&self.owner)
|
|
.ok_or("The process or chain no longer exists.")?;
|
|
if !matches!(owner.kind(), Kind::Process | Kind::FunctionalChain) {
|
|
return Err("Choose a process or chain.".into());
|
|
}
|
|
for id in [self.before, self.after].into_iter().flatten() {
|
|
if !is_contribution(p, self.owner, id) {
|
|
return Err("Choose contributions from this process or chain.".into());
|
|
}
|
|
}
|
|
if self.before.is_some() && self.before == self.after {
|
|
return Err("Choose different preceding and following contributions.".into());
|
|
}
|
|
let captured = self
|
|
.insertion
|
|
.link
|
|
.map(|id| {
|
|
p.relations
|
|
.get(&id)
|
|
.filter(|r| {
|
|
r.kind() == RelationKind::SequenceLink
|
|
&& is_contribution(p, self.owner, r.source())
|
|
&& is_contribution(p, self.owner, r.target())
|
|
})
|
|
.ok_or("The captured path no longer exists in this process.")
|
|
})
|
|
.transpose()?;
|
|
if captured
|
|
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
|
|
{
|
|
return Err("The captured path changed. Choose its position again.".into());
|
|
}
|
|
let matches: Vec<_> = sequence_links(p, self.owner)
|
|
.into_iter()
|
|
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
|
|
.collect();
|
|
let split_link = if self.tool == Tool::Connect {
|
|
None
|
|
} else if captured.is_some() {
|
|
captured
|
|
} else if !self.fixed && matches.len() == 1 {
|
|
Some(matches[0])
|
|
} else if !self.fixed && matches.len() > 1 {
|
|
return Err("Select the specific sequence link to insert on.".into());
|
|
} else {
|
|
None
|
|
};
|
|
let mut commands = preserved_order(
|
|
p,
|
|
self.owner,
|
|
if self.tool == Tool::Connect {
|
|
None
|
|
} else {
|
|
self.before.zip(self.after)
|
|
},
|
|
);
|
|
if self.tool == Tool::Connect {
|
|
let source = self.before.ok_or("Choose the preceding contribution.")?;
|
|
let target = self.after.ok_or("Choose the following contribution.")?;
|
|
if sequence_links(p, self.owner)
|
|
.iter()
|
|
.any(|r| r.source() == source && r.target() == target)
|
|
{
|
|
return Err("These contributions already have a sequence link; select it to edit its guard.".into());
|
|
}
|
|
if legacy_order(p, self.owner)
|
|
.windows(2)
|
|
.any(|pair| pair == [source, target])
|
|
{
|
|
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
|
|
}
|
|
commands.push(link(p, source, target, &self.guard));
|
|
return Ok(unique_batch(p, commands));
|
|
}
|
|
let target = if matches!(self.tool, Tool::Control) {
|
|
None
|
|
} else {
|
|
let id = self
|
|
.target
|
|
.ok_or("Choose the existing work, exchange or process to involve.")?;
|
|
if !targets(p, self.owner, self.tool).iter().any(|c| c.id == id)
|
|
|| !compatible_source(p, self, id)
|
|
{
|
|
return Err(
|
|
"The selected source is incompatible or would introduce recursive reuse."
|
|
.into(),
|
|
);
|
|
}
|
|
Some(id)
|
|
};
|
|
let default_name = target.map(|id| p.named(id)).unwrap_or(match self.control {
|
|
ControlKind::Choice => "Choose path",
|
|
ControlKind::Merge => "Merge paths",
|
|
ControlKind::Fork => "Start parallel paths",
|
|
ControlKind::Join => "Join paths",
|
|
ControlKind::Loop => "Repeat or exit",
|
|
});
|
|
let name = if self.name.trim().is_empty() {
|
|
default_name
|
|
} else {
|
|
self.name.trim()
|
|
};
|
|
let (first, last) = if self.tool == Tool::Branch {
|
|
let alternate = self.alternate.ok_or("Choose work for the second path.")?;
|
|
if !targets(p, self.owner, Tool::Work)
|
|
.iter()
|
|
.any(|c| c.id == alternate)
|
|
{
|
|
return Err("Choose same-layer work for the second path.".into());
|
|
}
|
|
let parallel = self.control == ControlKind::Fork;
|
|
let split = occurrence(
|
|
p,
|
|
self.owner,
|
|
if parallel {
|
|
"Start parallel paths"
|
|
} else {
|
|
"Choose path"
|
|
},
|
|
OccurrenceBody::Control {
|
|
kind: if parallel {
|
|
ControlKind::Fork
|
|
} else {
|
|
ControlKind::Choice
|
|
},
|
|
},
|
|
);
|
|
let merge = occurrence(
|
|
p,
|
|
self.owner,
|
|
if parallel {
|
|
"Join paths"
|
|
} else {
|
|
"Merge alternatives"
|
|
},
|
|
OccurrenceBody::Control {
|
|
kind: if parallel {
|
|
ControlKind::Join
|
|
} else {
|
|
ControlKind::Merge
|
|
},
|
|
},
|
|
);
|
|
let a = occurrence(
|
|
p,
|
|
self.owner,
|
|
name,
|
|
OccurrenceBody::Action {
|
|
action: target,
|
|
lifeline: None,
|
|
},
|
|
);
|
|
let b = occurrence(
|
|
p,
|
|
self.owner,
|
|
p.named(alternate),
|
|
OccurrenceBody::Action {
|
|
action: Some(alternate),
|
|
lifeline: None,
|
|
},
|
|
);
|
|
let ids = (split.id, a.id, b.id, merge.id);
|
|
commands.extend([split, a, b, merge].into_iter().map(Command::CreateElement));
|
|
commands.extend([
|
|
link(p, ids.0, ids.1, if parallel { "" } else { &self.guard }),
|
|
link(
|
|
p,
|
|
ids.0,
|
|
ids.2,
|
|
if parallel { "" } else { &self.alternate_guard },
|
|
),
|
|
link(p, ids.1, ids.3, ""),
|
|
link(p, ids.2, ids.3, ""),
|
|
]);
|
|
(ids.0, ids.3)
|
|
} else {
|
|
let body = match self.tool {
|
|
Tool::Work => OccurrenceBody::Action {
|
|
action: target,
|
|
lifeline: None,
|
|
},
|
|
Tool::Exchange => OccurrenceBody::Exchange {
|
|
exchange: target,
|
|
sender: None,
|
|
receiver: None,
|
|
message_kind: MessageKind::default(),
|
|
reply_to: None,
|
|
values: vec![],
|
|
},
|
|
Tool::Reuse => OccurrenceBody::SequenceReference { sequence: target },
|
|
Tool::Control => OccurrenceBody::Control { kind: self.control },
|
|
_ => unreachable!(),
|
|
};
|
|
let e = occurrence(p, self.owner, name, body);
|
|
let id = e.id;
|
|
commands.push(Command::CreateElement(e));
|
|
(id, id)
|
|
};
|
|
if let Some(existing) = split_link {
|
|
let mut updated = existing.clone();
|
|
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
|
|
*target = first;
|
|
}
|
|
commands.push(Command::EditRelation(existing.id, updated));
|
|
} else if let Some(before) = self.before {
|
|
commands.push(link(
|
|
p,
|
|
before,
|
|
first,
|
|
if self.tool == Tool::Branch {
|
|
""
|
|
} else {
|
|
&self.guard
|
|
},
|
|
));
|
|
}
|
|
if let Some(after) = self.after {
|
|
commands.push(link(p, last, after, ""));
|
|
}
|
|
let new_ids: Vec<_> = commands
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let mut order = owner.steps().expect("process steps").clone();
|
|
let slot = self.insertion.before.or(self.after);
|
|
let index = slot
|
|
.map(|id| {
|
|
order
|
|
.iter()
|
|
.position(|v| *v == id)
|
|
.ok_or("The presentation slot changed.")
|
|
})
|
|
.transpose()?
|
|
.unwrap_or(order.len());
|
|
order.splice(index..index, new_ids);
|
|
commands.push(Command::SetSteps(self.owner, order));
|
|
Ok(unique_batch(p, commands))
|
|
}
|
|
}
|
|
pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
|
|
let mut aliases: BTreeSet<String> = p
|
|
.identities()
|
|
.into_iter()
|
|
.map(|(_, alias)| alias.to_owned())
|
|
.collect();
|
|
for command in &mut commands {
|
|
let (id, alias) = match command {
|
|
Command::CreateElement(e) => (e.id, &mut e.alias),
|
|
Command::CreateRelation(r) => (r.id, &mut r.alias),
|
|
_ => continue,
|
|
};
|
|
if !aliases.insert(alias.clone()) {
|
|
*alias = format!("{}_{}", alias, id.simple());
|
|
aliases.insert(alias.clone());
|
|
}
|
|
}
|
|
Command::Batch(commands)
|
|
}
|
|
fn event(e: Event) -> Message {
|
|
Message::Behavior(if matches!(e, Event::Submit) {
|
|
super::Event::Submit
|
|
} else {
|
|
super::Event::Topology(e)
|
|
})
|
|
}
|
|
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
|
view::view(app, owner, out)
|
|
}
|
|
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
|
p.relations
|
|
.values()
|
|
.filter(|r| {
|
|
r.kind() == RelationKind::SequenceLink
|
|
&& is_contribution(p, owner, r.source())
|
|
&& is_contribution(p, owner, r.target())
|
|
})
|
|
.collect()
|
|
}
|
|
struct GraphNode {
|
|
id: Id,
|
|
rect: Rectangle,
|
|
title: String,
|
|
detail: String,
|
|
control: Option<ControlKind>,
|
|
source: Option<Id>,
|
|
preview: Vec<(Id, String)>,
|
|
preview_links: Vec<(Id, Id)>,
|
|
preview_total: usize,
|
|
}
|
|
struct GraphEdge {
|
|
id: Id,
|
|
source: Id,
|
|
target: Id,
|
|
guard: String,
|
|
points: Vec<Point>,
|
|
label: Rectangle,
|
|
}
|
|
struct Graph<'a> {
|
|
project: &'a Project,
|
|
interaction: Option<(Context, CanvasTool, bool)>,
|
|
scroll_offset: iced::Vector,
|
|
nodes: Vec<GraphNode>,
|
|
edges: Vec<GraphEdge>,
|
|
size: Size,
|
|
selected: Id,
|
|
scale: f32,
|
|
}
|
|
impl<'a> Graph<'a> {
|
|
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
|
|
Self::expanded(p, owner, selected, &BTreeSet::new())
|
|
}
|
|
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
|
|
let choices = contributions(p, owner);
|
|
let links = sequence_links(p, owner);
|
|
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
|
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
|
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
|
for r in &links {
|
|
*incoming.entry(r.target()).or_default() += 1;
|
|
}
|
|
let mut queue: Vec<_> = choices
|
|
.iter()
|
|
.filter(|c| incoming[&c.id] == 0)
|
|
.map(|c| c.id)
|
|
.collect();
|
|
let mut done = BTreeSet::new();
|
|
let mut offset = 0;
|
|
while offset < queue.len() {
|
|
let id = queue[offset];
|
|
offset += 1;
|
|
if !done.insert(id) {
|
|
continue;
|
|
}
|
|
for r in links.iter().filter(|r| r.source() == id) {
|
|
let next = r.target();
|
|
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
|
|
let count = incoming.get_mut(&next).expect("Graph endpoint");
|
|
*count = count.saturating_sub(1);
|
|
if *count == 0 {
|
|
queue.push(next);
|
|
}
|
|
}
|
|
}
|
|
// Cycles remain explicit edges; deterministic fallback placement must
|
|
// terminate even while users are building a loop or incomplete graph.
|
|
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
|
|
for c in &choices {
|
|
if !done.contains(&c.id) {
|
|
ranks.insert(c.id, fallback);
|
|
fallback += 1;
|
|
}
|
|
}
|
|
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
|
let mut nodes = vec![];
|
|
let mut size: Size = Size::new(640., 220.);
|
|
for c in choices {
|
|
let e = &p.elements[&c.id];
|
|
let rank = ranks[&c.id];
|
|
let slot = rows.entry(rank).or_insert(24.);
|
|
let mut node = GraphNode {
|
|
id: e.id,
|
|
rect: Rectangle::new(
|
|
Point::new(24. + rank as f32 * 420., *slot),
|
|
Size::new(270., 104.),
|
|
),
|
|
title: e.name.clone(),
|
|
detail: String::new(),
|
|
control: None,
|
|
source: None,
|
|
preview: vec![],
|
|
preview_links: vec![],
|
|
preview_total: 0,
|
|
};
|
|
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
|
|
match body {
|
|
OccurrenceBody::Action { action, .. } => {
|
|
node.detail = action
|
|
.map(|id| format!("Work · {}", p.named(id)))
|
|
.unwrap_or("Choose work in Properties".into())
|
|
}
|
|
OccurrenceBody::Exchange { exchange, .. } => {
|
|
node.detail = exchange
|
|
.and_then(|id| p.relations.get(&id))
|
|
.map(|r| {
|
|
format!(
|
|
"Exchange · {} → {}",
|
|
p.named(r.source()),
|
|
p.named(r.target())
|
|
)
|
|
})
|
|
.unwrap_or("Choose exchange in Properties".into())
|
|
}
|
|
OccurrenceBody::Control { kind } => {
|
|
node.control = Some(*kind);
|
|
node.detail = ControlChoice(*kind).to_string();
|
|
}
|
|
OccurrenceBody::SequenceReference { sequence } => {
|
|
node.source = *sequence;
|
|
node.rect.height = 218.;
|
|
node.detail = sequence
|
|
.map(|id| format!("ref · {}", p.named(id)))
|
|
.unwrap_or("Choose source in Properties".into());
|
|
if let Some(source) = sequence {
|
|
let contributions = contributions(p, *source);
|
|
node.preview_total = contributions.len();
|
|
node.preview = contributions
|
|
.into_iter()
|
|
.take(if expanded.contains(&e.id) {
|
|
usize::MAX
|
|
} else {
|
|
4
|
|
})
|
|
.map(|c| (c.id, p.named(c.id).to_owned()))
|
|
.collect();
|
|
node.rect.height =
|
|
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
|
|
let preview_ids: BTreeSet<_> =
|
|
node.preview.iter().map(|(id, _)| *id).collect();
|
|
node.preview_links = sequence_links(p, *source)
|
|
.into_iter()
|
|
.filter(|r| {
|
|
preview_ids.contains(&r.source())
|
|
&& preview_ids.contains(&r.target())
|
|
})
|
|
.map(|r| (r.source(), r.target()))
|
|
.collect();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
*slot += node.rect.height + 36.;
|
|
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
|
|
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
|
nodes.push(node);
|
|
}
|
|
let mut edges = vec![];
|
|
let mut routed_pairs = BTreeSet::new();
|
|
for (i, r) in links.into_iter().enumerate() {
|
|
let a = nodes
|
|
.iter()
|
|
.find(|n| n.id == r.source())
|
|
.expect("Graph source")
|
|
.rect;
|
|
let b = nodes
|
|
.iter()
|
|
.find(|n| n.id == r.target())
|
|
.expect("Graph target")
|
|
.rect;
|
|
let start = Point::new(a.x + a.width, a.center_y());
|
|
let end = Point::new(b.x, b.center_y());
|
|
// Parallel records keep distinct visible paths and guard hit targets.
|
|
// Their semantic endpoints and IDs remain unchanged.
|
|
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
|
|
let points = if forward {
|
|
let mid = (start.x + end.x) / 2.;
|
|
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
|
|
} else {
|
|
let below = size.height + 24. + i as f32 * 42.;
|
|
vec![
|
|
start,
|
|
Point::new(start.x + 18., start.y),
|
|
Point::new(start.x + 18., below),
|
|
Point::new(end.x - 16., below),
|
|
Point::new(end.x - 16., end.y),
|
|
end,
|
|
]
|
|
};
|
|
let guard = match &r.body {
|
|
RelationBody::SequenceLink { guard, .. } => guard.clone(),
|
|
_ => String::new(),
|
|
};
|
|
let label_at = if forward {
|
|
Point::new(end.x - 138., end.y - 43.)
|
|
} else {
|
|
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
|
|
};
|
|
let label = Rectangle::new(label_at, Size::new(132., 36.));
|
|
edges.push(GraphEdge {
|
|
id: r.id,
|
|
source: r.source(),
|
|
target: r.target(),
|
|
guard,
|
|
points,
|
|
label,
|
|
});
|
|
}
|
|
for e in &edges {
|
|
for pt in &e.points {
|
|
size.height = size.height.max(pt.y + 30.);
|
|
}
|
|
}
|
|
Self {
|
|
project: p,
|
|
interaction: None,
|
|
scroll_offset: iced::Vector::ZERO,
|
|
nodes,
|
|
edges,
|
|
size,
|
|
selected,
|
|
scale: 1.,
|
|
}
|
|
}
|
|
}
|
|
fn wrapped(value: &str, width: usize, lines: usize) -> String {
|
|
let mut output = Vec::new();
|
|
let mut current = String::new();
|
|
let words: Vec<String> = value
|
|
.split_whitespace()
|
|
.flat_map(|word| {
|
|
word.chars()
|
|
.collect::<Vec<_>>()
|
|
.chunks(width.max(1))
|
|
.map(|chunk| chunk.iter().collect::<String>())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.collect();
|
|
for word in &words {
|
|
if !current.is_empty() && current.chars().count() + word.chars().count() + 1 > width {
|
|
output.push(current);
|
|
current = String::new();
|
|
}
|
|
if !current.is_empty() {
|
|
current.push(' ');
|
|
}
|
|
current.push_str(word);
|
|
}
|
|
if !current.is_empty() {
|
|
output.push(current);
|
|
}
|
|
let more = output.len() > lines;
|
|
output.truncate(lines);
|
|
if more && let Some(last) = output.last_mut() {
|
|
last.push('…');
|
|
}
|
|
output.join("\n")
|
|
}
|
|
fn draw_text(f: &mut Frame, content: String, position: Point, size: f32, color: Color) {
|
|
f.fill_text(Text {
|
|
content,
|
|
position,
|
|
size: size.into(),
|
|
color,
|
|
..Default::default()
|
|
});
|
|
}
|
|
fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
|
|
if points.len() < 2 {
|
|
return;
|
|
}
|
|
f.stroke(
|
|
&Path::new(|p| {
|
|
p.move_to(points[0]);
|
|
for pt in &points[1..] {
|
|
p.line_to(*pt);
|
|
}
|
|
}),
|
|
Stroke::default().with_color(color).with_width(width),
|
|
);
|
|
let end = points[points.len() - 1];
|
|
let delta = end - points[points.len() - 2];
|
|
let length = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.);
|
|
let d = delta / length;
|
|
f.stroke(
|
|
&Path::new(|p| {
|
|
p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.);
|
|
p.line_to(end);
|
|
p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.);
|
|
}),
|
|
Stroke::default().with_color(color).with_width(width),
|
|
);
|
|
}
|
|
impl canvas::Program<Message> for Graph<'_> {
|
|
type State = Input;
|
|
fn update(
|
|
&self,
|
|
state: &mut Input,
|
|
event: &canvas::Event,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Option<Action<Message>> {
|
|
self.input(state, event, bounds, cursor)
|
|
}
|
|
fn draw(
|
|
&self,
|
|
state: &Input,
|
|
renderer: &Renderer,
|
|
_: &Theme,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Vec<canvas::Geometry> {
|
|
let mut f = Frame::new(renderer, bounds.size());
|
|
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
|
f.scale(self.scale);
|
|
for e in &self.edges {
|
|
let selected =
|
|
e.id == self.selected || e.source == self.selected || e.target == self.selected;
|
|
draw_arrow(
|
|
&mut f,
|
|
&e.points,
|
|
if selected { style::TEAL } else { style::MUTED },
|
|
if selected { 2.5 } else { 1.5 },
|
|
);
|
|
}
|
|
for n in &self.nodes {
|
|
let rect = Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into());
|
|
f.fill(&rect, Color::from_rgb8(243, 248, 248));
|
|
f.stroke(
|
|
&rect,
|
|
Stroke::default()
|
|
.with_color(if n.id == self.selected {
|
|
style::TEAL
|
|
} else {
|
|
style::LINE
|
|
})
|
|
.with_width(if n.id == self.selected { 2.5 } else { 1. }),
|
|
);
|
|
let text_x = n.rect.x + if n.control.is_some() { 68. } else { 12. };
|
|
if let Some(kind) = n.control {
|
|
let center = Point::new(n.rect.x + 33., n.rect.y + 34.);
|
|
let glyph = match kind {
|
|
ControlKind::Choice | ControlKind::Merge => Path::new(|p| {
|
|
p.move_to(Point::new(center.x, center.y - 22.));
|
|
p.line_to(Point::new(center.x + 23., center.y));
|
|
p.line_to(Point::new(center.x, center.y + 22.));
|
|
p.line_to(Point::new(center.x - 23., center.y));
|
|
p.close();
|
|
}),
|
|
ControlKind::Fork | ControlKind::Join => Path::rectangle(
|
|
Point::new(center.x - 23., center.y - 5.),
|
|
Size::new(46., 10.),
|
|
),
|
|
ControlKind::Loop => Path::circle(center, 23.),
|
|
};
|
|
f.stroke(
|
|
&glyph,
|
|
Stroke::default().with_color(style::TEAL).with_width(2.),
|
|
);
|
|
if matches!(kind, ControlKind::Fork | ControlKind::Join) {
|
|
f.fill(&glyph, style::TEAL);
|
|
} else {
|
|
draw_text(
|
|
&mut f,
|
|
if kind == ControlKind::Loop {
|
|
"IT"
|
|
} else {
|
|
"OR"
|
|
}
|
|
.into(),
|
|
Point::new(center.x - 10., center.y - 8.),
|
|
12.,
|
|
style::TEAL,
|
|
);
|
|
}
|
|
}
|
|
draw_text(
|
|
&mut f,
|
|
wrapped(&n.title, if n.control.is_some() { 25 } else { 34 }, 2),
|
|
Point::new(text_x, n.rect.y + 12.),
|
|
13.,
|
|
style::INK,
|
|
);
|
|
draw_text(
|
|
&mut f,
|
|
wrapped(&n.detail, 37, 2),
|
|
Point::new(n.rect.x + 12., n.rect.y + 60.),
|
|
11.,
|
|
style::MUTED,
|
|
);
|
|
if n.source.is_some() {
|
|
draw_text(
|
|
&mut f,
|
|
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
|
|
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
|
11.,
|
|
style::TEAL,
|
|
);
|
|
let mini: BTreeMap<_, _> = n
|
|
.preview
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (id, _))| {
|
|
(
|
|
*id,
|
|
Rectangle::new(
|
|
Point::new(
|
|
n.rect.x + 12. + (i % 2) as f32 * 125.,
|
|
n.rect.y + 118. + (i / 2) as f32 * 44.,
|
|
),
|
|
Size::new(113., 32.),
|
|
),
|
|
)
|
|
})
|
|
.collect();
|
|
for (a, b) in &n.preview_links {
|
|
let a = mini[a];
|
|
let b = mini[b];
|
|
draw_arrow(
|
|
&mut f,
|
|
&[
|
|
Point::new(a.x + a.width, a.center_y()),
|
|
Point::new(b.x, b.center_y()),
|
|
],
|
|
style::MUTED,
|
|
1.,
|
|
);
|
|
}
|
|
for (id, name) in &n.preview {
|
|
let r = mini[id];
|
|
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
|
f.stroke(
|
|
&Path::rectangle(r.position(), r.size()),
|
|
Stroke::default().with_color(style::LINE),
|
|
);
|
|
draw_text(
|
|
&mut f,
|
|
wrapped(name, 17, 2),
|
|
Point::new(r.x + 4., r.y + 3.),
|
|
9.,
|
|
style::INK,
|
|
);
|
|
}
|
|
if n.preview.is_empty() {
|
|
draw_text(
|
|
&mut f,
|
|
"Source has no contributions yet".into(),
|
|
Point::new(n.rect.x + 12., n.rect.y + 136.),
|
|
11.,
|
|
style::MUTED,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
for e in &self.edges {
|
|
if !e.guard.is_empty() {
|
|
f.fill_rectangle(e.label.position(), e.label.size(), Color::WHITE);
|
|
draw_text(
|
|
&mut f,
|
|
wrapped(&format!("[{}]", e.guard), 19, 2),
|
|
e.label.position(),
|
|
11.,
|
|
style::INK,
|
|
);
|
|
}
|
|
}
|
|
self.overlay(state, &mut f, cursor, bounds);
|
|
vec![f.into_geometry()]
|
|
}
|
|
fn mouse_interaction(
|
|
&self,
|
|
_: &Input,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> mouse::Interaction {
|
|
if cursor.position_in(bounds).is_some_and(|p| {
|
|
let p = Point::new(p.x / self.scale, p.y / self.scale);
|
|
self.nodes.iter().any(|n| n.rect.contains(p))
|
|
|| self.edges.iter().any(|e| {
|
|
e.label.contains(p)
|
|
|| e.points
|
|
.windows(2)
|
|
.any(|v| crate::diagram::distance_to_segment(p, v[0], v[1]) < 8.)
|
|
})
|
|
}) {
|
|
mouse::Interaction::Pointer
|
|
} else {
|
|
mouse::Interaction::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn fixture() -> (Project, Id, Id, Id, Id, Id) {
|
|
let mut p = Project::demo();
|
|
let owner = p
|
|
.make_element(Kind::Process, Layer::Oa, "Branch test", None)
|
|
.unwrap();
|
|
let owner_id = owner.id;
|
|
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
|
|
let a = p
|
|
.make_element(Kind::Activity, Layer::Oa, "Shared display name", None)
|
|
.unwrap();
|
|
let a_id = a.id;
|
|
p = execute_batch(&p, &[Command::CreateElement(a)]).unwrap();
|
|
let b = p
|
|
.make_element(Kind::Activity, Layer::Oa, "Shared display name", None)
|
|
.unwrap();
|
|
let b_id = b.id;
|
|
p = execute_batch(&p, &[Command::CreateElement(b)]).unwrap();
|
|
let before = occurrence(
|
|
&p,
|
|
owner_id,
|
|
"Start work",
|
|
OccurrenceBody::Action {
|
|
action: Some(a_id),
|
|
lifeline: None,
|
|
},
|
|
);
|
|
let after = occurrence(
|
|
&p,
|
|
owner_id,
|
|
"Finish work",
|
|
OccurrenceBody::Action {
|
|
action: Some(b_id),
|
|
lifeline: None,
|
|
},
|
|
);
|
|
let before_id = before.id;
|
|
let after_id = after.id;
|
|
p = execute_batch(
|
|
&p,
|
|
&[
|
|
Command::CreateElement(before),
|
|
Command::CreateElement(after),
|
|
],
|
|
)
|
|
.unwrap();
|
|
(p, owner_id, a_id, b_id, before_id, after_id)
|
|
}
|
|
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
|
Draft {
|
|
insertion: Insertion::default(),
|
|
fixed: false,
|
|
browsers: Default::default(),
|
|
owner,
|
|
tool: Tool::Branch,
|
|
name: String::new(),
|
|
target: Some(a),
|
|
alternate: Some(b),
|
|
before: Some(before),
|
|
after: Some(after),
|
|
guard: "request accepted".into(),
|
|
alternate_guard: "else".into(),
|
|
control: ControlKind::Choice,
|
|
}
|
|
}
|
|
fn legacy_fixture() -> (Project, Id) {
|
|
let mut p = Project::demo();
|
|
let owner = p
|
|
.make_element(Kind::Process, Layer::Oa, "Existing ordered path", None)
|
|
.unwrap();
|
|
let owner_id = owner.id;
|
|
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
|
|
let mut work = vec![];
|
|
for index in 0..4 {
|
|
let e = p
|
|
.make_element(
|
|
Kind::Activity,
|
|
Layer::Oa,
|
|
&format!("Ordered work {index}"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
work.push(e.id);
|
|
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
|
|
}
|
|
let mut exchanges = vec![];
|
|
for (index, pair) in work.windows(2).enumerate() {
|
|
let id = Id::new_v4();
|
|
let name = format!("Ordered exchange {index}");
|
|
let r = Relation {
|
|
id,
|
|
alias: p.fresh_alias(&name, "relation", id),
|
|
name,
|
|
description: String::new(),
|
|
body: RelationBody::new(RelationKind::Interaction, pair[0], pair[1]),
|
|
};
|
|
p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap();
|
|
exchanges.push(id);
|
|
}
|
|
let commands = (0..3)
|
|
.map(|index| {
|
|
Command::CreateElement(occurrence(
|
|
&p,
|
|
owner_id,
|
|
&format!("Ordered contribution {index}"),
|
|
OccurrenceBody::Exchange {
|
|
exchange: Some(exchanges[index % exchanges.len()]),
|
|
sender: None,
|
|
receiver: None,
|
|
message_kind: MessageKind::default(),
|
|
reply_to: None,
|
|
values: vec![],
|
|
},
|
|
))
|
|
})
|
|
.collect();
|
|
p = execute_batch(&p, &[unique_batch(&p, commands)]).unwrap();
|
|
(p, owner_id)
|
|
}
|
|
#[test]
|
|
fn empty_process_can_start_through_normal_authoring_events() {
|
|
let mut p = Project::demo();
|
|
let owner = p
|
|
.make_element(Kind::Process, Layer::Oa, "New operational process", None)
|
|
.unwrap();
|
|
let owner_id = owner.id;
|
|
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
|
|
let source = targets(&p, owner_id, Tool::Work).first().unwrap().clone();
|
|
let mut state = State::default();
|
|
state
|
|
.update(&p, Event::Start(owner_id, Tool::Work, None))
|
|
.unwrap();
|
|
state.update(&p, Event::Target(source.clone())).unwrap();
|
|
state
|
|
.update(&p, Event::Name("First contribution".into()))
|
|
.unwrap();
|
|
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
|
let result = execute_batch(&p, &[command]).unwrap();
|
|
let steps = result.elements[&owner_id].steps().unwrap();
|
|
assert_eq!(steps.len(), 1);
|
|
assert!(uses_explicit_topology(&result, owner_id));
|
|
assert!(
|
|
matches!(result.elements[&steps[0]].shared(), Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Action { action: Some(id), .. }, .. }) if *id == source.id)
|
|
);
|
|
assert_eq!(Graph::new(&result, owner_id, owner_id).nodes.len(), 1);
|
|
}
|
|
#[test]
|
|
fn legacy_edit_preserves_order_and_new_work_in_one_undo() {
|
|
let (p, owner) = legacy_fixture();
|
|
let original_order = legacy_order(&p, owner);
|
|
let source = targets(&p, owner, Tool::Work).first().unwrap().clone();
|
|
let mut state = State::default();
|
|
state
|
|
.update(&p, Event::Start(owner, Tool::Work, None))
|
|
.unwrap();
|
|
state.update(&p, Event::Target(source)).unwrap();
|
|
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
|
let mut session = Session::new(p.clone());
|
|
session.execute(command).unwrap();
|
|
let links = sequence_links(session.project(), owner);
|
|
for pair in original_order.windows(2) {
|
|
assert!(
|
|
links
|
|
.iter()
|
|
.any(|r| r.source() == pair[0] && r.target() == pair[1])
|
|
);
|
|
}
|
|
assert_eq!(
|
|
session.project().elements[&owner].steps().unwrap().len(),
|
|
original_order.len() + 1
|
|
);
|
|
assert!(uses_explicit_topology(session.project(), owner));
|
|
session.undo();
|
|
assert_eq!(session.project(), &p);
|
|
}
|
|
#[test]
|
|
fn legacy_branch_insertion_replaces_only_the_selected_implied_link() {
|
|
let (p, owner) = legacy_fixture();
|
|
let order = legacy_order(&p, owner);
|
|
let sources = targets(&p, owner, Tool::Work);
|
|
let mut state = State::default();
|
|
state
|
|
.update(&p, Event::Start(owner, Tool::Branch, Some(order[0])))
|
|
.unwrap();
|
|
assert_eq!(state.draft.as_ref().unwrap().after, Some(order[1]));
|
|
state.update(&p, Event::Target(sources[0].clone())).unwrap();
|
|
state
|
|
.update(&p, Event::Alternate(sources[1].clone()))
|
|
.unwrap();
|
|
state.update(&p, Event::Guard("ready".into())).unwrap();
|
|
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
|
let result = execute_batch(&p, &[command]).unwrap();
|
|
let links = sequence_links(&result, owner);
|
|
assert!(
|
|
!links
|
|
.iter()
|
|
.any(|r| r.source() == order[0] && r.target() == order[1])
|
|
);
|
|
for pair in order.windows(2).skip(1) {
|
|
assert!(
|
|
links
|
|
.iter()
|
|
.any(|r| r.source() == pair[0] && r.target() == pair[1])
|
|
);
|
|
}
|
|
assert_eq!(links.len(), order.len() - 2 + 6);
|
|
assert_eq!(
|
|
result.elements[&owner].steps().unwrap().len(),
|
|
order.len() + 4
|
|
);
|
|
}
|
|
#[test]
|
|
fn insertion_picker_only_offers_work_matching_adjacent_exchanges() {
|
|
let (p, owner) = legacy_fixture();
|
|
let order = legacy_order(&p, owner);
|
|
let expected = contribution_exchange(&p, order[0]).unwrap().target();
|
|
let mut state = State::default();
|
|
state
|
|
.update(&p, Event::Start(owner, Tool::Work, Some(order[0])))
|
|
.unwrap();
|
|
let d = state.draft.as_ref().unwrap();
|
|
let choices: Vec<_> = targets(&p, owner, Tool::Work)
|
|
.into_iter()
|
|
.filter(|choice| compatible_source(&p, d, choice.id))
|
|
.collect();
|
|
assert_eq!(choices.len(), 1);
|
|
assert_eq!(choices[0].id, expected);
|
|
state.update(&p, Event::Target(choices[0].clone())).unwrap();
|
|
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
|
assert!(execute_batch(&p, &[command]).is_ok());
|
|
}
|
|
#[test]
|
|
fn guarded_branch_is_one_transaction_with_distinct_source_identities() {
|
|
let (mut p, owner, a, b, before, after) = fixture();
|
|
p = execute_batch(&p, &[link(&p, before, after, "")]).unwrap();
|
|
let old_link = sequence_links(&p, owner)[0].id;
|
|
let mut session = Session::new(p.clone());
|
|
let command = branch(owner, a, b, before, after).command(&p).unwrap();
|
|
session.execute(command).unwrap();
|
|
let result = session.project();
|
|
assert!(result.relations.contains_key(&old_link));
|
|
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
|
assert_eq!(sequence_links(result, owner).len(), 6);
|
|
assert_eq!(
|
|
result
|
|
.elements
|
|
.values()
|
|
.filter(|e| e.kind() == Kind::Activity)
|
|
.count(),
|
|
p.elements
|
|
.values()
|
|
.filter(|e| e.kind() == Kind::Activity)
|
|
.count()
|
|
);
|
|
let involved: Vec<_> = result
|
|
.children(owner)
|
|
.filter(|e| !p.elements.contains_key(&e.id))
|
|
.filter_map(|e| match e.shared() {
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Action { action, .. },
|
|
..
|
|
}) => *action,
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert!(involved.contains(&a) && involved.contains(&b));
|
|
let graph = Graph::new(result, owner, owner);
|
|
assert_eq!(graph.edges.len(), 6);
|
|
assert!(graph.edges.iter().any(|e| e.guard == "request accepted"));
|
|
assert!(graph.edges.iter().any(|e| e.guard == "else"));
|
|
for edge in graph.edges.iter().filter(|e| !e.guard.is_empty()) {
|
|
assert!(
|
|
!graph
|
|
.nodes
|
|
.iter()
|
|
.any(|node| node.rect.contains(edge.label.center())),
|
|
"Guard must not cover a contribution"
|
|
);
|
|
}
|
|
let issues = validation::validate(result);
|
|
assert!(!issues.iter().any(|i| {
|
|
matches!(
|
|
i.code.as_str(),
|
|
"control_cardinality" | "ambiguous_sequence_branch" | "missing_sequence_guard"
|
|
) && i.element.is_some_and(|id| {
|
|
id == owner
|
|
|| result
|
|
.elements
|
|
.get(&id)
|
|
.is_some_and(|e| e.parent == Some(owner))
|
|
})
|
|
}));
|
|
session.undo();
|
|
assert_eq!(session.project(), &p);
|
|
}
|
|
#[test]
|
|
fn unlinked_contributions_do_not_gain_fictional_edges() {
|
|
let (p, owner, ..) = fixture();
|
|
let graph = Graph::new(&p, owner, owner);
|
|
assert_eq!(graph.nodes.len(), 2);
|
|
assert!(graph.edges.is_empty());
|
|
}
|
|
#[test]
|
|
fn reusable_preview_preserves_identity_and_filters_recursive_sources() {
|
|
let (mut p, owner, a, _, before, after) = fixture();
|
|
let source = p
|
|
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
|
|
.unwrap();
|
|
let source_id = source.id;
|
|
p = execute_batch(&p, &[Command::CreateElement(source)]).unwrap();
|
|
let inside = occurrence(
|
|
&p,
|
|
source_id,
|
|
"Existing contribution",
|
|
OccurrenceBody::Action {
|
|
action: Some(a),
|
|
lifeline: None,
|
|
},
|
|
);
|
|
let inside_id = inside.id;
|
|
p = execute_batch(&p, &[Command::CreateElement(inside)]).unwrap();
|
|
let mut d = branch(owner, a, a, before, after);
|
|
d.tool = Tool::Reuse;
|
|
d.target = Some(source_id);
|
|
let result = execute_batch(&p, &[d.command(&p).unwrap()]).unwrap();
|
|
let graph = Graph::new(&result, owner, owner);
|
|
let reference = graph
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.source == Some(source_id))
|
|
.unwrap();
|
|
assert_eq!(reference.preview[0].0, inside_id);
|
|
assert_eq!(result.children(source_id).count(), 1);
|
|
assert!(
|
|
!targets(&result, source_id, Tool::Reuse)
|
|
.iter()
|
|
.any(|c| c.id == owner)
|
|
);
|
|
assert!(
|
|
!targets(&result, owner, Tool::Reuse)
|
|
.iter()
|
|
.any(|c| c.id == owner)
|
|
);
|
|
}
|
|
#[test]
|
|
fn stale_or_invalid_submit_preserves_inputs_and_creates_nothing() {
|
|
let (p, owner, a, b, before, after) = fixture();
|
|
let mut state = State {
|
|
draft: Some(branch(owner, a, b, before, after)),
|
|
..State::default()
|
|
};
|
|
state
|
|
.update(
|
|
&p,
|
|
Event::Target(Choice {
|
|
id: Id::nil(),
|
|
label: "Missing".into(),
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert!(state.update(&p, Event::Submit).is_err());
|
|
assert!(state.draft.is_some());
|
|
state
|
|
.update(
|
|
&p,
|
|
Event::Target(Choice {
|
|
id: a,
|
|
label: "Work".into(),
|
|
}),
|
|
)
|
|
.unwrap();
|
|
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
|
assert!(state.draft.is_some());
|
|
assert!(matches!(command, Command::Batch(_)));
|
|
assert_eq!(p.elements[&owner].steps().unwrap().len(), 2);
|
|
let result = execute_batch(&p, &[command]).unwrap();
|
|
state.acknowledge_commit();
|
|
assert!(state.draft.is_none());
|
|
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
|
}
|
|
#[test]
|
|
fn cycle_projection_terminates_and_retains_actual_links() {
|
|
let (mut p, owner, _, _, before, after) = fixture();
|
|
for command in [
|
|
link(&p, before, after, "continue"),
|
|
link(&p, after, before, "retry"),
|
|
] {
|
|
if let Command::CreateRelation(mut r) = command {
|
|
r.alias = format!("cycle_{}", r.id.simple());
|
|
p.relations.insert(r.id, r);
|
|
}
|
|
}
|
|
let graph = Graph::new(&p, owner, owner);
|
|
assert_eq!(graph.edges.len(), 2);
|
|
assert!(graph.size.width.is_finite() && graph.size.height.is_finite());
|
|
assert!(graph.edges.iter().any(|e| e.guard == "retry"));
|
|
assert!(
|
|
graph
|
|
.edges
|
|
.iter()
|
|
.all(|e| e.label.x >= 0. && e.label.y >= 0.)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn long_engineering_identifiers_stay_inside_label_width() {
|
|
let label = wrapped(
|
|
"HandlePassengerServiceAvailabilityWhileAircraftIsDecompressing",
|
|
19,
|
|
2,
|
|
);
|
|
assert!(label.ends_with('…'));
|
|
assert_eq!(label.lines().count(), 2);
|
|
assert!(label.lines().all(|line| line.chars().count() <= 20));
|
|
}
|
|
|
|
#[test]
|
|
fn fitting_a_branch_keeps_all_nodes_visible_and_selection_uses_scaled_coordinates() {
|
|
let (p, owner, a, b, before, after) = fixture();
|
|
let p = execute_batch(
|
|
&p,
|
|
&[branch(owner, a, b, before, after).command(&p).unwrap()],
|
|
)
|
|
.unwrap();
|
|
let mut graph = Graph::new(&p, owner, owner);
|
|
let mut state = State::default();
|
|
state.viewport(Size::new(486., 460.));
|
|
state.zoom(None);
|
|
graph.scale = state.scale(graph.size, 500.);
|
|
assert!(graph.size.width * graph.scale <= 462.1);
|
|
assert!(graph.size.height * graph.scale <= 450.1);
|
|
let node = graph.nodes.last().unwrap();
|
|
let click = Point::new(
|
|
(node.rect.x + 20.) * graph.scale,
|
|
(node.rect.y + 20.) * graph.scale,
|
|
);
|
|
let result = canvas::Program::update(
|
|
&graph,
|
|
&mut Input::default(),
|
|
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
|
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
|
|
mouse::Cursor::Available(click),
|
|
)
|
|
.unwrap()
|
|
.into_inner()
|
|
.0;
|
|
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
|
|
state.zoom(Some(1.));
|
|
state.viewport(Size::new(800., 460.));
|
|
assert_eq!(state.scale(graph.size, 500.), 1.);
|
|
state.zoom(None);
|
|
assert!(state.scale(graph.size, 500.) < 1.);
|
|
}
|
|
}
|