Add direct diagram connections, movable ports, and contextual authoring
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
//! Semantic endpoints for direct canvas connections. Gesture state and visible
|
||||
//! hit targets belong to the canvas; ordinary core commands own model changes.
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Interaction,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
||||
match layer {
|
||||
Layer::Oa => &[Self::Interaction],
|
||||
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Interaction",
|
||||
Self::FunctionalExchange => "Functional exchange",
|
||||
Self::BoundaryExchange => "Boundary exchange",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Click a source activity, then a target activity.",
|
||||
Self::FunctionalExchange => {
|
||||
"Click an output port or source function, then an input port or target function. Function cards use automatic ports."
|
||||
}
|
||||
Self::BoundaryExchange => {
|
||||
"Click a source component port, then a port on another participant. Flow goes from Out to In; Standard is unoriented."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_start(self, project: &Project, source: Id) -> bool {
|
||||
match self {
|
||||
Self::Interaction => project
|
||||
.elements
|
||||
.get(&source)
|
||||
.is_some_and(|e| e.layer() == Layer::Oa && e.kind() == Kind::Activity),
|
||||
Self::FunctionalExchange => function_endpoint(project, source, true).is_some(),
|
||||
Self::BoundaryExchange => component_endpoint(project, source)
|
||||
.is_some_and(|contract| component_allows(contract, true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap feasibility for highlighting candidates. The caller intersects this
|
||||
/// with the actual projected nodes and ports; hidden objects are not hits.
|
||||
pub fn can_connect(self, project: &Project, source: Id, target: Id) -> bool {
|
||||
if !self.can_start(project, source) {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Self::Interaction => {
|
||||
validation::can_connect(project, RelationKind::Interaction, source, target)
|
||||
}
|
||||
Self::FunctionalExchange => {
|
||||
let (Some((source, _)), Some((target, _))) = (
|
||||
function_endpoint(project, source, true),
|
||||
function_endpoint(project, target, false),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
validation::can_connect(project, RelationKind::FunctionalExchange, source, target)
|
||||
}
|
||||
Self::BoundaryExchange => {
|
||||
component_endpoint(project, target).is_some()
|
||||
&& validation::can_connect_boundary(
|
||||
project,
|
||||
source,
|
||||
target,
|
||||
boundary_oriented(project, source),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn targets(self, project: &Project, source: Id) -> BTreeSet<Id> {
|
||||
if !self.can_start(project, source) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
project
|
||||
.elements
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|target| self.can_connect(project, source, *target))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return one validated history operation without modifying the input.
|
||||
/// FE endpoint generation uses the same atomic planner as the form workflow.
|
||||
/// No carried items are inferred from a port's allowed item declarations.
|
||||
pub fn command(self, project: &Project, source: Id, target: Id) -> Result<Command, ModelError> {
|
||||
if !self.can_connect(project, source, target) {
|
||||
return Err(ModelError(format!(
|
||||
"Choose compatible endpoints for {}. {}",
|
||||
self.label(),
|
||||
self.hint()
|
||||
)));
|
||||
}
|
||||
let id = Id::new_v4();
|
||||
if self == Self::FunctionalExchange {
|
||||
let (source, source_port) = function_endpoint(project, source, true)
|
||||
.expect("Functional source was checked above");
|
||||
let (target, target_port) = function_endpoint(project, target, false)
|
||||
.expect("Functional target was checked above");
|
||||
let name = engineering_name(project, self, source, target);
|
||||
return plan_functional_exchange(
|
||||
project,
|
||||
&FunctionalExchangeRequest {
|
||||
id,
|
||||
alias: project.fresh_alias(&name, "exchange", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
source,
|
||||
target,
|
||||
source_port,
|
||||
target_port,
|
||||
items: Vec::new(),
|
||||
},
|
||||
)
|
||||
.map(Command::Batch);
|
||||
}
|
||||
let name = engineering_name(project, self, source, target);
|
||||
let body = match self {
|
||||
Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target),
|
||||
Self::BoundaryExchange => RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: boundary_oriented(project, source),
|
||||
items: Vec::new(),
|
||||
},
|
||||
Self::FunctionalExchange => unreachable!("Functional planner returned above"),
|
||||
};
|
||||
let command = Command::CreateRelation(Relation {
|
||||
id,
|
||||
alias: project.fresh_alias(&name, "relation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body,
|
||||
});
|
||||
prepare_command(project, &command)?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clicked port determines its own function owner. It cannot be paired with
|
||||
/// a different remembered owner or silently substituted by automatic selection.
|
||||
fn function_endpoint(project: &Project, id: Id, outgoing: bool) -> Option<(Id, PortChoice)> {
|
||||
let element = project.elements.get(&id)?;
|
||||
if element.layer() != Layer::Sa {
|
||||
return None;
|
||||
}
|
||||
if element.kind() == Kind::Function {
|
||||
return Some((id, PortChoice::Auto));
|
||||
}
|
||||
match (port_contract(element)?, outgoing) {
|
||||
(PortContract::FunctionOutput { .. }, true)
|
||||
| (PortContract::FunctionInput { .. }, false) => {}
|
||||
_ => return None,
|
||||
}
|
||||
let owner = project.elements.get(&element.parent?)?;
|
||||
(owner.layer() == Layer::Sa && owner.kind() == Kind::Function)
|
||||
.then_some((owner.id, PortChoice::Reuse(id)))
|
||||
}
|
||||
|
||||
fn component_endpoint(project: &Project, id: Id) -> Option<&PortContract> {
|
||||
let element = project.elements.get(&id)?;
|
||||
let owner = project.elements.get(&element.parent?)?;
|
||||
if element.layer() != Layer::Sa || owner.layer() != Layer::Sa || !owner.kind().is_performer() {
|
||||
return None;
|
||||
}
|
||||
let contract = port_contract(element)?;
|
||||
match contract {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
..
|
||||
}
|
||||
| PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
} => Some(contract),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary_oriented(project: &Project, source: Id) -> bool {
|
||||
matches!(
|
||||
component_endpoint(project, source),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> String {
|
||||
let endpoint = |id| {
|
||||
if tool == Tool::BoundaryExchange
|
||||
&& let Some(owner) = project.elements.get(&id).and_then(|e| e.parent)
|
||||
{
|
||||
format!("{} / {}", project.named(owner), project.named(id))
|
||||
} else {
|
||||
project.named(id).to_owned()
|
||||
}
|
||||
};
|
||||
let arrow = if tool == Tool::BoundaryExchange && !boundary_oriented(project, source) {
|
||||
"—"
|
||||
} else {
|
||||
"→"
|
||||
};
|
||||
format!(
|
||||
"{}: {} {arrow} {}",
|
||||
tool.label(),
|
||||
endpoint(source),
|
||||
endpoint(target)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
|
||||
|
||||
fn element(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
|
||||
fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(p, id, owner, name, contract).unwrap();
|
||||
p.elements.insert(id, port);
|
||||
id
|
||||
}
|
||||
|
||||
fn flow(orientation: ComponentPortOrientation) -> PortContract {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
}
|
||||
}
|
||||
|
||||
fn input() -> PortContract {
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn output() -> PortContract {
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn created(project: &Project, command: &Command) -> (Project, Relation) {
|
||||
let prepared = prepare_command(project, command).unwrap();
|
||||
let relation = prepared.project.relations[&prepared.selected.unwrap()].clone();
|
||||
(prepared.project, relation)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_interaction_targets_only_distinct_oa_activities() {
|
||||
let mut p = Project::empty();
|
||||
let source = element(&mut p, Kind::Activity, Layer::Oa, "Send report");
|
||||
let target = element(&mut p, Kind::Activity, Layer::Oa, "Assess report");
|
||||
let actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let before = p.clone();
|
||||
assert_eq!(Tool::for_layer(Layer::Oa), &[Tool::Interaction]);
|
||||
assert_eq!(
|
||||
Tool::Interaction.targets(&p, source),
|
||||
BTreeSet::from([target])
|
||||
);
|
||||
for invalid in [source, actor, function, Id::new_v4()] {
|
||||
assert!(Tool::Interaction.command(&p, source, invalid).is_err());
|
||||
}
|
||||
let command = Tool::Interaction.command(&p, source, target).unwrap();
|
||||
assert!(matches!(command, Command::CreateRelation(_)));
|
||||
let (_, relation) = created(&p, &command);
|
||||
assert_eq!(relation.body.endpoints(), (source, target));
|
||||
assert_eq!(relation.name, "Interaction: Send report → Assess report");
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
|
||||
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
|
||||
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
|
||||
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
|
||||
let source = port(
|
||||
&mut p,
|
||||
a,
|
||||
"Reports out",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![report],
|
||||
},
|
||||
);
|
||||
let target = port(
|
||||
&mut p,
|
||||
b,
|
||||
"Results in",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![result],
|
||||
},
|
||||
);
|
||||
let other = port(&mut p, a, "Other output", output());
|
||||
let before = p.clone();
|
||||
let command = Tool::FunctionalExchange
|
||||
.command(&p, source, target)
|
||||
.unwrap();
|
||||
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 1));
|
||||
let (accepted, relation) = created(&p, &command);
|
||||
assert!(matches!(relation.body, RelationBody::FunctionalExchange {
|
||||
source: s, target: t, source_port: sp, target_port: tp, ref items,
|
||||
} if (s,t,sp,tp) == (a,b,source,target) && items.is_empty()));
|
||||
assert_eq!(accepted.elements, p.elements);
|
||||
assert_eq!(relation.name, "Functional exchange: Detect → Assess");
|
||||
for (s, t) in [
|
||||
(target, source),
|
||||
(source, other),
|
||||
(source, a),
|
||||
(source, source),
|
||||
] {
|
||||
assert!(!Tool::FunctionalExchange.can_connect(&p, s, t));
|
||||
assert!(Tool::FunctionalExchange.command(&p, s, t).is_err());
|
||||
}
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_cards_use_unique_reuse_and_atomic_generation_when_ambiguous() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
|
||||
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
|
||||
let out = port(&mut p, a, "Report out", output());
|
||||
let input = port(&mut p, b, "Report in", input());
|
||||
let (_, reused) = created(&p, &Tool::FunctionalExchange.command(&p, a, b).unwrap());
|
||||
assert!(
|
||||
matches!(reused.body, RelationBody::FunctionalExchange { source_port, target_port, .. }
|
||||
if (source_port,target_port) == (out,input))
|
||||
);
|
||||
let second = port(&mut p, a, "Other report out", output());
|
||||
let command = Tool::FunctionalExchange.command(&p, a, input).unwrap();
|
||||
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 2));
|
||||
let (accepted, generated) = created(&p, &command);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = generated.body
|
||||
else {
|
||||
panic!("Expected functional exchange")
|
||||
};
|
||||
assert_eq!(target_port, input);
|
||||
assert!(![out, second].contains(&source_port));
|
||||
assert_eq!(accepted.elements[&source_port].parent, Some(a));
|
||||
assert_eq!(accepted.elements.len(), p.elements.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_candidates_match_core_orientation_and_reject_same_owner_or_mixed_kind() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "Response system");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Dispatcher");
|
||||
let mut endpoints = Vec::new();
|
||||
for owner in [a, b] {
|
||||
for orientation in [
|
||||
ComponentPortOrientation::Out,
|
||||
ComponentPortOrientation::In,
|
||||
ComponentPortOrientation::InOut,
|
||||
ComponentPortOrientation::Unset,
|
||||
] {
|
||||
endpoints.push(port(
|
||||
&mut p,
|
||||
owner,
|
||||
&format!("Flow {orientation:?}"),
|
||||
flow(orientation),
|
||||
));
|
||||
}
|
||||
endpoints.push(port(
|
||||
&mut p,
|
||||
owner,
|
||||
"Control",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
));
|
||||
}
|
||||
for source in &endpoints {
|
||||
let oriented = boundary_oriented(&p, *source);
|
||||
let targets = Tool::BoundaryExchange.targets(&p, *source);
|
||||
for target in &endpoints {
|
||||
let expected = validation::can_connect_boundary(&p, *source, *target, oriented);
|
||||
assert_eq!(targets.contains(target), expected, "{source} → {target}");
|
||||
let command = Tool::BoundaryExchange.command(&p, *source, *target);
|
||||
assert_eq!(command.is_ok(), expected);
|
||||
if let Ok(command) = command {
|
||||
let (_, relation) = created(&p, &command);
|
||||
assert!(
|
||||
matches!(relation.body, RelationBody::BoundaryExchange { oriented: value, .. } if value == oriented)
|
||||
);
|
||||
assert!(relation.name.contains(" / "));
|
||||
assert_eq!(relation.name.contains('→'), oriented);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_feasibility_keeps_interface_warnings_and_final_carried_item_checks() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
let standard = PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
};
|
||||
let source = port(&mut p, a, "Service", standard.clone());
|
||||
let target = port(&mut p, b, "Required service", standard);
|
||||
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
|
||||
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
|
||||
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
|
||||
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
|
||||
for (interface, item) in [(provided, report), (required, result)] {
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides reporting",
|
||||
source,
|
||||
provided,
|
||||
);
|
||||
let requirement = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Requires results",
|
||||
target,
|
||||
required,
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
let before = p.clone();
|
||||
assert!(Tool::BoundaryExchange.targets(&p, source).contains(&target));
|
||||
assert!(validation::can_connect_boundary(&p, source, target, false));
|
||||
let (accepted, relation) = created(
|
||||
&p,
|
||||
&Tool::BoundaryExchange.command(&p, source, target).unwrap(),
|
||||
);
|
||||
let warnings: Vec<_> = validation::validate(&accepted)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.code == "unsatisfied_interface_requirement")
|
||||
.collect();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(warnings[0].severity, validation::Severity::Warning);
|
||||
assert_eq!(warnings[0].element, Some(requirement));
|
||||
assert_eq!(warnings[0].field.as_deref(), Some("target"));
|
||||
|
||||
let mut carrying = relation.clone();
|
||||
let RelationBody::BoundaryExchange { items, .. } = &mut carrying.body else {
|
||||
unreachable!()
|
||||
};
|
||||
items.push(result);
|
||||
assert!(prepare_command(&p, &Command::CreateRelation(carrying.clone())).is_err());
|
||||
assert_eq!(p, before, "Rejected carried items never mutate the input");
|
||||
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides results",
|
||||
source,
|
||||
required,
|
||||
);
|
||||
let prepared = prepare_command(&p, &Command::CreateRelation(carrying)).unwrap();
|
||||
assert!(
|
||||
validation::validate(&prepared.project)
|
||||
.iter()
|
||||
.all(|issue| issue.code != "unsatisfied_interface_requirement")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_port_owners_layers_and_contract_kinds_are_not_candidates() {
|
||||
let mut p = Project::empty();
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Function");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let activity = element(&mut p, Kind::Activity, Layer::Oa, "Activity");
|
||||
let function_on_system = port(&mut p, system, "Wrong function owner", output());
|
||||
let component_on_function = port(
|
||||
&mut p,
|
||||
function,
|
||||
"Wrong component owner",
|
||||
flow(ComponentPortOrientation::Out),
|
||||
);
|
||||
let oa_function_port = port(&mut p, activity, "Wrong layer", output());
|
||||
let malformed_standard = port(
|
||||
&mut p,
|
||||
system,
|
||||
"Wrong Standard orientation",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
);
|
||||
for source in [
|
||||
function_on_system,
|
||||
component_on_function,
|
||||
oa_function_port,
|
||||
malformed_standard,
|
||||
] {
|
||||
for tool in Tool::for_layer(Layer::Sa) {
|
||||
assert!(!tool.can_start(&p, source));
|
||||
assert!(tool.targets(&p, source).is_empty());
|
||||
assert!(tool.command(&p, source, function).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_connections_share_exact_source_history_and_model_parity() {
|
||||
let mut p = Project::empty();
|
||||
let oa_a = element(&mut p, Kind::Activity, Layer::Oa, "Detect incident");
|
||||
let oa_b = element(&mut p, Kind::Activity, Layer::Oa, "Assess incident");
|
||||
let sa_a = element(&mut p, Kind::Function, Layer::Sa, "Detect report");
|
||||
let sa_b = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let actor = element(&mut p, Kind::SystemActor, Layer::Sa, "Actor");
|
||||
let boundary_a = port(&mut p, system, "Send", flow(ComponentPortOrientation::Out));
|
||||
let boundary_b = port(&mut p, actor, "Receive", flow(ComponentPortOrientation::In));
|
||||
let mut bundle = format_bundle(&p);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// Preserve engineering rationale exactly.\n");
|
||||
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
for (tool, source, target) in [
|
||||
(Tool::Interaction, oa_a, oa_b),
|
||||
(Tool::FunctionalExchange, sa_a, sa_b),
|
||||
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
||||
] {
|
||||
let before = document.bundle().clone();
|
||||
let original = document.project().clone();
|
||||
let revision = document.revision();
|
||||
let command = tool.command(document.project(), source, target).unwrap();
|
||||
let expected = prepare_command(document.project(), &command).unwrap();
|
||||
let selected = document.execute(command).unwrap();
|
||||
assert_eq!(selected, expected.selected);
|
||||
assert_eq!(document.project(), &expected.project);
|
||||
assert_eq!(document.revision(), revision + 1);
|
||||
assert_eq!(
|
||||
compile_bundle(document.bundle(), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected.project
|
||||
);
|
||||
let changed = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert_eq!(document.project(), &original);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_feasibility_rejects_dangling_identity_in_a_malformed_element_map() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
let source = port(&mut p, a, "Send", flow(ComponentPortOrientation::Out));
|
||||
let target = port(&mut p, b, "Receive", flow(ComponentPortOrientation::In));
|
||||
assert!(Tool::BoundaryExchange.can_connect(&p, source, target));
|
||||
// An indexed key alone is not proof of a live model identity. Preserve
|
||||
// the reference check even when inspecting an inconsistent input model.
|
||||
p.elements.get_mut(&target).unwrap().id = Id::new_v4();
|
||||
let before = p.clone();
|
||||
assert!(!validation::can_connect_boundary(&p, source, target, true));
|
||||
assert!(!Tool::BoundaryExchange.targets(&p, source).contains(&target));
|
||||
assert!(Tool::BoundaryExchange.command(&p, source, target).is_err());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_command_validation_rejects_invalid_models_without_mutation() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Activity, Layer::Oa, "Send");
|
||||
let b = element(&mut p, Kind::Activity, Layer::Oa, "Receive");
|
||||
p.elements.get_mut(&a).unwrap().alias = p.elements[&b].alias.clone();
|
||||
let before = p.clone();
|
||||
assert!(
|
||||
Tool::Interaction.can_connect(&p, a, b),
|
||||
"Candidate highlighting checks the proposed link; commit checks the whole model"
|
||||
);
|
||||
assert!(Tool::Interaction.command(&p, a, b).is_err());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user