test fixtures

This commit is contained in:
Joe 2026-02-14 11:04:28 -06:00
parent bf65d8bfa8
commit bbd9c0e0fa
22 changed files with 2107 additions and 0 deletions

View file

@ -0,0 +1,222 @@
---
name: Two-way Sync Refs
overview: Add Ref property support to two-way sync, plus immediate ID assignment for newly added instances so they can be referenced by Refs right away. Plugin sends ephemeral server-side Ref IDs; server resolves them to Rojo_Ref_* path-based attributes on disk. No Rojo_Target_* or Rojo_Id used.
todos:
- id: plugin-filter
content: Remove `Ref` from UNENCODABLE_DATA_TYPES in propertyFilter.lua
status: completed
- id: plugin-encode
content: Add Ref encoding logic to encodePatchUpdate.lua using InstanceMap lookup, with deferred-ref support for unresolved targets
status: completed
- id: plugin-pass-map
content: Pass instanceMap from createPatchSet.lua to encodePatchUpdate(), return deferred properties for retry
status: completed
- id: plugin-defer
content: Update ChangeBatcher flush to re-queue deferred Ref properties for next batch
status: completed
- id: plugin-preinsert
content: Pre-insert new instances into InstanceMap with temp GUID during pull, so Ref encoding works immediately
status: cancelled
- id: shared-ref-helper
content: Extract shared helper for computing Rojo_Ref_* attribute from a Ref (path computation + attribute name formatting) into rojo_ref.rs or similar, usable by both ref_properties.rs and api.rs
status: completed
- id: server-ref-attrs
content: Handle Ref->Rojo_Ref_* attribute conversion in syncback_updated_properties() using the shared helper
status: completed
- id: server-merge-remove
content: Add attribute removal support to merge_or_build_meta()
status: completed
- id: server-honor-ids
content: Honor plugin-provided Ref IDs for added instances -- insert into RojoTree immediately with suppress
status: cancelled
isProject: false
---
# Two-Way Sync Ref Property Support
## Current Problem
Ref properties are blocked at two layers:
1. **Plugin** -- `Ref` is in `UNENCODABLE_DATA_TYPES` ([propertyFilter.lua](plugin/src/ChangeBatcher/propertyFilter.lua)), so `encodePatchUpdate.lua` skips them entirely.
2. **Server** -- `filter_properties_for_meta()` in [api.rs](src/web/api.rs) line 2288-2291 skips `Variant::Ref(_)`.
Additionally, newly added instances (via "pull") do not get stable IDs that the plugin can use for Ref encoding. The server ignores the plugin-generated temp GUID and lets the VFS watcher assign a different ID asynchronously.
## Design Principles
- **Plugin sends ephemeral IDs, server resolves to paths.** The plugin encodes Ref properties using server-side Ref IDs from the InstanceMap. The server maps these IDs to instance paths and writes `Rojo_Ref_*` attributes to disk.
- **Only `Rojo_Ref_*` on disk.** No `Rojo_Target_*` or `Rojo_Id` system. Path-based references only.
- **Immediate ID assignment for new instances.** When the plugin pulls a new instance, it gets a GUID that the server honors as the tree Ref ID. The plugin pre-inserts this into the InstanceMap so Ref encoding works without waiting for the VFS round-trip.
## Existing Infrastructure to Leverage
- [ref_properties.rs](src/syncback/ref_properties.rs) -- CLI syncback's path computation and `Rojo_Ref_*` attribute writing
- [patch_compute.rs](src/snapshot/patch_compute.rs) `compute_ref_properties()` -- reads `Rojo_Ref_*` attributes and resolves to `Variant::Ref` during forward-sync
- `WeakDom::full_path_of(ref, "/")` -- computes slash-separated instance path (root excluded)
- `RojoTree::get_instance_by_path()` in [tree.rs](src/snapshot/tree.rs) -- resolves path back to Ref
- [rojo_ref.rs](src/rojo_ref.rs) -- `REF_PATH_ATTRIBUTE_PREFIX = "Rojo_Ref_"`
## Data Flow
### Ref property change on existing instances
```mermaid
sequenceDiagram
participant Studio
participant Plugin
participant Server
participant FS as Filesystem
Studio->>Plugin: Instance.Changed fires for Ref property
Plugin->>Plugin: Read property value (Instance or nil)
Plugin->>Plugin: Look up target in InstanceMap -> server Ref ID
Plugin->>Server: POST /api/write with Variant::Ref(server_id)
Server->>Server: Compute target path via full_path_of()
Server->>FS: Write Rojo_Ref_PropName attribute to meta/model file
Server->>Server: Apply Ref to in-memory RojoTree
Note over Server,FS: suppress_path prevents VFS echo
```
### New instance addition with immediate ID
```mermaid
sequenceDiagram
participant User
participant Plugin
participant Server
participant Tree as RojoTree
User->>Plugin: Pulls new instance via confirmation dialog
Plugin->>Plugin: Generate GUID, pre-insert into InstanceMap
Plugin->>Server: POST /api/write, added["guid"] = instance data
Server->>Server: Write files to disk, suppress paths
Server->>Tree: Insert instance using plugin's GUID as Ref ID
Note over Plugin: Future Ref changes can reference this GUID immediately
```
## Changes
### Part A: Ref Encoding for Existing Instances
#### 1. Plugin: Remove Ref from filter
**File:** [propertyFilter.lua](plugin/src/ChangeBatcher/propertyFilter.lua)
Remove `Ref = true` from `UNENCODABLE_DATA_TYPES`. Only `UniqueId = true` remains.
#### 2. Plugin: Encode Ref properties with InstanceMap
**File:** [encodePatchUpdate.lua](plugin/src/ChangeBatcher/encodePatchUpdate.lua)
- Accept `instanceMap` as a new parameter
- When `descriptor.dataType == "Ref"`:
- Read the property value from the Studio instance (returns a Roblox Instance or nil)
- If nil: encode as `{ Ref = "00000000000000000000000000000000" }` (null ref)
- If Instance: look up `instanceMap.fromInstances[value]` to get the server Ref ID string, encode as `{ Ref = serverIdHex }`
- If Instance not in InstanceMap: mark property as **deferred** (return it separately so the ChangeBatcher can retry next batch)
- For non-Ref properties: existing `encodeProperty()` path unchanged
#### 3. Plugin: Pass InstanceMap and handle deferred Refs
**File:** [createPatchSet.lua](plugin/src/ChangeBatcher/createPatchSet.lua)
- Pass `instanceMap` to `encodePatchUpdate()`
- `encodePatchUpdate` returns both the update AND any deferred property names
- `createPatchSet` collects deferred properties and returns them alongside the patch
**File:** [ChangeBatcher/init.lua](plugin/src/ChangeBatcher/init.lua)
- In `__flush()`, receive deferred properties from `createPatchSet`
- Re-add deferred properties to `__pendingPropertyChanges` so they're retried in the next batch cycle
- This handles the case where a Ref target hasn't appeared in the InstanceMap yet (e.g., new instance not yet synced)
### Part B: Immediate ID Assignment for New Instances
#### 4. Plugin: Pre-insert new instances into InstanceMap
**File:** [ServeSession.lua](plugin/src/ServeSession.lua) (around line 770-784)
Currently the plugin generates a GUID for pulled instances but does NOT insert them into the InstanceMap:
```lua
local guid = HttpService:GenerateGUID(false)
local tempRef = string.gsub(guid, "-", ""):lower()
pullPatch.added[tempRef] = encoded
```
The `GenerateGUID` approach is kept as-is (Option D) -- it already produces a valid 32-char hex `Ref` with no conversion cost and no server-side changes needed. Stability across multiple pulls is not required since these are ephemeral session-scoped IDs.
**Change:** After generating the GUID, immediately insert the Studio instance into the InstanceMap:
```lua
self.__instanceMap:insert(tempRef, instance)
```
This makes the GUID available for Ref encoding immediately. When the server later broadcasts the applied patch (with the same GUID as the instance ID), the reconciler will find the existing InstanceMap entry and reuse the Studio instance rather than creating a duplicate.
#### 5. Server: Honor plugin-provided Ref IDs for additions
**File:** [api.rs](src/web/api.rs) in `handle_api_write()`
Currently the server ignores the `added` map keys (plugin-generated GUIDs) and sends an empty `added_instances` in the PatchSet:
```rust
added_instances: Vec::new(), // line 647
```
**Changes:**
- After writing files via `syncback_added_instance`, also insert the instance into the RojoTree directly, using the plugin's GUID (the map key) as the Ref ID
- Build an `InstanceSnapshot` from the `AddedInstance` data (name, className, properties)
- Set `InstigatingSource::Path` to the written file path
- Suppress the file paths for the VFS watcher (prevents duplicate detection)
- Include the instance in the PatchSet's `added_instances` so it's applied to the tree and broadcast
This ensures the GUID is the canonical ID for the instance in the tree, matching what the plugin pre-inserted into its InstanceMap.
#### 6. Server: Process additions before updates
**File:** [api.rs](src/web/api.rs) in `handle_api_write()`
Ensure additions are fully processed (files written + tree insertion) **before** processing updates. This allows Ref properties in the `updated` list to reference instances from the `added` list within the same write request.
### Part C: Server-Side Ref-to-Path Resolution
#### 6.5. Shared helper: Ref-to-attribute conversion
**File:** [rojo_ref.rs](src/rojo_ref.rs) or a new helper module
Extract the core logic of "given a Ref target and a property name, produce the `Rojo_Ref_*` attribute name and path value" into a shared function. This avoids duplicating the pattern between [ref_properties.rs](src/syncback/ref_properties.rs) (CLI syncback bulk mode) and [api.rs](src/web/api.rs) (two-way sync per-instance mode). Both call sites compute `format!("{REF_PATH_ATTRIBUTE_PREFIX}{}", prop_name)` and resolve the target path -- this should live in one place.
Also clean up the now-dead `Variant::Ref(_)` skip in `filter_properties_for_meta()` (line 2289) -- after this change, Ref properties are extracted before reaching that function, so the skip is unreachable in the two-way sync path. Remove it and add a comment explaining that Ref properties are handled upstream.
#### 7. Server: Convert Ref properties to Rojo_Ref_* attributes
**File:** [api.rs](src/web/api.rs) in `syncback_updated_properties()` (line 2490+)
Before calling `filter_properties_for_meta`:
- Extract `Variant::Ref` properties from the update's `changed_properties`
- For each Ref property, use the shared helper:
- If ref is null (`Ref::none()`): mark `Rojo_Ref_PropertyName` for removal from the meta file
- If ref is valid: compute target path via `tree.inner().full_path_of(target_ref, "/")`, add `Rojo_Ref_PropertyName = path` to the attributes map
- If target not found in tree: log warning and skip (still apply Ref to in-memory tree so session works)
- Remove the Ref entries from the props map before passing to `filter_properties_for_meta`
- Merge the computed `Rojo_Ref_*` entries into the `attributes` map returned by `filter_properties_for_meta`
#### 8. Server: Support attribute removal in merge_or_build_meta
**File:** [api.rs](src/web/api.rs) in `merge_or_build_meta()` (line 2659)
Add a `remove_attributes: &[String]` parameter. When merging into an existing file, remove any attributes whose keys are in this list from the `attributes` object. This handles nil Ref cleanup (removing a stale `Rojo_Ref_PrimaryPart` when PrimaryPart is set to nil).
## Edge Cases
- **Target not in tree:** Skip with warning. The deferred-ref mechanism on the plugin side will retry.
- **Target is nil:** Remove the `Rojo_Ref_*` attribute from the meta file via `remove_attributes`.
- **Ambiguous paths (duplicate-named siblings):** For this initial implementation, path-based refs are written as-is. If the path is ambiguous (duplicate-named siblings at any ancestor level), the Ref will still be written to the in-memory tree (so the current session works correctly), but the filesystem representation may resolve to the wrong sibling on rebuild. A follow-up will add proper disambiguation support. Log a warning when an ambiguous path is detected so it's visible.
- **VFS echo prevention:** `suppress_path()` prevents the VFS watcher from re-triggering on files we just wrote.
- **Reconciler receiving already-known instances:** When the applied patch includes an instance the plugin pre-inserted, `InstanceMap:insert()` calls `removeId`/`removeInstance` first, cleanly replacing the entry. The reconciler should detect the existing Studio instance and reuse it rather than creating a duplicate.
- **ProjectNode instances:** Already handled -- `syncback_updated_properties` returns early for `InstigatingSource::ProjectNode` (line 2523-2531).

View file

@ -71,4 +71,117 @@ return function()
expect(next(changes)).to.equal(nil) expect(next(changes)).to.equal(nil)
end) end)
-- -------------------------------------------------------------------
-- Ref property in patch creation
-- -------------------------------------------------------------------
describe("Ref properties", function()
local container
beforeEach(function()
container = Instance.new("Folder")
container.Name = "RefPatchTestContainer"
container.Parent = game:GetService("Workspace")
end)
afterEach(function()
if container then
container:Destroy()
container = nil
end
end)
it("should include Ref property changes in patch", function()
local instanceMap = InstanceMap.new()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL", model)
instanceMap:insert("PART", part)
local changes = {
[model] = { PrimaryPart = true },
}
local patch = createPatchSet(instanceMap, changes)
expect(#patch.updated).to.equal(1)
expect(patch.updated[1].changedProperties.PrimaryPart).to.be.ok()
expect(patch.updated[1].changedProperties.PrimaryPart.Ref).to.equal("PART")
end)
it("should skip Ref changes when target not tracked", function()
local instanceMap = InstanceMap.new()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL", model)
-- part NOT in instanceMap
local changes = {
[model] = { PrimaryPart = true },
}
local patch = createPatchSet(instanceMap, changes)
-- No encodable changes → empty updated
expect(#patch.updated).to.equal(0)
end)
it("should handle removal alongside Ref change on different instance", function()
local instanceMap = InstanceMap.new()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
local toRemove = Instance.new("Part")
toRemove.Parent = container
toRemove.Parent = nil -- Simulate deletion
instanceMap:insert("MODEL", model)
instanceMap:insert("PART", part)
instanceMap:insert("REMOVED", toRemove)
local changes = {
[model] = { PrimaryPart = true },
[toRemove] = { Parent = true },
}
local patch = createPatchSet(instanceMap, changes)
expect(#patch.removed).to.equal(1)
expect(#patch.updated).to.equal(1)
end)
it("should pass instanceMap in syncSourceOnly mode", function()
local instanceMap = InstanceMap.new()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL", model)
instanceMap:insert("PART", part)
local changes = {
[model] = { PrimaryPart = true },
}
-- In syncSourceOnly mode, only Source changes go through.
-- PrimaryPart is NOT Source, so it should be filtered out.
local patch = createPatchSet(instanceMap, changes, true)
expect(#patch.updated).to.equal(0)
end)
end)
end end

View file

@ -59,4 +59,198 @@ return function()
expect(color.Color3[2]).to.be.near(0.2, 0.01) expect(color.Color3[2]).to.be.near(0.2, 0.01)
expect(color.Color3[3]).to.be.near(0.9, 0.01) expect(color.Color3[3]).to.be.near(0.9, 0.01)
end) end)
-- -------------------------------------------------------------------
-- Ref property encoding tests
-- -------------------------------------------------------------------
local InstanceMap = require(script.Parent.Parent.InstanceMap)
describe("Ref property encoding", function()
local container
local instanceMap
beforeEach(function()
container = Instance.new("Folder")
container.Name = "RefTestContainer"
container.Parent = game:GetService("Workspace")
instanceMap = InstanceMap.new()
end)
afterEach(function()
instanceMap:stop()
if container then
container:Destroy()
container = nil
end
end)
it("should encode Ref property to tracked instance", function()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL_ID", model)
instanceMap:insert("PART_ID", part)
local update = encodePatchUpdate(model, "MODEL_ID", { PrimaryPart = true }, instanceMap)
expect(update).to.be.ok()
expect(update.changedProperties.PrimaryPart).to.be.ok()
expect(update.changedProperties.PrimaryPart.Ref).to.equal("PART_ID")
end)
it("should encode nil Ref as null ref", function()
local model = Instance.new("Model")
model.Parent = container
model.PrimaryPart = nil
instanceMap:insert("MODEL_ID", model)
local update = encodePatchUpdate(model, "MODEL_ID", { PrimaryPart = true }, instanceMap)
expect(update).to.be.ok()
expect(update.changedProperties.PrimaryPart).to.be.ok()
expect(update.changedProperties.PrimaryPart.Ref).to.equal("00000000000000000000000000000000")
end)
it("should skip Ref when target not in InstanceMap", function()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL_ID", model)
-- part is NOT inserted into instanceMap
local update = encodePatchUpdate(model, "MODEL_ID", { PrimaryPart = true }, instanceMap)
-- PrimaryPart should be skipped (not encoded)
if update then
expect(update.changedProperties.PrimaryPart).to.equal(nil)
end
end)
it("should encode Ref alongside non-Ref properties", function()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
model.Name = "TestModel"
instanceMap:insert("MODEL_ID", model)
instanceMap:insert("PART_ID", part)
local update = encodePatchUpdate(
model,
"MODEL_ID",
{ PrimaryPart = true, Name = true },
instanceMap
)
expect(update).to.be.ok()
expect(update.changedProperties.PrimaryPart).to.be.ok()
expect(update.changedProperties.PrimaryPart.Ref).to.equal("PART_ID")
expect(update.changedName).to.equal("TestModel")
end)
it("should return nil when only unresolvable Ref changes", function()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL_ID", model)
-- part not in map
local update = encodePatchUpdate(model, "MODEL_ID", { PrimaryPart = true }, instanceMap)
-- No encodable changes → nil
expect(update).to.equal(nil)
end)
it("should encode ObjectValue.Value Ref", function()
local objVal = Instance.new("ObjectValue")
objVal.Parent = container
local target = Instance.new("Part")
target.Parent = container
objVal.Value = target
instanceMap:insert("OBJVAL_ID", objVal)
instanceMap:insert("TARGET_ID", target)
local update = encodePatchUpdate(objVal, "OBJVAL_ID", { Value = true }, instanceMap)
expect(update).to.be.ok()
expect(update.changedProperties.Value).to.be.ok()
expect(update.changedProperties.Value.Ref).to.equal("TARGET_ID")
end)
it("should encode self-referencing Ref", function()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL_ID", model)
-- PrimaryPart points to part, which IS in the map
instanceMap:insert("PART_ID", part)
local update = encodePatchUpdate(model, "MODEL_ID", { PrimaryPart = true }, instanceMap)
expect(update).to.be.ok()
expect(update.changedProperties.PrimaryPart.Ref).to.equal("PART_ID")
end)
it("should warn when no InstanceMap provided for Ref property", function()
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
-- Pass nil for instanceMap
local update = encodePatchUpdate(model, "MODEL_ID", { PrimaryPart = true }, nil)
-- Should not crash; PrimaryPart is skipped
if update then
expect(update.changedProperties.PrimaryPart).to.equal(nil)
end
end)
it("should encode multiple Ref properties on same instance", function()
-- Use a Weld which has Part0 and Part1 ref properties
local weld = Instance.new("WeldConstraint")
weld.Parent = container
local partA = Instance.new("Part")
partA.Parent = container
local partB = Instance.new("Part")
partB.Parent = container
weld.Part0 = partA
weld.Part1 = partB
instanceMap:insert("WELD_ID", weld)
instanceMap:insert("PART_A", partA)
instanceMap:insert("PART_B", partB)
local update = encodePatchUpdate(
weld,
"WELD_ID",
{ Part0 = true, Part1 = true },
instanceMap
)
expect(update).to.be.ok()
expect(update.changedProperties.Part0).to.be.ok()
expect(update.changedProperties.Part0.Ref).to.equal("PART_A")
expect(update.changedProperties.Part1).to.be.ok()
expect(update.changedProperties.Part1.Ref).to.equal("PART_B")
end)
end)
end end

View file

@ -168,4 +168,125 @@ return function()
part:Destroy() part:Destroy()
end) end)
end) end)
-- -------------------------------------------------------------------
-- Ref property batching tests
-- -------------------------------------------------------------------
describe("Ref property batching", function()
local container
beforeEach(function()
container = Instance.new("Folder")
container.Name = "RefBatchTestContainer"
container.Parent = game:GetService("Workspace")
end)
afterEach(function()
if container then
container:Destroy()
container = nil
end
end)
it("should accumulate Ref property changes", function()
local instanceMap = InstanceMap.new()
local changeBatcher = ChangeBatcher.new(instanceMap, noop)
local model = Instance.new("Model")
model.Parent = container
instanceMap:insert("MODEL", model)
changeBatcher:add(model, "PrimaryPart")
local properties = changeBatcher.__pendingPropertyChanges[model]
expect(properties).to.be.ok()
expect(properties.PrimaryPart).to.equal(true)
changeBatcher:stop()
end)
it("should produce patch with Ref when target is tracked", function()
local instanceMap = InstanceMap.new()
local lastPatch = nil
local changeBatcher = ChangeBatcher.new(instanceMap, function(patch)
lastPatch = patch
end)
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL", model)
instanceMap:insert("PART", part)
changeBatcher.__pendingPropertyChanges[model] = { PrimaryPart = true }
changeBatcher:__cycle(1.0) -- Force flush
expect(lastPatch).to.be.ok()
expect(#lastPatch.updated).to.equal(1)
expect(lastPatch.updated[1].changedProperties.PrimaryPart).to.be.ok()
expect(lastPatch.updated[1].changedProperties.PrimaryPart.Ref).to.equal("PART")
changeBatcher:stop()
end)
it("should drop unresolvable Ref with warning", function()
local instanceMap = InstanceMap.new()
local lastPatch = nil
local changeBatcher = ChangeBatcher.new(instanceMap, function(patch)
lastPatch = patch
end)
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
instanceMap:insert("MODEL", model)
-- part NOT in instanceMap
changeBatcher.__pendingPropertyChanges[model] = { PrimaryPart = true }
changeBatcher:__cycle(1.0) -- Force flush
-- No encodable changes → no patch flushed
expect(lastPatch).to.equal(nil)
changeBatcher:stop()
end)
it("should batch Ref with other property changes", function()
local instanceMap = InstanceMap.new()
local lastPatch = nil
local changeBatcher = ChangeBatcher.new(instanceMap, function(patch)
lastPatch = patch
end)
local model = Instance.new("Model")
model.Parent = container
local part = Instance.new("Part")
part.Parent = model
model.PrimaryPart = part
model.Name = "TestModel"
instanceMap:insert("MODEL", model)
instanceMap:insert("PART", part)
changeBatcher.__pendingPropertyChanges[model] = {
PrimaryPart = true,
Name = true,
}
changeBatcher:__cycle(1.0) -- Force flush
expect(lastPatch).to.be.ok()
expect(#lastPatch.updated).to.equal(1)
expect(lastPatch.updated[1].changedProperties.PrimaryPart).to.be.ok()
expect(lastPatch.updated[1].changedName).to.equal("TestModel")
changeBatcher:stop()
end)
end)
end end

View file

@ -201,6 +201,51 @@ return function()
instanceMap:stop() instanceMap:stop()
end) end)
it("should decode ref after instance was inserted later", function()
local instanceMap = InstanceMap.new()
local part = Instance.new("Part")
part.Parent = container
local partId = generateId()
-- Try decoding BEFORE inserting → should fail
local success1, _ = decodeValue({ Ref = partId }, instanceMap)
expect(success1).to.equal(false)
-- Insert and try again → should succeed
instanceMap:insert(partId, part)
local success2, value2 = decodeValue({ Ref = partId }, instanceMap)
expect(success2).to.equal(true)
expect(value2).to.equal(part)
instanceMap:stop()
end)
it("should decode ref to different instance types", function()
local instanceMap = InstanceMap.new()
local folder = Instance.new("Folder")
folder.Parent = container
local folderId = generateId()
instanceMap:insert(folderId, folder)
local model = Instance.new("Model")
model.Parent = container
local modelId = generateId()
instanceMap:insert(modelId, model)
local s1, v1 = decodeValue({ Ref = folderId }, instanceMap)
expect(s1).to.equal(true)
expect(v1).to.equal(folder)
local s2, v2 = decodeValue({ Ref = modelId }, instanceMap)
expect(s2).to.equal(true)
expect(v2).to.equal(model)
instanceMap:stop()
end)
end) end)
describe("Vector3 decoding", function() describe("Vector3 decoding", function()

View file

@ -0,0 +1,10 @@
{
"name": "ref_forward_sync",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"$path": "src/Workspace"
}
}
}

View file

@ -0,0 +1,3 @@
{
"className": "Part"
}

View file

@ -0,0 +1,3 @@
{
"className": "Part"
}

View file

@ -0,0 +1,6 @@
{
"className": "Model",
"attributes": {
"Rojo_Ref_PrimaryPart": "Workspace/MyModel/Target"
}
}

View file

@ -0,0 +1,3 @@
{
"ignoreUnknownInstances": false
}

View file

@ -0,0 +1,10 @@
{
"name": "ref_two_way_sync",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"$path": "src/Workspace"
}
}
}

View file

@ -0,0 +1,3 @@
{
"className": "Part"
}

View file

@ -0,0 +1,3 @@
{
"className": "Part"
}

View file

@ -0,0 +1,3 @@
{
"className": "Model"
}

View file

@ -0,0 +1,3 @@
{
"className": "ObjectValue"
}

View file

@ -0,0 +1,2 @@
-- Test script for ref two-way sync tests
print("hello")

View file

@ -0,0 +1,3 @@
{
"ignoreUnknownInstances": false
}

View file

@ -50,3 +50,64 @@ impl fmt::Display for RojoRef {
write!(f, "{}", self.as_str()) write!(f, "{}", self.as_str())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use rbx_dom_weak::InstanceBuilder;
// -----------------------------------------------------------------------
// ref_attribute_name tests
// -----------------------------------------------------------------------
#[test]
fn ref_attr_name_primary_part() {
assert_eq!(ref_attribute_name("PrimaryPart"), "Rojo_Ref_PrimaryPart");
}
#[test]
fn ref_attr_name_value() {
assert_eq!(ref_attribute_name("Value"), "Rojo_Ref_Value");
}
#[test]
fn ref_attr_name_part0() {
assert_eq!(ref_attribute_name("Part0"), "Rojo_Ref_Part0");
}
#[test]
fn ref_attr_name_attachment0() {
assert_eq!(ref_attribute_name("Attachment0"), "Rojo_Ref_Attachment0");
}
#[test]
fn ref_attr_name_adornee() {
assert_eq!(ref_attribute_name("Adornee"), "Rojo_Ref_Adornee");
}
// -----------------------------------------------------------------------
// ref_target_path tests
// -----------------------------------------------------------------------
#[test]
fn ref_target_path_root_child() {
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let child = dom.insert(dom.root_ref(), InstanceBuilder::new("Workspace"));
assert_eq!(ref_target_path(&dom, child), "Workspace");
}
#[test]
fn ref_target_path_deeply_nested() {
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let a = dom.insert(dom.root_ref(), InstanceBuilder::new("A"));
let b = dom.insert(a, InstanceBuilder::new("B"));
let c = dom.insert(b, InstanceBuilder::new("C"));
assert_eq!(ref_target_path(&dom, c), "A/B/C");
}
#[test]
fn ref_target_path_root_is_empty() {
let dom = WeakDom::new(InstanceBuilder::new("DataModel"));
assert_eq!(ref_target_path(&dom, dom.root_ref()), "");
}
}

View file

@ -104,3 +104,225 @@ fn remove_property() {
fn empty_tree() -> RojoTree { fn empty_tree() -> RojoTree {
RojoTree::new(InstanceSnapshot::new().name("ROOT").class_name("ROOT")) RojoTree::new(InstanceSnapshot::new().name("ROOT").class_name("ROOT"))
} }
// ---------------------------------------------------------------------------
// Ref property patch application tests
// ---------------------------------------------------------------------------
use rbx_dom_weak::types::{Ref, Variant};
#[test]
fn apply_ref_property_update() {
let mut redactions = RedactionMap::default();
// Build a tree: ROOT > ChildA, ChildB
let mut tree = RojoTree::new(
InstanceSnapshot::new()
.name("ROOT")
.class_name("DataModel")
.children(vec![
InstanceSnapshot::new().name("ChildA").class_name("Part"),
InstanceSnapshot::new().name("ChildB").class_name("Model"),
]),
);
intern_tree(&tree, &mut redactions);
let root_id = tree.get_root_id();
let children: Vec<Ref> = tree
.get_instance(root_id)
.unwrap()
.children()
.to_vec();
let child_a_id = children[0];
let child_b_id = children[1];
// Apply a patch that sets a Ref property on ChildB pointing to ChildA
let patch_set = PatchSet {
updated_instances: vec![PatchUpdate {
id: child_b_id,
changed_name: None,
changed_class_name: None,
changed_properties: UstrMap::from_iter([(
ustr("PrimaryPart"),
Some(Variant::Ref(child_a_id)),
)]),
changed_metadata: None,
}],
..Default::default()
};
let applied = apply_patch_set(&mut tree, patch_set);
assert_eq!(applied.updated.len(), 1);
// Verify the Ref property was set
let child_b = tree.get_instance(child_b_id).unwrap();
let pp = child_b.properties().get(&ustr("PrimaryPart"));
assert!(
matches!(pp, Some(Variant::Ref(r)) if *r == child_a_id),
"PrimaryPart should point to ChildA"
);
}
#[test]
fn apply_nil_ref_property_update() {
let mut redactions = RedactionMap::default();
let mut tree = RojoTree::new(
InstanceSnapshot::new()
.name("ROOT")
.class_name("DataModel")
.children(vec![InstanceSnapshot::new()
.name("Model")
.class_name("Model")]),
);
intern_tree(&tree, &mut redactions);
let root_id = tree.get_root_id();
let model_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
// First set PrimaryPart to something
{
let mut model = tree.get_instance_mut(model_id).unwrap();
model
.properties_mut()
.insert(ustr("PrimaryPart"), Variant::Ref(root_id));
}
// Now apply a patch that sets PrimaryPart to nil (Ref::none())
let patch_set = PatchSet {
updated_instances: vec![PatchUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: UstrMap::from_iter([(
ustr("PrimaryPart"),
Some(Variant::Ref(Ref::none())),
)]),
changed_metadata: None,
}],
..Default::default()
};
let applied = apply_patch_set(&mut tree, patch_set);
assert_eq!(applied.updated.len(), 1);
let model = tree.get_instance(model_id).unwrap();
let pp = model.properties().get(&ustr("PrimaryPart"));
assert!(
matches!(pp, Some(Variant::Ref(r)) if r.is_none()),
"PrimaryPart should be nil after applying null Ref"
);
}
#[test]
fn apply_multiple_ref_property_updates() {
let mut tree = RojoTree::new(
InstanceSnapshot::new()
.name("ROOT")
.class_name("DataModel")
.children(vec![
InstanceSnapshot::new().name("PartA").class_name("Part"),
InstanceSnapshot::new().name("PartB").class_name("Part"),
InstanceSnapshot::new().name("Model").class_name("Model"),
]),
);
let root_id = tree.get_root_id();
let children: Vec<Ref> = tree
.get_instance(root_id)
.unwrap()
.children()
.to_vec();
let part_a = children[0];
let part_b = children[1];
let model_id = children[2];
// Apply multiple Ref property updates in the same patch
let patch_set = PatchSet {
updated_instances: vec![
PatchUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: UstrMap::from_iter([(
ustr("PrimaryPart"),
Some(Variant::Ref(part_a)),
)]),
changed_metadata: None,
},
PatchUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: UstrMap::from_iter([(
ustr("CustomRef"),
Some(Variant::Ref(part_b)),
)]),
changed_metadata: None,
},
],
..Default::default()
};
let applied = apply_patch_set(&mut tree, patch_set);
assert_eq!(applied.updated.len(), 2);
let model = tree.get_instance(model_id).unwrap();
assert!(matches!(
model.properties().get(&ustr("PrimaryPart")),
Some(Variant::Ref(r)) if *r == part_a
));
assert!(matches!(
model.properties().get(&ustr("CustomRef")),
Some(Variant::Ref(r)) if *r == part_b
));
}
#[test]
fn apply_ref_to_nonexistent_instance() {
let mut tree = RojoTree::new(
InstanceSnapshot::new()
.name("ROOT")
.class_name("DataModel")
.children(vec![InstanceSnapshot::new()
.name("Model")
.class_name("Model")]),
);
let root_id = tree.get_root_id();
let model_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
// Create a Ref to an ID that doesn't exist in the tree
let fake_ref = Ref::new();
let patch_set = PatchSet {
updated_instances: vec![PatchUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: UstrMap::from_iter([(
ustr("PrimaryPart"),
Some(Variant::Ref(fake_ref)),
)]),
changed_metadata: None,
}],
..Default::default()
};
// Should not crash -- the Ref is set on the tree instance even if the
// target doesn't exist (dangling refs are allowed in WeakDom)
let applied = apply_patch_set(&mut tree, patch_set);
assert_eq!(applied.updated.len(), 1);
}

View file

@ -145,3 +145,361 @@ fn remove_child() {
fn empty_tree() -> RojoTree { fn empty_tree() -> RojoTree {
RojoTree::new(InstanceSnapshot::new().name("ROOT").class_name("ROOT")) RojoTree::new(InstanceSnapshot::new().name("ROOT").class_name("ROOT"))
} }
// ---------------------------------------------------------------------------
// Rojo_Ref_* attribute resolution tests
// ---------------------------------------------------------------------------
use rbx_dom_weak::types::{Attributes, Variant};
/// Helper: build a tree with ROOT > Workspace > Part (to support path resolution).
fn tree_with_workspace_part() -> RojoTree {
let snapshot = InstanceSnapshot::new()
.name("ROOT")
.class_name("DataModel")
.children(vec![InstanceSnapshot::new()
.name("Workspace")
.class_name("Workspace")
.children(vec![InstanceSnapshot::new()
.name("Part")
.class_name("Part")])]);
RojoTree::new(snapshot)
}
/// Helper: build a tree with ROOT > Workspace > Model > Target + OtherPart.
fn tree_with_model_and_parts() -> RojoTree {
let snapshot = InstanceSnapshot::new()
.name("ROOT")
.class_name("DataModel")
.children(vec![InstanceSnapshot::new()
.name("Workspace")
.class_name("Workspace")
.children(vec![InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.children(vec![
InstanceSnapshot::new()
.name("Target")
.class_name("Part"),
InstanceSnapshot::new()
.name("OtherPart")
.class_name("Part"),
])])]);
RojoTree::new(snapshot)
}
#[test]
fn ref_attr_resolves_primary_part() {
let tree = tree_with_model_and_parts();
// Find the Model instance in the tree
let root_id = tree.get_root_id();
let workspace_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let model_id = tree
.get_instance(workspace_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
// Create a snapshot of Model with Rojo_Ref_PrimaryPart attribute
let mut attrs = Attributes::new();
attrs.insert(
"Rojo_Ref_PrimaryPart".into(),
Variant::String("Workspace/Model/Target".into()),
);
let snapshot = InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.properties(UstrMap::from_iter([(
ustr("Attributes"),
Variant::Attributes(attrs),
)]))
.children(vec![
InstanceSnapshot::new().name("Target").class_name("Part"),
InstanceSnapshot::new()
.name("OtherPart")
.class_name("Part"),
]);
let patch_set = compute_patch_set(Some(snapshot), &tree, model_id);
// The patch should contain an update with PrimaryPart as a Variant::Ref
let has_primary_part_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties.get(&ustr("PrimaryPart")).map_or(false, |v| {
matches!(v, Some(Variant::Ref(r)) if r.is_some())
})
});
assert!(
has_primary_part_ref,
"Rojo_Ref_PrimaryPart should resolve to a Variant::Ref in the patch"
);
}
#[test]
fn ref_attr_resolves_value() {
let tree = tree_with_workspace_part();
let root_id = tree.get_root_id();
let workspace_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
// Add an ObjectValue child to Workspace in the tree
let mut tree = tree;
let objval_id = tree.insert_instance(
workspace_id,
InstanceSnapshot::new()
.name("MyRef")
.class_name("ObjectValue"),
);
// Create snapshot with Rojo_Ref_Value pointing to Workspace/Part
let mut attrs = Attributes::new();
attrs.insert(
"Rojo_Ref_Value".into(),
Variant::String("Workspace/Part".into()),
);
let snapshot = InstanceSnapshot::new()
.name("MyRef")
.class_name("ObjectValue")
.properties(UstrMap::from_iter([(
ustr("Attributes"),
Variant::Attributes(attrs),
)]));
let patch_set = compute_patch_set(Some(snapshot), &tree, objval_id);
let has_value_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties
.get(&ustr("Value"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
});
assert!(
has_value_ref,
"Rojo_Ref_Value should resolve to a Variant::Ref in the patch"
);
}
#[test]
fn ref_attr_multiple_on_same_instance() {
let tree = tree_with_model_and_parts();
let root_id = tree.get_root_id();
let workspace_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let model_id = tree
.get_instance(workspace_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
// Multiple Rojo_Ref_* attributes
let mut attrs = Attributes::new();
attrs.insert(
"Rojo_Ref_PrimaryPart".into(),
Variant::String("Workspace/Model/Target".into()),
);
attrs.insert(
"Rojo_Ref_CustomRef".into(),
Variant::String("Workspace/Model/OtherPart".into()),
);
let snapshot = InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.properties(UstrMap::from_iter([(
ustr("Attributes"),
Variant::Attributes(attrs),
)]))
.children(vec![
InstanceSnapshot::new().name("Target").class_name("Part"),
InstanceSnapshot::new()
.name("OtherPart")
.class_name("Part"),
]);
let patch_set = compute_patch_set(Some(snapshot), &tree, model_id);
let update = patch_set
.updated_instances
.iter()
.find(|u| u.id == model_id);
assert!(update.is_some(), "Should have an update for the Model");
let update = update.unwrap();
let has_pp = update
.changed_properties
.get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some()));
let has_custom = update
.changed_properties
.get(&ustr("CustomRef"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some()));
assert!(has_pp, "PrimaryPart ref should resolve");
assert!(has_custom, "CustomRef ref should resolve");
}
#[test]
fn ref_attr_nonexistent_path_resolves_to_none() {
let tree = tree_with_workspace_part();
let root_id = tree.get_root_id();
let workspace_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let mut tree = tree;
let model_id = tree.insert_instance(
workspace_id,
InstanceSnapshot::new().name("Model").class_name("Model"),
);
let mut attrs = Attributes::new();
attrs.insert(
"Rojo_Ref_PrimaryPart".into(),
Variant::String("Workspace/DoesNotExist".into()),
);
let snapshot = InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.properties(UstrMap::from_iter([(
ustr("Attributes"),
Variant::Attributes(attrs),
)]));
let patch_set = compute_patch_set(Some(snapshot), &tree, model_id);
// Non-existent path should resolve to None (nil Ref)
let has_nil_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties
.get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_none()) || v.is_none())
});
assert!(
has_nil_ref || patch_set.updated_instances.is_empty(),
"Non-existent path should not produce a valid Ref"
);
}
#[test]
fn ref_attr_with_regular_attributes_mixed() {
let tree = tree_with_model_and_parts();
let root_id = tree.get_root_id();
let workspace_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let model_id = tree
.get_instance(workspace_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
// Mix of Rojo_Ref_* and regular attributes
let mut attrs = Attributes::new();
attrs.insert(
"Rojo_Ref_PrimaryPart".into(),
Variant::String("Workspace/Model/Target".into()),
);
attrs.insert("CustomAttribute".into(), Variant::String("hello".into()));
attrs.insert("NumberAttr".into(), Variant::Float64(42.0));
let snapshot = InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.properties(UstrMap::from_iter([(
ustr("Attributes"),
Variant::Attributes(attrs),
)]))
.children(vec![
InstanceSnapshot::new().name("Target").class_name("Part"),
InstanceSnapshot::new()
.name("OtherPart")
.class_name("Part"),
]);
let patch_set = compute_patch_set(Some(snapshot), &tree, model_id);
// Should have PrimaryPart as Ref AND the regular attributes should
// remain in the Attributes property (not be extracted as Ref properties).
let has_pp = patch_set.updated_instances.iter().any(|u| {
u.changed_properties
.get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(_))))
});
assert!(has_pp, "PrimaryPart should be resolved from Rojo_Ref_*");
}
#[test]
fn ref_attr_empty_path_resolves_to_root() {
let tree = tree_with_workspace_part();
let root_id = tree.get_root_id();
let workspace_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let mut tree = tree;
let model_id = tree.insert_instance(
workspace_id,
InstanceSnapshot::new().name("Model").class_name("Model"),
);
let mut attrs = Attributes::new();
attrs.insert(
"Rojo_Ref_PrimaryPart".into(),
Variant::String("".into()),
);
let snapshot = InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.properties(UstrMap::from_iter([(
ustr("Attributes"),
Variant::Attributes(attrs),
)]));
let patch_set = compute_patch_set(Some(snapshot), &tree, model_id);
// Empty path should resolve to root (RojoTree.get_instance_by_path("") returns root)
let has_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties
.get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if *r == root_id))
});
assert!(has_ref, "Empty path should resolve to root instance");
}

View file

@ -803,3 +803,196 @@ fn api_write_existing_instance() {
); );
}); });
} }
// ===========================================================================
// Rojo_Ref_* path-based forward-sync tests
//
// These tests validate that Rojo_Ref_* attributes in meta/model files
// correctly resolve to Variant::Ref properties during forward sync
// (filesystem → server → plugin).
// ===========================================================================
/// Initial load: Model with Rojo_Ref_PrimaryPart attribute should have
/// PrimaryPart resolved as a Variant::Ref in the read response.
#[test]
fn ref_path_initial_load() {
run_serve_test("ref_forward_sync", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let read_response = session.get_api_read(info.root_instance_id).unwrap();
let instances = &read_response.instances;
// Find MyModel and check it has a PrimaryPart Ref property
let model = instances
.values()
.find(|inst| inst.name == "MyModel")
.expect("MyModel should exist in read response");
let has_primary_part = model
.properties
.iter()
.any(|(name, _)| name == "PrimaryPart");
assert!(
has_primary_part,
"MyModel should have PrimaryPart property resolved from Rojo_Ref_PrimaryPart"
);
});
}
/// Add a Rojo_Ref_PrimaryPart attribute to an existing meta file.
/// The forward-sync patch should include a PrimaryPart Ref update.
#[test]
fn ref_path_add_attribute_to_existing_meta() {
run_serve_test("ref_forward_sync", |session, mut redactions| {
let _info = session.get_api_rojo().unwrap();
// Modify MyModel's meta to change PrimaryPart to OtherPart
let meta_path = session
.path()
.join("src/Workspace/MyModel/init.meta.json5");
let socket_packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || {
fs::write(
&meta_path,
r#"{
"className": "Model",
"attributes": {
"Rojo_Ref_PrimaryPart": "Workspace/MyModel/OtherPart"
}
}"#,
)
.unwrap();
})
.unwrap();
// Verify we got a patch -- the PrimaryPart should be updated
let redacted = socket_packet.intern_and_redact(&mut redactions, ());
assert_yaml_snapshot!("ref_path_change_target_patch", redacted);
});
}
/// Remove a Rojo_Ref_PrimaryPart attribute from a meta file.
/// The forward-sync patch should update PrimaryPart to nil.
#[test]
fn ref_path_remove_attribute() {
run_serve_test("ref_forward_sync", |session, mut redactions| {
let _info = session.get_api_rojo().unwrap();
let meta_path = session
.path()
.join("src/Workspace/MyModel/init.meta.json5");
let socket_packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || {
// Remove the Rojo_Ref_PrimaryPart attribute
fs::write(
&meta_path,
r#"{
"className": "Model",
"attributes": {}
}"#,
)
.unwrap();
})
.unwrap();
let redacted = socket_packet.intern_and_redact(&mut redactions, ());
assert_yaml_snapshot!("ref_path_remove_attr_patch", redacted);
});
}
/// Add a new model file with a Rojo_Ref_Value attribute.
/// The forward-sync patch should include the new instance with Value resolved.
#[test]
fn ref_path_new_file_with_ref_attr() {
run_serve_test("ref_forward_sync", |session, mut redactions| {
let _info = session.get_api_rojo().unwrap();
let new_file = session
.path()
.join("src/Workspace/MyModel/RefValue.model.json5");
let socket_packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || {
fs::write(
&new_file,
r#"{
"className": "ObjectValue",
"attributes": {
"Rojo_Ref_Value": "Workspace/MyModel/Target"
}
}"#,
)
.unwrap();
})
.unwrap();
let redacted = socket_packet.intern_and_redact(&mut redactions, ());
assert_yaml_snapshot!("ref_path_new_file_patch", redacted);
});
}
/// Rojo_Ref_* pointing to a non-existent path should not crash and
/// the property should not be set.
#[test]
fn ref_path_nonexistent_target_no_crash() {
run_serve_test("ref_forward_sync", |session, mut redactions| {
let _info = session.get_api_rojo().unwrap();
let meta_path = session
.path()
.join("src/Workspace/MyModel/init.meta.json5");
let socket_packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || {
fs::write(
&meta_path,
r#"{
"className": "Model",
"attributes": {
"Rojo_Ref_PrimaryPart": "Workspace/NonExistent/Part"
}
}"#,
)
.unwrap();
})
.unwrap();
// Should receive a patch without crashing.
// The PrimaryPart might be nil or missing (not a resolved Ref).
let redacted = socket_packet.intern_and_redact(&mut redactions, ());
assert_yaml_snapshot!("ref_path_nonexistent_patch", redacted);
});
}
/// Multiple Rojo_Ref_* attributes on the same instance should all resolve.
#[test]
fn ref_path_multiple_attributes() {
run_serve_test("ref_forward_sync", |session, mut redactions| {
let _info = session.get_api_rojo().unwrap();
let meta_path = session
.path()
.join("src/Workspace/MyModel/init.meta.json5");
let socket_packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || {
fs::write(
&meta_path,
r#"{
"className": "Model",
"attributes": {
"Rojo_Ref_PrimaryPart": "Workspace/MyModel/Target",
"Rojo_Ref_CustomRef": "Workspace/MyModel/OtherPart"
}
}"#,
)
.unwrap();
})
.unwrap();
let redacted = socket_packet.intern_and_redact(&mut redactions, ());
assert_yaml_snapshot!("ref_path_multiple_attrs_patch", redacted);
});
}

View file

@ -5977,3 +5977,529 @@ fn fuzz_filesystem_chaos() {
session.assert_tree_fresh(); session.assert_tree_fresh();
}); });
} }
// ===========================================================================
// Ref property two-way sync tests
//
// These tests validate the full /api/write pipeline for Variant::Ref
// properties. The server should convert Ref properties to Rojo_Ref_*
// path-based attributes in meta/model files.
// ===========================================================================
/// Helper: get session info and find instances by name in the ref_two_way_sync fixture.
fn ref_test_setup(
session: &crate::rojo_test::serve_util::TestServeSession,
) -> (
librojo::SessionId,
Ref, // workspace_id
Ref, // model_id
Ref, // part1_id
Ref, // part2_id
Ref, // objval_id
) {
let info = session.get_api_rojo().unwrap();
let root_read = session.get_api_read(info.root_instance_id).unwrap();
let (workspace_id, _) = find_by_name(&root_read.instances, "Workspace");
let ws_read = session.get_api_read(workspace_id).unwrap();
let (model_id, _) = find_by_name(&ws_read.instances, "TestModel");
let model_read = session.get_api_read(model_id).unwrap();
let (part1_id, _) = find_by_name(&model_read.instances, "Part1");
let (part2_id, _) = find_by_name(&model_read.instances, "Part2");
let (objval_id, _) = find_by_name(&ws_read.instances, "TestObjectValue");
(info.session_id, workspace_id, model_id, part1_id, part2_id, objval_id)
}
/// Read a JSON5 file and return its parsed value.
fn read_json5_file(path: &Path) -> serde_json::Value {
let content = fs::read_to_string(path).unwrap_or_else(|e| {
panic!("Failed to read {}: {}", path.display(), e);
});
json5::from_str(&content).unwrap_or_else(|e| {
panic!(
"Failed to parse JSON5 at {}: {}\nContent: {}",
path.display(),
e,
content
);
})
}
/// Check if a JSON5 file's attributes contain a specific Rojo_Ref_* key with expected value.
fn assert_meta_has_ref_attr(path: &Path, attr_name: &str, expected_path: &str) {
let val = read_json5_file(path);
let attr_val = val
.get("attributes")
.and_then(|a| a.get(attr_name))
.and_then(|v| v.as_str());
assert_eq!(
attr_val,
Some(expected_path),
"File {} should have attributes.{} = {:?}, got {:?}",
path.display(),
attr_name,
expected_path,
attr_val,
);
}
/// Check that a JSON5 file does NOT have a specific attribute key.
fn assert_meta_no_ref_attr(path: &Path, attr_name: &str) {
if !path.exists() {
return; // File doesn't exist means no attribute, which is correct.
}
let val = read_json5_file(path);
let has_attr = val
.get("attributes")
.and_then(|a| a.get(attr_name))
.is_some();
assert!(
!has_attr,
"File {} should NOT have attributes.{}",
path.display(),
attr_name,
);
}
/// Poll until a JSON5 file exists and has the expected Rojo_Ref_* attribute.
fn poll_meta_has_ref_attr(path: &Path, attr_name: &str, expected_path: &str) {
let start = Instant::now();
loop {
if path.exists() {
if let Ok(content) = fs::read_to_string(path) {
if let Ok(val) = json5::from_str::<serde_json::Value>(&content) {
if let Some(attr_val) = val
.get("attributes")
.and_then(|a| a.get(attr_name))
.and_then(|v| v.as_str())
{
if attr_val == expected_path {
return;
}
}
}
}
}
if start.elapsed() > Duration::from_millis(API_POLL_TIMEOUT_MS) {
// Final check with detailed error
assert_meta_has_ref_attr(path, attr_name, expected_path);
return;
}
thread::sleep(Duration::from_millis(50));
}
}
// ---------------------------------------------------------------------------
// Basic Ref write operations
// ---------------------------------------------------------------------------
#[test]
fn ref_set_primary_part() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, part1_id, _, _) = ref_test_setup(&session);
let mut props = UstrMap::default();
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
let meta_path = session
.path()
.join("src/Workspace/TestModel/init.meta.json5");
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_PrimaryPart",
"Workspace/TestModel/Part1",
);
});
}
#[test]
fn ref_set_object_value() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, _, part1_id, _, objval_id) = ref_test_setup(&session);
let mut props = UstrMap::default();
props.insert(ustr("Value"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: objval_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
let meta_path = session
.path()
.join("src/Workspace/TestObjectValue.meta.json5");
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_Value",
"Workspace/TestModel/Part1",
);
});
}
// ---------------------------------------------------------------------------
// Nil Ref operations
// ---------------------------------------------------------------------------
#[test]
fn ref_set_primary_part_to_nil() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, part1_id, _, _) = ref_test_setup(&session);
// First set PrimaryPart to Part1
let mut props = UstrMap::default();
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
let meta_path = session
.path()
.join("src/Workspace/TestModel/init.meta.json5");
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_PrimaryPart",
"Workspace/TestModel/Part1",
);
// Now set PrimaryPart to nil
let mut props2 = UstrMap::default();
props2.insert(ustr("PrimaryPart"), Some(Variant::Ref(Ref::none())));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props2,
changed_metadata: None,
});
// Wait for processing then verify attribute removed
thread::sleep(Duration::from_millis(300));
assert_meta_no_ref_attr(&meta_path, "Rojo_Ref_PrimaryPart");
});
}
#[test]
fn ref_nil_when_no_prior_attr() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, _, _, _) = ref_test_setup(&session);
// Set PrimaryPart to nil without having set it before
let mut props = UstrMap::default();
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(Ref::none())));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
// Should not crash; meta file may or may not exist
thread::sleep(Duration::from_millis(300));
let meta_path = session
.path()
.join("src/Workspace/TestModel/init.meta.json5");
assert_meta_no_ref_attr(&meta_path, "Rojo_Ref_PrimaryPart");
});
}
// ---------------------------------------------------------------------------
// Ref target changes
// ---------------------------------------------------------------------------
#[test]
fn ref_change_target() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, part1_id, part2_id, _) = ref_test_setup(&session);
// Set PrimaryPart to Part1
let mut props1 = UstrMap::default();
props1.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props1,
changed_metadata: None,
});
let meta_path = session
.path()
.join("src/Workspace/TestModel/init.meta.json5");
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_PrimaryPart",
"Workspace/TestModel/Part1",
);
// Change PrimaryPart to Part2
let mut props2 = UstrMap::default();
props2.insert(ustr("PrimaryPart"), Some(Variant::Ref(part2_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props2,
changed_metadata: None,
});
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_PrimaryPart",
"Workspace/TestModel/Part2",
);
});
}
#[test]
fn ref_set_nil_then_set_again() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, part1_id, _, _) = ref_test_setup(&session);
let meta_path = session
.path()
.join("src/Workspace/TestModel/init.meta.json5");
// Set → nil → set again
let mut props1 = UstrMap::default();
props1.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props1,
changed_metadata: None,
});
poll_meta_has_ref_attr(&meta_path, "Rojo_Ref_PrimaryPart", "Workspace/TestModel/Part1");
let mut props2 = UstrMap::default();
props2.insert(ustr("PrimaryPart"), Some(Variant::Ref(Ref::none())));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props2,
changed_metadata: None,
});
thread::sleep(Duration::from_millis(300));
assert_meta_no_ref_attr(&meta_path, "Rojo_Ref_PrimaryPart");
let mut props3 = UstrMap::default();
props3.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props3,
changed_metadata: None,
});
poll_meta_has_ref_attr(&meta_path, "Rojo_Ref_PrimaryPart", "Workspace/TestModel/Part1");
});
}
// ---------------------------------------------------------------------------
// Ref with other property changes
// ---------------------------------------------------------------------------
#[test]
fn ref_with_name_change() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, part1_id, _, _) = ref_test_setup(&session);
let mut props = UstrMap::default();
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: Some("RenamedModel".to_string()),
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
// Both name change and ref change should be processed.
// The name change triggers a filesystem rename handled by ChangeProcessor.
// The ref change writes Rojo_Ref_PrimaryPart to the meta file.
// After rename, the meta file is at the new location.
thread::sleep(Duration::from_millis(500));
// Check the renamed directory has the ref attribute
let meta_path = session
.path()
.join("src/Workspace/RenamedModel/init.meta.json5");
if meta_path.exists() {
assert_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_PrimaryPart",
"Workspace/RenamedModel/Part1",
);
}
// Note: the exact path in the ref depends on when the rename is processed.
// This test verifies the combination doesn't crash.
});
}
#[test]
fn ref_only_change_creates_meta() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, part1_id, _, _) = ref_test_setup(&session);
// Sending ONLY a Ref property change (no Source, no Name, no other props)
let mut props = UstrMap::default();
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
let meta_path = session
.path()
.join("src/Workspace/TestModel/init.meta.json5");
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_PrimaryPart",
"Workspace/TestModel/Part1",
);
});
}
// ---------------------------------------------------------------------------
// Ref on different file formats
// ---------------------------------------------------------------------------
#[test]
fn ref_on_model_json5_instance() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, _, part1_id, _, objval_id) = ref_test_setup(&session);
let mut props = UstrMap::default();
props.insert(ustr("Value"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: objval_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
// ObjectValue is a .model.json5 — Ref attribute should be added
// to adjacent .meta.json5 (since model files get adjacent meta)
let meta_path = session
.path()
.join("src/Workspace/TestObjectValue.meta.json5");
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_Value",
"Workspace/TestModel/Part1",
);
});
}
#[test]
fn ref_existing_meta_preserved() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, part1_id, _, _) = ref_test_setup(&session);
// The Model's init.meta.json5 already has className: "Model".
// After adding a Ref attribute, className should still be present.
let mut props = UstrMap::default();
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
let meta_path = session
.path()
.join("src/Workspace/TestModel/init.meta.json5");
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_PrimaryPart",
"Workspace/TestModel/Part1",
);
// Verify existing content wasn't clobbered
let val = read_json5_file(&meta_path);
assert_eq!(
val.get("className").and_then(|v| v.as_str()),
Some("Model"),
"Existing className should be preserved after adding Ref attribute"
);
});
}
// ---------------------------------------------------------------------------
// Error cases
// ---------------------------------------------------------------------------
#[test]
fn ref_to_nonexistent_instance_no_crash() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, _, _, _) = ref_test_setup(&session);
// Use a random Ref that doesn't exist in the tree
let fake_ref = Ref::new();
let mut props = UstrMap::default();
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(fake_ref)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
// Should not crash. The Ref is logged as warning and skipped.
thread::sleep(Duration::from_millis(300));
});
}
#[test]
fn ref_mixed_valid_and_invalid() {
run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, part1_id, _, _) = ref_test_setup(&session);
// Send both a valid Ref and a regular property
let fake_ref = Ref::new();
let mut props = UstrMap::default();
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(part1_id)));
// Also try to set a non-existent ref property (should be ignored)
props.insert(ustr("SomeOtherRef"), Some(Variant::Ref(fake_ref)));
send_update(&session, &session_id, InstanceUpdate {
id: model_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
});
// The valid PrimaryPart Ref should still be written
let meta_path = session
.path()
.join("src/Workspace/TestModel/init.meta.json5");
poll_meta_has_ref_attr(
&meta_path,
"Rojo_Ref_PrimaryPart",
"Workspace/TestModel/Part1",
);
});
}