Files
ARCADIA-Studio/crates/oa-desktop/src/workflow/counterparts.rs
T

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));
}
}
}