mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-21 21:25:16 +00:00
New snapshot tests (#134)
* Changes project-related structures to use `BTreeMap` instead of `HashMap` for children to aid determiniusm * Changes imfs-related structures to have total ordering and use `BTreeSet` instead of `HashSet` * Upgrades dependencies to `bx_dom_weak`1.2.0 and rbx_xml 0.5.0 to aid in more determinism stuff * Re-exposes the `RbxSession`'s root project via `root_project()` * Implements `Default` for a couple things * Tweaks visualization code to support visualizing trees not attached to an `RbxSession` * Adds an ID-invariant comparison method for `rbx_tree` relying on previous determinism changes * Adds a (disabled) test to start finding issues in the reconciler with regards to communicativity of snapshot application * Adds a snapshot testing system that operates on `RbxTree` and associated metadata, which are committed in this change
This commit is contained in:
committed by
GitHub
parent
ad93631ef8
commit
ec0a1f1ce4
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{HashMap, HashSet, BTreeSet},
|
||||
fs,
|
||||
path::PathBuf,
|
||||
};
|
||||
@@ -80,7 +80,7 @@ fn base_tree() -> Result<(TempDir, Imfs, ExpectedImfs, TestResources), Error> {
|
||||
expected_roots.insert(root.path().to_path_buf());
|
||||
|
||||
let root_item = {
|
||||
let mut children = HashSet::new();
|
||||
let mut children = BTreeSet::new();
|
||||
children.insert(foo_path.clone());
|
||||
children.insert(bar_path.clone());
|
||||
|
||||
@@ -91,7 +91,7 @@ fn base_tree() -> Result<(TempDir, Imfs, ExpectedImfs, TestResources), Error> {
|
||||
};
|
||||
|
||||
let foo_item = {
|
||||
let mut children = HashSet::new();
|
||||
let mut children = BTreeSet::new();
|
||||
children.insert(baz_path.clone());
|
||||
|
||||
ImfsItem::Directory(ImfsDirectory {
|
||||
@@ -199,7 +199,7 @@ fn adding_folder() -> Result<(), Error> {
|
||||
}
|
||||
|
||||
let folder_item = {
|
||||
let mut children = HashSet::new();
|
||||
let mut children = BTreeSet::new();
|
||||
children.insert(file1_path.clone());
|
||||
children.insert(file2_path.clone());
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#[macro_use] extern crate lazy_static;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, BTreeMap},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ fn single_partition_game() {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut replicated_storage_children = HashMap::new();
|
||||
let mut replicated_storage_children = BTreeMap::new();
|
||||
replicated_storage_children.insert("Foo".to_string(), foo);
|
||||
|
||||
let replicated_storage = ProjectNode {
|
||||
@@ -73,7 +73,7 @@ fn single_partition_game() {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut root_children = HashMap::new();
|
||||
let mut root_children = BTreeMap::new();
|
||||
root_children.insert("ReplicatedStorage".to_string(), replicated_storage);
|
||||
root_children.insert("HttpService".to_string(), http_service);
|
||||
|
||||
|
||||
115
server/tests/snapshot_reconciler.rs
Normal file
115
server/tests/snapshot_reconciler.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
mod test_util;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use rbx_dom_weak::{RbxTree, RbxInstanceProperties};
|
||||
|
||||
use librojo::{
|
||||
snapshot_reconciler::{RbxSnapshotInstance, reconcile_subtree},
|
||||
};
|
||||
|
||||
use test_util::tree::trees_equal;
|
||||
|
||||
// TODO: Snapshot application isn't communicative right now with the current
|
||||
// snapshot reconciler. In practice this mostly isn't a problem, but presents
|
||||
// a problem trying to rely on determinism to make snapshot tests.
|
||||
// #[test]
|
||||
fn patch_communicativity() {
|
||||
let base_tree = RbxTree::new(RbxInstanceProperties {
|
||||
name: "DataModel".into(),
|
||||
class_name: "DataModel".into(),
|
||||
properties: HashMap::new(),
|
||||
});
|
||||
|
||||
let patch_a = RbxSnapshotInstance {
|
||||
name: "DataModel".into(),
|
||||
class_name: "DataModel".into(),
|
||||
children: vec![
|
||||
RbxSnapshotInstance {
|
||||
name: "Child-A".into(),
|
||||
class_name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let patch_b = RbxSnapshotInstance {
|
||||
name: "DataModel".into(),
|
||||
class_name: "DataModel".into(),
|
||||
children: vec![
|
||||
RbxSnapshotInstance {
|
||||
name: "Child-B".into(),
|
||||
class_name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let patch_combined = RbxSnapshotInstance {
|
||||
name: "DataModel".into(),
|
||||
class_name: "DataModel".into(),
|
||||
children: vec![
|
||||
RbxSnapshotInstance {
|
||||
name: "Child-A".into(),
|
||||
class_name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
RbxSnapshotInstance {
|
||||
name: "Child-B".into(),
|
||||
class_name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let root_id = base_tree.get_root_id();
|
||||
|
||||
let mut tree_a = base_tree.clone();
|
||||
|
||||
reconcile_subtree(
|
||||
&mut tree_a,
|
||||
root_id,
|
||||
&patch_a,
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
);
|
||||
|
||||
reconcile_subtree(
|
||||
&mut tree_a,
|
||||
root_id,
|
||||
&patch_combined,
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
);
|
||||
|
||||
let mut tree_b = base_tree.clone();
|
||||
|
||||
reconcile_subtree(
|
||||
&mut tree_b,
|
||||
root_id,
|
||||
&patch_b,
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
);
|
||||
|
||||
reconcile_subtree(
|
||||
&mut tree_b,
|
||||
root_id,
|
||||
&patch_combined,
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
);
|
||||
|
||||
match trees_equal(&tree_a, &tree_b) {
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("{}", e),
|
||||
}
|
||||
}
|
||||
68
server/tests/snapshot_snapshots.rs
Normal file
68
server/tests/snapshot_snapshots.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
mod test_util;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use librojo::{
|
||||
imfs::Imfs,
|
||||
project::Project,
|
||||
rbx_snapshot::{SnapshotContext, snapshot_project_tree},
|
||||
};
|
||||
|
||||
use crate::test_util::{
|
||||
snapshot::*,
|
||||
};
|
||||
|
||||
macro_rules! generate_snapshot_tests {
|
||||
($($name: ident),*) => {
|
||||
$(
|
||||
paste::item! {
|
||||
#[test]
|
||||
fn [<snapshot_ $name>]() {
|
||||
let _ = env_logger::try_init();
|
||||
|
||||
let tests_folder = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test-projects");
|
||||
let project_folder = tests_folder.join(stringify!($name));
|
||||
run_snapshot_test(&project_folder);
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
generate_snapshot_tests!(
|
||||
empty,
|
||||
multi_partition_game,
|
||||
nested_partitions,
|
||||
single_partition_game,
|
||||
single_partition_model,
|
||||
transmute_partition
|
||||
);
|
||||
|
||||
fn run_snapshot_test(path: &Path) {
|
||||
println!("Running snapshot from project: {}", path.display());
|
||||
|
||||
let project = Project::load_fuzzy(path)
|
||||
.expect("Couldn't load project file for snapshot test");
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)
|
||||
.expect("Could not add IMFS roots to snapshot project");
|
||||
|
||||
let context = SnapshotContext {
|
||||
plugin_context: None,
|
||||
};
|
||||
|
||||
let mut snapshot = snapshot_project_tree(&context, &imfs, &project)
|
||||
.expect("Could not generate snapshot for snapshot test");
|
||||
|
||||
if let Some(snapshot) = snapshot.as_mut() {
|
||||
anonymize_snapshot(path, snapshot);
|
||||
}
|
||||
|
||||
match read_expected_snapshot(path) {
|
||||
Some(expected_snapshot) => assert_eq!(snapshot, expected_snapshot),
|
||||
None => write_expected_snapshot(path, &snapshot),
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use librojo::{
|
||||
imfs::Imfs,
|
||||
project::{Project, ProjectNode},
|
||||
rbx_snapshot::{SnapshotContext, snapshot_project_tree},
|
||||
snapshot_reconciler::{RbxSnapshotInstance},
|
||||
};
|
||||
|
||||
macro_rules! generate_snapshot_tests {
|
||||
($($name: ident),*) => {
|
||||
$(
|
||||
paste::item! {
|
||||
#[test]
|
||||
fn [<snapshot_ $name>]() {
|
||||
let tests_folder = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test-projects");
|
||||
let project_folder = tests_folder.join(stringify!($name));
|
||||
run_snapshot_test(&project_folder);
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
generate_snapshot_tests!(
|
||||
empty,
|
||||
nested_partitions,
|
||||
single_partition_game,
|
||||
single_partition_model,
|
||||
transmute_partition
|
||||
);
|
||||
|
||||
const SNAPSHOT_EXPECTED_NAME: &str = "expected-snapshot.json";
|
||||
|
||||
fn run_snapshot_test(path: &Path) {
|
||||
println!("Running snapshot from project: {}", path.display());
|
||||
|
||||
let project = Project::load_fuzzy(path)
|
||||
.expect("Couldn't load project file for snapshot test");
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)
|
||||
.expect("Could not add IMFS roots to snapshot project");
|
||||
|
||||
let context = SnapshotContext {
|
||||
plugin_context: None,
|
||||
};
|
||||
|
||||
let mut snapshot = snapshot_project_tree(&context, &imfs, &project)
|
||||
.expect("Could not generate snapshot for snapshot test");
|
||||
|
||||
if let Some(snapshot) = snapshot.as_mut() {
|
||||
anonymize_snapshot(path, snapshot);
|
||||
}
|
||||
|
||||
match read_expected_snapshot(path) {
|
||||
Some(expected_snapshot) => assert_eq!(snapshot, expected_snapshot),
|
||||
None => write_expected_snapshot(path, &snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshots contain absolute paths, which simplifies much of Rojo.
|
||||
///
|
||||
/// For saving snapshots to the disk, we should strip off the project folder
|
||||
/// path to make them machine-independent. This doesn't work for paths that fall
|
||||
/// outside of the project folder, but that's okay here.
|
||||
///
|
||||
/// We also need to sort children, since Rojo tends to enumerate the filesystem
|
||||
/// in an unpredictable order.
|
||||
fn anonymize_snapshot(project_folder_path: &Path, snapshot: &mut RbxSnapshotInstance) {
|
||||
match snapshot.metadata.source_path.as_mut() {
|
||||
Some(path) => *path = anonymize_path(project_folder_path, path),
|
||||
None => {},
|
||||
}
|
||||
|
||||
match snapshot.metadata.project_definition.as_mut() {
|
||||
Some((_, project_node)) => anonymize_project_node(project_folder_path, project_node),
|
||||
None => {},
|
||||
}
|
||||
|
||||
snapshot.children.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
|
||||
for child in snapshot.children.iter_mut() {
|
||||
anonymize_snapshot(project_folder_path, child);
|
||||
}
|
||||
}
|
||||
|
||||
fn anonymize_project_node(project_folder_path: &Path, project_node: &mut ProjectNode) {
|
||||
match project_node.path.as_mut() {
|
||||
Some(path) => *path = anonymize_path(project_folder_path, path),
|
||||
None => {},
|
||||
}
|
||||
|
||||
for child_node in project_node.children.values_mut() {
|
||||
anonymize_project_node(project_folder_path, child_node);
|
||||
}
|
||||
}
|
||||
|
||||
fn anonymize_path(project_folder_path: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.strip_prefix(project_folder_path)
|
||||
.expect("Could not anonymize absolute path")
|
||||
.to_path_buf()
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_expected_snapshot(path: &Path) -> Option<Option<RbxSnapshotInstance<'static>>> {
|
||||
let contents = fs::read(path.join(SNAPSHOT_EXPECTED_NAME)).ok()?;
|
||||
let snapshot: Option<RbxSnapshotInstance<'static>> = serde_json::from_slice(&contents)
|
||||
.expect("Could not deserialize snapshot");
|
||||
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
fn write_expected_snapshot(path: &Path, snapshot: &Option<RbxSnapshotInstance>) {
|
||||
let mut file = File::create(path.join(SNAPSHOT_EXPECTED_NAME))
|
||||
.expect("Could not open file to write snapshot");
|
||||
|
||||
serde_json::to_writer_pretty(&mut file, snapshot)
|
||||
.expect("Could not serialize snapshot to file");
|
||||
}
|
||||
@@ -1,31 +1,13 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::fs::{create_dir, copy};
|
||||
use std::path::Path;
|
||||
use std::io;
|
||||
|
||||
use rouille::Request;
|
||||
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use librojo::web::Server;
|
||||
|
||||
pub trait HttpTestUtil {
|
||||
fn get_string(&self, url: &str) -> String;
|
||||
}
|
||||
|
||||
impl HttpTestUtil for Server {
|
||||
fn get_string(&self, url: &str) -> String {
|
||||
let info_request = Request::fake_http("GET", url, vec![], vec![]);
|
||||
let response = self.handle_request(&info_request);
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
|
||||
let (mut reader, _) = response.data.into_reader_and_size();
|
||||
let mut body = String::new();
|
||||
reader.read_to_string(&mut body).unwrap();
|
||||
|
||||
body
|
||||
}
|
||||
}
|
||||
pub mod snapshot;
|
||||
pub mod tree;
|
||||
|
||||
pub fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
|
||||
for entry in WalkDir::new(from) {
|
||||
@@ -51,4 +33,4 @@ pub fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
79
server/tests/test_util/snapshot.rs
Normal file
79
server/tests/test_util/snapshot.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use librojo::{
|
||||
project::ProjectNode,
|
||||
snapshot_reconciler::RbxSnapshotInstance,
|
||||
rbx_session::MetadataPerInstance,
|
||||
};
|
||||
|
||||
const SNAPSHOT_EXPECTED_NAME: &str = "expected-snapshot.json";
|
||||
|
||||
/// Snapshots contain absolute paths, which simplifies much of Rojo.
|
||||
///
|
||||
/// For saving snapshots to the disk, we should strip off the project folder
|
||||
/// path to make them machine-independent. This doesn't work for paths that fall
|
||||
/// outside of the project folder, but that's okay here.
|
||||
///
|
||||
/// We also need to sort children, since Rojo tends to enumerate the filesystem
|
||||
/// in an unpredictable order.
|
||||
pub fn anonymize_snapshot(project_folder_path: &Path, snapshot: &mut RbxSnapshotInstance) {
|
||||
anonymize_metadata(project_folder_path, &mut snapshot.metadata);
|
||||
|
||||
snapshot.children.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
|
||||
for child in snapshot.children.iter_mut() {
|
||||
anonymize_snapshot(project_folder_path, child);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anonymize_metadata(project_folder_path: &Path, metadata: &mut MetadataPerInstance) {
|
||||
match metadata.source_path.as_mut() {
|
||||
Some(path) => *path = anonymize_path(project_folder_path, path),
|
||||
None => {},
|
||||
}
|
||||
|
||||
match metadata.project_definition.as_mut() {
|
||||
Some((_, project_node)) => anonymize_project_node(project_folder_path, project_node),
|
||||
None => {},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anonymize_project_node(project_folder_path: &Path, project_node: &mut ProjectNode) {
|
||||
match project_node.path.as_mut() {
|
||||
Some(path) => *path = anonymize_path(project_folder_path, path),
|
||||
None => {},
|
||||
}
|
||||
|
||||
for child_node in project_node.children.values_mut() {
|
||||
anonymize_project_node(project_folder_path, child_node);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anonymize_path(project_folder_path: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.strip_prefix(project_folder_path)
|
||||
.expect("Could not anonymize absolute path")
|
||||
.to_path_buf()
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_expected_snapshot(path: &Path) -> Option<Option<RbxSnapshotInstance<'static>>> {
|
||||
let contents = fs::read(path.join(SNAPSHOT_EXPECTED_NAME)).ok()?;
|
||||
let snapshot: Option<RbxSnapshotInstance<'static>> = serde_json::from_slice(&contents)
|
||||
.expect("Could not deserialize snapshot");
|
||||
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
pub fn write_expected_snapshot(path: &Path, snapshot: &Option<RbxSnapshotInstance>) {
|
||||
let mut file = File::create(path.join(SNAPSHOT_EXPECTED_NAME))
|
||||
.expect("Could not open file to write snapshot");
|
||||
|
||||
serde_json::to_writer_pretty(&mut file, snapshot)
|
||||
.expect("Could not serialize snapshot to file");
|
||||
}
|
||||
351
server/tests/test_util/tree.rs
Normal file
351
server/tests/test_util/tree.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
//! Defines a mechanism to compare two RbxTree objects and generate a useful
|
||||
//! diff if they aren't the same. These methods ignore IDs, which are randomly
|
||||
//! generated whenever a tree is constructed anyways. This makes matching up
|
||||
//! pairs of instances that should be the same potentially difficult.
|
||||
//!
|
||||
//! It relies on a couple different ideas:
|
||||
//! - Instances with the same name and class name are matched as the same
|
||||
//! instance. See basic_equal for this logic
|
||||
//! - A path of period-delimited names (like Roblox's GetFullName) should be
|
||||
//! enough to debug most issues. If it isn't, we can do something fun like
|
||||
//! generate GraphViz graphs.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
hash::Hash,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use log::error;
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use rbx_dom_weak::{RbxId, RbxTree};
|
||||
|
||||
use librojo::{
|
||||
rbx_session::MetadataPerInstance,
|
||||
live_session::LiveSession,
|
||||
visualize::{VisualizeRbxTree, graphviz_to_svg},
|
||||
};
|
||||
|
||||
use super::snapshot::anonymize_metadata;
|
||||
|
||||
/// Marks a 'step' in the test, which will snapshot the session's current
|
||||
/// RbxTree object and compare it against the saved snapshot if it exists.
|
||||
pub fn tree_step(step: &str, live_session: &LiveSession, source_path: &Path) {
|
||||
let rbx_session = live_session.rbx_session.lock().unwrap();
|
||||
let tree = rbx_session.get_tree();
|
||||
|
||||
let project_folder = live_session.root_project().folder_location();
|
||||
let metadata = rbx_session.get_all_instance_metadata()
|
||||
.iter()
|
||||
.map(|(key, meta)| {
|
||||
let mut meta = meta.clone();
|
||||
anonymize_metadata(project_folder, &mut meta);
|
||||
(*key, meta)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tree_with_metadata = TreeWithMetadata {
|
||||
tree: Cow::Borrowed(&tree),
|
||||
metadata: Cow::Owned(metadata),
|
||||
};
|
||||
|
||||
match read_tree_by_name(source_path, step) {
|
||||
Some(expected) => match trees_and_metadata_equal(&expected, &tree_with_metadata) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("Trees at step '{}' were not equal.\n{}", step, e);
|
||||
|
||||
let expected_gv = format!("{}", VisualizeRbxTree {
|
||||
tree: &expected.tree,
|
||||
metadata: &expected.metadata,
|
||||
});
|
||||
|
||||
let actual_gv = format!("{}", VisualizeRbxTree {
|
||||
tree: &tree_with_metadata.tree,
|
||||
metadata: &tree_with_metadata.metadata,
|
||||
});
|
||||
|
||||
let output_dir = PathBuf::from("failed-snapshots");
|
||||
fs::create_dir_all(&output_dir)
|
||||
.expect("Could not create failed-snapshots directory");
|
||||
|
||||
let expected_basename = format!("{}-{}-expected", live_session.root_project().name, step);
|
||||
let actual_basename = format!("{}-{}-actual", live_session.root_project().name, step);
|
||||
|
||||
let mut expected_out = output_dir.join(expected_basename);
|
||||
let mut actual_out = output_dir.join(actual_basename);
|
||||
|
||||
match (graphviz_to_svg(&expected_gv), graphviz_to_svg(&actual_gv)) {
|
||||
(Some(expected_svg), Some(actual_svg)) => {
|
||||
expected_out.set_extension("svg");
|
||||
actual_out.set_extension("svg");
|
||||
|
||||
fs::write(&expected_out, expected_svg)
|
||||
.expect("Couldn't write expected SVG");
|
||||
|
||||
fs::write(&actual_out, actual_svg)
|
||||
.expect("Couldn't write actual SVG");
|
||||
}
|
||||
_ => {
|
||||
expected_out.set_extension("gv");
|
||||
actual_out.set_extension("gv");
|
||||
|
||||
fs::write(&expected_out, expected_gv)
|
||||
.expect("Couldn't write expected GV");
|
||||
|
||||
fs::write(&actual_out, actual_gv)
|
||||
.expect("Couldn't write actual GV");
|
||||
}
|
||||
}
|
||||
|
||||
error!("Output at {} and {}", expected_out.display(), actual_out.display());
|
||||
|
||||
panic!("Tree mismatch at step '{}'", step);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
write_tree_by_name(source_path, step, &tree_with_metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_cow_map<K: Clone + Eq + Hash, V: Clone>() -> Cow<'static, HashMap<K, V>> {
|
||||
Cow::Owned(HashMap::new())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TreeWithMetadata<'a> {
|
||||
#[serde(flatten)]
|
||||
pub tree: Cow<'a, RbxTree>,
|
||||
|
||||
#[serde(default = "new_cow_map")]
|
||||
pub metadata: Cow<'a, HashMap<RbxId, MetadataPerInstance>>,
|
||||
}
|
||||
|
||||
fn read_tree_by_name(path: &Path, identifier: &str) -> Option<TreeWithMetadata<'static>> {
|
||||
let mut file_path = path.join(identifier);
|
||||
file_path.set_extension("tree.json");
|
||||
|
||||
let contents = fs::read(&file_path).ok()?;
|
||||
let tree: TreeWithMetadata = serde_json::from_slice(&contents)
|
||||
.expect("Could not deserialize tree");
|
||||
|
||||
Some(tree)
|
||||
}
|
||||
|
||||
fn write_tree_by_name(path: &Path, identifier: &str, tree: &TreeWithMetadata) {
|
||||
let mut file_path = path.join(identifier);
|
||||
file_path.set_extension("tree.json");
|
||||
|
||||
let mut file = File::create(file_path)
|
||||
.expect("Could not open file to write tree");
|
||||
|
||||
serde_json::to_writer_pretty(&mut file, tree)
|
||||
.expect("Could not serialize tree to file");
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TreeMismatch {
|
||||
pub path: Cow<'static, str>,
|
||||
pub detail: Cow<'static, str>,
|
||||
}
|
||||
|
||||
impl TreeMismatch {
|
||||
pub fn new<'a, A: Into<Cow<'a, str>>, B: Into<Cow<'a, str>>>(path: A, detail: B) -> TreeMismatch {
|
||||
TreeMismatch {
|
||||
path: Cow::Owned(path.into().into_owned()),
|
||||
detail: Cow::Owned(detail.into().into_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_parent(mut self, name: &str) -> TreeMismatch {
|
||||
self.path.to_mut().insert(0, '.');
|
||||
self.path.to_mut().insert_str(0, name);
|
||||
|
||||
TreeMismatch {
|
||||
path: self.path,
|
||||
detail: self.detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TreeMismatch {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(formatter, "Tree mismatch at path {}", self.path)?;
|
||||
writeln!(formatter, "{}", self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trees_equal(
|
||||
left_tree: &RbxTree,
|
||||
right_tree: &RbxTree,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left = TreeWithMetadata {
|
||||
tree: Cow::Borrowed(left_tree),
|
||||
metadata: Cow::Owned(HashMap::new()),
|
||||
};
|
||||
|
||||
let right = TreeWithMetadata {
|
||||
tree: Cow::Borrowed(right_tree),
|
||||
metadata: Cow::Owned(HashMap::new()),
|
||||
};
|
||||
|
||||
trees_and_metadata_equal(&left, &right)
|
||||
}
|
||||
|
||||
fn trees_and_metadata_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
right_tree: &TreeWithMetadata,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_id = left_tree.tree.get_root_id();
|
||||
let right_id = right_tree.tree.get_root_id();
|
||||
|
||||
instances_equal(left_tree, left_id, right_tree, right_id)
|
||||
}
|
||||
|
||||
fn instances_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
basic_equal(left_tree, left_id, right_tree, right_id)?;
|
||||
properties_equal(left_tree, left_id, right_tree, right_id)?;
|
||||
children_equal(left_tree, left_id, right_tree, right_id)?;
|
||||
metadata_equal(left_tree, left_id, right_tree, right_id)
|
||||
}
|
||||
|
||||
fn basic_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_instance = left_tree.tree.get_instance(left_id)
|
||||
.expect("ID did not exist in left tree");
|
||||
|
||||
let right_instance = right_tree.tree.get_instance(right_id)
|
||||
.expect("ID did not exist in right tree");
|
||||
|
||||
if left_instance.name != right_instance.name {
|
||||
let message = format!("Name did not match ('{}' vs '{}')", left_instance.name, right_instance.name);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
|
||||
if left_instance.class_name != right_instance.class_name {
|
||||
let message = format!("Class name did not match ('{}' vs '{}')", left_instance.class_name, right_instance.class_name);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn properties_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_instance = left_tree.tree.get_instance(left_id)
|
||||
.expect("ID did not exist in left tree");
|
||||
|
||||
let right_instance = right_tree.tree.get_instance(right_id)
|
||||
.expect("ID did not exist in right tree");
|
||||
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
for (key, left_value) in &left_instance.properties {
|
||||
visited.insert(key);
|
||||
|
||||
let right_value = right_instance.properties.get(key);
|
||||
|
||||
if Some(left_value) != right_value {
|
||||
let message = format!(
|
||||
"Property {}:\n\tLeft: {:?}\n\tRight: {:?}",
|
||||
key,
|
||||
Some(left_value),
|
||||
right_value,
|
||||
);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
}
|
||||
|
||||
for (key, right_value) in &right_instance.properties {
|
||||
if visited.contains(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let left_value = left_instance.properties.get(key);
|
||||
|
||||
if left_value != Some(right_value) {
|
||||
let message = format!(
|
||||
"Property {}:\n\tLeft: {:?}\n\tRight: {:?}",
|
||||
key,
|
||||
left_value,
|
||||
Some(right_value),
|
||||
);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn children_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_instance = left_tree.tree.get_instance(left_id)
|
||||
.expect("ID did not exist in left tree");
|
||||
|
||||
let right_instance = right_tree.tree.get_instance(right_id)
|
||||
.expect("ID did not exist in right tree");
|
||||
|
||||
let left_children = left_instance.get_children_ids();
|
||||
let right_children = right_instance.get_children_ids();
|
||||
|
||||
if left_children.len() != right_children.len() {
|
||||
return Err(TreeMismatch::new(&left_instance.name, "Instances had different numbers of children"));
|
||||
}
|
||||
|
||||
for (left_child_id, right_child_id) in left_children.iter().zip(right_children) {
|
||||
instances_equal(left_tree, *left_child_id, right_tree, *right_child_id)
|
||||
.map_err(|e| e.add_parent(&left_instance.name))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_meta = left_tree.metadata.get(&left_id);
|
||||
let right_meta = right_tree.metadata.get(&right_id);
|
||||
|
||||
if left_meta != right_meta {
|
||||
let left_instance = left_tree.tree.get_instance(left_id)
|
||||
.expect("Left instance didn't exist in tree");
|
||||
|
||||
let message = format!(
|
||||
"Metadata mismatch:\n\tLeft: {:?}\n\tRight: {:?}",
|
||||
left_meta,
|
||||
right_meta,
|
||||
);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
68
server/tests/tree_snapshots.rs
Normal file
68
server/tests/tree_snapshots.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
mod test_util;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tempfile::{tempdir, TempDir};
|
||||
|
||||
use librojo::{
|
||||
live_session::LiveSession,
|
||||
project::Project,
|
||||
};
|
||||
|
||||
use crate::test_util::{
|
||||
copy_recursive,
|
||||
tree::tree_step,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn multi_partition_game() {
|
||||
let _ = env_logger::try_init();
|
||||
let source_path = project_path("multi_partition_game");
|
||||
|
||||
let (dir, live_session) = start_session(&source_path);
|
||||
tree_step("initial", &live_session, &source_path);
|
||||
|
||||
let added_path = dir.path().join("a/added");
|
||||
fs::create_dir_all(&added_path)
|
||||
.expect("Couldn't create directory");
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
|
||||
tree_step("with_dir", &live_session, &source_path);
|
||||
|
||||
let moved_path = dir.path().join("b/added");
|
||||
fs::rename(&added_path, &moved_path)
|
||||
.expect("Couldn't rename directory");
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
|
||||
tree_step("with_moved_dir", &live_session, &source_path);
|
||||
}
|
||||
|
||||
/// Find the path to the given test project relative to the manifest.
|
||||
fn project_path(name: &str) -> PathBuf {
|
||||
let mut path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test-projects");
|
||||
path.push(name);
|
||||
path
|
||||
}
|
||||
|
||||
/// Starts a new LiveSession for the project located at the given file path.
|
||||
fn start_session(source_path: &Path) -> (TempDir, LiveSession) {
|
||||
let dir = tempdir()
|
||||
.expect("Couldn't create temporary directory");
|
||||
|
||||
copy_recursive(&source_path, dir.path())
|
||||
.expect("Couldn't copy project to temporary directory");
|
||||
|
||||
let project = Arc::new(Project::load_fuzzy(dir.path())
|
||||
.expect("Couldn't load project from temp directory"));
|
||||
|
||||
let live_session = LiveSession::new(Arc::clone(&project))
|
||||
.expect("Couldn't start live session");
|
||||
|
||||
(dir, live_session)
|
||||
}
|
||||
Reference in New Issue
Block a user