mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 22:11:38 +00:00
Complete implementation of the 3-pass matching algorithm for ambiguous path handling
- Finalized the transition from a duplicate-name blacklist system to a comprehensive 3-pass matching algorithm for both syncback and forward operations, enhancing the handling of ambiguous paths. - Integrated a new `matching.lua` module to facilitate instance matching during hydration, utilizing unique name matching, class name narrowing, and structural fingerprinting. - Updated various components, including `ChangeBatcher`, `Reconciler`, and `snapshot` modules, to leverage the new matching logic, ensuring robust file management and improved performance. - Removed legacy duplicate handling code and streamlined the encoding process for instances with duplicate names. These changes significantly enhance the clarity and efficiency of file operations, paving the way for improved project management and user experience.
This commit is contained in:
parent
cc77a2cd8a
commit
d6162ac210
21 changed files with 2507 additions and 846 deletions
769
src/syncback/matching.rs
Normal file
769
src/syncback/matching.rs
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
//! 3-pass instance matching algorithm for syncback.
|
||||
//!
|
||||
//! Pairs new children (from Roblox file) to old children (from filesystem)
|
||||
//! using progressively more expensive matching signals:
|
||||
//!
|
||||
//! - **Pass 1** (O(n)): unique name matching + ClassName narrowing within
|
||||
//! same-name groups.
|
||||
//! - **Pass 2**: Ref property discriminators. If a Ref target is already
|
||||
//! matched in Pass 1, use it to differentiate candidates.
|
||||
//! - **Pass 3**: pairwise similarity scoring using precomputed subtree
|
||||
//! hashes. Greedy best-match-first with DOM child order as tiebreaker.
|
||||
//!
|
||||
//! The algorithm is designed for the syncback context where both "new" and
|
||||
//! "old" sides are WeakDom instances.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use blake3::Hash;
|
||||
use rbx_dom_weak::{
|
||||
types::{Ref, Variant},
|
||||
ustr, WeakDom,
|
||||
};
|
||||
|
||||
/// Result of the matching algorithm.
|
||||
#[derive(Debug)]
|
||||
pub struct MatchResult {
|
||||
/// Pairs of (new_ref, old_ref) that were matched.
|
||||
pub matched: Vec<(Ref, Ref)>,
|
||||
/// New children with no match on the old side (to be created).
|
||||
pub unmatched_new: Vec<Ref>,
|
||||
/// Old children with no match on the new side (to be deleted).
|
||||
pub unmatched_old: Vec<Ref>,
|
||||
}
|
||||
|
||||
/// Run the 3-pass matching algorithm.
|
||||
///
|
||||
/// `new_children` and `old_children` are the Refs of children under the same
|
||||
/// parent in their respective WeakDoms. `new_hashes` and `old_hashes` are
|
||||
/// optional precomputed subtree hashes (from `hash_tree()`).
|
||||
pub fn match_children(
|
||||
new_children: &[Ref],
|
||||
old_children: &[Ref],
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
) -> MatchResult {
|
||||
if new_children.is_empty() && old_children.is_empty() {
|
||||
return MatchResult {
|
||||
matched: Vec::new(),
|
||||
unmatched_new: Vec::new(),
|
||||
unmatched_old: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut matched: Vec<(Ref, Ref)> = Vec::new();
|
||||
let mut remaining_new: Vec<Ref> = new_children.to_vec();
|
||||
let mut remaining_old: Vec<Ref> = old_children.to_vec();
|
||||
|
||||
// ---- Pass 1: unique name matching + ClassName narrowing ----
|
||||
pass1_name_and_class(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
&mut matched,
|
||||
);
|
||||
|
||||
// Fast path: if all matched, skip Passes 2 and 3.
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return MatchResult {
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Pass 2: Ref property discriminators ----
|
||||
// Snapshot the already-matched pairs so we can borrow matched mutably.
|
||||
let matched_snapshot: Vec<(Ref, Ref)> = matched.clone();
|
||||
pass2_ref_discriminators(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
&matched_snapshot,
|
||||
&mut matched,
|
||||
);
|
||||
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return MatchResult {
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Pass 3: similarity scoring ----
|
||||
pass3_similarity(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
new_children,
|
||||
old_children,
|
||||
&mut matched,
|
||||
);
|
||||
|
||||
MatchResult {
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass 1: Match instances with unique names, then narrow same-name groups by
|
||||
/// ClassName.
|
||||
fn pass1_name_and_class(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
// Build name→refs maps for both sides.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: Vec<Ref> = Vec::new();
|
||||
let mut matched_old: Vec<Ref> = Vec::new();
|
||||
|
||||
// Sort names for deterministic iteration order.
|
||||
let mut sorted_names: Vec<&String> = new_by_name.keys().collect();
|
||||
sorted_names.sort();
|
||||
|
||||
for name in sorted_names {
|
||||
let new_refs = &new_by_name[name];
|
||||
let Some(old_refs) = old_by_name.get(name.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Case 1: exactly one on each side → instant match.
|
||||
if new_refs.len() == 1 && old_refs.len() == 1 {
|
||||
matched.push((new_refs[0], old_refs[0]));
|
||||
matched_new.push(new_refs[0]);
|
||||
matched_old.push(old_refs[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: multiple on at least one side → try ClassName narrowing.
|
||||
// Build (Name, ClassName) → refs maps within this name group.
|
||||
let mut new_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in new_refs {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in old_refs {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// Match (Name, ClassName) pairs that are unique on both sides.
|
||||
for (class_name, new_class_refs) in &new_by_class {
|
||||
if let Some(old_class_refs) = old_by_class.get(class_name) {
|
||||
if new_class_refs.len() == 1 && old_class_refs.len() == 1 {
|
||||
matched.push((new_class_refs[0], old_class_refs[0]));
|
||||
matched_new.push(new_class_refs[0]);
|
||||
matched_old.push(old_class_refs[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove matched refs from remaining lists.
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Pass 2: Use pre-resolved Ref properties to differentiate instances within
|
||||
/// same-name groups.
|
||||
fn pass2_ref_discriminators(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
already_matched: &[(Ref, Ref)],
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build bidirectional match maps: new→old and old→new.
|
||||
let new_to_old: HashMap<Ref, Ref> = already_matched.iter().copied().collect();
|
||||
let old_to_new: HashMap<Ref, Ref> = already_matched
|
||||
.iter()
|
||||
.map(|&(n, o)| (o, n))
|
||||
.collect();
|
||||
|
||||
// Collect Ref property targets for each remaining instance.
|
||||
let new_ref_targets: HashMap<Ref, Vec<Ref>> = remaining_new
|
||||
.iter()
|
||||
.map(|&r| (r, collect_ref_property_targets(new_dom, r)))
|
||||
.collect();
|
||||
let old_ref_targets: HashMap<Ref, Vec<Ref>> = remaining_old
|
||||
.iter()
|
||||
.map(|&r| (r, collect_ref_property_targets(old_dom, r)))
|
||||
.collect();
|
||||
|
||||
// Group remaining by name for matching within groups.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: Vec<Ref> = Vec::new();
|
||||
let mut matched_old: Vec<Ref> = Vec::new();
|
||||
|
||||
for (name, new_refs) in &new_by_name {
|
||||
let Some(old_refs) = old_by_name.get(name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// For each new instance in this group, compute a "ref signature":
|
||||
// the set of matched counterparts its Ref targets point to.
|
||||
for &new_ref in new_refs {
|
||||
if matched_new.contains(&new_ref) {
|
||||
continue;
|
||||
}
|
||||
let new_targets = &new_ref_targets[&new_ref];
|
||||
if new_targets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Map new targets through the match map to get "translated" targets.
|
||||
let translated: Vec<Ref> = new_targets
|
||||
.iter()
|
||||
.filter_map(|t| new_to_old.get(t).copied())
|
||||
.collect();
|
||||
if translated.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find old candidates that have Ref targets matching the translated set.
|
||||
let mut candidates: Vec<Ref> = Vec::new();
|
||||
for &old_ref in old_refs {
|
||||
if matched_old.contains(&old_ref) {
|
||||
continue;
|
||||
}
|
||||
let old_targets = &old_ref_targets[&old_ref];
|
||||
let old_translated: Vec<Ref> = old_targets
|
||||
.iter()
|
||||
.filter_map(|t| old_to_new.get(t).copied())
|
||||
.collect();
|
||||
|
||||
// Check if the ref signatures overlap.
|
||||
if translated.iter().any(|t| old_targets.contains(t))
|
||||
|| old_translated.iter().any(|t| new_targets.contains(t))
|
||||
{
|
||||
candidates.push(old_ref);
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.len() == 1 {
|
||||
matched.push((new_ref, candidates[0]));
|
||||
matched_new.push(new_ref);
|
||||
matched_old.push(candidates[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Pass 3: Similarity scoring using subtree hashes and greedy assignment.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pass3_similarity(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
original_new_children: &[Ref],
|
||||
original_old_children: &[Ref],
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group remaining by name.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: Vec<Ref> = Vec::new();
|
||||
let mut matched_old: Vec<Ref> = Vec::new();
|
||||
|
||||
for (name, new_refs) in &new_by_name {
|
||||
let Some(old_refs) = old_by_name.get(name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Build all candidate pairs with their similarity scores.
|
||||
let mut pairs: Vec<(Ref, Ref, u32)> = Vec::new();
|
||||
for &new_ref in new_refs {
|
||||
if matched_new.contains(&new_ref) {
|
||||
continue;
|
||||
}
|
||||
for &old_ref in old_refs {
|
||||
if matched_old.contains(&old_ref) {
|
||||
continue;
|
||||
}
|
||||
let score = compute_similarity(
|
||||
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes,
|
||||
);
|
||||
pairs.push((new_ref, old_ref, score));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending. Tiebreaker: original DOM child order
|
||||
// (lower index = earlier in children list = preferred).
|
||||
pairs.sort_by(|a, b| {
|
||||
b.2.cmp(&a.2).then_with(|| {
|
||||
let a_new_idx = original_new_children
|
||||
.iter()
|
||||
.position(|&r| r == a.0)
|
||||
.unwrap_or(usize::MAX);
|
||||
let b_new_idx = original_new_children
|
||||
.iter()
|
||||
.position(|&r| r == b.0)
|
||||
.unwrap_or(usize::MAX);
|
||||
a_new_idx.cmp(&b_new_idx).then_with(|| {
|
||||
let a_old_idx = original_old_children
|
||||
.iter()
|
||||
.position(|&r| r == a.1)
|
||||
.unwrap_or(usize::MAX);
|
||||
let b_old_idx = original_old_children
|
||||
.iter()
|
||||
.position(|&r| r == b.1)
|
||||
.unwrap_or(usize::MAX);
|
||||
a_old_idx.cmp(&b_old_idx)
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
// Greedy assignment: pick highest-scored pair, remove both, repeat.
|
||||
for (new_ref, old_ref, _score) in &pairs {
|
||||
if matched_new.contains(new_ref) || matched_old.contains(old_ref) {
|
||||
continue;
|
||||
}
|
||||
matched.push((*new_ref, *old_ref));
|
||||
matched_new.push(*new_ref);
|
||||
matched_old.push(*old_ref);
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Compute a similarity score between two instances.
|
||||
/// Higher = more similar. Signals checked in priority order:
|
||||
/// ClassName (100), Tags (50), Attributes (30), Hash equality (1000),
|
||||
/// Properties overlap (10 per match).
|
||||
fn compute_similarity(
|
||||
new_ref: Ref,
|
||||
old_ref: Ref,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
) -> u32 {
|
||||
let new_inst = match new_dom.get_by_ref(new_ref) {
|
||||
Some(i) => i,
|
||||
None => return 0,
|
||||
};
|
||||
let old_inst = match old_dom.get_by_ref(old_ref) {
|
||||
Some(i) => i,
|
||||
None => return 0,
|
||||
};
|
||||
|
||||
let mut score: u32 = 0;
|
||||
|
||||
// ClassName match is a strong signal.
|
||||
if new_inst.class == old_inst.class {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
// Hash equality: very likely identical content.
|
||||
if let (Some(nh), Some(oh)) = (new_hashes, old_hashes) {
|
||||
if let (Some(new_hash), Some(old_hash)) = (nh.get(&new_ref), oh.get(&old_ref)) {
|
||||
if new_hash == old_hash {
|
||||
score += 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags comparison.
|
||||
if let (Some(Variant::Tags(new_tags)), Some(Variant::Tags(old_tags))) = (
|
||||
new_inst.properties.get(&ustr("Tags")),
|
||||
old_inst.properties.get(&ustr("Tags")),
|
||||
) {
|
||||
if new_tags == old_tags {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes comparison.
|
||||
if let (Some(Variant::Attributes(new_attrs)), Some(Variant::Attributes(old_attrs))) = (
|
||||
new_inst.properties.get(&ustr("Attributes")),
|
||||
old_inst.properties.get(&ustr("Attributes")),
|
||||
) {
|
||||
if new_attrs == old_attrs {
|
||||
score += 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Children count similarity (cheap structural signal).
|
||||
let new_child_count = new_inst.children().len();
|
||||
let old_child_count = old_inst.children().len();
|
||||
if new_child_count == old_child_count {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Collect all Ref-valued property targets for an instance.
|
||||
fn collect_ref_property_targets(dom: &WeakDom, inst_ref: Ref) -> Vec<Ref> {
|
||||
let inst = match dom.get_by_ref(inst_ref) {
|
||||
Some(i) => i,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let mut targets = Vec::new();
|
||||
for value in inst.properties.values() {
|
||||
if let Variant::Ref(target) = value {
|
||||
if !target.is_none() {
|
||||
targets.push(*target);
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rbx_dom_weak::InstanceBuilder;
|
||||
|
||||
fn build_test_dom() -> (WeakDom, Ref, Ref, Ref) {
|
||||
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let root = dom.root_ref();
|
||||
let child_a = dom.insert(root, InstanceBuilder::new("Folder").with_name("Alpha"));
|
||||
let child_b = dom.insert(root, InstanceBuilder::new("Script").with_name("Beta"));
|
||||
(dom, root, child_a, child_b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names_match_instantly() {
|
||||
let (new_dom, new_root, new_a, new_b) = build_test_dom();
|
||||
let (old_dom, old_root, old_a, old_b) = build_test_dom();
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
|
||||
// Alpha matched to Alpha, Beta to Beta
|
||||
for (new_ref, old_ref) in &result.matched {
|
||||
let new_name = &new_dom.get_by_ref(*new_ref).unwrap().name;
|
||||
let old_name = &old_dom.get_by_ref(*old_ref).unwrap().name;
|
||||
assert_eq!(new_name, old_name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_name_narrowing() {
|
||||
// Two instances named "Handler" but different classes
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
let new_a = new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Script").with_name("Handler"),
|
||||
);
|
||||
let new_b = new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("ModuleScript").with_name("Handler"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
let old_a = old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("ModuleScript").with_name("Handler"),
|
||||
);
|
||||
let old_b = old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Script").with_name("Handler"),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
|
||||
// Verify Script matched to Script, ModuleScript to ModuleScript.
|
||||
for (new_ref, old_ref) in &result.matched {
|
||||
let new_class = &new_dom.get_by_ref(*new_ref).unwrap().class;
|
||||
let old_class = &old_dom.get_by_ref(*old_ref).unwrap().class;
|
||||
assert_eq!(new_class, old_class);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_new_and_old() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Alpha"),
|
||||
);
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("NewOnly"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Alpha"),
|
||||
);
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("OldOnly"),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
assert_eq!(result.matched.len(), 1);
|
||||
assert_eq!(result.unmatched_new.len(), 1);
|
||||
assert_eq!(result.unmatched_old.len(), 1);
|
||||
|
||||
let unmatched_new_name = &new_dom
|
||||
.get_by_ref(result.unmatched_new[0])
|
||||
.unwrap()
|
||||
.name;
|
||||
assert_eq!(unmatched_new_name, "NewOnly");
|
||||
|
||||
let unmatched_old_name = &old_dom
|
||||
.get_by_ref(result.unmatched_old[0])
|
||||
.unwrap()
|
||||
.name;
|
||||
assert_eq!(unmatched_old_name, "OldOnly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_fall_to_pass3() {
|
||||
// Two Folders named "Data" on each side, same class.
|
||||
// Pass 1 can't distinguish, Pass 3 uses greedy assignment.
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
// Both should be matched (greedy assignment).
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_children() {
|
||||
let new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
|
||||
let result = match_children(&[], &[], &new_dom, &old_dom, None, None);
|
||||
|
||||
assert!(result.matched.is_empty());
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_new_no_old() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("A"),
|
||||
);
|
||||
|
||||
let old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &[], &new_dom, &old_dom, None, None);
|
||||
|
||||
assert!(result.matched.is_empty());
|
||||
assert_eq!(result.unmatched_new.len(), 1);
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_stability() {
|
||||
// Run the same matching twice; verify identical results.
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("X"),
|
||||
);
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Script").with_name("Y"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("X"),
|
||||
);
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Script").with_name("Y"),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let r1 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
let r2 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
assert_eq!(r1.matched.len(), r2.matched.len());
|
||||
for (a, b) in r1.matched.iter().zip(r2.matched.iter()) {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue