417 lines
14 KiB
Rust
417 lines
14 KiB
Rust
//! Transient exact-target selection; admission and model operations belong to adapters.
|
|
use crate::diagram_usage::{Surface, Target};
|
|
use oa_core::Id;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct Scope {
|
|
/// Document-instance identity, not the persisted project UUID.
|
|
pub document: Id,
|
|
pub surface: Surface,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct Context {
|
|
pub scope: Scope,
|
|
pub revision: u64,
|
|
pub epoch: u64,
|
|
}
|
|
|
|
/// Insertion-ordered unique targets. Membership does not grant edit capability.
|
|
#[derive(Debug)]
|
|
pub(crate) struct Selection {
|
|
scope: Scope,
|
|
targets: Vec<Target>,
|
|
primary: Option<Target>,
|
|
generation: u64,
|
|
}
|
|
|
|
/// Immutable operation input, including the selection lifetime that captured it.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct Snapshot {
|
|
context: Context,
|
|
generation: u64,
|
|
targets: Vec<Target>,
|
|
primary: Option<Target>,
|
|
}
|
|
|
|
impl Selection {
|
|
pub fn new(scope: Scope) -> Self {
|
|
Self {
|
|
scope,
|
|
targets: Vec::new(),
|
|
primary: None,
|
|
generation: 0,
|
|
}
|
|
}
|
|
|
|
pub fn scope(&self) -> Scope {
|
|
self.scope
|
|
}
|
|
|
|
pub fn targets(&self) -> &[Target] {
|
|
&self.targets
|
|
}
|
|
|
|
pub fn primary(&self) -> Option<&Target> {
|
|
self.primary.as_ref()
|
|
}
|
|
|
|
pub fn contains(&self, target: &Target) -> bool {
|
|
self.targets.contains(target)
|
|
}
|
|
|
|
/// Replace with one valid target. Rejected admission preserves the old set.
|
|
pub fn replace(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
|
if !admitted(&target, valid) || (self.targets.len() == 1 && self.contains(&target)) {
|
|
return false;
|
|
}
|
|
self.targets = vec![target.clone()];
|
|
self.primary = Some(target);
|
|
self.changed();
|
|
true
|
|
}
|
|
|
|
/// Add a new member as primary. Adding an existing exact target is a no-op.
|
|
pub fn add(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
|
if !admitted(&target, valid) || self.contains(&target) {
|
|
return false;
|
|
}
|
|
self.targets.push(target.clone());
|
|
self.primary = Some(target);
|
|
self.changed();
|
|
true
|
|
}
|
|
|
|
/// Removal is allowed even if the target is no longer valid in the model.
|
|
pub fn remove(&mut self, target: &Target) -> bool {
|
|
let Some(index) = self.targets.iter().position(|member| member == target) else {
|
|
return false;
|
|
};
|
|
self.targets.remove(index);
|
|
if self.primary.as_ref() == Some(target) {
|
|
self.primary = self.targets.last().cloned();
|
|
}
|
|
self.changed();
|
|
true
|
|
}
|
|
|
|
pub fn toggle(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
|
if self.contains(&target) {
|
|
self.remove(&target)
|
|
} else {
|
|
self.add(target, valid)
|
|
}
|
|
}
|
|
|
|
/// Inspect another selected member without changing membership or its order.
|
|
pub fn set_primary(&mut self, target: &Target) -> bool {
|
|
if !self.contains(target) || self.primary.as_ref() == Some(target) {
|
|
return false;
|
|
}
|
|
self.primary = Some(target.clone());
|
|
self.changed();
|
|
true
|
|
}
|
|
|
|
pub fn clear(&mut self) -> bool {
|
|
if self.targets.is_empty() {
|
|
return false;
|
|
}
|
|
self.targets.clear();
|
|
self.primary = None;
|
|
self.changed();
|
|
true
|
|
}
|
|
|
|
/// Change owner or prune exact targets against the current rendered surface.
|
|
/// The predicate checks identity/visibility, not eligibility for one action.
|
|
/// Revision-only changes may retain survivors; no semantic retargeting occurs.
|
|
pub fn reconcile(&mut self, scope: Scope, valid: impl FnMut(&Target) -> bool) -> bool {
|
|
if self.scope != scope {
|
|
self.scope = scope;
|
|
self.targets.clear();
|
|
self.primary = None;
|
|
self.changed();
|
|
return true;
|
|
}
|
|
let previous_len = self.targets.len();
|
|
self.targets.retain(valid);
|
|
if self.targets.len() == previous_len {
|
|
return false;
|
|
}
|
|
if !self
|
|
.primary
|
|
.as_ref()
|
|
.is_some_and(|target| self.contains(target))
|
|
{
|
|
self.primary = self.targets.last().cloned();
|
|
}
|
|
self.changed();
|
|
true
|
|
}
|
|
|
|
/// Capture input, including an empty selection before a first pointer press.
|
|
/// Does not grant permission to mutate; adapters still guard drafts, modals,
|
|
/// capability and geometry. A mismatched owner cannot capture a snapshot.
|
|
pub fn snapshot(&self, context: Context) -> Option<Snapshot> {
|
|
if context.scope != self.scope {
|
|
return None;
|
|
}
|
|
Some(Snapshot {
|
|
context,
|
|
generation: self.generation,
|
|
targets: self.targets.clone(),
|
|
primary: self.primary.clone(),
|
|
})
|
|
}
|
|
|
|
/// Check before any operation side effect, using the caller's live context.
|
|
/// Generation rejects remove/re-add and primary-change/restore ABA cycles.
|
|
pub fn matches(&self, snapshot: &Snapshot, current: Context) -> bool {
|
|
snapshot.context == current
|
|
&& current.scope == self.scope
|
|
&& snapshot.generation == self.generation
|
|
&& snapshot.targets == self.targets
|
|
&& self.primary == snapshot.primary
|
|
}
|
|
|
|
fn changed(&mut self) {
|
|
self.generation = self
|
|
.generation
|
|
.checked_add(1)
|
|
.expect("selection generation exhausted");
|
|
}
|
|
}
|
|
|
|
impl Snapshot {
|
|
pub fn context(&self) -> Context {
|
|
self.context
|
|
}
|
|
|
|
pub fn targets(&self) -> &[Target] {
|
|
&self.targets
|
|
}
|
|
|
|
pub fn primary(&self) -> Option<&Target> {
|
|
self.primary.as_ref()
|
|
}
|
|
}
|
|
|
|
fn admitted(target: &Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
|
!matches!(target, Target::Whole) && valid(target)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use oa_core::OccurrenceEndpoint;
|
|
|
|
fn id(value: u128) -> Id {
|
|
Id::from_u128(value)
|
|
}
|
|
|
|
fn scope() -> Scope {
|
|
Scope {
|
|
document: id(1),
|
|
surface: Surface::Saved(id(2)),
|
|
}
|
|
}
|
|
|
|
fn context() -> Context {
|
|
Context {
|
|
scope: scope(),
|
|
revision: 7,
|
|
epoch: 11,
|
|
}
|
|
}
|
|
|
|
fn node(value: u128) -> Target {
|
|
Target::Placement(id(value))
|
|
}
|
|
|
|
#[test]
|
|
fn exact_duplicates_are_noops_but_distinct_visual_identities_survive() {
|
|
let mut selection = Selection::new(scope());
|
|
let port_a = Target::Port {
|
|
port: id(30),
|
|
placement: id(3),
|
|
};
|
|
let port_b = Target::Port {
|
|
port: id(30),
|
|
placement: id(4),
|
|
};
|
|
assert!(selection.add(node(3), |_| true));
|
|
assert!(selection.add(port_a.clone(), |_| true));
|
|
let captured = selection.snapshot(context()).unwrap();
|
|
assert!(!selection.add(node(3), |_| true));
|
|
assert!(!selection.add(port_a.clone(), |_| true));
|
|
assert!(selection.matches(&captured, context()));
|
|
assert!(selection.add(port_b.clone(), |_| true));
|
|
assert_eq!(selection.targets(), &[node(3), port_a, port_b.clone()]);
|
|
assert_eq!(selection.primary(), Some(&port_b));
|
|
assert_eq!(
|
|
captured.targets(),
|
|
&[
|
|
node(3),
|
|
Target::Port {
|
|
port: id(30),
|
|
placement: id(3)
|
|
}
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn primary_removal_uses_latest_survivor_without_reordering() {
|
|
let mut selection = Selection::new(scope());
|
|
for value in [3, 4, 5] {
|
|
selection.add(node(value), |_| true);
|
|
}
|
|
assert!(selection.set_primary(&node(3)));
|
|
assert_eq!(selection.targets(), &[node(3), node(4), node(5)]);
|
|
assert!(!selection.set_primary(&node(99)));
|
|
assert!(selection.remove(&node(4)));
|
|
assert_eq!(selection.primary(), Some(&node(3)));
|
|
assert!(selection.remove(&node(3)));
|
|
assert_eq!(selection.primary(), Some(&node(5)));
|
|
// A now-invalid member remains removable; validity applies to additions.
|
|
assert!(selection.toggle(node(5), |_| false));
|
|
assert!(selection.targets().is_empty());
|
|
assert_eq!(selection.primary(), None);
|
|
let empty = selection.snapshot(context()).unwrap();
|
|
assert!(empty.targets().is_empty());
|
|
assert_eq!(empty.primary(), None);
|
|
assert!(selection.matches(&empty, context()));
|
|
selection.add(node(5), |_| true);
|
|
assert!(!selection.matches(&empty, context()));
|
|
}
|
|
|
|
#[test]
|
|
fn whole_and_rejected_targets_cannot_replace_an_existing_selection() {
|
|
let mut selection = Selection::new(scope());
|
|
selection.add(node(3), |_| true);
|
|
let captured = selection.snapshot(context()).unwrap();
|
|
assert!(!selection.replace(Target::Whole, |_| true));
|
|
assert!(!selection.add(Target::Whole, |_| true));
|
|
assert!(!selection.toggle(Target::Whole, |_| true));
|
|
assert!(!selection.replace(node(4), |_| false));
|
|
assert!(!selection.toggle(node(4), |_| false));
|
|
assert!(selection.matches(&captured, context()));
|
|
assert!(selection.replace(node(4), |_| true));
|
|
assert_eq!(selection.targets(), &[node(4)]);
|
|
assert_eq!(selection.primary(), Some(&node(4)));
|
|
assert!(!selection.replace(node(4), |_| true));
|
|
assert!(selection.clear());
|
|
assert!(!selection.clear());
|
|
assert_eq!(selection.primary(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn reused_process_occurrences_reconcile_by_full_path_never_definition() {
|
|
let owner = Scope {
|
|
surface: Surface::Process(id(2)),
|
|
..scope()
|
|
};
|
|
let mut selection = Selection::new(owner);
|
|
let occurrence = |path| {
|
|
Target::ProcessOccurrence(OccurrenceEndpoint {
|
|
references: vec![id(path), id(50)],
|
|
occurrence: id(60),
|
|
})
|
|
};
|
|
let first = occurrence(10);
|
|
let second = occurrence(20);
|
|
selection.add(first.clone(), |_| true);
|
|
selection.add(second.clone(), |_| true);
|
|
assert_eq!(selection.targets(), &[first.clone(), second.clone()]);
|
|
assert!(selection.reconcile(owner, |target| target == &first));
|
|
assert_eq!(selection.targets(), std::slice::from_ref(&first));
|
|
assert_eq!(selection.primary(), Some(&first));
|
|
// A new call path with the same definition is not a replacement identity.
|
|
let replacement = occurrence(70);
|
|
assert!(selection.reconcile(owner, |target| target == &replacement));
|
|
assert!(selection.targets().is_empty());
|
|
assert_eq!(selection.primary(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn revision_reconciliation_retains_exact_survivors_but_invalidates_operations() {
|
|
let mut selection = Selection::new(scope());
|
|
selection.add(node(3), |_| true);
|
|
selection.add(node(4), |_| true);
|
|
selection.set_primary(&node(3));
|
|
let captured = selection.snapshot(context()).unwrap();
|
|
let next = Context {
|
|
revision: 8,
|
|
..context()
|
|
};
|
|
assert!(!selection.reconcile(scope(), |_| true));
|
|
assert_eq!(selection.targets(), &[node(3), node(4)]);
|
|
assert_eq!(selection.primary(), Some(&node(3)));
|
|
assert!(!selection.matches(&captured, next));
|
|
let accepted = selection.snapshot(next).unwrap();
|
|
assert!(selection.matches(&accepted, next));
|
|
assert!(selection.reconcile(scope(), |target| target != &node(4)));
|
|
assert_eq!(selection.primary(), Some(&node(3)));
|
|
assert!(!selection.matches(&accepted, next));
|
|
}
|
|
|
|
#[test]
|
|
fn changing_document_or_surface_clears_even_identical_target_ids() {
|
|
for changed in [
|
|
Scope {
|
|
document: id(99),
|
|
..scope()
|
|
},
|
|
Scope {
|
|
surface: Surface::Saved(id(99)),
|
|
..scope()
|
|
},
|
|
Scope {
|
|
surface: Surface::Scenario(id(2)),
|
|
..scope()
|
|
},
|
|
] {
|
|
let mut selection = Selection::new(scope());
|
|
selection.add(node(3), |_| true);
|
|
let captured = selection.snapshot(context()).unwrap();
|
|
assert!(selection.reconcile(changed, |_| true));
|
|
assert_eq!(selection.scope(), changed);
|
|
assert!(selection.targets().is_empty());
|
|
assert_eq!(selection.primary(), None);
|
|
assert!(!selection.matches(&captured, context()));
|
|
assert!(selection.snapshot(context()).is_none());
|
|
selection.reconcile(scope(), |_| true);
|
|
selection.add(node(3), |_| true);
|
|
assert!(!selection.matches(&captured, context()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stale_epoch_selection_and_primary_cycles_do_not_regain_authority() {
|
|
let mut selection = Selection::new(scope());
|
|
selection.add(node(3), |_| true);
|
|
selection.add(node(4), |_| true);
|
|
let captured = selection.snapshot(context()).unwrap();
|
|
assert_eq!(captured.context(), context());
|
|
assert_eq!(captured.primary(), Some(&node(4)));
|
|
assert!(!selection.matches(
|
|
&captured,
|
|
Context {
|
|
epoch: 12,
|
|
..context()
|
|
}
|
|
));
|
|
selection.set_primary(&node(3));
|
|
selection.set_primary(&node(4));
|
|
assert!(!selection.matches(&captured, context()));
|
|
let restored_primary = selection.snapshot(context()).unwrap();
|
|
selection.remove(&node(4));
|
|
selection.add(node(4), |_| true);
|
|
assert_eq!(selection.targets(), captured.targets());
|
|
assert_eq!(selection.primary(), captured.primary());
|
|
assert!(!selection.matches(&restored_primary, context()));
|
|
assert!(selection.matches(&selection.snapshot(context()).unwrap(), context()));
|
|
}
|
|
}
|