mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
test fixtures
This commit is contained in:
parent
bf65d8bfa8
commit
bbd9c0e0fa
22 changed files with 2107 additions and 0 deletions
222
.cursor/plans/two-way_sync_refs_6e154eef.plan.md
Normal file
222
.cursor/plans/two-way_sync_refs_6e154eef.plan.md
Normal 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).
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
10
rojo-test/serve-tests/ref_forward_sync/default.project.json5
Normal file
10
rojo-test/serve-tests/ref_forward_sync/default.project.json5
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "ref_forward_sync",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"Workspace": {
|
||||||
|
"$className": "Workspace",
|
||||||
|
"$path": "src/Workspace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"className": "Part"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"className": "Part"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"className": "Model",
|
||||||
|
"attributes": {
|
||||||
|
"Rojo_Ref_PrimaryPart": "Workspace/MyModel/Target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"ignoreUnknownInstances": false
|
||||||
|
}
|
||||||
10
rojo-test/serve-tests/ref_two_way_sync/default.project.json5
Normal file
10
rojo-test/serve-tests/ref_two_way_sync/default.project.json5
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "ref_two_way_sync",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"Workspace": {
|
||||||
|
"$className": "Workspace",
|
||||||
|
"$path": "src/Workspace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"className": "Part"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"className": "Part"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"className": "Model"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"className": "ObjectValue"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Test script for ref two-way sync tests
|
||||||
|
print("hello")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"ignoreUnknownInstances": false
|
||||||
|
}
|
||||||
|
|
@ -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()), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue