mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
- Added `#[allow(clippy::too_many_arguments)]` annotations in `change_processor.rs` and `matching.rs` to suppress warnings for functions with multiple parameters. - Updated `serve_session.rs` to include `clippy::type_complexity` in the `#[allow(dead_code)]` annotation for better code quality. - Simplified array initialization in `file_names.rs` for consistency. - Enhanced texture filtering logic in `matching_fixtures.rs` by replacing `map_or` with `is_some_and` for improved readability. These changes contribute to a cleaner codebase and address static analysis warnings, ensuring better maintainability.
615 lines
20 KiB
Rust
615 lines
20 KiB
Rust
//! Instance matching algorithm for syncback.
|
|
//!
|
|
//! Pairs new children (from Roblox file) to old children (from filesystem)
|
|
//! by minimizing total reconciler changes.
|
|
//!
|
|
//! Algorithm per parent:
|
|
//! 1. Group by (Name, ClassName) -- 1:1 groups instant-match
|
|
//! 2. Ambiguous groups: change-count scoring + greedy assignment
|
|
//! (with hash fast-path: hash equal = 0 cost)
|
|
//!
|
|
//! The change count = how many things the reconciler would need to touch
|
|
//! to turn instance A into instance B.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use blake3::Hash;
|
|
use rbx_dom_weak::{types::Ref, WeakDom};
|
|
|
|
use crate::variant_eq::variant_eq;
|
|
|
|
const UNMATCHED_PENALTY: u32 = 10_000;
|
|
|
|
/// Maximum recursion depth for `compute_change_count`. Beyond this depth,
|
|
/// only flat property comparison is used (no subtree recursion).
|
|
const MAX_SCORING_DEPTH: u32 = 3;
|
|
|
|
/// Result of the matching algorithm.
|
|
#[derive(Debug)]
|
|
pub struct MatchResult {
|
|
pub matched: Vec<(Ref, Ref)>,
|
|
pub unmatched_new: Vec<Ref>,
|
|
pub unmatched_old: Vec<Ref>,
|
|
}
|
|
|
|
/// Match new children to old children, minimizing total changes.
|
|
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();
|
|
|
|
// ================================================================
|
|
// Fast-path: Group by (Name, ClassName) -- 1:1 groups instant-match
|
|
// ================================================================
|
|
let mut new_by_key: HashMap<(String, String), Vec<Ref>> = HashMap::new();
|
|
for &r in &remaining_new {
|
|
if let Some(inst) = new_dom.get_by_ref(r) {
|
|
new_by_key
|
|
.entry((inst.name.clone(), inst.class.to_string()))
|
|
.or_default()
|
|
.push(r);
|
|
}
|
|
}
|
|
|
|
let mut old_by_key: HashMap<(String, String), Vec<Ref>> = HashMap::new();
|
|
for &r in &remaining_old {
|
|
if let Some(inst) = old_dom.get_by_ref(r) {
|
|
old_by_key
|
|
.entry((inst.name.clone(), inst.class.to_string()))
|
|
.or_default()
|
|
.push(r);
|
|
}
|
|
}
|
|
|
|
let mut matched_new: HashSet<Ref> = HashSet::new();
|
|
let mut matched_old: HashSet<Ref> = HashSet::new();
|
|
|
|
// 1:1 groups: instant match
|
|
// Sort keys for deterministic iteration
|
|
let mut sorted_keys: Vec<_> = new_by_key.keys().cloned().collect();
|
|
sorted_keys.sort();
|
|
|
|
for key in &sorted_keys {
|
|
let Some(new_refs) = new_by_key.get(key) else {
|
|
continue;
|
|
};
|
|
let Some(old_refs) = old_by_key.get(key) else {
|
|
continue;
|
|
};
|
|
if new_refs.len() == 1 && old_refs.len() == 1 {
|
|
matched.push((new_refs[0], old_refs[0]));
|
|
matched_new.insert(new_refs[0]);
|
|
matched_old.insert(old_refs[0]);
|
|
}
|
|
}
|
|
|
|
remaining_new.retain(|r| !matched_new.contains(r));
|
|
remaining_old.retain(|r| !matched_old.contains(r));
|
|
|
|
if remaining_new.is_empty() || remaining_old.is_empty() {
|
|
return MatchResult {
|
|
matched,
|
|
unmatched_new: remaining_new,
|
|
unmatched_old: remaining_old,
|
|
};
|
|
}
|
|
|
|
// ================================================================
|
|
// Ambiguous groups: change-count scoring + greedy assignment
|
|
// ================================================================
|
|
for key in &sorted_keys {
|
|
let Some(new_refs) = new_by_key.get(key) else {
|
|
continue;
|
|
};
|
|
let Some(old_refs) = old_by_key.get(key) else {
|
|
continue;
|
|
};
|
|
|
|
let avail_new: Vec<Ref> = new_refs
|
|
.iter()
|
|
.filter(|r| !matched_new.contains(r))
|
|
.copied()
|
|
.collect();
|
|
let avail_old: Vec<Ref> = old_refs
|
|
.iter()
|
|
.filter(|r| !matched_old.contains(r))
|
|
.copied()
|
|
.collect();
|
|
|
|
if avail_new.is_empty() || avail_old.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Handle remaining 1:1 (from groups that had multiple but some were
|
|
// already matched, leaving 1 on each side)
|
|
if avail_new.len() == 1 && avail_old.len() == 1 {
|
|
matched.push((avail_new[0], avail_old[0]));
|
|
matched_new.insert(avail_new[0]);
|
|
matched_old.insert(avail_old[0]);
|
|
continue;
|
|
}
|
|
|
|
// Score all (A, B) pairs using recursive change-count scoring
|
|
let mut pairs: Vec<(u32, Ref, Ref)> = Vec::new();
|
|
let mut best_so_far = u32::MAX;
|
|
|
|
for &new_ref in &avail_new {
|
|
for &old_ref in &avail_old {
|
|
let cost = compute_change_count(
|
|
new_ref,
|
|
old_ref,
|
|
new_dom,
|
|
old_dom,
|
|
new_hashes,
|
|
old_hashes,
|
|
best_so_far,
|
|
0,
|
|
);
|
|
pairs.push((cost, new_ref, old_ref));
|
|
if cost < best_so_far {
|
|
best_so_far = cost;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stable sort by cost ascending
|
|
pairs.sort_by_key(|&(cost, _, _)| cost);
|
|
|
|
// Greedy assign
|
|
for &(_, new_ref, old_ref) in &pairs {
|
|
if matched_new.contains(&new_ref) || matched_old.contains(&old_ref) {
|
|
continue;
|
|
}
|
|
matched.push((new_ref, old_ref));
|
|
matched_new.insert(new_ref);
|
|
matched_old.insert(old_ref);
|
|
}
|
|
}
|
|
|
|
remaining_new.retain(|r| !matched_new.contains(r));
|
|
remaining_old.retain(|r| !matched_old.contains(r));
|
|
|
|
MatchResult {
|
|
matched,
|
|
unmatched_new: remaining_new,
|
|
unmatched_old: remaining_old,
|
|
}
|
|
}
|
|
|
|
/// Lightweight matching result used during recursive scoring.
|
|
struct ScoringMatchResult {
|
|
matched: Vec<(usize, usize)>,
|
|
unmatched_new: usize,
|
|
unmatched_old: usize,
|
|
}
|
|
|
|
/// Match children by reference for scoring purposes (non-consuming).
|
|
/// Groups by (Name, ClassName), instant-matches 1:1 groups, and scores
|
|
/// ambiguous groups using `compute_change_count` (mutually recursive).
|
|
fn match_children_for_scoring(
|
|
new_children: &[Ref],
|
|
old_children: &[Ref],
|
|
new_dom: &WeakDom,
|
|
old_dom: &WeakDom,
|
|
new_hashes: Option<&HashMap<Ref, Hash>>,
|
|
old_hashes: Option<&HashMap<Ref, Hash>>,
|
|
depth: u32,
|
|
) -> ScoringMatchResult {
|
|
let mut new_matched = vec![false; new_children.len()];
|
|
let mut old_matched = vec![false; old_children.len()];
|
|
let mut matched = Vec::new();
|
|
|
|
if new_children.is_empty() && old_children.is_empty() {
|
|
return ScoringMatchResult {
|
|
matched,
|
|
unmatched_new: 0,
|
|
unmatched_old: 0,
|
|
};
|
|
}
|
|
|
|
// Group by (Name, ClassName)
|
|
let mut new_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
|
for (i, &r) in new_children.iter().enumerate() {
|
|
if let Some(inst) = new_dom.get_by_ref(r) {
|
|
new_by_key
|
|
.entry((inst.name.clone(), inst.class.to_string()))
|
|
.or_default()
|
|
.push(i);
|
|
}
|
|
}
|
|
|
|
let mut old_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
|
for (i, &r) in old_children.iter().enumerate() {
|
|
if let Some(inst) = old_dom.get_by_ref(r) {
|
|
old_by_key
|
|
.entry((inst.name.clone(), inst.class.to_string()))
|
|
.or_default()
|
|
.push(i);
|
|
}
|
|
}
|
|
|
|
// 1:1 groups: instant match
|
|
for (key, new_indices) in &new_by_key {
|
|
if let Some(old_indices) = old_by_key.get(key) {
|
|
if new_indices.len() == 1 && old_indices.len() == 1 {
|
|
let ni = new_indices[0];
|
|
let oi = old_indices[0];
|
|
matched.push((ni, oi));
|
|
new_matched[ni] = true;
|
|
old_matched[oi] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ambiguous groups: score + greedy assign
|
|
for (key, new_indices) in &new_by_key {
|
|
let Some(old_indices) = old_by_key.get(key) else {
|
|
continue;
|
|
};
|
|
|
|
let avail_new: Vec<usize> = new_indices
|
|
.iter()
|
|
.filter(|&&ni| !new_matched[ni])
|
|
.copied()
|
|
.collect();
|
|
let avail_old: Vec<usize> = old_indices
|
|
.iter()
|
|
.filter(|&&oi| !old_matched[oi])
|
|
.copied()
|
|
.collect();
|
|
|
|
if avail_new.is_empty() || avail_old.is_empty() {
|
|
continue;
|
|
}
|
|
if avail_new.len() == 1 && avail_old.len() == 1 {
|
|
let ni = avail_new[0];
|
|
let oi = avail_old[0];
|
|
matched.push((ni, oi));
|
|
new_matched[ni] = true;
|
|
old_matched[oi] = true;
|
|
continue;
|
|
}
|
|
|
|
let mut pairs: Vec<(u32, usize, usize)> = Vec::new();
|
|
let mut best_so_far = u32::MAX;
|
|
for &ni in &avail_new {
|
|
for &oi in &avail_old {
|
|
let cost = compute_change_count(
|
|
new_children[ni],
|
|
old_children[oi],
|
|
new_dom,
|
|
old_dom,
|
|
new_hashes,
|
|
old_hashes,
|
|
best_so_far,
|
|
depth,
|
|
);
|
|
pairs.push((cost, ni, oi));
|
|
if cost < best_so_far {
|
|
best_so_far = cost;
|
|
}
|
|
}
|
|
}
|
|
|
|
pairs.sort_by_key(|&(cost, _, _)| cost);
|
|
|
|
for &(_, ni, oi) in &pairs {
|
|
if new_matched[ni] || old_matched[oi] {
|
|
continue;
|
|
}
|
|
matched.push((ni, oi));
|
|
new_matched[ni] = true;
|
|
old_matched[oi] = true;
|
|
}
|
|
}
|
|
|
|
let unmatched_new = new_matched.iter().filter(|&&m| !m).count();
|
|
let unmatched_old = old_matched.iter().filter(|&&m| !m).count();
|
|
|
|
ScoringMatchResult {
|
|
matched,
|
|
unmatched_new,
|
|
unmatched_old,
|
|
}
|
|
}
|
|
|
|
/// Compute total change count between two WeakDom instances, including
|
|
/// recursive subtree scoring. Hash fast-path: if hashes match, return 0.
|
|
///
|
|
/// Mutually recursive with `match_children_for_scoring`.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn compute_change_count(
|
|
new_ref: Ref,
|
|
old_ref: Ref,
|
|
new_dom: &WeakDom,
|
|
old_dom: &WeakDom,
|
|
new_hashes: Option<&HashMap<Ref, Hash>>,
|
|
old_hashes: Option<&HashMap<Ref, Hash>>,
|
|
best_so_far: u32,
|
|
depth: u32,
|
|
) -> u32 {
|
|
// Hash fast-path: identical subtree = 0 cost
|
|
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 {
|
|
return 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut cost = count_own_diffs(new_ref, old_ref, new_dom, old_dom);
|
|
if cost >= best_so_far || depth >= MAX_SCORING_DEPTH {
|
|
return cost;
|
|
}
|
|
|
|
let new_inst = match new_dom.get_by_ref(new_ref) {
|
|
Some(i) => i,
|
|
None => return UNMATCHED_PENALTY,
|
|
};
|
|
let old_inst = match old_dom.get_by_ref(old_ref) {
|
|
Some(i) => i,
|
|
None => return UNMATCHED_PENALTY,
|
|
};
|
|
|
|
let new_children = new_inst.children();
|
|
let old_children = old_inst.children();
|
|
|
|
if new_children.is_empty() && old_children.is_empty() {
|
|
return cost;
|
|
}
|
|
|
|
let scoring = match_children_for_scoring(
|
|
new_children,
|
|
old_children,
|
|
new_dom,
|
|
old_dom,
|
|
new_hashes,
|
|
old_hashes,
|
|
depth + 1,
|
|
);
|
|
|
|
for &(ni, oi) in &scoring.matched {
|
|
let remaining = best_so_far.saturating_sub(cost);
|
|
cost += compute_change_count(
|
|
new_children[ni],
|
|
old_children[oi],
|
|
new_dom,
|
|
old_dom,
|
|
new_hashes,
|
|
old_hashes,
|
|
remaining,
|
|
depth + 1,
|
|
);
|
|
if cost >= best_so_far {
|
|
return cost;
|
|
}
|
|
}
|
|
|
|
cost = cost
|
|
.saturating_add((scoring.unmatched_new + scoring.unmatched_old) as u32 * UNMATCHED_PENALTY);
|
|
|
|
cost
|
|
}
|
|
|
|
/// Count own property diffs between two WeakDom instances (flat, non-recursive).
|
|
/// Each differing property = +1. Children count diff = +1.
|
|
/// Does NOT include hash fast-path (handled by `compute_change_count`).
|
|
///
|
|
/// Properties present on only one side are skipped when their value matches
|
|
/// the class default (see `src/snapshot/matching.rs` for full rationale).
|
|
fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &WeakDom) -> u32 {
|
|
let new_inst = match new_dom.get_by_ref(new_ref) {
|
|
Some(i) => i,
|
|
None => return UNMATCHED_PENALTY,
|
|
};
|
|
let old_inst = match old_dom.get_by_ref(old_ref) {
|
|
Some(i) => i,
|
|
None => return UNMATCHED_PENALTY,
|
|
};
|
|
|
|
let mut cost: u32 = 0;
|
|
|
|
let class_data = rbx_reflection_database::get()
|
|
.ok()
|
|
.and_then(|db| db.classes.get(new_inst.class.as_str()));
|
|
|
|
for (key, new_val) in new_inst.properties.iter() {
|
|
if let Some(old_val) = old_inst.properties.get(key) {
|
|
if !variant_eq(new_val, old_val) {
|
|
cost += 1;
|
|
}
|
|
} else {
|
|
let is_default = class_data
|
|
.and_then(|cd| cd.default_properties.get(key.as_str()))
|
|
.is_some_and(|default| variant_eq(new_val, default));
|
|
if !is_default {
|
|
cost += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (key, old_val) in old_inst.properties.iter() {
|
|
if !new_inst.properties.contains_key(key) {
|
|
let is_default = class_data
|
|
.and_then(|cd| cd.default_properties.get(key.as_str()))
|
|
.is_some_and(|default| variant_eq(old_val, default));
|
|
if !is_default {
|
|
cost += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if new_inst.children().len() != old_inst.children().len() {
|
|
cost += 1;
|
|
}
|
|
|
|
cost
|
|
}
|
|
|
|
#[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, _, _) = build_test_dom();
|
|
let (old_dom, old_root, _, _) = 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());
|
|
|
|
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_grouping() {
|
|
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
|
let new_root = new_dom.root_ref();
|
|
new_dom.insert(
|
|
new_root,
|
|
InstanceBuilder::new("Script").with_name("Handler"),
|
|
);
|
|
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();
|
|
old_dom.insert(
|
|
old_root,
|
|
InstanceBuilder::new("ModuleScript").with_name("Handler"),
|
|
);
|
|
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);
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_names_matched() {
|
|
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);
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn matching_stability() {
|
|
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);
|
|
}
|
|
}
|
|
}
|