316 lines
11 KiB
Rust
316 lines
11 KiB
Rust
//! 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));
|
|
}
|
|
}
|
|
}
|