From acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 13 Sep 2026 08:12:59 -0700 Subject: [PATCH] Make ordinary counterpart selection searchable and verify transition lifecycle --- crates/oa-core/src/transitions.rs | 14 +- .../transition_counterpart_compatibility.rs | 353 ++++++++++ crates/oa-core/tests/transition_lifecycle.rs | 628 ++++++++++++++++++ crates/oa-desktop/src/workflow.rs | 76 +-- .../oa-desktop/src/workflow/counterparts.rs | 315 +++++++++ crates/oa-desktop/src/workflow/recovery.rs | 1 + docs/v2-interface.md | 2 + 7 files changed, 1346 insertions(+), 43 deletions(-) create mode 100644 crates/oa-core/tests/transition_counterpart_compatibility.rs create mode 100644 crates/oa-core/tests/transition_lifecycle.rs create mode 100644 crates/oa-desktop/src/workflow/counterparts.rs diff --git a/crates/oa-core/src/transitions.rs b/crates/oa-core/src/transitions.rs index 2abc00c2..e72395a4 100644 --- a/crates/oa-core/src/transitions.rs +++ b/crates/oa-core/src/transitions.rs @@ -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)) { diff --git a/crates/oa-core/tests/transition_counterpart_compatibility.rs b/crates/oa-core/tests/transition_counterpart_compatibility.rs new file mode 100644 index 00000000..c0ac0af5 --- /dev/null +++ b/crates/oa-core/tests/transition_counterpart_compatibility.rs @@ -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); +} diff --git a/crates/oa-core/tests/transition_lifecycle.rs b/crates/oa-core/tests/transition_lifecycle.rs new file mode 100644 index 00000000..fdf47d9d --- /dev/null +++ b/crates/oa-core/tests/transition_lifecycle.rs @@ -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, + exchange: Id, + diagram: Id, + nodes: Vec, + artifacts: Vec, +} + +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::>(); + 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::>(); + 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::>(); + 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); +} diff --git a/crates/oa-desktop/src/workflow.rs b/crates/oa-desktop/src/workflow.rs index 3f9de83f..244b518b 100644 --- a/crates/oa-desktop/src/workflow.rs +++ b/crates/oa-desktop/src/workflow.rs @@ -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, + counterpart_queries: std::collections::BTreeMap, } impl Transition { fn presentation(&self, p: &Project) -> (Project, Vec) { @@ -515,6 +518,7 @@ impl Workflow { request, plan, communication_ports: Default::default(), + counterpart_queries: Default::default(), }))) } /// Return a complete accepted candidate only on explicit Apply. Intermediate @@ -619,6 +623,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 @@ -1148,48 +1158,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::>(); - 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!( "{} · {}", @@ -1200,14 +1179,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) { diff --git a/crates/oa-desktop/src/workflow/counterparts.rs b/crates/oa-desktop/src/workflow/counterparts.rs new file mode 100644 index 00000000..d1b8d9ca --- /dev/null +++ b/crates/oa-desktop/src/workflow/counterparts.rs @@ -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> { + 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::>(); + 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, +) -> 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 { + 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::>(); + 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) -> Vec { + 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![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)); + } + } +} diff --git a/crates/oa-desktop/src/workflow/recovery.rs b/crates/oa-desktop/src/workflow/recovery.rs index 6567d86c..cbf26627 100644 --- a/crates/oa-desktop/src/workflow/recovery.rs +++ b/crates/oa-desktop/src/workflow/recovery.rs @@ -202,6 +202,7 @@ impl Workflow { request, plan, communication_ports, + counterpart_queries: Default::default(), }; transition.sync_communication_ports(); transition.plan = diff --git a/docs/v2-interface.md b/docs/v2-interface.md index c4d3dce1..72d3e88f 100644 --- a/docs/v2-interface.md +++ b/docs/v2-interface.md @@ -49,6 +49,8 @@ P1 extension to frozen relation interface: FunctionalExchange now has `source_po `transitions::counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec` 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, scope:SelectedOnly|Required|Subtree, mappings:Vec, fields:Vec, resolutions:Vec, destination_diagram:Option}`. `TransitionRequest::new(direction,sources)` defaults to selected-only and name/description fields. `TransitionMapping {source,target:Option,target_kind:Option}` 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.