mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Add audit documentation and enhance two-way sync handling
- Introduced a comprehensive audit guide for the production-grade sync feature, detailing the evaluation criteria for round-trip identity and code quality. - Added a new plan for auditing the two-way sync Ref property feature, outlining specific tasks and expected behaviors. - Updated `ServeSession.lua` to include the instance map in patch updates, ensuring accurate property encoding. - Enhanced `encodeInstance.lua` to prevent encoding Ref properties during instance addition, delegating this to the patch update process. - Implemented regression tests to highlight known limitations in Ref handling, ensuring better coverage and identification of existing bugs. These changes improve the clarity of the sync feature's audit process and strengthen the handling of Ref properties in the system.
This commit is contained in:
parent
c181172460
commit
4070b48308
15 changed files with 1559 additions and 138 deletions
234
src/rojo_ref.rs
234
src/rojo_ref.rs
|
|
@ -1,6 +1,9 @@
|
|||
use std::{fmt, sync::Arc};
|
||||
use std::{borrow::Cow, fmt, sync::Arc};
|
||||
|
||||
use rbx_dom_weak::{types::Ref, WeakDom};
|
||||
use rbx_dom_weak::{
|
||||
types::{Ref, Variant},
|
||||
WeakDom,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Legacy: ID attribute on target instances (kept for backwards compatibility)
|
||||
|
|
@ -19,12 +22,124 @@ pub fn ref_attribute_name(prop_name: &str) -> String {
|
|||
format!("{REF_PATH_ATTRIBUTE_PREFIX}{prop_name}")
|
||||
}
|
||||
|
||||
/// Compute the `Rojo_Target_*` attribute name for a given property name.
|
||||
///
|
||||
/// Example: `ref_target_attribute_name("Value")` returns `"Rojo_Target_Value"`.
|
||||
pub fn ref_target_attribute_name(prop_name: &str) -> String {
|
||||
format!("{REF_POINTER_ATTRIBUTE_PREFIX}{prop_name}")
|
||||
}
|
||||
|
||||
/// Escape "/" in an instance name for use in ref paths.
|
||||
/// Uses "\/" as escape sequence. Also escapes existing backslashes to "\\"
|
||||
/// so the escaping is unambiguous.
|
||||
pub fn escape_ref_path_segment(name: &str) -> Cow<'_, str> {
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
Cow::Owned(name.replace('\\', "\\\\").replace('/', "\\/"))
|
||||
} else {
|
||||
Cow::Borrowed(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unescape a ref path segment back to an instance name.
|
||||
/// Reverses the escaping done by `escape_ref_path_segment`.
|
||||
pub fn unescape_ref_path_segment(segment: &str) -> Cow<'_, str> {
|
||||
if segment.contains('\\') {
|
||||
let mut result = String::with_capacity(segment.len());
|
||||
let mut chars = segment.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
match chars.next() {
|
||||
Some('/') => result.push('/'),
|
||||
Some('\\') => result.push('\\'),
|
||||
Some(other) => {
|
||||
result.push('\\');
|
||||
result.push(other);
|
||||
}
|
||||
None => result.push('\\'),
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
Cow::Owned(result)
|
||||
} else {
|
||||
Cow::Borrowed(segment)
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a ref path string into segments, handling escaped "/" characters.
|
||||
/// Splits on unescaped "/" only, then unescapes each segment.
|
||||
pub fn split_ref_path(path: &str) -> Vec<String> {
|
||||
if path.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut segments = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = path.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
// Escaped character -- keep both the backslash and next char
|
||||
current.push(c);
|
||||
if let Some(&next) = chars.peek() {
|
||||
current.push(next);
|
||||
chars.next();
|
||||
}
|
||||
} else if c == '/' {
|
||||
// Unescaped separator
|
||||
segments.push(unescape_ref_path_segment(¤t).into_owned());
|
||||
current = String::new();
|
||||
} else {
|
||||
current.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Push the last segment
|
||||
segments.push(unescape_ref_path_segment(¤t).into_owned());
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
/// Compute the path to a target instance for use in a `Rojo_Ref_*` attribute.
|
||||
/// Returns the slash-separated path from the root (root name excluded).
|
||||
/// Instance names containing "/" are escaped as "\/" to prevent ambiguity.
|
||||
///
|
||||
/// Example: an instance at `DataModel > Workspace > Part1` returns `"Workspace/Part1"`.
|
||||
/// An instance named `"A/B"` at Workspace returns `"Workspace/A\/B"`.
|
||||
pub fn ref_target_path(dom: &WeakDom, target_ref: Ref) -> String {
|
||||
dom.full_path_of(target_ref, "/")
|
||||
let root_ref = dom.root_ref();
|
||||
let mut components: Vec<Cow<'_, str>> = dom
|
||||
.ancestors_of(target_ref)
|
||||
.filter(|inst| inst.referent() != root_ref)
|
||||
.map(|inst| escape_ref_path_segment(&inst.name))
|
||||
.collect();
|
||||
components.reverse();
|
||||
components.join("/")
|
||||
}
|
||||
|
||||
/// Extract a string value from a Variant that may be String or BinaryString.
|
||||
/// Returns None with a warning for non-string types or invalid UTF-8.
|
||||
/// Used by compute_ref_properties and defer_ref_properties for parsing
|
||||
/// Rojo_Ref_* and Rojo_Target_* attribute values.
|
||||
pub fn variant_as_str<'a>(value: &'a Variant, attr_name: &str) -> Option<&'a str> {
|
||||
match value {
|
||||
Variant::String(s) => Some(s.as_str()),
|
||||
Variant::BinaryString(bytes) => match std::str::from_utf8(bytes.as_ref()) {
|
||||
Ok(s) => Some(s),
|
||||
Err(_) => {
|
||||
log::warn!("Attribute {attr_name} contains invalid UTF-8 BinaryString");
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
log::warn!(
|
||||
"Attribute {attr_name} is of type {:?} when it was expected to be a String",
|
||||
value.ty()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO add an internment strategy for RojoRefs
|
||||
|
|
@ -110,4 +225,117 @@ mod tests {
|
|||
let dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
assert_eq!(ref_target_path(&dom, dom.root_ref()), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Escape/unescape tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn escape_name_without_slash() {
|
||||
assert_eq!(escape_ref_path_segment("Part1"), Cow::Borrowed("Part1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_name_with_slash() {
|
||||
let escaped = escape_ref_path_segment("A/B");
|
||||
assert_eq!(escaped.as_ref(), "A\\/B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_name_with_backslash() {
|
||||
let escaped = escape_ref_path_segment("A\\B");
|
||||
assert_eq!(escaped.as_ref(), "A\\\\B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_name_with_both() {
|
||||
let escaped = escape_ref_path_segment("A/B\\C");
|
||||
assert_eq!(escaped.as_ref(), "A\\/B\\\\C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_plain_segment() {
|
||||
assert_eq!(unescape_ref_path_segment("Part1"), Cow::Borrowed("Part1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_escaped_slash() {
|
||||
assert_eq!(unescape_ref_path_segment("A\\/B").as_ref(), "A/B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_escaped_backslash() {
|
||||
assert_eq!(unescape_ref_path_segment("A\\\\B").as_ref(), "A\\B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_unescape_round_trip() {
|
||||
let original = "A/B\\C/D";
|
||||
let escaped = escape_ref_path_segment(original);
|
||||
let unescaped = unescape_ref_path_segment(&escaped);
|
||||
assert_eq!(unescaped.as_ref(), original);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// split_ref_path tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn split_simple_path() {
|
||||
let segments = split_ref_path("Workspace/Model/Part");
|
||||
assert_eq!(segments.len(), 3);
|
||||
assert_eq!(segments[0], "Workspace");
|
||||
assert_eq!(segments[1], "Model");
|
||||
assert_eq!(segments[2], "Part");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_path_with_escaped_slash() {
|
||||
let segments = split_ref_path("Workspace/A\\/B/Part");
|
||||
assert_eq!(segments.len(), 3);
|
||||
assert_eq!(segments[0], "Workspace");
|
||||
assert_eq!(segments[1], "A/B"); // unescaped
|
||||
assert_eq!(segments[2], "Part");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_empty_path() {
|
||||
let segments = split_ref_path("");
|
||||
assert!(segments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_single_segment() {
|
||||
let segments = split_ref_path("Workspace");
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0], "Workspace");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ref_target_path with "/" in instance name
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ref_target_path_name_with_slash() {
|
||||
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let ws = dom.insert(dom.root_ref(), InstanceBuilder::new("Workspace"));
|
||||
let child = dom.insert(ws, InstanceBuilder::new("A/B"));
|
||||
let path = ref_target_path(&dom, child);
|
||||
assert_eq!(path, "Workspace/A\\/B");
|
||||
|
||||
// Verify round-trip: split_ref_path should produce the correct segments
|
||||
let segments = split_ref_path(&path);
|
||||
assert_eq!(segments.len(), 2);
|
||||
assert_eq!(segments[0], "Workspace");
|
||||
assert_eq!(segments[1], "A/B");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ref_target_attribute_name tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ref_target_attr_name_value() {
|
||||
assert_eq!(ref_target_attribute_name("Value"), "Rojo_Target_Value");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue