Make ordinary counterpart selection searchable and verify transition lifecycle
This commit is contained in:
@@ -750,7 +750,7 @@ pub fn plan_transition(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !compatible_existing(project, *source, target, request.direction) {
|
if !counterpart_compatible(project, *source, target, request.direction) {
|
||||||
conflict(
|
conflict(
|
||||||
&mut plan,
|
&mut plan,
|
||||||
Some(*source),
|
Some(*source),
|
||||||
@@ -1317,12 +1317,22 @@ fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection)
|
|||||||
}
|
}
|
||||||
inferred_kind(source, direction) == Some(target)
|
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,
|
p: &Project,
|
||||||
source: Id,
|
source: Id,
|
||||||
target: Id,
|
target: Id,
|
||||||
direction: TransitionDirection,
|
direction: TransitionDirection,
|
||||||
) -> bool {
|
) -> 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)) {
|
match (p.elements.get(&source), p.elements.get(&target)) {
|
||||||
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
|
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
|
||||||
_ => match (p.relations.get(&source), p.relations.get(&target)) {
|
_ => 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,628 @@
|
|||||||
|
//! 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,
|
||||||
|
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 oa_core::{transitions::*, *};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
mod communication;
|
mod communication;
|
||||||
|
mod counterparts;
|
||||||
pub(crate) mod recovery;
|
pub(crate) mod recovery;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod transition_tests;
|
mod transition_tests;
|
||||||
@@ -34,6 +35,7 @@ pub enum Event {
|
|||||||
Scope(String),
|
Scope(String),
|
||||||
Destination(Choice),
|
Destination(Choice),
|
||||||
MappingTarget(Id, Choice),
|
MappingTarget(Id, Choice),
|
||||||
|
MappingQuery(Id, String),
|
||||||
MappingKind(Id, Kind),
|
MappingKind(Id, Kind),
|
||||||
CommunicationPort(Id, bool, communication::PortMenuChoice),
|
CommunicationPort(Id, bool, communication::PortMenuChoice),
|
||||||
OperationOmission(Id, bool),
|
OperationOmission(Id, bool),
|
||||||
@@ -274,6 +276,7 @@ pub struct Transition {
|
|||||||
pub request: TransitionRequest,
|
pub request: TransitionRequest,
|
||||||
pub plan: TransitionPlan,
|
pub plan: TransitionPlan,
|
||||||
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
||||||
|
counterpart_queries: std::collections::BTreeMap<Id, String>,
|
||||||
}
|
}
|
||||||
impl Transition {
|
impl Transition {
|
||||||
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
|
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
|
||||||
@@ -515,6 +518,7 @@ impl Workflow {
|
|||||||
request,
|
request,
|
||||||
plan,
|
plan,
|
||||||
communication_ports: Default::default(),
|
communication_ports: Default::default(),
|
||||||
|
counterpart_queries: Default::default(),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
/// Return a complete accepted candidate only on explicit Apply. Intermediate
|
/// Return a complete accepted candidate only on explicit Apply. Intermediate
|
||||||
@@ -619,6 +623,12 @@ impl Workflow {
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Self::Transition(d) => {
|
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 {
|
match &event {
|
||||||
Event::OperationOmission(source, enabled) => {
|
Event::OperationOmission(source, enabled) => {
|
||||||
d.request
|
d.request
|
||||||
@@ -1148,48 +1158,17 @@ impl Workflow {
|
|||||||
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
|
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 mapping = d.request.mappings.iter().find(|m| m.source == source);
|
||||||
let existing = p
|
let target_choice = counterparts::target(p, source, &d.request);
|
||||||
.traces
|
let target = target_choice.flatten();
|
||||||
.values()
|
let query = d
|
||||||
.filter_map(|t| {
|
.counterpart_queries
|
||||||
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
|
.get(&source)
|
||||||
{
|
.map_or("", String::as_str);
|
||||||
Some(t.sa)
|
let targets = counterparts::choices(p, source, d.request.direction, query);
|
||||||
} else if d.request.direction == TransitionDirection::SaToOa
|
let no_matches = targets.len() == 1;
|
||||||
&& t.sa == source
|
let selected = target_choice.map(|target| {
|
||||||
{
|
counterparts::selected(p, source, d.request.direction, target)
|
||||||
Some(t.oa)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
|
|
||||||
if existing.len() == 1 {
|
|
||||||
Some(existing[0])
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
let mut targets = vec![Choice {
|
|
||||||
id: Id::nil(),
|
|
||||||
label: "Create new counterpart".into(),
|
|
||||||
}];
|
|
||||||
targets.extend(
|
|
||||||
p.elements
|
|
||||||
.keys()
|
|
||||||
.chain(p.relations.keys())
|
|
||||||
.filter(|id| {
|
|
||||||
p.layer_of(**id) == Some(d.request.direction.target_layer())
|
|
||||||
&& p.elements.contains_key(&source)
|
|
||||||
== p.elements.contains_key(id)
|
|
||||||
})
|
|
||||||
.map(|id| choice(p, *id)),
|
|
||||||
);
|
|
||||||
let selected = targets
|
|
||||||
.iter()
|
|
||||||
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
|
|
||||||
.cloned();
|
|
||||||
let mut item = column![
|
let mut item = column![
|
||||||
text(format!(
|
text(format!(
|
||||||
"{} · {}",
|
"{} · {}",
|
||||||
@@ -1200,14 +1179,29 @@ impl Workflow {
|
|||||||
.unwrap_or("Relationship")
|
.unwrap_or("Relationship")
|
||||||
))
|
))
|
||||||
.size(14),
|
.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(
|
pick_list(targets, selected, move |v| Message::Workflow(
|
||||||
Event::MappingTarget(source, v)
|
Event::MappingTarget(source, v)
|
||||||
))
|
))
|
||||||
|
.placeholder("Choose a counterpart")
|
||||||
.text_size(12)
|
.text_size(12)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
]
|
]
|
||||||
.spacing(7);
|
.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()
|
if target.is_none()
|
||||||
&& let Some(e) = p.elements.get(&source)
|
&& let Some(e) = p.elements.get(&source)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
request,
|
||||||
plan,
|
plan,
|
||||||
communication_ports,
|
communication_ports,
|
||||||
|
counterpart_queries: Default::default(),
|
||||||
};
|
};
|
||||||
transition.sync_communication_ports();
|
transition.sync_communication_ports();
|
||||||
transition.plan =
|
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_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.
|
`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.
|
`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