Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/topology.rs
T
Codex 01bb52a76d
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Improve behavior authoring and typed trace history (untested checkpoint)
2026-09-10 11:07:07 -07:00

2101 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
pub(crate) mod contextual;
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, serde::Serialize, serde::Deserialize)]
pub enum Tool {
Work,
Exchange,
Reuse,
Control,
Connect,
Branch,
Contextual,
}
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",
Self::Contextual => "Connect qualified contributions",
}
}
}
#[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),
Contextual(contextual::Event),
Shelf(Shelf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
#[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, Option<oa_core::topology_context::ContextAddress>)>,
draft: Option<Draft>,
viewport: Option<Size>,
zoom: Option<f32>,
fit: bool,
pub(crate) focused: bool,
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
selected_context: Option<oa_core::topology_context::ContextAddress>,
shelf: Option<Shelf>,
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Browser {
search: String,
page: usize,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(with = "recovery_insertion")]
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,
#[serde(default)]
contextual: Option<contextual::Draft>,
}
impl State {
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
Ok(Self {
draft: Some(draft),
..Default::default()
})
}
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::Shelf(shelf) => {
self.shelf = (self.shelf != Some(shelf)).then_some(shelf);
}
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,
contextual: (tool == Tool::Contextual)
.then(|| contextual::Draft::new(p, owner, selected)),
});
if let Some(endpoint) = self
.qualified_selection(p, owner)
.map(|address| address.endpoint.clone())
&& let Some(contextual) =
self.draft.as_mut().and_then(|d| d.contextual.as_mut())
{
contextual.seed_source(endpoint);
}
}
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,
Event::Contextual(event) => {
if let Some(contextual) = &mut d.contextual {
contextual.update(event);
}
}
_ => {}
}
}
}
}
Ok(None)
}
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
if !self.fit {
return self.zoom.unwrap_or(1.);
}
let available = self.fit_area(fallback_width);
(available.width / graph.width.max(1.))
.min(available.height / graph.height.max(1.))
.clamp(0.05, 1.)
}
fn fit_area(&self, fallback_width: f32) -> Size {
// The sensor measures the inner scroller, independent of graph content.
// Leave a small gutter for scrollbars; before first layout use a compact
// provisional viewport and recompute when the sensor reports actual size.
let viewport = self.viewport.unwrap_or(Size::new(fallback_width, 180.));
Size::new(
(viewport.width - 24.).max(1.),
(viewport.height - 10.).max(1.),
)
}
fn canvas_offset(&self) -> iced::Vector {
self.scroll_offsets[0]
}
fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool {
let available = self.fit_area(fallback_width);
self.fit && (graph.width * 0.05 > available.width || graph.height * 0.05 > available.height)
}
}
mod recovery_insertion {
use super::*;
use serde::{Deserialize, Serialize};
pub fn serialize<S: serde::Serializer>(
value: &Insertion,
serializer: S,
) -> Result<S::Ok, S::Error> {
(value.link, value.before).serialize(serializer)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Insertion, D::Error> {
let (link, before) = <(Option<Id>, Option<Id>)>::deserialize(deserializer)?;
Ok(Insertion { link, before })
}
}
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());
}
if (self.tool == Tool::Contextual) != self.contextual.is_some() {
return Err("The qualified link draft and selected tool do not agree.".into());
}
if let Some(draft) = &self.contextual {
return draft.command(p, self.owner, &self.name);
}
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 {
operation: None,
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<(OccurrenceEndpoint, String)>,
preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>,
preview_total: usize,
}
impl GraphNode {
fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle {
self.preview
.iter()
.position(|(candidate, _)| candidate == endpoint)
.map(|i| {
Rectangle::new(
Point::new(
self.rect.x + 12. + (i % 2) as f32 * 125.,
self.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
)
})
.unwrap_or(self.rect)
}
}
struct GraphEdge {
id: Id,
source: Id,
target: Id,
guard: String,
points: Vec<Point>,
label: Rectangle,
incidence: bool,
layer: EdgeLayer,
}
// Ordered exchange adjacency is a projection, never a persisted relation ID.
struct OrderedPath {
source: Id,
target: Id,
points: Vec<Point>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EdgeLayer {
Direct,
Contextual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PaintPass {
DirectEdges,
NodeBackgrounds,
ContextualEdges,
NodeContents,
Labels,
}
const PAINT_PASSES: [PaintPass; 5] = [
PaintPass::DirectEdges,
PaintPass::NodeBackgrounds,
PaintPass::ContextualEdges,
PaintPass::NodeContents,
PaintPass::Labels,
];
mod contextual_routing;
struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
ordered_paths: Vec<OrderedPath>,
ordered: bool,
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 {
Self::expanded_qualified(p, owner, selected, expanded, &BTreeSet::new())
}
fn expanded_qualified(
p: &'a Project,
owner: Id,
selected: Id,
expanded: &BTreeSet<Id>,
expanded_paths: &BTreeSet<OccurrenceEndpoint>,
) -> Self {
let context_index = oa_core::topology_context::TopologyIndex::new(p);
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let order = legacy_order(p, owner);
let adjacency: Vec<_> = links
.iter()
.map(|r| (r.source(), r.target()))
.chain(order.windows(2).map(|pair| (pair[0], pair[1])))
.collect();
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 &(_, target) in &adjacency {
*incoming.entry(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 &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
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 = contextual::preview(
&context_index,
p,
owner,
e.id,
expanded_paths,
if expanded.contains(&e.id) { 512 } else { 4 },
)
.into_iter()
.map(|endpoint| {
let name = format!(
"{}{}",
"› ".repeat(endpoint.references.len().saturating_sub(1).min(3)),
p.named(endpoint.occurrence)
);
(endpoint, name)
})
.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.clone()).collect();
node.preview_links = sequence_links(p, *source)
.into_iter()
.filter(|r| {
preview_ids.contains(&OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.source(),
}) && preview_ids.contains(&OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.target(),
})
})
.map(|r| {
(
OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.source(),
},
OccurrenceEndpoint {
references: vec![e.id],
occurrence: 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 ordered_paths = order
.windows(2)
.map(|pair| {
let a = nodes
.iter()
.find(|n| n.id == pair[0])
.expect("ordered source")
.rect;
let b = nodes
.iter()
.find(|n| n.id == pair[1])
.expect("ordered target")
.rect;
OrderedPath {
source: pair[0],
target: pair[1],
points: vec![
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
],
}
})
.collect();
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,
incidence: false,
layer: EdgeLayer::Direct,
});
}
let mut contextual_pairs = BTreeSet::new();
for r in p.relations.values() {
let RelationBody::ContextualLink {
owner: caller,
source,
target,
purpose,
} = &r.body
else {
continue;
};
if *caller != owner {
continue;
}
let source_id = source
.references
.first()
.copied()
.unwrap_or(source.occurrence);
let target_id = target
.references
.first()
.copied()
.unwrap_or(target.occurrence);
let Some(a) = nodes
.iter()
.find(|n| n.id == source_id)
.map(|n| contextual_routing::anchor(n, source, false))
else {
continue;
};
let Some(b) = nodes
.iter()
.find(|n| n.id == target_id)
.map(|n| contextual_routing::anchor(n, target, true))
else {
continue;
};
let below = size.height + 30. + edges.len() as f32 * 48.;
let first = contextual_pairs.insert((source.clone(), target.clone()));
let (points, label) = contextual_routing::route(a, b, below, first);
let (caption, incidence) = match purpose {
ContextualLinkPurpose::Precedence { guard } => {
(format!("Precedence · {guard}"), false)
}
ContextualLinkPurpose::Exchange { exchange } => {
(format!("Assembly · {}", p.named(*exchange)), true)
}
ContextualLinkPurpose::CommonWork { work } => {
(format!("Common work · {}", p.named(*work)), true)
}
};
edges.push(GraphEdge {
id: r.id,
source: source_id,
target: target_id,
guard: caption,
points,
label,
incidence,
layer: EdgeLayer::Contextual,
});
}
for e in &edges {
for pt in &e.points {
size.height = size.height.max(pt.y + 30.);
if e.layer == EdgeLayer::Contextual {
size.width = size.width.max(pt.x + 24.);
}
}
if e.layer == EdgeLayer::Contextual {
size.width = size.width.max(e.label.x + e.label.width + 24.);
size.height = size.height.max(e.label.y + e.label.height + 24.);
}
}
Self {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
nodes,
edges,
ordered_paths,
ordered: !order.is_empty(),
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 pass in PAINT_PASSES {
match pass {
PaintPass::DirectEdges | PaintPass::ContextualEdges => {
if pass == PaintPass::DirectEdges {
for path in &self.ordered_paths {
let selected =
path.source == self.selected || path.target == self.selected;
draw_arrow(
&mut f,
&path.points,
if selected { style::TEAL } else { style::MUTED },
if selected { 2.5 } else { 1.5 },
);
}
}
for e in self.edges.iter().filter(|edge| {
edge.layer
== if pass == PaintPass::DirectEdges {
EdgeLayer::Direct
} else {
EdgeLayer::Contextual
}
}) {
let selected = e.id == self.selected
|| e.source == self.selected
|| e.target == self.selected;
if e.incidence {
f.stroke(
&Path::new(|path| {
path.move_to(e.points[0]);
for point in &e.points[1..] {
path.line_to(*point);
}
}),
Stroke::default()
.with_color(if selected { style::TEAL } else { style::MUTED })
.with_width(if selected { 2.5 } else { 1.5 }),
);
} else {
draw_arrow(
&mut f,
&e.points,
if selected { style::TEAL } else { style::MUTED },
if selected { 2.5 } else { 1.5 },
);
}
}
}
PaintPass::NodeBackgrounds => {
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. }),
);
}
}
PaintPass::NodeContents => {
for n in &self.nodes {
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!(
"{} shown · {} direct contributions",
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.clone(),
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,
);
}
}
}
}
PaintPass::Labels => {
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.ordered_paths.iter().any(|path| {
path.points.windows(2).any(|pair| {
crate::diagram::distance_to_segment(p, pair[0], pair[1]) < 8. / self.scale
})
})
|| 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,
contextual: None,
}
}
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 {
operation: None,
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,
OccurrenceEndpoint {
references: vec![reference.id],
occurrence: 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.);
}
#[test]
fn process_fit_uses_measured_height_and_reports_minimum_without_changing_manual_zoom() {
let mut state = State::default();
let graph = Size::new(420., 960.);
state.zoom(None);
let mut previous = 0.;
for height in [180., 320., 600.] {
state.viewport(Size::new(1000., height));
let scale = state.scale(graph, 200.);
assert!(scale > previous);
assert!(graph.height * scale <= height - 10. + 0.01);
assert!(!state.fit_limited(graph, 200.));
previous = scale;
}
state.viewport(Size::new(500., 180.));
let huge = Size::new(500., 10_000.);
assert_eq!(state.scale(huge, 200.), 0.05);
assert!(state.fit_limited(huge, 200.));
state.zoom(Some(1.));
for height in [180., 600.] {
state.viewport(Size::new(500., height));
assert_eq!(state.scale(huge, 200.), 1.);
assert!(!state.fit_limited(huge, 200.));
}
}
#[test]
fn process_fit_disclosures_and_focus_preserve_rejected_raw_draft_and_accepted_source() {
let (p, owner, _, _, _, _) = fixture();
let mut app = App::with_project(p);
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
for input in [
Event::Start(owner, Tool::Contextual, None),
Event::Name(" pending 雨 ".into()),
Event::Contextual(contextual::Event::Guard(" unavailable && 雨 ".into())),
] {
let _ = app.update(event(input));
}
let before = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
assert!(app.behavior.prepare_pending(app.project()).is_err());
for (shelf, expected) in [
(Shelf::Tools, Some(Shelf::Tools)),
(Shelf::Selection, Some(Shelf::Selection)),
(Shelf::Selection, None),
] {
let focused = app.behavior.topology.focused;
let _ = app.update(event(Event::Shelf(shelf)));
assert_eq!(app.behavior.topology.shelf, expected);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert_eq!(app.behavior.topology.focused, !focused);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), before);
assert!(app.behavior.prepare_pending(app.project()).is_err());
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted));
}
}
#[test]
fn process_fit_canvas_selection_ignores_retained_ordered_page_scroll() {
let (p, owner, _, _, _, _) = fixture();
let mut state = State::default();
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(11., 19.));
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 370.));
let context = Context {
document: p.id,
owner,
revision: 0,
epoch: 0,
};
for scale in [0.45, 1.] {
let mut graph = Graph::new(&p, owner, owner);
graph.scale = scale;
graph.scroll_offset = state.canvas_offset();
graph.interaction = Some((context, CanvasTool::Select, true));
let node = &graph.nodes[0];
let id = node.id;
let point = node.rect.center();
let bounds = Rectangle::new(Point::new(40., 100.), Size::new(800., 300.));
let screen = Point::new(
bounds.x + point.x * scale - 11.,
bounds.y + point.y * scale - 19.,
);
let message = graph
.input(
&mut Input::default(),
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(screen),
)
.and_then(|a| a.into_inner().0);
assert!(
matches!(message,Some(Message::TopologyCanvas(CanvasEvent::Select(c,actual))) if c==context && actual==id)
);
}
}
}