mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
- 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.
460 lines
16 KiB
Rust
460 lines
16 KiB
Rust
//! Build -> Syncback -> Rebuild roundtrip tests.
|
|
//!
|
|
//! These tests verify that:
|
|
//! 1. Building a project to rbxl
|
|
//! 2. Syncing back to a fresh directory
|
|
//! 3. Rebuilding from the syncback result
|
|
//!
|
|
//! Produces semantically identical output. This validates that the syncback
|
|
//! system correctly reconstructs project structure from binary place files.
|
|
|
|
use std::collections::HashMap;
|
|
use std::{fs, path::Path};
|
|
|
|
use rbx_dom_weak::{ustr, WeakDom};
|
|
use tempfile::tempdir;
|
|
|
|
use crate::rojo_test::io_util::BUILD_TESTS_PATH;
|
|
use crate::rojo_test::roundtrip_util::{
|
|
copy_project_dir, ensure_project_dirs_exist, run_rojo_build, run_rojo_syncback_clean,
|
|
};
|
|
|
|
/// Generate roundtrip tests for build-test projects.
|
|
///
|
|
/// Each test:
|
|
/// 1. Builds the project to an rbxl file
|
|
/// 2. Creates a fresh directory with just the project file
|
|
/// 3. Syncs back from the rbxl to the fresh directory
|
|
/// 4. Rebuilds from the syncback result
|
|
/// 5. Compares the two rbxl files for semantic equality
|
|
macro_rules! roundtrip_tests {
|
|
($($test_name:ident),* $(,)?) => {$(
|
|
#[test]
|
|
fn $test_name() {
|
|
run_roundtrip_test(stringify!($test_name));
|
|
}
|
|
)*};
|
|
}
|
|
|
|
fn run_roundtrip_test(build_test_name: &str) {
|
|
let _ = env_logger::try_init();
|
|
|
|
let project_path = Path::new(BUILD_TESTS_PATH).join(build_test_name);
|
|
|
|
// 1. Build original project -> rbxm (model format, not place)
|
|
let (_tmp1, original_rbxm) = run_rojo_build(&project_path, "original.rbxm");
|
|
|
|
// 2. Create fresh syncback target with full project structure
|
|
let syncback_dir = tempdir().expect("Failed to create temp dir");
|
|
copy_project_dir(&project_path, syncback_dir.path());
|
|
|
|
// 3. Syncback from rbxm to directory
|
|
assert!(
|
|
run_rojo_syncback_clean(syncback_dir.path(), &original_rbxm),
|
|
"Syncback should succeed for {}",
|
|
build_test_name
|
|
);
|
|
|
|
// 3b. Ensure base project directories still exist after syncback.
|
|
// Clean mode may remove directories that only contain meta files or .gitkeep,
|
|
// but the project still needs them for the rebuild.
|
|
ensure_project_dirs_exist(syncback_dir.path());
|
|
|
|
// 4. Build from syncback result -> rbxm
|
|
let (_tmp2, roundtrip_rbxm) = run_rojo_build(syncback_dir.path(), "roundtrip.rbxm");
|
|
|
|
// 5. Compare both rbxm files (semantic comparison)
|
|
assert_rbx_equal(&original_rbxm, &roundtrip_rbxm, build_test_name);
|
|
}
|
|
|
|
/// Compare two rbxl files for semantic equality.
|
|
///
|
|
/// This compares the instance trees, checking that all instances have the same
|
|
/// names, class names, and properties (excluding transient properties like Ref).
|
|
fn assert_rbx_equal(file_a: &Path, file_b: &Path, test_name: &str) {
|
|
let data_a = fs::read(file_a).expect("Failed to read original rbxl");
|
|
let data_b = fs::read(file_b).expect("Failed to read roundtrip rbxl");
|
|
|
|
let dom_a = rbx_binary::from_reader(data_a.as_slice()).expect("Failed to parse original rbxl");
|
|
let dom_b = rbx_binary::from_reader(data_b.as_slice()).expect("Failed to parse roundtrip rbxl");
|
|
|
|
// Compare the root's children (DataModel children)
|
|
compare_children(
|
|
&dom_a,
|
|
dom_a.root_ref(),
|
|
&dom_b,
|
|
dom_b.root_ref(),
|
|
test_name,
|
|
);
|
|
}
|
|
|
|
/// Recursively compare children of two instances.
|
|
///
|
|
/// Uses sorted Vec-based comparison (not HashMap) so that duplicate-named
|
|
/// children are correctly handled. HashMap would silently overwrite entries
|
|
/// sharing a name, making the test blind to lost duplicates.
|
|
fn compare_children(
|
|
dom_a: &WeakDom,
|
|
ref_a: rbx_dom_weak::types::Ref,
|
|
dom_b: &WeakDom,
|
|
ref_b: rbx_dom_weak::types::Ref,
|
|
test_name: &str,
|
|
) {
|
|
let inst_a = dom_a.get_by_ref(ref_a).expect("Instance A should exist");
|
|
let inst_b = dom_b.get_by_ref(ref_b).expect("Instance B should exist");
|
|
|
|
let children_a = inst_a.children();
|
|
let children_b = inst_b.children();
|
|
|
|
if children_a.len() != children_b.len() {
|
|
let names_a: Vec<&str> = children_a
|
|
.iter()
|
|
.filter_map(|r| dom_a.get_by_ref(*r).map(|i| i.name.as_str()))
|
|
.collect();
|
|
let names_b: Vec<&str> = children_b
|
|
.iter()
|
|
.filter_map(|r| dom_b.get_by_ref(*r).map(|i| i.name.as_str()))
|
|
.collect();
|
|
panic!(
|
|
"[{}] Child count mismatch under '{}': original has {} children {:?}, \
|
|
roundtrip has {} children {:?}",
|
|
test_name,
|
|
inst_a.name,
|
|
children_a.len(),
|
|
names_a,
|
|
children_b.len(),
|
|
names_b,
|
|
);
|
|
}
|
|
|
|
let mut pairs_a: Vec<(&str, rbx_dom_weak::types::Ref)> = children_a
|
|
.iter()
|
|
.filter_map(|&r| dom_a.get_by_ref(r).map(|i| (i.name.as_str(), r)))
|
|
.collect();
|
|
let mut pairs_b: Vec<(&str, rbx_dom_weak::types::Ref)> = children_b
|
|
.iter()
|
|
.filter_map(|&r| dom_b.get_by_ref(r).map(|i| (i.name.as_str(), r)))
|
|
.collect();
|
|
|
|
pairs_a.sort_by_key(|(name, _)| *name);
|
|
pairs_b.sort_by_key(|(name, _)| *name);
|
|
|
|
for (i, ((name_a, ref_child_a), (name_b, ref_child_b))) in
|
|
pairs_a.iter().zip(pairs_b.iter()).enumerate()
|
|
{
|
|
if name_a != name_b {
|
|
panic!(
|
|
"[{}] Child name mismatch under '{}' at sorted index {}: \
|
|
original='{}', roundtrip='{}'",
|
|
test_name, inst_a.name, i, name_a, name_b,
|
|
);
|
|
}
|
|
|
|
let child_a = dom_a.get_by_ref(*ref_child_a).unwrap();
|
|
let child_b = dom_b.get_by_ref(*ref_child_b).unwrap();
|
|
|
|
if child_a.class != child_b.class {
|
|
panic!(
|
|
"[{}] Class mismatch for '{}' (sorted index {}): original={}, roundtrip={}",
|
|
test_name, name_a, i, child_a.class, child_b.class
|
|
);
|
|
}
|
|
|
|
compare_properties(child_a, child_b, test_name);
|
|
|
|
compare_children(dom_a, *ref_child_a, dom_b, *ref_child_b, test_name);
|
|
}
|
|
}
|
|
|
|
/// Compare properties of two instances.
|
|
///
|
|
/// Focuses on properties that matter for roundtrip correctness:
|
|
/// - Source (for scripts)
|
|
/// - Value (for value types)
|
|
/// - Other serializable properties
|
|
fn compare_properties(
|
|
inst_a: &rbx_dom_weak::Instance,
|
|
inst_b: &rbx_dom_weak::Instance,
|
|
test_name: &str,
|
|
) {
|
|
// Check Source property for scripts
|
|
if let (Some(source_a), Some(source_b)) = (
|
|
inst_a.properties.get(&ustr("Source")),
|
|
inst_b.properties.get(&ustr("Source")),
|
|
) {
|
|
if source_a != source_b {
|
|
panic!(
|
|
"[{}] Source property differs for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}",
|
|
test_name, inst_a.name, inst_a.class, source_a, source_b
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check Value property for value types
|
|
if let (Some(value_a), Some(value_b)) = (
|
|
inst_a.properties.get(&ustr("Value")),
|
|
inst_b.properties.get(&ustr("Value")),
|
|
) {
|
|
if value_a != value_b {
|
|
panic!(
|
|
"[{}] Value property differs for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}",
|
|
test_name, inst_a.name, inst_a.class, value_a, value_b
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check Attributes if present
|
|
if let (Some(attrs_a), Some(attrs_b)) = (
|
|
inst_a.properties.get(&ustr("Attributes")),
|
|
inst_b.properties.get(&ustr("Attributes")),
|
|
) {
|
|
if attrs_a != attrs_b {
|
|
panic!(
|
|
"[{}] Attributes differ for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}",
|
|
test_name, inst_a.name, inst_a.class, attrs_a, attrs_b
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check Tags if present
|
|
if let (Some(tags_a), Some(tags_b)) = (
|
|
inst_a.properties.get(&ustr("Tags")),
|
|
inst_b.properties.get(&ustr("Tags")),
|
|
) {
|
|
if tags_a != tags_b {
|
|
panic!(
|
|
"[{}] Tags differ for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}",
|
|
test_name, inst_a.name, inst_a.class, tags_a, tags_b
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ROUNDTRIP TESTS
|
|
// =============================================================================
|
|
//
|
|
// These are build-test projects that should roundtrip cleanly through
|
|
// build -> syncback -> rebuild.
|
|
//
|
|
// Some projects are excluded because they:
|
|
// - Use sync rules with custom extensions (sync_rule_* projects)
|
|
// - Have CSV files that may not roundtrip identically
|
|
// - Have special project configurations that affect syncback
|
|
|
|
roundtrip_tests! {
|
|
// Basic script types
|
|
client_in_folder,
|
|
client_init,
|
|
module_in_folder,
|
|
module_init,
|
|
server_in_folder,
|
|
server_init,
|
|
|
|
// Nested structures
|
|
deep_nesting,
|
|
init_with_children,
|
|
|
|
// Meta files and properties
|
|
// Note: init_meta_class_name and init_meta_properties are tested separately
|
|
// below with #[ignore] because they have meta-only directories
|
|
script_meta_disabled,
|
|
|
|
// JSON models
|
|
json_model_in_folder,
|
|
json_model_legacy_name,
|
|
|
|
// Text files
|
|
txt,
|
|
txt_in_folder,
|
|
|
|
// Binary models (rbxm/rbxmx)
|
|
rbxm_in_folder,
|
|
rbxmx_in_folder,
|
|
// Note: rbxmx_ref is tested separately below with #[ignore] because
|
|
// Ref IDs differ between builds (expected behavior)
|
|
|
|
// Composed projects
|
|
project_composed_default,
|
|
project_composed_file,
|
|
project_root_name,
|
|
|
|
// Special cases
|
|
attributes,
|
|
gitkeep,
|
|
optional,
|
|
weldconstraint,
|
|
|
|
// Service inference
|
|
// Note: infer_service_name is tested separately below with #[ignore]
|
|
// because it has project-only instances
|
|
infer_starter_player,
|
|
|
|
// Issue regression tests
|
|
issue_546,
|
|
|
|
// Slugified names and metadata overrides
|
|
meta_name_override,
|
|
model_json_name_override,
|
|
dedup_suffix_with_meta,
|
|
}
|
|
|
|
// =============================================================================
|
|
// TESTS WITH KNOWN LIMITATIONS
|
|
// =============================================================================
|
|
|
|
/// Test for project with meta-only directory that sets className.
|
|
///
|
|
/// IGNORED: This project has `$path: "Lighting"` pointing to a directory that
|
|
/// only contains `init.meta.json5` (which sets className to Lighting). Clean mode
|
|
/// removes the meta file as it has no script content, then `ensure_project_dirs_exist`
|
|
/// recreates it as an empty directory. The rebuild then creates a Folder instead
|
|
/// of Lighting because the className info is lost.
|
|
///
|
|
/// This is expected behavior for clean mode - meta files that only set class/properties
|
|
/// (without script content) are not preserved during roundtrip.
|
|
#[test]
|
|
#[ignore = "Clean mode removes meta-only directories, losing className info"]
|
|
fn init_meta_class_name() {
|
|
run_roundtrip_test("init_meta_class_name");
|
|
}
|
|
|
|
/// Test for project with meta-only directory that sets properties.
|
|
///
|
|
/// IGNORED: Same issue as init_meta_class_name - the meta file sets properties
|
|
/// but has no script content, so clean mode removes it.
|
|
#[test]
|
|
#[ignore = "Clean mode removes meta-only directories, losing properties info"]
|
|
fn init_meta_properties() {
|
|
run_roundtrip_test("init_meta_properties");
|
|
}
|
|
|
|
/// Test for project with instances defined only in the project file.
|
|
///
|
|
/// IGNORED: The project defines HttpService directly in the project file without
|
|
/// any `$path`. This instance doesn't get serialized to the rbxm (it has no content),
|
|
/// so syncback fails when it can't find HttpService in the input file.
|
|
#[test]
|
|
#[ignore = "Project-only instances (without $path) don't exist in rbxm"]
|
|
fn infer_service_name() {
|
|
run_roundtrip_test("infer_service_name");
|
|
}
|
|
|
|
/// Test for rbxmx files with Ref properties (ObjectValue).
|
|
///
|
|
/// IGNORED: Ref property IDs are internal identifiers that differ between builds.
|
|
/// This is expected behavior - Refs point to other instances by internal ID, and
|
|
/// those IDs are regenerated each time the file is built.
|
|
#[test]
|
|
#[ignore = "Ref property IDs differ between builds (expected)"]
|
|
fn rbxmx_ref() {
|
|
run_roundtrip_test("rbxmx_ref");
|
|
}
|
|
|
|
// =============================================================================
|
|
// SYNCBACK IDEMPOTENCY TEST
|
|
// =============================================================================
|
|
|
|
/// Build -> syncback -> syncback again. The second syncback must produce
|
|
/// zero filesystem changes (i.e., the result is identical to the first).
|
|
/// This validates that syncback output is stable and self-consistent.
|
|
#[test]
|
|
fn syncback_idempotency() {
|
|
let _ = env_logger::try_init();
|
|
|
|
// Use dedup_suffix_with_meta as it exercises slugified names + meta
|
|
let project_path = Path::new(BUILD_TESTS_PATH).join("dedup_suffix_with_meta");
|
|
|
|
// 1. Build original -> rbxm
|
|
let (_tmp1, original_rbxm) = run_rojo_build(&project_path, "original.rbxm");
|
|
|
|
// 2. First syncback
|
|
let first_dir = tempdir().expect("Failed to create temp dir");
|
|
copy_project_dir(&project_path, first_dir.path());
|
|
assert!(
|
|
run_rojo_syncback_clean(first_dir.path(), &original_rbxm),
|
|
"First syncback should succeed"
|
|
);
|
|
ensure_project_dirs_exist(first_dir.path());
|
|
|
|
// Snapshot all files after first syncback
|
|
let first_snapshot = snapshot_dir(first_dir.path());
|
|
|
|
// 3. Build from first syncback result -> rbxm
|
|
let (_tmp2, first_rbxm) = run_rojo_build(first_dir.path(), "first.rbxm");
|
|
|
|
// 4. Second syncback (into a copy of the first syncback result)
|
|
let second_dir = tempdir().expect("Failed to create temp dir");
|
|
copy_project_dir(first_dir.path(), second_dir.path());
|
|
assert!(
|
|
run_rojo_syncback_clean(second_dir.path(), &first_rbxm),
|
|
"Second syncback should succeed"
|
|
);
|
|
ensure_project_dirs_exist(second_dir.path());
|
|
|
|
// 5. Snapshot all files after second syncback
|
|
let second_snapshot = snapshot_dir(second_dir.path());
|
|
|
|
// 6. Compare snapshots — must be identical
|
|
assert_eq!(
|
|
first_snapshot.len(),
|
|
second_snapshot.len(),
|
|
"File count should be identical between first and second syncback.\n\
|
|
First: {:?}\nSecond: {:?}",
|
|
first_snapshot.keys().collect::<Vec<_>>(),
|
|
second_snapshot.keys().collect::<Vec<_>>(),
|
|
);
|
|
|
|
for (path, first_content) in &first_snapshot {
|
|
let second_content = second_snapshot.get(path).unwrap_or_else(|| {
|
|
panic!(
|
|
"File {:?} exists after first syncback but not after second",
|
|
path
|
|
)
|
|
});
|
|
assert_eq!(
|
|
first_content, second_content,
|
|
"File {:?} changed between first and second syncback",
|
|
path
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Snapshot all files under a directory into a map of relative_path -> contents.
|
|
fn snapshot_dir(root: &Path) -> HashMap<String, Vec<u8>> {
|
|
let mut result = HashMap::new();
|
|
snapshot_dir_inner(root, root, &mut result);
|
|
result
|
|
}
|
|
|
|
fn snapshot_dir_inner(root: &Path, current: &Path, map: &mut HashMap<String, Vec<u8>>) {
|
|
if let Ok(entries) = fs::read_dir(current) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
snapshot_dir_inner(root, &path, map);
|
|
} else {
|
|
let relative = path
|
|
.strip_prefix(root)
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
if let Ok(content) = fs::read(&path) {
|
|
map.insert(relative, content);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// WINDOWS RESERVED NAME ROUNDTRIP TEST
|
|
// =============================================================================
|
|
|
|
/// Instances named after Windows reserved names (CON, PRN) should round-trip
|
|
/// correctly: slugified on disk, real name in meta, rebuild produces correct
|
|
/// instance names.
|
|
#[test]
|
|
fn reserved_name_roundtrip() {
|
|
run_roundtrip_test("reserved_name_override");
|
|
}
|