Make ordinary counterpart selection searchable and verify transition lifecycle
This commit is contained in:
@@ -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>) {
|
||||
@@ -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::<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!(
|
||||
"{} · {}",
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user