Compose transition lifecycle and preserve authored attachments in both directions
This commit is contained in:
@@ -750,7 +750,7 @@ pub fn plan_transition(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !compatible_existing(project, *source, target, request.direction) {
|
||||
if !counterpart_compatible(project, *source, target, request.direction) {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
@@ -1317,12 +1317,22 @@ fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection)
|
||||
}
|
||||
inferred_kind(source, direction) == Some(target)
|
||||
}
|
||||
fn compatible_existing(
|
||||
/// Whether existing records have compatible layers, categories and kinds for
|
||||
/// an ordinary transition. This is candidate eligibility, not approval of a
|
||||
/// complete plan: properties, dependencies and structural conflicts still need
|
||||
/// the normal preview and apply validation. Manual traces are not restricted
|
||||
/// by this predicate.
|
||||
pub fn counterpart_compatible(
|
||||
p: &Project,
|
||||
source: Id,
|
||||
target: Id,
|
||||
direction: TransitionDirection,
|
||||
) -> bool {
|
||||
if p.layer_of(source) != Some(direction.source_layer())
|
||||
|| p.layer_of(target) != Some(direction.target_layer())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match (p.elements.get(&source), p.elements.get(&target)) {
|
||||
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
|
||||
_ => match (p.relations.get(&source), p.relations.get(&target)) {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Existing counterpart eligibility is a layer/category/kind decision, not
|
||||
//! validation or acceptance of an entire transition plan. The catalog fixtures
|
||||
//! below intentionally contain partial records (unowned states, etc.). The
|
||||
//! final regression separately starts from a structurally valid real project.
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn element(p: &mut Project, layer: Layer, kind: Kind) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let body = if kind == Kind::Port {
|
||||
// Legacy ports can exist in preserved OA models. This does not offer
|
||||
// new typed SA implementation ports for creation in portless OA.
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: None,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ElementBody::new(kind, layer).unwrap()
|
||||
};
|
||||
p.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("candidate_{}", id.simple()),
|
||||
name: "Identical candidate label".into(),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body,
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_element_candidates_obey_explicit_engineering_families_in_both_directions() {
|
||||
// Contract: language.rst §5 trace families and the explicit capability /
|
||||
// mission alternative. State constructors remain eligible for explicit
|
||||
// review across the state family; selecting a candidate does not accept a
|
||||
// constructor change or make invalid ownership legal.
|
||||
let mut allowed: BTreeSet<(Kind, Kind)> = [
|
||||
(Kind::Activity, Kind::Function),
|
||||
(Kind::Entity, Kind::System),
|
||||
(Kind::Entity, Kind::SystemActor),
|
||||
(Kind::Actor, Kind::System),
|
||||
(Kind::Actor, Kind::SystemActor),
|
||||
(Kind::Capability, Kind::SystemCapability),
|
||||
(Kind::OperationalMission, Kind::Mission),
|
||||
(Kind::Capability, Kind::Mission),
|
||||
(Kind::Process, Kind::FunctionalChain),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
for kind in [
|
||||
Kind::ExchangeItem,
|
||||
Kind::ExchangeCategory,
|
||||
Kind::DataType,
|
||||
Kind::DataField,
|
||||
Kind::Interface,
|
||||
Kind::Port,
|
||||
Kind::Scenario,
|
||||
Kind::Lifeline,
|
||||
Kind::Occurrence,
|
||||
Kind::StateMachine,
|
||||
Kind::Region,
|
||||
Kind::Event,
|
||||
Kind::Constraint,
|
||||
] {
|
||||
allowed.insert((kind, kind));
|
||||
}
|
||||
let vertices = [
|
||||
Kind::State,
|
||||
Kind::Mode,
|
||||
Kind::Initial,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::Junction,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
];
|
||||
for oa in vertices {
|
||||
for sa in vertices {
|
||||
allowed.insert((oa, sa));
|
||||
}
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
let oa: BTreeMap<_, _> = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| kind.supports(Layer::Oa))
|
||||
.map(|kind| (kind, element(&mut p, Layer::Oa, kind)))
|
||||
.collect();
|
||||
let sa: BTreeMap<_, _> = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| kind.supports(Layer::Sa))
|
||||
.map(|kind| (kind, element(&mut p, Layer::Sa, kind)))
|
||||
.collect();
|
||||
let original = p.clone();
|
||||
for (&oa_kind, &oa_id) in &oa {
|
||||
for (&sa_kind, &sa_id) in &sa {
|
||||
let expected = allowed.contains(&(oa_kind, sa_kind));
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
|
||||
expected,
|
||||
"OA {oa_kind:?} → SA {sa_kind:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
|
||||
expected,
|
||||
"SA {sa_kind:?} → OA {oa_kind:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
p, original,
|
||||
"Eligibility must not establish correspondence or edit records"
|
||||
);
|
||||
}
|
||||
|
||||
fn relation(p: &mut Project, kind: RelationKind, anchor: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
// Only the semantic layer and relation category participate in candidate
|
||||
// eligibility. Endpoint compatibility belongs to full plan validation.
|
||||
p.relations.insert(
|
||||
id,
|
||||
Relation {
|
||||
id,
|
||||
alias: format!("relation_{}", id.simple()),
|
||||
name: "Identical relation label".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(kind, anchor, anchor),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_relation_candidates_distinguish_work_boundary_and_shared_relations() {
|
||||
// Work and boundary exchanges are different families. The boundary /
|
||||
// boundary pair permits preserved legacy OA boundary records; ordinary
|
||||
// portless OA communication means map to SA boundary exchanges.
|
||||
let mut allowed = vec![
|
||||
(RelationKind::Interaction, RelationKind::FunctionalExchange),
|
||||
(
|
||||
RelationKind::CommunicationMean,
|
||||
RelationKind::BoundaryExchange,
|
||||
),
|
||||
(
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::BoundaryExchange,
|
||||
),
|
||||
];
|
||||
let shared = [
|
||||
RelationKind::Allocation,
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
RelationKind::DataAssociation,
|
||||
RelationKind::ExchangeAllocation,
|
||||
RelationKind::CapabilityInvolvement,
|
||||
RelationKind::CapabilityInclude,
|
||||
RelationKind::CapabilityExtend,
|
||||
RelationKind::CapabilityGeneralization,
|
||||
RelationKind::MissionInvolvement,
|
||||
RelationKind::MissionCapability,
|
||||
RelationKind::SequenceLink,
|
||||
RelationKind::ContextualLink,
|
||||
RelationKind::ChainInvolvement,
|
||||
RelationKind::InterfaceProvision,
|
||||
RelationKind::InterfaceRequirement,
|
||||
RelationKind::StateTransition,
|
||||
RelationKind::Availability,
|
||||
RelationKind::StateAction,
|
||||
];
|
||||
for kind in shared {
|
||||
allowed.push((kind, kind));
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
let oa_anchor = element(&mut p, Layer::Oa, Kind::Activity);
|
||||
let sa_anchor = element(&mut p, Layer::Sa, Kind::Function);
|
||||
let oa: Vec<_> = shared
|
||||
.into_iter()
|
||||
.chain([
|
||||
RelationKind::Interaction,
|
||||
RelationKind::CommunicationMean,
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::RoleRealization,
|
||||
RelationKind::RoleAssignment,
|
||||
])
|
||||
.map(|kind| (kind, relation(&mut p, kind, oa_anchor)))
|
||||
.collect();
|
||||
let sa: Vec<_> = shared
|
||||
.into_iter()
|
||||
.chain([
|
||||
RelationKind::FunctionalExchange,
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::PortAllocation,
|
||||
])
|
||||
.map(|kind| (kind, relation(&mut p, kind, sa_anchor)))
|
||||
.collect();
|
||||
for &(oa_kind, oa_id) in &oa {
|
||||
for &(sa_kind, sa_id) in &sa {
|
||||
let expected = allowed.contains(&(oa_kind, sa_kind));
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
|
||||
expected,
|
||||
"OA {oa_kind:?} → SA {sa_kind:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
|
||||
expected,
|
||||
"SA {sa_kind:?} → OA {oa_kind:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_eligibility_rejects_wrong_layers_categories_and_missing_identities() {
|
||||
let mut p = Project::empty();
|
||||
let oa = element(&mut p, Layer::Oa, Kind::Activity);
|
||||
let sa = element(&mut p, Layer::Sa, Kind::Function);
|
||||
let oa_exchange = relation(&mut p, RelationKind::Interaction, oa);
|
||||
let sa_exchange = relation(&mut p, RelationKind::FunctionalExchange, sa);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
let node = p.place(diagram, sa, Position { x: 1., y: 2. });
|
||||
for (direction, source, target, source_relation, target_relation) in [
|
||||
(
|
||||
TransitionDirection::OaToSa,
|
||||
oa,
|
||||
sa,
|
||||
oa_exchange,
|
||||
sa_exchange,
|
||||
),
|
||||
(
|
||||
TransitionDirection::SaToOa,
|
||||
sa,
|
||||
oa,
|
||||
sa_exchange,
|
||||
oa_exchange,
|
||||
),
|
||||
] {
|
||||
assert!(counterpart_compatible(&p, source, target, direction));
|
||||
assert!(counterpart_compatible(
|
||||
&p,
|
||||
source_relation,
|
||||
target_relation,
|
||||
direction
|
||||
));
|
||||
for (a, b) in [
|
||||
(target, source),
|
||||
(source, source),
|
||||
(target, target),
|
||||
(target_relation, source_relation),
|
||||
(source_relation, source_relation),
|
||||
(source, target_relation),
|
||||
(source_relation, target),
|
||||
] {
|
||||
assert!(!counterpart_compatible(&p, a, b, direction));
|
||||
}
|
||||
for absent_or_nonsemantic in [Id::nil(), Id::new_v4(), p.id, diagram, node] {
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
absent_or_nonsemantic,
|
||||
direction
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
absent_or_nonsemantic,
|
||||
target,
|
||||
direction
|
||||
));
|
||||
}
|
||||
}
|
||||
p.elements.remove(&sa);
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
oa,
|
||||
sa,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
sa,
|
||||
oa,
|
||||
TransitionDirection::SaToOa
|
||||
));
|
||||
// The retained relation header cannot borrow a layer from a deleted owner.
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
oa_exchange,
|
||||
sa_exchange,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_picker_rejects_real_sa_port_and_agrees_with_explicit_plan_refusal() {
|
||||
let p = Project::demo();
|
||||
let original = p.clone();
|
||||
assert!(validation::validate(&p).is_empty());
|
||||
let source = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Activity)
|
||||
.unwrap()
|
||||
.id;
|
||||
let port = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Port)
|
||||
.unwrap()
|
||||
.id;
|
||||
let function = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Function)
|
||||
.unwrap()
|
||||
.id;
|
||||
assert!(counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
function,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
port,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(port),
|
||||
target_kind: None,
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(
|
||||
plan.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(source)
|
||||
&& conflict.target == Some(port)
|
||||
&& conflict.field.is_none())
|
||||
);
|
||||
assert!(apply_transition(&p, &plan).is_err());
|
||||
assert_eq!(p, original);
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
//! Endpoint lifecycle acceptance for the ordinary, prose-reviewed transition API.
|
||||
//! OA is portless; the SA half contains typed ports. Deletion expectations below
|
||||
//! are fixture-owned identity lists, never results of the deletion implementation.
|
||||
use oa_core::{
|
||||
language, persistence,
|
||||
source_project::{ProjectLimits, SourceBundle, compile_bundle},
|
||||
transitions::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Side {
|
||||
root: Id,
|
||||
elements: Vec<Id>,
|
||||
exchange: Id,
|
||||
diagram: Id,
|
||||
nodes: Vec<Id>,
|
||||
artifacts: Vec<Id>,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
source: Side,
|
||||
target: Side,
|
||||
trace: Id,
|
||||
trace_artifact: Id,
|
||||
}
|
||||
|
||||
fn artifact(p: &mut Project, owner: Id, label: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: p.fresh_alias(label, "evidence", id),
|
||||
name: label.into(),
|
||||
description: "Independently authored rationale".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "git:engineering/design.md".into(),
|
||||
revision: Some("review-17".into()),
|
||||
fragment: Some("retained-decision".into()),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn side(p: &mut Project, layer: Layer) -> Side {
|
||||
let (kind, prefix, relation) = match layer {
|
||||
Layer::Oa => (Kind::Activity, "Operational", RelationKind::Interaction),
|
||||
Layer::Sa => (
|
||||
Kind::Function,
|
||||
"Technical",
|
||||
RelationKind::FunctionalExchange,
|
||||
),
|
||||
};
|
||||
let root = p
|
||||
.add_element(kind, layer, &format!("{prefix} design"), None)
|
||||
.unwrap();
|
||||
p.elements.get_mut(&root).unwrap().description =
|
||||
format!("{prefix} independently reviewed intent");
|
||||
let send = p
|
||||
.add_element(kind, layer, &format!("{prefix} send"), Some(root))
|
||||
.unwrap();
|
||||
let receive = p
|
||||
.add_element(kind, layer, &format!("{prefix} receive"), Some(root))
|
||||
.unwrap();
|
||||
p.elements.get_mut(&send).unwrap().description =
|
||||
"Preserve the detailed sending responsibility".into();
|
||||
p.elements.get_mut(&receive).unwrap().description =
|
||||
"Preserve the detailed receiving responsibility".into();
|
||||
// A shared, separately owned definition survives deletion of its users.
|
||||
let item = p
|
||||
.add_element(
|
||||
Kind::ExchangeItem,
|
||||
layer,
|
||||
&format!("{prefix} information"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
p.add_element(
|
||||
Kind::DataField,
|
||||
layer,
|
||||
&format!("{prefix} payload"),
|
||||
Some(item),
|
||||
)
|
||||
.unwrap();
|
||||
let exchange = p.relate(relation, &format!("{prefix} delivery"), send, receive);
|
||||
let mut elements = vec![root, send, receive];
|
||||
match &mut p.relations.get_mut(&exchange).unwrap().body {
|
||||
RelationBody::Interaction { items, .. } => *items = vec![item],
|
||||
RelationBody::FunctionalExchange {
|
||||
items,
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => {
|
||||
*items = vec![item];
|
||||
let (output, input) = (*source_port, *target_port);
|
||||
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item],
|
||||
},
|
||||
legacy: None,
|
||||
};
|
||||
*p.elements.get_mut(&input).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::FunctionInput {
|
||||
incoming_items: vec![item],
|
||||
},
|
||||
legacy: None,
|
||||
};
|
||||
elements.extend([output, input]);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
p.relations.get_mut(&exchange).unwrap().description =
|
||||
"Retain direction, item contract and relation identity".into();
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
|
||||
let nodes = [root, send, receive]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, element)| {
|
||||
p.place(
|
||||
diagram,
|
||||
element,
|
||||
Position {
|
||||
x: 43.25 + index as f32 * 271.,
|
||||
y: 97.5 + index as f32 * 83.,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let root_node = p
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&nodes[0])
|
||||
.unwrap();
|
||||
root_node.size = Size {
|
||||
width: 611.,
|
||||
height: 319.,
|
||||
};
|
||||
root_node.collapsed = true;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: exchange,
|
||||
// Both directions must preserve authored endpoint preferences,
|
||||
// including dormant preferences on the SA semantic-port edge.
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![Position {
|
||||
x: 337.5,
|
||||
y: 423.25,
|
||||
}],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.7,
|
||||
offset: Position { x: 11., y: -17. },
|
||||
}),
|
||||
});
|
||||
let artifacts = [root, send, exchange, nodes[0]]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, owner)| artifact(p, owner, &format!("{prefix} evidence {index}")))
|
||||
.collect();
|
||||
artifact(p, item, &format!("{prefix} shared item evidence"));
|
||||
Side {
|
||||
root,
|
||||
elements,
|
||||
exchange,
|
||||
diagram,
|
||||
nodes,
|
||||
artifacts,
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted(p: &Project) {
|
||||
let issues = validation::validate(p);
|
||||
// This fixture deliberately isolates decomposition and correspondence from
|
||||
// performer allocation and process/chain/scenario inclusion. Each surviving
|
||||
// leaf (including a newly transitioned root) must have exactly those two
|
||||
// incomplete-work warnings; each surviving local exchange lacks a behavior
|
||||
// context. Check exact record IDs and fields, including duplicate warnings.
|
||||
// The system of interest and item contracts are complete fixture facts.
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.all(|issue| issue.severity == Severity::Warning),
|
||||
"{issues:?}"
|
||||
);
|
||||
let mut expected = Vec::new();
|
||||
for element in p.elements.values().filter(|element| {
|
||||
matches!(element.kind(), Kind::Activity | Kind::Function)
|
||||
&& !p
|
||||
.elements
|
||||
.values()
|
||||
.any(|child| child.parent == Some(element.id) && child.kind() == element.kind())
|
||||
}) {
|
||||
expected.push(("unallocated_leaf", Some(element.id), Some("allocation")));
|
||||
expected.push(("work_without_behavior_context", Some(element.id), None));
|
||||
}
|
||||
for relation in p.relations.values().filter(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange
|
||||
)
|
||||
}) {
|
||||
expected.push(("exchange_without_behavior_context", Some(relation.id), None));
|
||||
}
|
||||
let mut actual = issues
|
||||
.iter()
|
||||
.map(|issue| (issue.code.as_str(), issue.element, issue.field.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
expected.sort();
|
||||
actual.sort();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Unexpected fixture diagnostics: {issues:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn round_trip(p: &Project) -> String {
|
||||
accepted(p);
|
||||
let json = serde_json::to_vec_pretty(p).unwrap();
|
||||
assert_eq!(persistence::decode(&json).unwrap(), *p);
|
||||
let source = language::format(p);
|
||||
let compiled = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source.clone())]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(compiled.decoded.project, *p);
|
||||
assert_eq!(language::format(&compiled.decoded.project), source);
|
||||
source
|
||||
}
|
||||
|
||||
fn explicit(direction: TransitionDirection, source: Id, target: Id) -> TransitionRequest {
|
||||
let mut request = TransitionRequest::new(direction, vec![source]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
request
|
||||
}
|
||||
|
||||
fn fixture(direction: TransitionDirection) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
project.name = "Endpoint lifecycle conservation".into();
|
||||
project
|
||||
.add_element(Kind::System, Layer::Sa, "System of interest", None)
|
||||
.unwrap();
|
||||
let oa = side(&mut project, Layer::Oa);
|
||||
let sa = side(&mut project, Layer::Sa);
|
||||
let (source, target) = match direction {
|
||||
TransitionDirection::OaToSa => (oa, sa),
|
||||
TransitionDirection::SaToOa => (sa, oa),
|
||||
};
|
||||
accepted(&project);
|
||||
let before = project.clone();
|
||||
let mut request = explicit(direction, source.root, target.root);
|
||||
for field in [TransitionField::Name, TransitionField::Description] {
|
||||
request.resolutions.push(FieldResolution {
|
||||
source: source.root,
|
||||
field,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
});
|
||||
}
|
||||
let plan = plan_transition(&project, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
project = apply_transition(&project, &plan).unwrap();
|
||||
let trace = assert_only_new_trace(&before, &project, direction, source.root, target.root);
|
||||
let mut without_trace = project.clone();
|
||||
without_trace.traces.remove(&trace);
|
||||
assert_eq!(
|
||||
without_trace, before,
|
||||
"Initial review must only add correspondence"
|
||||
);
|
||||
let trace_artifact = artifact(&mut project, trace, "Correspondence rationale");
|
||||
round_trip(&project);
|
||||
Fixture {
|
||||
project,
|
||||
source,
|
||||
target,
|
||||
trace,
|
||||
trace_artifact,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_only_new_trace(
|
||||
before: &Project,
|
||||
after: &Project,
|
||||
direction: TransitionDirection,
|
||||
source: Id,
|
||||
target: Id,
|
||||
) -> Id {
|
||||
let added = after
|
||||
.traces
|
||||
.values()
|
||||
.filter(|trace| !before.traces.contains_key(&trace.id))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(after.traces.len(), before.traces.len() + 1);
|
||||
let trace = added[0];
|
||||
let (oa, sa) = match direction {
|
||||
TransitionDirection::OaToSa => (source, target),
|
||||
TransitionDirection::SaToOa => (target, source),
|
||||
};
|
||||
assert_eq!(
|
||||
(trace.oa, trace.sa, trace.kind),
|
||||
(oa, sa, TraceKind::Refinement)
|
||||
);
|
||||
assert_eq!(
|
||||
trace.baseline,
|
||||
Some(TraceBaseline {
|
||||
oa: after.properties(oa).unwrap(),
|
||||
sa: after.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(trace_drift(after, trace), Drift::Unchanged);
|
||||
trace.id
|
||||
}
|
||||
|
||||
fn expected_deletion(fixture: &Fixture, side: &Side) -> Project {
|
||||
let mut expected = fixture.project.clone();
|
||||
for id in &side.elements {
|
||||
assert!(expected.elements.remove(id).is_some());
|
||||
}
|
||||
assert!(expected.relations.remove(&side.exchange).is_some());
|
||||
assert!(expected.traces.remove(&fixture.trace).is_some());
|
||||
for id in side.artifacts.iter().chain([&fixture.trace_artifact]) {
|
||||
assert!(expected.artifact_links.remove(id).is_some());
|
||||
}
|
||||
let diagram = expected.diagrams.get_mut(&side.diagram).unwrap();
|
||||
for id in &side.nodes {
|
||||
assert!(diagram.nodes.remove(id).is_some());
|
||||
}
|
||||
// This fixture authored exactly one route, for its removed local exchange.
|
||||
assert_eq!(diagram.routes.len(), 1);
|
||||
assert_eq!(diagram.routes[0].edge, side.exchange);
|
||||
diagram.routes.clear();
|
||||
expected
|
||||
}
|
||||
|
||||
fn unchanged_retry(p: &Project, direction: TransitionDirection, source: Id, target: Id) {
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let plan = plan_transition(p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
plan.changes[0].source,
|
||||
plan.changes[0].target,
|
||||
plan.changes[0].action
|
||||
),
|
||||
(source, target, TransitionAction::Reuse)
|
||||
);
|
||||
assert!(plan.changes[0].fields.is_empty());
|
||||
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
|
||||
let mut session = Session::new(p.clone());
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::Replace(apply_transition(p, &plan).unwrap()))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), p);
|
||||
assert_eq!(session.revision(), revision);
|
||||
assert!(!session.can_undo());
|
||||
}
|
||||
|
||||
fn source_deletion(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let original_source = round_trip(&fixture.project);
|
||||
let expected = expected_deletion(&fixture, &fixture.source);
|
||||
let mut session = Session::new(fixture.project.clone());
|
||||
session
|
||||
.execute(Command::Delete(fixture.source.root))
|
||||
.unwrap();
|
||||
// Whole-project equality covers every retained target child, typed SA port,
|
||||
// exchange, placement, route, artifact, shared item and unchanged identity.
|
||||
assert_eq!(session.project(), &expected);
|
||||
let deleted_source = round_trip(session.project());
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &fixture.project);
|
||||
assert_eq!(round_trip(session.project()), original_source);
|
||||
assert!(!session.can_undo(), "Deletion is one history transaction");
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert_eq!(round_trip(session.project()), deleted_source);
|
||||
}
|
||||
|
||||
fn implicit_creation(
|
||||
p: &Project,
|
||||
direction: TransitionDirection,
|
||||
source: Id,
|
||||
forbidden_target: Id,
|
||||
) -> (Project, Id) {
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let plan = plan_transition(p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
let change = &plan.changes[0];
|
||||
assert_eq!(change.source, source);
|
||||
assert_eq!(change.action, TransitionAction::Create);
|
||||
let target = change.target;
|
||||
assert_ne!(target, forbidden_target);
|
||||
assert!(!p.contains(target));
|
||||
let after = apply_transition(p, &plan).unwrap();
|
||||
let trace = assert_only_new_trace(p, &after, direction, source, target);
|
||||
let created = &after.elements[&target];
|
||||
assert_eq!(created.id, target, "Apply preserves the preview identity");
|
||||
assert_eq!(created.name, p.elements[&source].name);
|
||||
assert_eq!(created.description, p.elements[&source].description);
|
||||
assert_eq!(created.parent, None);
|
||||
assert_eq!(
|
||||
created.body,
|
||||
match direction {
|
||||
TransitionDirection::OaToSa => ElementBody::Sa(SaBody::Function),
|
||||
TransitionDirection::SaToOa => ElementBody::Oa(OaBody::Activity),
|
||||
}
|
||||
);
|
||||
assert!(!created.alias.is_empty());
|
||||
assert!(
|
||||
!p.identities()
|
||||
.iter()
|
||||
.any(|(_, alias)| *alias == created.alias)
|
||||
);
|
||||
// Remove only the independently expected two additions. Any incidental
|
||||
// mutation, clone of target children or extra correspondence fails here.
|
||||
let mut retained = after.clone();
|
||||
retained.elements.remove(&target);
|
||||
retained.traces.remove(&trace);
|
||||
assert_eq!(retained, *p);
|
||||
round_trip(&after);
|
||||
// Replaying a creation plan after acceptance violates its snapshot, while
|
||||
// a fresh unchanged request reuses the accepted counterpart.
|
||||
assert!(
|
||||
apply_transition(&after, &plan)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("stale")
|
||||
);
|
||||
unchanged_retry(&after, direction, source, target);
|
||||
(after, target)
|
||||
}
|
||||
|
||||
fn target_deletion(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let request = TransitionRequest::new(direction, vec![fixture.source.root]);
|
||||
let old_plan = plan_transition(&fixture.project, &request).unwrap();
|
||||
assert!(old_plan.conflicts.is_empty());
|
||||
let expected = expected_deletion(&fixture, &fixture.target);
|
||||
let mut session = Session::new(fixture.project.clone());
|
||||
session
|
||||
.execute(Command::Delete(fixture.target.root))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
round_trip(session.project());
|
||||
let revision = session.revision();
|
||||
assert!(
|
||||
apply_transition(session.project(), &old_plan)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("stale")
|
||||
);
|
||||
let explicit_plan = plan_transition(
|
||||
session.project(),
|
||||
&explicit(direction, fixture.source.root, fixture.target.root),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
explicit_plan
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(fixture.source.root)
|
||||
&& conflict.target == Some(fixture.target.root)
|
||||
&& conflict.field.is_none())
|
||||
);
|
||||
assert!(apply_transition(session.project(), &explicit_plan).is_err());
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert_eq!(session.revision(), revision);
|
||||
let (created, _) = implicit_creation(
|
||||
session.project(),
|
||||
direction,
|
||||
fixture.source.root,
|
||||
fixture.target.root,
|
||||
);
|
||||
session.execute(Command::Replace(created.clone())).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &fixture.project);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &created);
|
||||
round_trip(session.project());
|
||||
}
|
||||
|
||||
fn source_recreation(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let mut replacement = fixture.project.elements[&fixture.source.root].clone();
|
||||
replacement.id = Id::new_v4();
|
||||
let source = replacement.id;
|
||||
// Same alias, name and prose, different UUID: name matching must never
|
||||
// silently relink the retained engineering design.
|
||||
assert_ne!(source, fixture.source.root);
|
||||
let deleted = execute_batch(&fixture.project, &[Command::Delete(fixture.source.root)]).unwrap();
|
||||
assert_eq!(deleted, expected_deletion(&fixture, &fixture.source));
|
||||
let recreated =
|
||||
execute_batch(&deleted, &[Command::CreateElement(replacement.clone())]).unwrap();
|
||||
let mut expected = deleted.clone();
|
||||
expected.elements.insert(source, replacement);
|
||||
assert_eq!(recreated, expected);
|
||||
round_trip(&recreated);
|
||||
let (implicit, new_target) =
|
||||
implicit_creation(&recreated, direction, source, fixture.target.root);
|
||||
assert!(implicit.elements.contains_key(&fixture.target.root));
|
||||
assert_ne!(new_target, fixture.target.root);
|
||||
|
||||
// The explicit branch starts from exactly the same recreated project.
|
||||
let mut request = explicit(direction, source, fixture.target.root);
|
||||
let unreviewed = plan_transition(&recreated, &request).unwrap();
|
||||
for field in [TransitionField::Name, TransitionField::Description] {
|
||||
assert!(
|
||||
unreviewed
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(source)
|
||||
&& conflict.target == Some(fixture.target.root)
|
||||
&& conflict.field == Some(field))
|
||||
);
|
||||
}
|
||||
assert!(apply_transition(&recreated, &unreviewed).is_err());
|
||||
request.resolutions = vec![
|
||||
FieldResolution {
|
||||
source,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::TakeSource,
|
||||
},
|
||||
FieldResolution {
|
||||
source,
|
||||
field: TransitionField::Description,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
},
|
||||
];
|
||||
let plan = plan_transition(&recreated, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
assert_eq!(
|
||||
(plan.changes[0].target, plan.changes[0].action),
|
||||
(fixture.target.root, TransitionAction::Update)
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changes[0].fields,
|
||||
vec![FieldChange {
|
||||
field: TransitionField::Name,
|
||||
before: recreated.elements[&fixture.target.root].name.clone(),
|
||||
after: recreated.elements[&source].name.clone(),
|
||||
}]
|
||||
);
|
||||
let relinked = apply_transition(&recreated, &plan).unwrap();
|
||||
let new_trace = assert_only_new_trace(
|
||||
&recreated,
|
||||
&relinked,
|
||||
direction,
|
||||
source,
|
||||
fixture.target.root,
|
||||
);
|
||||
assert_ne!(
|
||||
new_trace, fixture.trace,
|
||||
"Deleted correspondence is not silently resurrected"
|
||||
);
|
||||
let mut expected = recreated.clone();
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&fixture.target.root)
|
||||
.unwrap()
|
||||
.name = recreated.elements[&source].name.clone();
|
||||
let mut without_trace = relinked.clone();
|
||||
without_trace.traces.remove(&new_trace);
|
||||
assert_eq!(
|
||||
without_trace, expected,
|
||||
"Only the reviewed target name may change; rich target design survives"
|
||||
);
|
||||
round_trip(&relinked);
|
||||
unchanged_retry(&relinked, direction, source, fixture.target.root);
|
||||
let original_source = round_trip(&recreated);
|
||||
let mut session = Session::new(recreated.clone());
|
||||
session.execute(Command::Replace(relinked)).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &recreated);
|
||||
assert_eq!(round_trip(session.project()), original_source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oa_source_deletion_preserves_rich_sa_design_and_exact_undo() {
|
||||
source_deletion(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn sa_source_deletion_preserves_rich_portless_oa_design_and_exact_undo() {
|
||||
source_deletion(TransitionDirection::SaToOa);
|
||||
}
|
||||
#[test]
|
||||
fn deleted_sa_target_refuses_old_plans_and_implicit_retry_creates_once() {
|
||||
target_deletion(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn deleted_oa_target_refuses_old_plans_and_implicit_retry_creates_once() {
|
||||
target_deletion(TransitionDirection::SaToOa);
|
||||
}
|
||||
#[test]
|
||||
fn recreated_oa_identity_requires_explicit_relink_to_retained_sa_design() {
|
||||
source_recreation(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn recreated_sa_identity_requires_explicit_relink_to_retained_oa_design() {
|
||||
source_recreation(TransitionDirection::SaToOa);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use iced::{
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
mod communication;
|
||||
mod counterparts;
|
||||
pub(crate) mod recovery;
|
||||
#[cfg(test)]
|
||||
mod transition_tests;
|
||||
@@ -34,6 +35,7 @@ pub enum Event {
|
||||
Scope(String),
|
||||
Destination(Choice),
|
||||
MappingTarget(Id, Choice),
|
||||
MappingQuery(Id, String),
|
||||
MappingKind(Id, Kind),
|
||||
CommunicationPort(Id, bool, communication::PortMenuChoice),
|
||||
OperationOmission(Id, bool),
|
||||
@@ -274,6 +276,7 @@ pub struct Transition {
|
||||
pub request: TransitionRequest,
|
||||
pub plan: TransitionPlan,
|
||||
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
||||
counterpart_queries: std::collections::BTreeMap<Id, String>,
|
||||
}
|
||||
impl Transition {
|
||||
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
|
||||
@@ -516,6 +519,7 @@ impl Workflow {
|
||||
request,
|
||||
plan,
|
||||
communication_ports: Default::default(),
|
||||
counterpart_queries: Default::default(),
|
||||
})))
|
||||
}
|
||||
/// Return a complete accepted candidate only on explicit Apply. Intermediate
|
||||
@@ -620,6 +624,12 @@ impl Workflow {
|
||||
_ => {}
|
||||
},
|
||||
Self::Transition(d) => {
|
||||
if let Event::MappingQuery(source, query) = event {
|
||||
d.counterpart_queries.insert(source, query);
|
||||
// Searching is presentation only: preserve reviewed decisions,
|
||||
// preview identities and the exact recovery snapshot.
|
||||
return Ok(None);
|
||||
}
|
||||
match &event {
|
||||
Event::OperationOmission(source, enabled) => {
|
||||
d.request
|
||||
@@ -1149,48 +1159,17 @@ impl Workflow {
|
||||
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
|
||||
}
|
||||
let mapping = d.request.mappings.iter().find(|m| m.source == source);
|
||||
let existing = p
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|t| {
|
||||
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
|
||||
{
|
||||
Some(t.sa)
|
||||
} else if d.request.direction == TransitionDirection::SaToOa
|
||||
&& t.sa == source
|
||||
{
|
||||
Some(t.oa)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
|
||||
if existing.len() == 1 {
|
||||
Some(existing[0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let target_choice = counterparts::target(p, source, &d.request);
|
||||
let target = target_choice.flatten();
|
||||
let query = d
|
||||
.counterpart_queries
|
||||
.get(&source)
|
||||
.map_or("", String::as_str);
|
||||
let targets = counterparts::choices(p, source, d.request.direction, query);
|
||||
let no_matches = targets.len() == 1;
|
||||
let selected = target_choice.map(|target| {
|
||||
counterparts::selected(p, source, d.request.direction, target)
|
||||
});
|
||||
let mut targets = vec![Choice {
|
||||
id: Id::nil(),
|
||||
label: "Create new counterpart".into(),
|
||||
}];
|
||||
targets.extend(
|
||||
p.elements
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.filter(|id| {
|
||||
p.layer_of(**id) == Some(d.request.direction.target_layer())
|
||||
&& p.elements.contains_key(&source)
|
||||
== p.elements.contains_key(id)
|
||||
})
|
||||
.map(|id| choice(p, *id)),
|
||||
);
|
||||
let selected = targets
|
||||
.iter()
|
||||
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
|
||||
.cloned();
|
||||
let mut item = column![
|
||||
text(format!(
|
||||
"{} · {}",
|
||||
@@ -1201,14 +1180,29 @@ impl Workflow {
|
||||
.unwrap_or("Relationship")
|
||||
))
|
||||
.size(14),
|
||||
text_input("Find counterpart by name or @alias", query)
|
||||
.on_input(move |query| Message::Workflow(Event::MappingQuery(
|
||||
source, query
|
||||
)))
|
||||
.size(12)
|
||||
.padding(8),
|
||||
pick_list(targets, selected, move |v| Message::Workflow(
|
||||
Event::MappingTarget(source, v)
|
||||
))
|
||||
.placeholder("Choose a counterpart")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(7);
|
||||
if no_matches {
|
||||
let message = if query.trim().is_empty() {
|
||||
"No compatible existing counterparts. Create a new one."
|
||||
} else {
|
||||
"No matching existing counterparts. Clear the search or create a new one."
|
||||
};
|
||||
item = item.push(text(message).size(11));
|
||||
}
|
||||
if target.is_none()
|
||||
&& let Some(e) = p.elements.get(&source)
|
||||
{
|
||||
@@ -1414,16 +1408,6 @@ impl Workflow {
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
text(format!(
|
||||
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
|
||||
d.plan.changes.len(),
|
||||
d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(),
|
||||
d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(),
|
||||
d.plan.warnings.len()
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
let enabled = match self {
|
||||
@@ -1440,12 +1424,50 @@ impl Workflow {
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
column![
|
||||
scrollable(content.padding(iced::Padding {
|
||||
right: 14.,
|
||||
..Default::default()
|
||||
}))
|
||||
.height(480),
|
||||
let mut footer = column![].spacing(8);
|
||||
if let Self::Transition(d) = self {
|
||||
let boundary_choices = d
|
||||
.plan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|c| d.boundary_choice(p, c))
|
||||
.count();
|
||||
footer = footer.push(
|
||||
text(format!(
|
||||
"{} reviewed items · {} boundary choices · {} conflicts · {} warnings",
|
||||
d.plan.changes.len(),
|
||||
boundary_choices,
|
||||
d.plan.conflicts.len() - boundary_choices,
|
||||
d.plan.warnings.len()
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
let guidance = if d.destination_error(p).is_some() {
|
||||
Some("Choose a compatible diagram in Show result in, above the mappings.".into())
|
||||
} else if boundary_choices > 0 {
|
||||
Some("Choose the counterpart kind in each mapping that needs a system boundary decision.".into())
|
||||
} else if let Some(field) = d.plan.conflicts.iter().find_map(|c| c.field) {
|
||||
let field = match field {
|
||||
TransitionField::Name => "Name",
|
||||
TransitionField::Description => "Description",
|
||||
};
|
||||
Some(format!(
|
||||
"Review {field} below the mappings: choose Keep target, Take source, or merged text."
|
||||
))
|
||||
} else if !d.plan.conflicts.is_empty() {
|
||||
Some("Review the conflict messages below the mappings and complete the required decisions.".into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(guidance) = guidance {
|
||||
footer = footer.push(
|
||||
text(guidance)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
}
|
||||
footer = footer.push(
|
||||
row![
|
||||
Space::new().width(Fill),
|
||||
button(text("Cancel").size(12))
|
||||
@@ -1465,7 +1487,15 @@ impl Workflow {
|
||||
.padding(10)
|
||||
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
|
||||
]
|
||||
.spacing(10)
|
||||
.spacing(10),
|
||||
);
|
||||
column![
|
||||
scrollable(content.padding(iced::Padding {
|
||||
right: 14.,
|
||||
..Default::default()
|
||||
}))
|
||||
.height(480),
|
||||
footer
|
||||
]
|
||||
.spacing(18)
|
||||
.into()
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Search is separate from the committed counterpart mapping.
|
||||
use super::*;
|
||||
|
||||
/// Outer None requires a choice between distinct traced targets; inner None
|
||||
/// means creation (explicit, or no existing correspondence).
|
||||
pub(super) fn target(p: &Project, source: Id, request: &TransitionRequest) -> Option<Option<Id>> {
|
||||
if let Some(mapping) = request.mappings.iter().find(|m| m.source == source) {
|
||||
return Some(mapping.target);
|
||||
}
|
||||
let existing = p
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|trace| match request.direction {
|
||||
TransitionDirection::OaToSa if trace.oa == source => Some(trace.sa),
|
||||
TransitionDirection::SaToOa if trace.sa == source => Some(trace.oa),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
match existing.len() {
|
||||
0 => Some(None),
|
||||
1 => Some(existing.first().copied()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_choice() -> Choice {
|
||||
Choice {
|
||||
id: Id::nil(),
|
||||
label: "Create new counterpart".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(p: &Project, id: Id) -> Option<(&str, &str, String)> {
|
||||
if let Some(e) = p.elements.get(&id) {
|
||||
Some((&e.name, &e.alias, e.kind().label().into()))
|
||||
} else {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.map(|r| (r.name.as_str(), r.alias.as_str(), r.kind().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn label(p: &Project, id: Id) -> Choice {
|
||||
let label = match identity(p, id) {
|
||||
Some((name, alias, kind)) => format!("{} · {kind} · @{alias}", short(name, 48)),
|
||||
None => format!("Missing counterpart · {id}"),
|
||||
};
|
||||
Choice { id, label }
|
||||
}
|
||||
|
||||
pub(super) fn selected(
|
||||
p: &Project,
|
||||
source: Id,
|
||||
direction: TransitionDirection,
|
||||
target: Option<Id>,
|
||||
) -> Choice {
|
||||
let Some(target) = target else {
|
||||
return new_choice();
|
||||
};
|
||||
let mut choice = label(p, target);
|
||||
if p.contains(target) && !counterpart_compatible(p, source, target, direction) {
|
||||
choice.label.push_str(" (incompatible)");
|
||||
}
|
||||
choice
|
||||
}
|
||||
|
||||
pub(super) fn choices(
|
||||
p: &Project,
|
||||
source: Id,
|
||||
direction: TransitionDirection,
|
||||
query: &str,
|
||||
) -> Vec<Choice> {
|
||||
let query = query.trim().to_lowercase();
|
||||
let mut candidates = p
|
||||
.elements
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.copied()
|
||||
.filter(|id| counterpart_compatible(p, source, *id, direction))
|
||||
.filter_map(|id| {
|
||||
let (name, alias, _) = identity(p, id)?;
|
||||
let name = name.to_lowercase();
|
||||
let alias = alias.to_lowercase();
|
||||
let matches = if let Some(query) = query.strip_prefix('@') {
|
||||
alias.contains(query)
|
||||
} else {
|
||||
name.contains(&query) || alias.contains(&query)
|
||||
};
|
||||
matches.then_some((name, alias, id))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
std::iter::once(new_choice())
|
||||
.chain(candidates.into_iter().map(|(_, _, id)| label(p, id)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let source = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess pressure", None)
|
||||
.unwrap();
|
||||
let first = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Assess pressure", None)
|
||||
.unwrap();
|
||||
let second = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Assess pressure", None)
|
||||
.unwrap();
|
||||
let incompatible = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Assess pressure", None)
|
||||
.unwrap();
|
||||
p.elements.get_mut(&first).unwrap().alias = "alpha_assess".into();
|
||||
p.elements.get_mut(&second).unwrap().alias = "beta_assess".into();
|
||||
(p, source, first, second, incompatible)
|
||||
}
|
||||
|
||||
fn ids(choices: Vec<Choice>) -> Vec<Id> {
|
||||
choices.into_iter().map(|c| c.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_disambiguates_names_with_aliases_and_retains_creation() {
|
||||
let (p, source, first, second, _) = fixture();
|
||||
let all = choices(&p, source, TransitionDirection::OaToSa, "");
|
||||
assert_eq!(
|
||||
all.iter().map(|c| c.id).collect::<Vec<_>>(),
|
||||
vec![Id::nil(), first, second]
|
||||
);
|
||||
assert!(all[1].label.contains("@alpha_assess"));
|
||||
assert!(all[2].label.contains("@beta_assess"));
|
||||
assert_ne!(all[1].label, all[2].label);
|
||||
for (query, expected) in [
|
||||
(" ASSESS PRESSURE ", vec![Id::nil(), first, second]),
|
||||
(" @BETA_ ", vec![Id::nil(), second]),
|
||||
("ALPHA", vec![Id::nil(), first]),
|
||||
("@pressure", vec![Id::nil()]),
|
||||
("no match", vec![Id::nil()]),
|
||||
] {
|
||||
assert_eq!(
|
||||
ids(choices(&p, source, TransitionDirection::OaToSa, query)),
|
||||
expected,
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
ids(choices(&p, first, TransitionDirection::SaToOa, "PRESSURE")),
|
||||
vec![Id::nil(), source]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_preserves_reviewed_request_plan_ids_and_recovery_exactly() {
|
||||
let (mut p, source, first, _, _) = fixture();
|
||||
let second = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Second source card", None)
|
||||
.unwrap();
|
||||
let before_project = p.clone();
|
||||
// Exercise both an existing reviewed target and generated preview IDs.
|
||||
for chosen in [Some(first), None] {
|
||||
let mut w = Workflow::transition(&p, source).unwrap();
|
||||
let Workflow::Transition(t) = &mut w else {
|
||||
panic!()
|
||||
};
|
||||
t.request.sources.push(second);
|
||||
t.plan = plan_transition(&p, &t.request).unwrap();
|
||||
w.update(
|
||||
&p,
|
||||
Event::MappingTarget(
|
||||
source,
|
||||
selected(&p, source, TransitionDirection::OaToSa, chosen),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
w.update(
|
||||
&p,
|
||||
Event::Resolve(
|
||||
source,
|
||||
TransitionField::Name,
|
||||
FieldDecision::Value("Reviewed name".into()),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let saved = w.recovery_snapshot().unwrap();
|
||||
let Workflow::Transition(t) = &w else {
|
||||
panic!()
|
||||
};
|
||||
let plan = t.plan.clone();
|
||||
let request = t.request.clone();
|
||||
for query in ["assess", "@beta", "no match", ""] {
|
||||
assert!(
|
||||
w.update(&p, Event::MappingQuery(source, query.into()))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(w.recovery_snapshot().unwrap(), saved);
|
||||
let Workflow::Transition(t) = &w else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(t.plan, plan);
|
||||
assert_eq!(t.request, request);
|
||||
assert_eq!(t.counterpart_queries[&source], query);
|
||||
assert_eq!(target(&p, source, &t.request), Some(chosen));
|
||||
assert_eq!(
|
||||
selected(&p, source, t.request.direction, chosen).id,
|
||||
chosen.unwrap_or(Id::nil())
|
||||
);
|
||||
}
|
||||
w.update(&p, Event::MappingQuery(second, "other card".into()))
|
||||
.unwrap();
|
||||
let Workflow::Transition(t) = &w else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(t.counterpart_queries[&source], "");
|
||||
assert_eq!(t.counterpart_queries[&second], "other card");
|
||||
assert_eq!(t.plan, plan);
|
||||
let restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
let Workflow::Transition(t) = restored else {
|
||||
panic!()
|
||||
};
|
||||
assert!(t.counterpart_queries.is_empty());
|
||||
assert_eq!(t.request, request);
|
||||
}
|
||||
assert_eq!(p, before_project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_invalid_or_missing_mapping_stays_visible_and_refuses_apply() {
|
||||
let (p, source, _, _, incompatible) = fixture();
|
||||
for chosen in [incompatible, Id::from_u128(987654321)] {
|
||||
let mut w = Workflow::transition(&p, source).unwrap();
|
||||
w.update(
|
||||
&p,
|
||||
Event::MappingTarget(
|
||||
source,
|
||||
Choice {
|
||||
id: chosen,
|
||||
label: "Recovered choice".into(),
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let saved = w.recovery_snapshot().unwrap();
|
||||
let mut restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
|
||||
restored
|
||||
.update(&p, Event::MappingQuery(source, "no match".into()))
|
||||
.unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
let Workflow::Transition(t) = &restored else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(target(&p, source, &t.request), Some(Some(chosen)));
|
||||
let display = selected(&p, source, t.request.direction, Some(chosen));
|
||||
assert_eq!(display.id, chosen);
|
||||
assert!(display.label.contains(if chosen == incompatible {
|
||||
"incompatible"
|
||||
} else {
|
||||
"Missing counterpart"
|
||||
}));
|
||||
assert!(
|
||||
!choices(&p, source, t.request.direction, "")
|
||||
.iter()
|
||||
.any(|c| c.id == chosen)
|
||||
);
|
||||
assert!(restored.update(&p, Event::Apply).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_inference_counts_distinct_targets_and_explicit_mapping_wins() {
|
||||
let (mut p, source, first, second, _) = fixture();
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
|
||||
assert_eq!(target(&p, source, &request), Some(None));
|
||||
for (i, counterpart) in [first, first, second].into_iter().enumerate() {
|
||||
let id = Id::from_u128(100 + i as u128);
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{i}"),
|
||||
name: format!("Trace {i}"),
|
||||
description: String::new(),
|
||||
oa: source,
|
||||
sa: counterpart,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
target(&p, source, &request),
|
||||
if i < 2 { Some(Some(first)) } else { None }
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
target(
|
||||
&p,
|
||||
first,
|
||||
&TransitionRequest::new(TransitionDirection::SaToOa, vec![first])
|
||||
),
|
||||
Some(Some(source))
|
||||
);
|
||||
for chosen in [Some(second), None] {
|
||||
request.mappings = vec![TransitionMapping {
|
||||
source,
|
||||
target: chosen,
|
||||
target_kind: None,
|
||||
}];
|
||||
assert_eq!(target(&p, source, &request), Some(chosen));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ impl Workflow {
|
||||
request,
|
||||
plan,
|
||||
communication_ports,
|
||||
counterpart_queries: Default::default(),
|
||||
};
|
||||
transition.sync_communication_ports();
|
||||
transition.plan =
|
||||
|
||||
@@ -49,6 +49,8 @@ P1 extension to frozen relation interface: FunctionalExchange now has `source_po
|
||||
|
||||
`transitions::counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Kind>` is the shared choice policy for UI and other clients. It returns supported explicit element counterpart kinds in stable `Kind::ALL` order using the same compatibility predicate as transition validation; an empty list means no automatic element mapping.
|
||||
|
||||
`transitions::counterpart_compatible(&Project, source: Id, target: Id, direction: TransitionDirection) -> bool` checks existing-record candidate eligibility: both IDs must exist in the direction's respective layers, belong to the same element/relation category, and have compatible transition kinds. It does not approve properties, dependencies or structural changes; clients still preview and apply a complete plan. Manual trace creation retains its separate policy. The desktop counterpart search uses this predicate, filters by name or `@alias`, and sorts by name then alias and ID. Search is presentation state: it does not replan, change mappings or reviewed field decisions, or enter workflow recovery data. A selected target remains visible when filtered out; invalid or missing recovered targets remain visible with their error and require correction. Multiple traces to the same target count as one inferred counterpart; distinct traced targets require an explicit choice.
|
||||
|
||||
`TransitionRequest {direction:OaToSa|SaToOa, sources:Vec<Id>, scope:SelectedOnly|Required|Subtree, mappings:Vec<TransitionMapping>, fields:Vec<TransitionField>, resolutions:Vec<FieldResolution>, destination_diagram:Option<Id>}`. `TransitionRequest::new(direction,sources)` defaults to selected-only and name/description fields. `TransitionMapping {source,target:Option<Id>,target_kind:Option<Kind>}` selects reuse or explicit creation kind. An explicit mapping with target=None means create a new counterpart even if another trace exists. Without mapping, one existing trace is reused; multiple traces require selection.
|
||||
|
||||
`FieldResolution {source,field:Name|Description,decision:TakeSource|KeepTarget|Value(String)}`. `TransitionPlan {base_fingerprint,request,changes,conflicts,warnings,commands}`. Changes carry source/target IDs, Create/Update/Reuse and before/after field changes. Conflicts have optional source/target/field and message. Conflicts prevent apply. The deterministic fingerprint guards stale plans, is not a security hash, and does not introduce persistent project revision fields. Planned IDs and aliases remain unchanged during apply. Server owns request idempotency/revision storage. Apply is one candidate transaction; Session adoption can use Command::Replace(candidate) for one Undo.
|
||||
|
||||
Reference in New Issue
Block a user