Compose transition lifecycle and preserve authored attachments in both directions

This commit is contained in:
2026-09-23 00:49:26 -07:00
parent a9b9c81eab
commit ffa7b16003
7 changed files with 1409 additions and 60 deletions
+12 -2
View File
@@ -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);
}
+88 -58
View File
@@ -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 =
+2
View File
@@ -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.