//! Instance matching algorithm for forward sync. //! //! Pairs snapshot children (from filesystem re-snapshot) to tree children //! (existing RojoTree instances) by minimizing total reconciler changes. //! //! Algorithm per parent: //! 1. Group by (Name, ClassName) -- 1:1 groups instant-match //! 2. Ambiguous groups: recursive change-count scoring + greedy assignment //! //! The change count = how many things the reconciler would need to touch //! to turn instance A into instance B, including the entire subtree. use std::collections::HashMap; use rbx_dom_weak::types::Ref; use crate::variant_eq::variant_eq; use super::{InstanceSnapshot, InstanceWithMeta, RojoTree}; 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). Prevents /// O(n^k) explosion on deeply nested trees with many same-named instances. const MAX_SCORING_DEPTH: u32 = 3; /// Result of the forward sync matching algorithm. pub struct ForwardMatchResult { /// Pairs of (snapshot_child, tree_ref) that were matched. pub matched: Vec<(InstanceSnapshot, Ref)>, /// Snapshot children with no match in the tree (to be added). pub unmatched_snapshot: Vec, /// Tree children with no match in the snapshot (to be removed). pub unmatched_tree: Vec, } /// Match snapshot children to tree children, minimizing total changes. pub fn match_forward( snapshot_children: Vec, tree_children: &[Ref], tree: &RojoTree, ) -> ForwardMatchResult { if snapshot_children.is_empty() && tree_children.is_empty() { return ForwardMatchResult { matched: Vec::new(), unmatched_snapshot: Vec::new(), unmatched_tree: Vec::new(), }; } let snap_available: Vec> = snapshot_children.into_iter().map(Some).collect(); let mut snap_matched: Vec = vec![false; snap_available.len()]; let mut tree_available: Vec = vec![true; tree_children.len()]; let mut matched: Vec<(usize, usize)> = Vec::new(); // ================================================================ // Fast-path: Group by (Name, ClassName) -- 1:1 groups instant-match // ================================================================ let mut snap_by_key: HashMap<(String, String), Vec> = HashMap::new(); for (i, snap_opt) in snap_available.iter().enumerate() { if let Some(snap) = snap_opt { snap_by_key .entry((snap.name.to_string(), snap.class_name.to_string())) .or_default() .push(i); } } let mut tree_by_key: HashMap<(String, String), Vec> = HashMap::new(); for (i, &child_ref) in tree_children.iter().enumerate() { if let Some(inst) = tree.get_instance(child_ref) { tree_by_key .entry((inst.name().to_string(), inst.class_name().to_string())) .or_default() .push(i); } } // 1:1 groups: instant match for (key, snap_indices) in &snap_by_key { if let Some(tree_indices) = tree_by_key.get(key) { if snap_indices.len() == 1 && tree_indices.len() == 1 { let si = snap_indices[0]; let ti = tree_indices[0]; matched.push((si, ti)); snap_matched[si] = true; tree_available[ti] = false; } } } // Early exit if nothing remains let snap_remaining = snap_matched.iter().filter(|&&m| !m).count(); let tree_remaining = tree_available.iter().filter(|&&a| a).count(); if snap_remaining == 0 || tree_remaining == 0 { return build_result(snap_available, tree_children, &tree_available, matched); } // ================================================================ // Ambiguous groups: change-count scoring + greedy assignment // ================================================================ for (key, snap_indices) in &snap_by_key { let Some(tree_indices) = tree_by_key.get(key) else { continue; }; // Collect unmatched indices in this group let avail_snap: Vec = snap_indices .iter() .filter(|&&si| !snap_matched[si]) .copied() .collect(); let avail_tree: Vec = tree_indices .iter() .filter(|&&ti| tree_available[ti]) .copied() .collect(); if avail_snap.is_empty() || avail_tree.is_empty() { continue; } // 1:1 already handled above; skip if not truly ambiguous if avail_snap.len() <= 1 && avail_tree.len() <= 1 { if avail_snap.len() == 1 && avail_tree.len() == 1 { let si = avail_snap[0]; let ti = avail_tree[0]; matched.push((si, ti)); snap_matched[si] = true; tree_available[ti] = false; } continue; } // Score all (A, B) pairs using recursive change-count scoring let mut pairs: Vec<(u32, usize, usize)> = Vec::new(); let mut best_so_far = u32::MAX; for &si in &avail_snap { let snap = match &snap_available[si] { Some(s) => s, None => continue, }; for &ti in &avail_tree { let cost = compute_change_count(snap, tree_children[ti], tree, best_so_far, 0); pairs.push((cost, si, ti)); if cost < best_so_far { best_so_far = cost; } } } // Stable sort by cost ascending (slice::sort_by is stable in Rust) pairs.sort_by_key(|&(cost, _, _)| cost); // Greedy assign for &(_, si, ti) in &pairs { if snap_matched[si] || !tree_available[ti] { continue; } matched.push((si, ti)); snap_matched[si] = true; tree_available[ti] = false; } } build_result(snap_available, tree_children, &tree_available, matched) } /// Build the final result, consuming the snapshot children. fn build_result( mut snap_available: Vec>, tree_children: &[Ref], tree_available: &[bool], matched_indices: Vec<(usize, usize)>, ) -> ForwardMatchResult { let mut matched = Vec::with_capacity(matched_indices.len()); for (si, ti) in matched_indices { if let Some(snap) = snap_available[si].take() { matched.push((snap, tree_children[ti])); } } let unmatched_snapshot: Vec = snap_available.into_iter().flatten().collect(); let unmatched_tree: Vec = tree_children .iter() .enumerate() .filter(|(i, _)| tree_available[*i]) .map(|(_, r)| *r) .collect(); ForwardMatchResult { matched, unmatched_snapshot, unmatched_tree, } } /// Lightweight matching result used during recursive scoring. /// Returns index pairs (snap_index, tree_child_index) without consuming /// the snapshots. struct ScoringMatchResult { matched: Vec<(usize, usize)>, unmatched_snap: usize, unmatched_tree: 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( snap_children: &[InstanceSnapshot], tree_children: &[Ref], tree: &RojoTree, depth: u32, ) -> ScoringMatchResult { let mut snap_matched = vec![false; snap_children.len()]; let mut tree_matched = vec![false; tree_children.len()]; let mut matched = Vec::new(); if snap_children.is_empty() && tree_children.is_empty() { return ScoringMatchResult { matched, unmatched_snap: 0, unmatched_tree: 0, }; } // Group by (Name, ClassName) let mut snap_by_key: HashMap<(String, String), Vec> = HashMap::new(); for (i, snap) in snap_children.iter().enumerate() { snap_by_key .entry((snap.name.to_string(), snap.class_name.to_string())) .or_default() .push(i); } let mut tree_by_key: HashMap<(String, String), Vec> = HashMap::new(); for (i, &child_ref) in tree_children.iter().enumerate() { if let Some(inst) = tree.get_instance(child_ref) { tree_by_key .entry((inst.name().to_string(), inst.class_name().to_string())) .or_default() .push(i); } } // 1:1 groups: instant match for (key, snap_indices) in &snap_by_key { if let Some(tree_indices) = tree_by_key.get(key) { if snap_indices.len() == 1 && tree_indices.len() == 1 { let si = snap_indices[0]; let ti = tree_indices[0]; matched.push((si, ti)); snap_matched[si] = true; tree_matched[ti] = true; } } } // Ambiguous groups: score + greedy assign for (key, snap_indices) in &snap_by_key { let Some(tree_indices) = tree_by_key.get(key) else { continue; }; let avail_snap: Vec = snap_indices .iter() .filter(|&&si| !snap_matched[si]) .copied() .collect(); let avail_tree: Vec = tree_indices .iter() .filter(|&&ti| !tree_matched[ti]) .copied() .collect(); if avail_snap.is_empty() || avail_tree.is_empty() { continue; } if avail_snap.len() == 1 && avail_tree.len() == 1 { let si = avail_snap[0]; let ti = avail_tree[0]; matched.push((si, ti)); snap_matched[si] = true; tree_matched[ti] = true; continue; } let mut pairs: Vec<(u32, usize, usize)> = Vec::new(); let mut best_so_far = u32::MAX; for &si in &avail_snap { for &ti in &avail_tree { let cost = compute_change_count( &snap_children[si], tree_children[ti], tree, best_so_far, depth, ); pairs.push((cost, si, ti)); if cost < best_so_far { best_so_far = cost; } } } pairs.sort_by_key(|&(cost, _, _)| cost); for &(_, si, ti) in &pairs { if snap_matched[si] || tree_matched[ti] { continue; } matched.push((si, ti)); snap_matched[si] = true; tree_matched[ti] = true; } } let unmatched_snap = snap_matched.iter().filter(|&&m| !m).count(); let unmatched_tree = tree_matched.iter().filter(|&&m| !m).count(); ScoringMatchResult { matched, unmatched_snap, unmatched_tree, } } /// Compute total change count between a snapshot and a tree instance, /// including recursive subtree scoring. Returns the number of reconciler /// operations needed to turn the tree instance into the snapshot. /// /// Mutually recursive with `match_children_for_scoring`: this function /// scores a single pair, while `match_children_for_scoring` groups and /// assigns children pairs (calling this function for ambiguous scoring). fn compute_change_count( snap: &InstanceSnapshot, tree_ref: Ref, tree: &RojoTree, best_so_far: u32, depth: u32, ) -> u32 { let inst = match tree.get_instance(tree_ref) { Some(i) => i, None => return UNMATCHED_PENALTY, }; let mut cost = count_own_diffs(snap, &inst); if cost >= best_so_far || depth >= MAX_SCORING_DEPTH { return cost; } let snap_children = &snap.children; let tree_children = inst.children(); if snap_children.is_empty() && tree_children.is_empty() { return cost; } let scoring = match_children_for_scoring(snap_children, tree_children, tree, depth + 1); for &(si, ti) in &scoring.matched { let remaining = best_so_far.saturating_sub(cost); cost += compute_change_count( &snap_children[si], tree_children[ti], tree, remaining, depth + 1, ); if cost >= best_so_far { return cost; } } cost = cost.saturating_add( (scoring.unmatched_snap + scoring.unmatched_tree) as u32 * UNMATCHED_PENALTY, ); cost } /// Count own property diffs between a snapshot and a tree instance. /// Each differing property = +1. Tags and Attributes counted granularly. /// Children count diff = +1. /// /// Syncback strips default-valued properties from model/meta files, so /// filesystem snapshots may omit properties that the tree (populated from /// Studio via two-way sync) has at their class defaults. To avoid inflating /// match costs with phantom diffs, properties present on only one side are /// skipped when their value matches the class default. fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta) -> u32 { let mut cost: u32 = 0; let snap_props = &snap.properties; let inst_props = inst.properties(); let class_data = rbx_reflection_database::get() .ok() .and_then(|db| db.classes.get(snap.class_name.as_str())); // Properties present on the snapshot side for (key, snap_val) in snap_props.iter() { if let Some(inst_val) = inst_props.get(key) { if !variant_eq(snap_val, inst_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(snap_val, default)); if !is_default { cost += 1; } } } // Properties present only on the tree side for (key, inst_val) in inst_props.iter() { if !snap_props.contains_key(key) { let is_default = class_data .and_then(|cd| cd.default_properties.get(key.as_str())) .is_some_and(|default| variant_eq(inst_val, default)); if !is_default { cost += 1; } } } // Children count diff if snap.children.len() != inst.children().len() { cost += 1; } cost } #[cfg(test)] mod tests { use super::*; use crate::snapshot::{InstanceMetadata, InstanceSnapshot, RojoTree}; use rbx_dom_weak::{ustr, HashMapExt as _}; use std::borrow::Cow; fn make_snapshot(name: &str, class: &str) -> InstanceSnapshot { InstanceSnapshot { snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Owned(name.to_string()), class_name: ustr(class), properties: Default::default(), children: Vec::new(), } } fn make_tree_with_children(children: &[(&str, &str)]) -> (RojoTree, Vec) { let child_snapshots: Vec = children .iter() .map(|(name, class)| make_snapshot(name, class)) .collect(); let root_snap = InstanceSnapshot { snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Borrowed("DataModel"), class_name: ustr("DataModel"), properties: Default::default(), children: child_snapshots, }; let tree = RojoTree::new(root_snap); let root_id = tree.get_root_id(); let child_ids: Vec = tree.get_instance(root_id).unwrap().children().to_vec(); (tree, child_ids) } #[test] fn unique_names_match() { let snaps = vec![ make_snapshot("Alpha", "Folder"), make_snapshot("Beta", "Script"), ]; let (tree, children) = make_tree_with_children(&[("Alpha", "Folder"), ("Beta", "Script")]); let result = match_forward(snaps, &children, &tree); assert_eq!(result.matched.len(), 2); assert!(result.unmatched_snapshot.is_empty()); assert!(result.unmatched_tree.is_empty()); } #[test] fn class_narrowing() { let snaps = vec![ make_snapshot("Handler", "Script"), make_snapshot("Handler", "ModuleScript"), ]; let (tree, children) = make_tree_with_children(&[("Handler", "ModuleScript"), ("Handler", "Script")]); let result = match_forward(snaps, &children, &tree); assert_eq!(result.matched.len(), 2); for (snap, tree_ref) in &result.matched { let inst = tree.get_instance(*tree_ref).unwrap(); assert_eq!(snap.class_name.as_str(), inst.class_name().as_str()); } } #[test] fn unmatched_both_sides() { let snaps = vec![ make_snapshot("A", "Folder"), make_snapshot("NewOnly", "Folder"), ]; let (tree, children) = make_tree_with_children(&[("A", "Folder"), ("OldOnly", "Folder")]); let result = match_forward(snaps, &children, &tree); assert_eq!(result.matched.len(), 1); assert_eq!(result.unmatched_snapshot.len(), 1); assert_eq!(result.unmatched_tree.len(), 1); assert_eq!(result.unmatched_snapshot[0].name.as_ref(), "NewOnly"); } #[test] fn duplicate_names_greedy() { let snaps = vec![ make_snapshot("Data", "Folder"), make_snapshot("Data", "Folder"), ]; let (tree, children) = make_tree_with_children(&[("Data", "Folder"), ("Data", "Folder")]); let result = match_forward(snaps, &children, &tree); assert_eq!(result.matched.len(), 2); assert!(result.unmatched_snapshot.is_empty()); assert!(result.unmatched_tree.is_empty()); } #[test] fn empty_both() { let snaps: Vec = vec![]; let (tree, children) = make_tree_with_children(&[]); let result = match_forward(snaps, &children, &tree); assert!(result.matched.is_empty()); } fn make_snapshot_with_props( name: &str, class: &str, props: Vec<(&str, rbx_dom_weak::types::Variant)>, ) -> InstanceSnapshot { let mut properties = rbx_dom_weak::UstrMap::new(); for (key, val) in props { properties.insert(ustr(key), val); } InstanceSnapshot { snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Owned(name.to_string()), class_name: ustr(class), properties, children: Vec::new(), } } #[test] fn many_same_name_parts_matched_by_properties() { // Simulate a Ladder model with 10 "Line" Parts, each with a different // Anchored value (as a simple distinguishing property). // The matching should pair each to its correct counterpart. use rbx_dom_weak::types::Variant; let count = 10; let snaps: Vec = (0..count) .map(|i| { make_snapshot_with_props( "Line", "Part", vec![("Transparency", Variant::Float32(i as f32 * 0.1))], ) }) .collect(); // Build tree with same parts but in reversed order -- the matching // should still pair them correctly by property content, not position. let child_snapshots: Vec = (0..count) .rev() .map(|i| { make_snapshot_with_props( "Line", "Part", vec![("Transparency", Variant::Float32(i as f32 * 0.1))], ) }) .collect(); let root_snap = InstanceSnapshot { snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Borrowed("DataModel"), class_name: ustr("DataModel"), properties: Default::default(), children: child_snapshots, }; let tree = RojoTree::new(root_snap); let root_id = tree.get_root_id(); let tree_children: Vec = tree.get_instance(root_id).unwrap().children().to_vec(); let result = match_forward(snaps, &tree_children, &tree); assert_eq!(result.matched.len(), count); assert!(result.unmatched_snapshot.is_empty()); assert!(result.unmatched_tree.is_empty()); // Verify each pair has matching Transparency values (correct pairing) for (snap, tree_ref) in &result.matched { let inst = tree.get_instance(*tree_ref).unwrap(); let snap_val = snap.properties.get(&ustr("Transparency")); let tree_val = inst.properties().get(&ustr("Transparency")); assert_eq!( snap_val, tree_val, "Mismatched Transparency: snap has {:?}, tree has {:?}", snap_val, tree_val ); } } #[test] fn fifteen_same_name_identical_parts() { // 15 Parts all named "Line" with identical properties. // All pairs score the same -- greedy picks by stable child order. // Should match all 15 without panicking or cross-matching. let count = 15; let snaps: Vec = (0..count).map(|_| make_snapshot("Line", "Part")).collect(); let (tree, children) = make_tree_with_children(&(0..count).map(|_| ("Line", "Part")).collect::>()); let result = match_forward(snaps, &children, &tree); assert_eq!(result.matched.len(), count); assert!(result.unmatched_snapshot.is_empty()); assert!(result.unmatched_tree.is_empty()); } fn make_snapshot_with_children( name: &str, class: &str, children: Vec, ) -> InstanceSnapshot { InstanceSnapshot { snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Owned(name.to_string()), class_name: ustr(class), properties: Default::default(), children, } } #[test] fn recursive_matching_distinguishes_by_children() { // Two Folders both named "Data" with identical top-level properties // but different children. The recursive scoring should pair each // Folder with the tree Folder that has matching children. use rbx_dom_weak::types::Variant; let snap_a = make_snapshot_with_children( "Data", "Folder", vec![make_snapshot_with_props( "Child", "Part", vec![("Transparency", Variant::Float32(0.0))], )], ); let snap_b = make_snapshot_with_children( "Data", "Folder", vec![make_snapshot_with_props( "Child", "Part", vec![("Transparency", Variant::Float32(1.0))], )], ); let snaps = vec![snap_a, snap_b]; // Build tree with the two Folders in REVERSED order let tree_child_a = make_snapshot_with_children( "Data", "Folder", vec![make_snapshot_with_props( "Child", "Part", vec![("Transparency", Variant::Float32(1.0))], )], ); let tree_child_b = make_snapshot_with_children( "Data", "Folder", vec![make_snapshot_with_props( "Child", "Part", vec![("Transparency", Variant::Float32(0.0))], )], ); let root_snap = InstanceSnapshot { snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Borrowed("DataModel"), class_name: ustr("DataModel"), properties: Default::default(), children: vec![tree_child_a, tree_child_b], }; let tree = RojoTree::new(root_snap); let root_id = tree.get_root_id(); let tree_children: Vec = tree.get_instance(root_id).unwrap().children().to_vec(); let result = match_forward(snaps, &tree_children, &tree); assert_eq!(result.matched.len(), 2); assert!(result.unmatched_snapshot.is_empty()); assert!(result.unmatched_tree.is_empty()); // Verify recursive matching paired by children content, not position. // snap[0] has child Transparency=0.0, should match tree[1] (also 0.0). // snap[1] has child Transparency=1.0, should match tree[0] (also 1.0). for (snap, tree_ref) in &result.matched { let tree_inst = tree.get_instance(*tree_ref).unwrap(); let tree_child_ref = tree_inst.children()[0]; let tree_child = tree.get_instance(tree_child_ref).unwrap(); let snap_child = &snap.children[0]; let snap_val = snap_child.properties.get(&ustr("Transparency")); let tree_val = tree_child.properties().get(&ustr("Transparency")); assert_eq!( snap_val, tree_val, "Recursive matching failed: snap child Transparency={:?}, tree child Transparency={:?}", snap_val, tree_val ); } } /// Helper: builds a RojoTree from child snapshots (with properties). fn make_tree_from_snapshots(children: Vec) -> (RojoTree, Vec) { let root_snap = InstanceSnapshot { snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Borrowed("DataModel"), class_name: ustr("DataModel"), properties: Default::default(), children, }; let tree = RojoTree::new(root_snap); let root_id = tree.get_root_id(); let child_ids: Vec = tree.get_instance(root_id).unwrap().children().to_vec(); (tree, child_ids) } // ================================================================ // Default-omission matching tests // // These reproduce the scenario where syncback strips default-valued // properties from model files, creating an asymmetry between the // snapshot (sparse) and tree (full properties from Studio). // // The matching must still pair instances correctly despite some // snapshots missing properties that the tree has at their defaults. // ================================================================ #[test] fn reflection_database_has_expected_defaults() { // Validates the reflection database has the defaults we rely on. use rbx_dom_weak::types::Variant; let db = rbx_reflection_database::get().expect("reflection database should load"); let texture = db .classes .get("Texture") .expect("Texture class should exist"); let face_default = texture .default_properties .get("Face") .expect("Texture should have Face default"); match face_default { Variant::Enum(e) => assert_eq!(e.to_u32(), 5, "Face default should be Front (5)"), other => panic!("Face default should be Enum, got {:?}", other), } let part = db.classes.get("Part").expect("Part class should exist"); let anchored_default = part .default_properties .get("Anchored") .expect("Part should have Anchored default"); assert_eq!( anchored_default, &Variant::Bool(false), "Anchored default should be false" ); } #[test] fn texture_face_six_instances_default_omitted() { // Exact reproduction of the Texture Face bug: // 6 Textures all named "Texture", each with a different Face. // Snapshot side: 5 have explicit Face, 1 omits Face (Front=default). // Tree side: all 6 have explicit Face (populated from Studio). // // Without the default-aware fix, the snapshot without Face scores // equally against all tree instances, steals one by iteration order, // and displaces the correct match. use rbx_dom_weak::types::{Enum, Variant}; let faces = [ ("Top", Enum::from_u32(1)), ("Right", Enum::from_u32(0)), ("Left", Enum::from_u32(3)), ("Bottom", Enum::from_u32(4)), ("Back", Enum::from_u32(2)), ]; let face_front = Enum::from_u32(5); let shared_props = vec![("Transparency", Variant::Float32(0.65))]; // Snapshots: 5 with explicit Face + 1 without (Front default) let mut snaps: Vec = faces .iter() .map(|(_, face_enum)| { let mut props = shared_props.clone(); props.push(("Face", Variant::Enum(*face_enum))); make_snapshot_with_props("Texture", "Texture", props) }) .collect(); // The Front one: NO Face property (default omitted by syncback) snaps.push(make_snapshot_with_props( "Texture", "Texture", shared_props.clone(), )); // Tree: all 6 with explicit Face (Studio always has all properties) let mut tree_children: Vec = faces .iter() .map(|(_, face_enum)| { let mut props = shared_props.clone(); props.push(("Face", Variant::Enum(*face_enum))); make_snapshot_with_props("Texture", "Texture", props) }) .collect(); tree_children.push(make_snapshot_with_props("Texture", "Texture", { let mut props = shared_props.clone(); props.push(("Face", Variant::Enum(face_front))); props })); let (tree, tree_refs) = make_tree_from_snapshots(tree_children); let result = match_forward(snaps, &tree_refs, &tree); assert_eq!(result.matched.len(), 6); assert!(result.unmatched_snapshot.is_empty()); assert!(result.unmatched_tree.is_empty()); // Verify each pair has matching Face values. for (snap, tree_ref) in &result.matched { let inst = tree.get_instance(*tree_ref).unwrap(); let snap_face = snap.properties.get(&ustr("Face")); let tree_face = inst.properties().get(&ustr("Face")).unwrap(); match snap_face { Some(sv) => { assert!( variant_eq(sv, tree_face), "Face mismatch: snap={:?}, tree={:?}", sv, tree_face ); } None => { // Snapshot omitted Face = must match tree Face=Front (5) assert!( variant_eq(tree_face, &Variant::Enum(face_front)), "Snapshot without Face should match Front, got {:?}", tree_face ); } } } } #[test] fn texture_face_twelve_instances_two_groups() { // Exact reproduction of the user's real scenario: // 12 Textures split into two groups of 6 by TextureContent. // Each group covers all 6 faces, with Front omitted (default). // Tree has all 12 with explicit Face. use rbx_dom_weak::types::{Enum, Variant}; let face_values: Vec = vec![ Enum::from_u32(1), // Top Enum::from_u32(0), // Right Enum::from_u32(3), // Left Enum::from_u32(4), // Bottom Enum::from_u32(2), // Back ]; let face_front = Enum::from_u32(5); let mut snaps = Vec::new(); let mut tree_children = Vec::new(); for group_transparency in [0.6_f32, 0.65_f32] { // 5 with explicit Face for face in &face_values { snaps.push(make_snapshot_with_props( "Texture", "Texture", vec![ ("Transparency", Variant::Float32(group_transparency)), ("Face", Variant::Enum(*face)), ], )); tree_children.push(make_snapshot_with_props( "Texture", "Texture", vec![ ("Transparency", Variant::Float32(group_transparency)), ("Face", Variant::Enum(*face)), ], )); } // 1 without Face (Front default) snaps.push(make_snapshot_with_props( "Texture", "Texture", vec![("Transparency", Variant::Float32(group_transparency))], )); tree_children.push(make_snapshot_with_props( "Texture", "Texture", vec![ ("Transparency", Variant::Float32(group_transparency)), ("Face", Variant::Enum(face_front)), ], )); } // Reverse tree order to make it harder tree_children.reverse(); let (tree, tree_refs) = make_tree_from_snapshots(tree_children); let result = match_forward(snaps, &tree_refs, &tree); assert_eq!(result.matched.len(), 12); assert!(result.unmatched_snapshot.is_empty()); assert!(result.unmatched_tree.is_empty()); for (snap, tree_ref) in &result.matched { let inst = tree.get_instance(*tree_ref).unwrap(); let snap_face = snap.properties.get(&ustr("Face")); let tree_face = inst.properties().get(&ustr("Face")).unwrap(); // Transparency must match (distinguishes the two groups) let snap_t = snap.properties.get(&ustr("Transparency")).unwrap(); let tree_t = inst.properties().get(&ustr("Transparency")).unwrap(); assert!( variant_eq(snap_t, tree_t), "Group mismatch: snap Transparency={:?}, tree={:?}", snap_t, tree_t ); match snap_face { Some(sv) => assert!( variant_eq(sv, tree_face), "Face mismatch: snap={:?}, tree={:?}", sv, tree_face ), None => assert!( variant_eq(tree_face, &Variant::Enum(face_front)), "Omitted Face should match Front, got {:?}", tree_face ), } } } #[test] fn part_anchored_default_omitted() { // 4 Parts named "Block": 3 have Anchored=true (explicit), // 1 omits Anchored (false is the default). Tree has all 4. use rbx_dom_weak::types::Variant; let mut snaps = Vec::new(); let mut tree_children = Vec::new(); for i in 0..3 { let props = vec![ ("Transparency", Variant::Float32(i as f32 * 0.1)), ("Anchored", Variant::Bool(true)), ]; snaps.push(make_snapshot_with_props("Block", "Part", props.clone())); tree_children.push(make_snapshot_with_props("Block", "Part", props)); } // The one with default Anchored=false: snapshot omits it snaps.push(make_snapshot_with_props( "Block", "Part", vec![("Transparency", Variant::Float32(0.3))], )); tree_children.push(make_snapshot_with_props( "Block", "Part", vec![ ("Transparency", Variant::Float32(0.3)), ("Anchored", Variant::Bool(false)), ], )); tree_children.reverse(); let (tree, tree_refs) = make_tree_from_snapshots(tree_children); let result = match_forward(snaps, &tree_refs, &tree); assert_eq!(result.matched.len(), 4); for (snap, tree_ref) in &result.matched { let inst = tree.get_instance(*tree_ref).unwrap(); let snap_t = snap.properties.get(&ustr("Transparency")).unwrap(); let tree_t = inst.properties().get(&ustr("Transparency")).unwrap(); assert!( variant_eq(snap_t, tree_t), "Transparency mismatch: snap={:?}, tree={:?}", snap_t, tree_t ); } } #[test] fn multiple_defaults_omitted_all_at_once() { // Tree instances have MANY extra properties all at their class defaults. // Snapshots omit all of them. The match should not be thrown off. use rbx_dom_weak::types::Variant; // Two sparse snapshots differing only by Transparency let snap_a = make_snapshot_with_props( "Wall", "Part", vec![("Transparency", Variant::Float32(0.0))], ); let snap_b = make_snapshot_with_props( "Wall", "Part", vec![("Transparency", Variant::Float32(1.0))], ); // Tree has Transparency + many known Part defaults from Studio. // These are all at their default values, so count_own_diffs should // skip them (not inflate match cost). let extra_defaults: Vec<(&str, Variant)> = vec![ ("Anchored", Variant::Bool(false)), ("CastShadow", Variant::Bool(true)), ("CanCollide", Variant::Bool(true)), ("CanTouch", Variant::Bool(true)), ("Locked", Variant::Bool(false)), ("Massless", Variant::Bool(false)), ]; let mut tree_props_a = vec![("Transparency", Variant::Float32(0.0))]; tree_props_a.extend(extra_defaults.iter().cloned()); let mut tree_props_b = vec![("Transparency", Variant::Float32(1.0))]; tree_props_b.extend(extra_defaults.iter().cloned()); // Tree in reversed order let (tree, tree_refs) = make_tree_from_snapshots(vec![ make_snapshot_with_props("Wall", "Part", tree_props_b), make_snapshot_with_props("Wall", "Part", tree_props_a), ]); let result = match_forward(vec![snap_a, snap_b], &tree_refs, &tree); assert_eq!(result.matched.len(), 2, "Both should match"); for (snap, tree_ref) in &result.matched { let inst = tree.get_instance(*tree_ref).unwrap(); let snap_t = snap.properties.get(&ustr("Transparency")).unwrap(); let tree_t = inst.properties().get(&ustr("Transparency")).unwrap(); assert!( variant_eq(snap_t, tree_t), "Part with 6 extra default props mismatched: snap={:?}, tree={:?}", snap_t, tree_t ); } } #[test] fn ten_ambiguous_textures_stress() { // 10 Textures: 8 with unique Face values (some repeated across // groups), 2 with Front omitted. Two distinguishing groups by // Transparency. The matcher must handle this without mis-pairing. use rbx_dom_weak::types::{Enum, Variant}; let faces_group_a = [ Some(Enum::from_u32(1)), // Top Some(Enum::from_u32(0)), // Right Some(Enum::from_u32(3)), // Left None, // Front (default, omitted) Some(Enum::from_u32(4)), // Bottom ]; let faces_group_b = [ Some(Enum::from_u32(2)), // Back Some(Enum::from_u32(4)), // Bottom None, // Front (default, omitted) Some(Enum::from_u32(1)), // Top Some(Enum::from_u32(3)), // Left ]; let face_front = Enum::from_u32(5); let mut snaps = Vec::new(); let mut tree_children = Vec::new(); for (group_t, faces) in [(0.5_f32, &faces_group_a[..]), (0.8_f32, &faces_group_b[..])] { for face_opt in faces { let mut snap_props = vec![("Transparency", Variant::Float32(group_t))]; let mut tree_props = vec![("Transparency", Variant::Float32(group_t))]; if let Some(face) = face_opt { snap_props.push(("Face", Variant::Enum(*face))); tree_props.push(("Face", Variant::Enum(*face))); } else { // Snapshot omits Face; tree has Front tree_props.push(("Face", Variant::Enum(face_front))); } snaps.push(make_snapshot_with_props("Texture", "Texture", snap_props)); tree_children.push(make_snapshot_with_props("Texture", "Texture", tree_props)); } } // Shuffle tree order (reverse) tree_children.reverse(); let (tree, tree_refs) = make_tree_from_snapshots(tree_children); let result = match_forward(snaps, &tree_refs, &tree); assert_eq!(result.matched.len(), 10); assert!(result.unmatched_snapshot.is_empty()); assert!(result.unmatched_tree.is_empty()); for (snap, tree_ref) in &result.matched { let inst = tree.get_instance(*tree_ref).unwrap(); // Transparency must match (group discriminator) let snap_t = snap.properties.get(&ustr("Transparency")).unwrap(); let tree_t = inst.properties().get(&ustr("Transparency")).unwrap(); assert!( variant_eq(snap_t, tree_t), "Group mismatch: snap={:?}, tree={:?}", snap_t, tree_t ); // Face must match let snap_face = snap.properties.get(&ustr("Face")); let tree_face = inst.properties().get(&ustr("Face")).unwrap(); match snap_face { Some(sv) => assert!( variant_eq(sv, tree_face), "Face mismatch: snap={:?}, tree={:?}", sv, tree_face ), None => assert!( variant_eq(tree_face, &Variant::Enum(face_front)), "Omitted Face should match Front, got {:?}", tree_face ), } } } #[test] fn depth_limit_completes_quickly() { // Deeply nested tree (depth=6) with same-named instances at each level. // Should complete without hanging thanks to the depth limit. fn make_deep_snap(depth: u32) -> InstanceSnapshot { if depth == 0 { return make_snapshot("Leaf", "Part"); } make_snapshot_with_children( "Node", "Folder", vec![make_deep_snap(depth - 1), make_deep_snap(depth - 1)], ) } let snaps = vec![make_deep_snap(6), make_deep_snap(6)]; let tree_children_snaps = vec![make_deep_snap(6), make_deep_snap(6)]; let root_snap = InstanceSnapshot { snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Borrowed("DataModel"), class_name: ustr("DataModel"), properties: Default::default(), children: tree_children_snaps, }; let tree = RojoTree::new(root_snap); let root_id = tree.get_root_id(); let tree_children: Vec = tree.get_instance(root_id).unwrap().children().to_vec(); let start = std::time::Instant::now(); let result = match_forward(snaps, &tree_children, &tree); let elapsed = start.elapsed(); assert_eq!(result.matched.len(), 2); assert!( elapsed.as_secs() < 5, "Depth-limited matching took too long: {:?}", elapsed ); } }