rojo/src/snapshot/matching.rs
Joe df22500102 Refactor code for improved clarity and maintainability
- 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.
2026-02-17 20:04:52 -06:00

1221 lines
44 KiB
Rust

//! 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<InstanceSnapshot>,
/// Tree children with no match in the snapshot (to be removed).
pub unmatched_tree: Vec<Ref>,
}
/// Match snapshot children to tree children, minimizing total changes.
pub fn match_forward(
snapshot_children: Vec<InstanceSnapshot>,
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<Option<InstanceSnapshot>> =
snapshot_children.into_iter().map(Some).collect();
let mut snap_matched: Vec<bool> = vec![false; snap_available.len()];
let mut tree_available: Vec<bool> = 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<usize>> = 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<usize>> = 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<usize> = snap_indices
.iter()
.filter(|&&si| !snap_matched[si])
.copied()
.collect();
let avail_tree: Vec<usize> = 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<Option<InstanceSnapshot>>,
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<InstanceSnapshot> = snap_available.into_iter().flatten().collect();
let unmatched_tree: Vec<Ref> = 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<usize>> = 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<usize>> = 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<usize> = snap_indices
.iter()
.filter(|&&si| !snap_matched[si])
.copied()
.collect();
let avail_tree: Vec<usize> = 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<Ref>) {
let child_snapshots: Vec<InstanceSnapshot> = 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<Ref> = 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<InstanceSnapshot> = 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<InstanceSnapshot> = (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<InstanceSnapshot> = (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<Ref> = 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<InstanceSnapshot> =
(0..count).map(|_| make_snapshot("Line", "Part")).collect();
let (tree, children) =
make_tree_with_children(&(0..count).map(|_| ("Line", "Part")).collect::<Vec<_>>());
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 {
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<Ref> = 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<InstanceSnapshot>) -> (RojoTree, Vec<Ref>) {
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<Ref> = 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<InstanceSnapshot> = 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<InstanceSnapshot> = 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<Enum> = 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<Ref> = 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
);
}
}