mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Update reflection database and enhance deduplication processes
- Updated the `rbx-dom` subproject to the latest commit for improved functionality. - Enhanced the `/audit` command documentation to emphasize the importance of entering Plan mode before executing any actions. - Improved the `atlas-dedup.mdc` file by adding details on user-initiated renames and ensuring ref path integrity during deduplication. - Cleared out deprecated material color entries in `allValues.json` for better clarity. - Updated `database.json` to reflect new properties and classes, including `AssetQualityService` and `BaseCoreGuiConfiguration`. - Introduced new scripts for updating the reflection database, streamlining the process for generating necessary files. These changes improve the overall functionality and maintainability of the project, ensuring better handling of deduplication and reflection updates.
This commit is contained in:
parent
85787f3452
commit
0afe6be441
12 changed files with 3220 additions and 283 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
# /audit - Production-Grade Sync Feature Audit
|
# /audit - Production-Grade Sync Feature Audit
|
||||||
|
|
||||||
|
**IMMEDIATE ACTION:** Before reading any files, running any commands, or performing any analysis, you MUST call `SwitchMode` with `target_mode_id: "plan"`. Do not proceed with any other step until you are in Plan mode. This is non-negotiable.
|
||||||
|
|
||||||
**Mode:** This command runs in **Plan mode** (read-only). It does NOT directly apply code changes. The deliverable is a `.cursor/plans/*.plan.md` file containing every approved fix, ready to be executed in a subsequent Agent-mode session.
|
**Mode:** This command runs in **Plan mode** (read-only). It does NOT directly apply code changes. The deliverable is a `.cursor/plans/*.plan.md` file containing every approved fix, ready to be executed in a subsequent Agent-mode session.
|
||||||
|
|
||||||
**Workflow:** Analyze -> Report -> Quiz user on each fix -> Write plan file.
|
**Workflow:** Switch to Plan mode -> Analyze -> Report -> Quiz user on each fix -> Write plan file.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,15 @@ After successful dedup rename (e.g., `Foo~1` → `Foo`):
|
||||||
5. Updates the index: both path keys and filesystem paths
|
5. Updates the index: both path keys and filesystem paths
|
||||||
6. Suppresses VFS events for each file write
|
6. Suppresses VFS events for each file write
|
||||||
|
|
||||||
|
### Ref Path Updates After User-Initiated Rename
|
||||||
|
|
||||||
|
User-initiated renames (via the plugin's two-way sync) also update `Rojo_Ref_*` paths. The flow:
|
||||||
|
1. `old_ref_path` is captured BEFORE the rename (line ~1268, `ref_target_path_from_tree`)
|
||||||
|
2. Rename handler sets `new_ref_segment` to the new filesystem name (directory rename line ~1350, file rename line ~1512)
|
||||||
|
3. After rename handling, `update_ref_paths_after_rename(old_ref_path, new_ref_path)` is called (line ~1812) to update all meta/model files containing refs to the renamed instance
|
||||||
|
|
||||||
|
This ensures both dedup-triggered and user-initiated renames maintain ref path integrity.
|
||||||
|
|
||||||
### Metadata Consistency
|
### Metadata Consistency
|
||||||
|
|
||||||
After dedup rename, the survivor's metadata must be updated:
|
After dedup rename, the survivor's metadata must be updated:
|
||||||
|
|
@ -487,9 +496,9 @@ The greedy assignment may produce suboptimal pairing in rare ambiguous groups. A
|
||||||
|
|
||||||
Phase 2e from the original plan: optional cache mapping `parent_ref → (children_list_hash, match_assignments)` to avoid re-running matching when children haven't changed. Optimization only, not needed for correctness.
|
Phase 2e from the original plan: optional cache mapping `parent_ref → (children_list_hash, match_assignments)` to avoid re-running matching when children haven't changed. Optimization only, not needed for correctness.
|
||||||
|
|
||||||
### Stale Ref Paths After Rename
|
### ~~Stale Ref Paths After Rename~~ (Resolved)
|
||||||
|
|
||||||
When a Ref target is renamed, the stored `Rojo_Ref_*` path becomes stale. During two-way sync, `update_ref_paths_after_rename` handles this for dedup-triggered renames. However, user-initiated renames via the plugin do NOT automatically update ref paths in meta files. The path remains stale until the next full syncback. This is a known round-trip violation.
|
Both dedup-triggered renames and user-initiated renames now update `Rojo_Ref_*` paths automatically. See "Ref Path Updates" in the Integration Point 1 section for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,113 +261,7 @@
|
||||||
},
|
},
|
||||||
"MaterialColors": {
|
"MaterialColors": {
|
||||||
"value": {
|
"value": {
|
||||||
"MaterialColors": {
|
"MaterialColors": {}
|
||||||
"Grass": [
|
|
||||||
106,
|
|
||||||
127,
|
|
||||||
63
|
|
||||||
],
|
|
||||||
"Slate": [
|
|
||||||
63,
|
|
||||||
127,
|
|
||||||
107
|
|
||||||
],
|
|
||||||
"Concrete": [
|
|
||||||
127,
|
|
||||||
102,
|
|
||||||
63
|
|
||||||
],
|
|
||||||
"Brick": [
|
|
||||||
138,
|
|
||||||
86,
|
|
||||||
62
|
|
||||||
],
|
|
||||||
"Sand": [
|
|
||||||
143,
|
|
||||||
126,
|
|
||||||
95
|
|
||||||
],
|
|
||||||
"WoodPlanks": [
|
|
||||||
139,
|
|
||||||
109,
|
|
||||||
79
|
|
||||||
],
|
|
||||||
"Rock": [
|
|
||||||
102,
|
|
||||||
108,
|
|
||||||
111
|
|
||||||
],
|
|
||||||
"Glacier": [
|
|
||||||
101,
|
|
||||||
176,
|
|
||||||
234
|
|
||||||
],
|
|
||||||
"Snow": [
|
|
||||||
195,
|
|
||||||
199,
|
|
||||||
218
|
|
||||||
],
|
|
||||||
"Sandstone": [
|
|
||||||
137,
|
|
||||||
90,
|
|
||||||
71
|
|
||||||
],
|
|
||||||
"Mud": [
|
|
||||||
58,
|
|
||||||
46,
|
|
||||||
36
|
|
||||||
],
|
|
||||||
"Basalt": [
|
|
||||||
30,
|
|
||||||
30,
|
|
||||||
37
|
|
||||||
],
|
|
||||||
"Ground": [
|
|
||||||
102,
|
|
||||||
92,
|
|
||||||
59
|
|
||||||
],
|
|
||||||
"CrackedLava": [
|
|
||||||
232,
|
|
||||||
156,
|
|
||||||
74
|
|
||||||
],
|
|
||||||
"Asphalt": [
|
|
||||||
115,
|
|
||||||
123,
|
|
||||||
107
|
|
||||||
],
|
|
||||||
"Cobblestone": [
|
|
||||||
132,
|
|
||||||
123,
|
|
||||||
90
|
|
||||||
],
|
|
||||||
"Ice": [
|
|
||||||
129,
|
|
||||||
194,
|
|
||||||
224
|
|
||||||
],
|
|
||||||
"LeafyGrass": [
|
|
||||||
115,
|
|
||||||
132,
|
|
||||||
74
|
|
||||||
],
|
|
||||||
"Salt": [
|
|
||||||
198,
|
|
||||||
189,
|
|
||||||
181
|
|
||||||
],
|
|
||||||
"Limestone": [
|
|
||||||
206,
|
|
||||||
173,
|
|
||||||
148
|
|
||||||
],
|
|
||||||
"Pavement": [
|
|
||||||
148,
|
|
||||||
148,
|
|
||||||
140
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"ty": "MaterialColors"
|
"ty": "MaterialColors"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
2
rbx-dom
2
rbx-dom
|
|
@ -1 +1 @@
|
||||||
Subproject commit 3cd393d73a6ce81282b1999797026beeaa91d6f2
|
Subproject commit da64cdabab805c063b3d31b1b8a5dbe90373790c
|
||||||
44
scripts/update-reflection.ps1
Normal file
44
scripts/update-reflection.ps1
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Update Reflection Database
|
||||||
|
# Regenerates database.msgpack (Rust) and database.json (Lua) from Roblox Studio.
|
||||||
|
# Requires: Roblox Studio installed locally.
|
||||||
|
# Usage: .\scripts\update-reflection.ps1 [--dry-run]
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Push-Location rbx-dom
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host "Dry run: generating reflection database (no output files)" -ForegroundColor Yellow
|
||||||
|
cargo run --bin rbx_reflector -- generate --patches patches
|
||||||
|
} else {
|
||||||
|
Write-Host "Generating reflection database..." -ForegroundColor Cyan
|
||||||
|
cargo run --bin rbx_reflector -- generate --patches patches rbx_reflection_database/database.msgpack rbx_dom_lua/src/database.json
|
||||||
|
|
||||||
|
Write-Host "Generating allValues.json..." -ForegroundColor Cyan
|
||||||
|
cargo run --bin rbx_reflector -- values rbx_dom_lua/src/allValues.json
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Failed with exit code $LASTEXITCODE" -ForegroundColor Red
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Copying to plugin/rbx_dom_lua/..." -ForegroundColor Cyan
|
||||||
|
Copy-Item rbx_dom_lua/src/database.json ../plugin/rbx_dom_lua/database.json
|
||||||
|
Copy-Item rbx_dom_lua/src/allValues.json ../plugin/rbx_dom_lua/allValues.json
|
||||||
|
Copy-Item rbx_dom_lua/src/Error.lua ../plugin/rbx_dom_lua/Error.lua
|
||||||
|
Copy-Item rbx_dom_lua/src/base64.lua ../plugin/rbx_dom_lua/base64.lua
|
||||||
|
Copy-Item rbx_dom_lua/src/PropertyDescriptor.lua ../plugin/rbx_dom_lua/PropertyDescriptor.lua
|
||||||
|
Copy-Item rbx_dom_lua/src/customProperties.lua ../plugin/rbx_dom_lua/customProperties.lua
|
||||||
|
# NOTE: init.lua and EncodedValue.lua are intentionally different in the plugin
|
||||||
|
# (findClassDescriptor, Int64/msgpack handling, float serialization) — do NOT copy them.
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Done." -ForegroundColor Green
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
32
scripts/update-reflection.sh
Normal file
32
scripts/update-reflection.sh
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Update Reflection Database
|
||||||
|
# Regenerates database.msgpack (Rust) and database.json (Lua) from Roblox Studio.
|
||||||
|
# Requires: Roblox Studio installed locally.
|
||||||
|
# Usage: bash scripts/update-reflection.sh [--dry-run]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../rbx-dom"
|
||||||
|
|
||||||
|
if [ "$1" = "--dry-run" ]; then
|
||||||
|
echo "Dry run: generating reflection database (no output files)"
|
||||||
|
cargo run --bin rbx_reflector -- generate --patches patches
|
||||||
|
else
|
||||||
|
echo "Generating reflection database..."
|
||||||
|
cargo run --bin rbx_reflector -- generate --patches patches rbx_reflection_database/database.msgpack rbx_dom_lua/src/database.json
|
||||||
|
|
||||||
|
echo "Generating allValues.json..."
|
||||||
|
cargo run --bin rbx_reflector -- values rbx_dom_lua/src/allValues.json
|
||||||
|
|
||||||
|
echo "Copying to plugin/rbx_dom_lua/..."
|
||||||
|
cp rbx_dom_lua/src/database.json ../plugin/rbx_dom_lua/database.json
|
||||||
|
cp rbx_dom_lua/src/allValues.json ../plugin/rbx_dom_lua/allValues.json
|
||||||
|
cp rbx_dom_lua/src/Error.lua ../plugin/rbx_dom_lua/Error.lua
|
||||||
|
cp rbx_dom_lua/src/base64.lua ../plugin/rbx_dom_lua/base64.lua
|
||||||
|
cp rbx_dom_lua/src/PropertyDescriptor.lua ../plugin/rbx_dom_lua/PropertyDescriptor.lua
|
||||||
|
cp rbx_dom_lua/src/customProperties.lua ../plugin/rbx_dom_lua/customProperties.lua
|
||||||
|
# NOTE: init.lua and EncodedValue.lua are intentionally different in the plugin
|
||||||
|
# (findClassDescriptor, Int64/msgpack handling, float serialization) — do NOT copy them.
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
|
|
@ -1084,7 +1084,7 @@ impl JobThreadContext {
|
||||||
let mut dedup_metadata_updates: Vec<(Ref, PathBuf)> = Vec::new();
|
let mut dedup_metadata_updates: Vec<(Ref, PathBuf)> = Vec::new();
|
||||||
|
|
||||||
for &removed_id in &patch_set.removed_instances {
|
for &removed_id in &patch_set.removed_instances {
|
||||||
let (parent_ref, removed_fs_name) = {
|
let (parent_ref, removed_fs_name, removed_file_dir) = {
|
||||||
let Some(inst) = tree.get_instance(removed_id) else {
|
let Some(inst) = tree.get_instance(removed_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -1096,7 +1096,23 @@ impl JobThreadContext {
|
||||||
}
|
}
|
||||||
let parent = inst.parent();
|
let parent = inst.parent();
|
||||||
let fs_name = tree.filesystem_name_for(removed_id);
|
let fs_name = tree.filesystem_name_for(removed_id);
|
||||||
(parent, fs_name)
|
// Derive the directory containing this file from its own
|
||||||
|
// instigating source path. This works even when the parent
|
||||||
|
// is a ProjectNode (where instigating_source is not a Path).
|
||||||
|
let file_dir = match source {
|
||||||
|
InstigatingSource::Path(p) => {
|
||||||
|
let fname = p.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||||||
|
if fname.starts_with("init.") {
|
||||||
|
// Directory-format: path is dir/init.luau,
|
||||||
|
// the containing dir is the grandparent
|
||||||
|
p.parent().and_then(|d| d.parent()).map(|d| d.to_path_buf())
|
||||||
|
} else {
|
||||||
|
p.parent().map(|d| d.to_path_buf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
(parent, fs_name, file_dir)
|
||||||
};
|
};
|
||||||
|
|
||||||
if parent_ref.is_none() {
|
if parent_ref.is_none() {
|
||||||
|
|
@ -1157,15 +1173,11 @@ impl JobThreadContext {
|
||||||
|
|
||||||
let extension = removed_extension;
|
let extension = removed_extension;
|
||||||
|
|
||||||
// Get the parent directory path
|
// Get the parent directory path (derived from the removed
|
||||||
let parent_dir = {
|
// instance's own path, not the parent's instigating source,
|
||||||
let Some(parent) = tree.get_instance(parent_ref) else {
|
// because the parent may be a ProjectNode).
|
||||||
continue;
|
let Some(parent_dir) = removed_file_dir.clone() else {
|
||||||
};
|
continue;
|
||||||
match &parent.metadata().instigating_source {
|
|
||||||
Some(InstigatingSource::Path(p)) => p.clone(),
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let action = compute_cleanup_action(
|
let action = compute_cleanup_action(
|
||||||
|
|
@ -1196,6 +1208,33 @@ impl JobThreadContext {
|
||||||
);
|
);
|
||||||
self.unsuppress_path(&to);
|
self.unsuppress_path(&to);
|
||||||
} else {
|
} else {
|
||||||
|
// Also rename the adjacent meta file if it exists
|
||||||
|
// (standalone files only; directory-format instances
|
||||||
|
// have init.meta.json5 inside the directory which
|
||||||
|
// moves automatically with the dir rename).
|
||||||
|
if from.is_file() || !from.exists() {
|
||||||
|
if let (Some(from_parent), Some(from_name), Some(to_name)) = (
|
||||||
|
from.parent(),
|
||||||
|
from.file_stem().and_then(|s| s.to_str()),
|
||||||
|
to.file_stem().and_then(|s| s.to_str()),
|
||||||
|
) {
|
||||||
|
let from_base = strip_script_suffix(from_name);
|
||||||
|
let to_base = strip_script_suffix(to_name);
|
||||||
|
let old_meta =
|
||||||
|
from_parent.join(format!("{}.meta.json5", from_base));
|
||||||
|
if old_meta.exists() {
|
||||||
|
let new_meta =
|
||||||
|
from_parent.join(format!("{}.meta.json5", to_base));
|
||||||
|
self.suppress_path_any(&old_meta);
|
||||||
|
self.suppress_path(&new_meta);
|
||||||
|
if fs::rename(&old_meta, &new_meta).is_err() {
|
||||||
|
self.unsuppress_path_any(&old_meta);
|
||||||
|
self.unsuppress_path(&new_meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update ref paths if the renamed instance has refs
|
// Update ref paths if the renamed instance has refs
|
||||||
let old_ref_segment =
|
let old_ref_segment =
|
||||||
from.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
from.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||||||
|
|
|
||||||
|
|
@ -385,12 +385,22 @@ fn compute_change_count(
|
||||||
/// Count own property diffs between a snapshot and a tree instance.
|
/// Count own property diffs between a snapshot and a tree instance.
|
||||||
/// Each differing property = +1. Tags and Attributes counted granularly.
|
/// Each differing property = +1. Tags and Attributes counted granularly.
|
||||||
/// Children count diff = +1.
|
/// Children count diff = +1.
|
||||||
|
///
|
||||||
|
/// Syncback strips default-valued properties from model/meta files, so
|
||||||
|
/// filesystem snapshots may omit properties that the tree (populated from
|
||||||
|
/// Studio via two-way sync) has at their class defaults. To avoid inflating
|
||||||
|
/// match costs with phantom diffs, properties present on only one side are
|
||||||
|
/// skipped when their value matches the class default.
|
||||||
fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta) -> u32 {
|
fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta) -> u32 {
|
||||||
let mut cost: u32 = 0;
|
let mut cost: u32 = 0;
|
||||||
|
|
||||||
let snap_props = &snap.properties;
|
let snap_props = &snap.properties;
|
||||||
let inst_props = inst.properties();
|
let inst_props = inst.properties();
|
||||||
|
|
||||||
|
let class_data = rbx_reflection_database::get()
|
||||||
|
.ok()
|
||||||
|
.and_then(|db| db.classes.get(snap.class_name.as_str()));
|
||||||
|
|
||||||
// Properties present on the snapshot side
|
// Properties present on the snapshot side
|
||||||
for (key, snap_val) in snap_props.iter() {
|
for (key, snap_val) in snap_props.iter() {
|
||||||
if let Some(inst_val) = inst_props.get(key) {
|
if let Some(inst_val) = inst_props.get(key) {
|
||||||
|
|
@ -398,14 +408,24 @@ fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta) -> u32 {
|
||||||
cost += 1;
|
cost += 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cost += 1; // Missing on tree side
|
let is_default = class_data
|
||||||
|
.and_then(|cd| cd.default_properties.get(key.as_str()))
|
||||||
|
.is_some_and(|default| variant_eq(snap_val, default));
|
||||||
|
if !is_default {
|
||||||
|
cost += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Properties present only on the tree side
|
// Properties present only on the tree side
|
||||||
for key in inst_props.keys() {
|
for (key, inst_val) in inst_props.iter() {
|
||||||
if !snap_props.contains_key(key) {
|
if !snap_props.contains_key(key) {
|
||||||
cost += 1;
|
let is_default = class_data
|
||||||
|
.and_then(|cd| cd.default_properties.get(key.as_str()))
|
||||||
|
.is_some_and(|default| variant_eq(inst_val, default));
|
||||||
|
if !is_default {
|
||||||
|
cost += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ fn compute_children_patches(
|
||||||
let instance_children = instance.children().to_vec();
|
let instance_children = instance.children().to_vec();
|
||||||
let snapshot_children = take(&mut snapshot.children);
|
let snapshot_children = take(&mut snapshot.children);
|
||||||
|
|
||||||
// Use the 3-pass matching algorithm instead of greedy name+class.
|
// Use the recursive change-count scoring + greedy assignment matching algorithm.
|
||||||
let match_result = match_forward(snapshot_children, &instance_children, tree);
|
let match_result = match_forward(snapshot_children, &instance_children, tree);
|
||||||
|
|
||||||
// Matched pairs: recursively compute patches.
|
// Matched pairs: recursively compute patches.
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,9 @@ fn compute_change_count(
|
||||||
/// Count own property diffs between two WeakDom instances (flat, non-recursive).
|
/// Count own property diffs between two WeakDom instances (flat, non-recursive).
|
||||||
/// Each differing property = +1. Children count diff = +1.
|
/// Each differing property = +1. Children count diff = +1.
|
||||||
/// Does NOT include hash fast-path (handled by `compute_change_count`).
|
/// Does NOT include hash fast-path (handled by `compute_change_count`).
|
||||||
|
///
|
||||||
|
/// Properties present on only one side are skipped when their value matches
|
||||||
|
/// the class default (see `src/snapshot/matching.rs` for full rationale).
|
||||||
fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &WeakDom) -> u32 {
|
fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &WeakDom) -> u32 {
|
||||||
let new_inst = match new_dom.get_by_ref(new_ref) {
|
let new_inst = match new_dom.get_by_ref(new_ref) {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
|
|
@ -420,19 +423,33 @@ fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &Weak
|
||||||
|
|
||||||
let mut cost: u32 = 0;
|
let mut cost: u32 = 0;
|
||||||
|
|
||||||
|
let class_data = rbx_reflection_database::get()
|
||||||
|
.ok()
|
||||||
|
.and_then(|db| db.classes.get(new_inst.class.as_str()));
|
||||||
|
|
||||||
for (key, new_val) in new_inst.properties.iter() {
|
for (key, new_val) in new_inst.properties.iter() {
|
||||||
if let Some(old_val) = old_inst.properties.get(key) {
|
if let Some(old_val) = old_inst.properties.get(key) {
|
||||||
if !variant_eq(new_val, old_val) {
|
if !variant_eq(new_val, old_val) {
|
||||||
cost += 1;
|
cost += 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cost += 1;
|
let is_default = class_data
|
||||||
|
.and_then(|cd| cd.default_properties.get(key.as_str()))
|
||||||
|
.is_some_and(|default| variant_eq(new_val, default));
|
||||||
|
if !is_default {
|
||||||
|
cost += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for key in old_inst.properties.keys() {
|
for (key, old_val) in old_inst.properties.iter() {
|
||||||
if !new_inst.properties.contains_key(key) {
|
if !new_inst.properties.contains_key(key) {
|
||||||
cost += 1;
|
let is_default = class_data
|
||||||
|
.and_then(|cd| cd.default_properties.get(key.as_str()))
|
||||||
|
.is_some_and(|default| variant_eq(old_val, default));
|
||||||
|
if !is_default {
|
||||||
|
cost += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7377,3 +7377,796 @@ fn ref_same_batch_add_folder_target_no_extension() {
|
||||||
poll_meta_has_ref_attr(&meta_path, "Rojo_Ref_PrimaryPart", "Workspace/FolderTarget");
|
poll_meta_has_ref_attr(&meta_path, "Rojo_Ref_PrimaryPart", "Workspace/FolderTarget");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests: Dedup suffix cleanup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Add two colliding ModuleScripts (creates X_Y.luau + X_Y~1.luau), then
|
||||||
|
/// delete one. The remaining instance should lose its dedup suffix (group-to-1
|
||||||
|
/// cleanup rule).
|
||||||
|
#[test]
|
||||||
|
fn delete_deduped_instance_group_to_1_cleanup() {
|
||||||
|
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||||
|
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||||
|
|
||||||
|
let mut props1 = HashMap::new();
|
||||||
|
props1.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String("-- survivor".to_string()),
|
||||||
|
);
|
||||||
|
let mut props2 = HashMap::new();
|
||||||
|
props2.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String("-- doomed".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut added_map = HashMap::new();
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "X/Y".to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: props1,
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "X:Y".to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: props2,
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let write_request = WriteRequest {
|
||||||
|
session_id: info.session_id,
|
||||||
|
removed: vec![],
|
||||||
|
added: added_map,
|
||||||
|
updated: vec![],
|
||||||
|
};
|
||||||
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
let src = session.path().join("src");
|
||||||
|
let base = src.join("X_Y.luau");
|
||||||
|
let deduped = src.join("X_Y~1.luau");
|
||||||
|
|
||||||
|
poll_file_exists(&base, "X_Y.luau after add");
|
||||||
|
poll_file_exists(&deduped, "X_Y~1.luau after add");
|
||||||
|
|
||||||
|
// Wait for VFS to settle
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Re-read the tree to find instance IDs
|
||||||
|
let rs_read = session.get_api_read(rs_id).unwrap();
|
||||||
|
|
||||||
|
// Read the base file to figure out which instance maps to it
|
||||||
|
let base_source = fs::read_to_string(&base).unwrap();
|
||||||
|
let doomed_name = if base_source.contains("-- doomed") {
|
||||||
|
// Base file has the doomed source -- find by that name
|
||||||
|
// The meta file tells us the real name
|
||||||
|
let base_meta = src.join("X_Y.meta.json5");
|
||||||
|
let meta_content = fs::read_to_string(&base_meta).unwrap();
|
||||||
|
if meta_content.contains("\"X/Y\"") {
|
||||||
|
"X/Y"
|
||||||
|
} else {
|
||||||
|
"X:Y"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Base file has the survivor source -- the other one is doomed
|
||||||
|
let dedup_meta = src.join("X_Y~1.meta.json5");
|
||||||
|
let meta_content = fs::read_to_string(&dedup_meta).unwrap();
|
||||||
|
if meta_content.contains("\"X/Y\"") {
|
||||||
|
"X/Y"
|
||||||
|
} else {
|
||||||
|
"X:Y"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (doomed_id, _) = find_by_name(&rs_read.instances, doomed_name);
|
||||||
|
send_removal(&session, &info.session_id, vec![doomed_id]);
|
||||||
|
|
||||||
|
// After group-to-1 cleanup: only X_Y.luau should remain, no ~1 suffix
|
||||||
|
poll_not_exists(&deduped, "X_Y~1.luau after group-to-1 cleanup");
|
||||||
|
assert_file_exists(&base, "X_Y.luau should survive");
|
||||||
|
|
||||||
|
// The survivor's meta should still have a name field (since it's slugified)
|
||||||
|
let meta = src.join("X_Y.meta.json5");
|
||||||
|
assert_file_exists(&meta, "Meta for survivor");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add three colliding ModuleScripts, then delete the base instance.
|
||||||
|
/// The ~1 instance should be promoted to base (base-name promotion rule).
|
||||||
|
#[test]
|
||||||
|
fn delete_base_deduped_instance_promotes_lowest() {
|
||||||
|
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||||
|
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||||
|
|
||||||
|
let names = ["X/Y", "X:Y", "X|Y"];
|
||||||
|
let mut added_map = HashMap::new();
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
let mut props = HashMap::new();
|
||||||
|
props.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String(format!("-- inst {}", i + 1)),
|
||||||
|
);
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: name.to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: props,
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let write_request = WriteRequest {
|
||||||
|
session_id: info.session_id,
|
||||||
|
removed: vec![],
|
||||||
|
added: added_map,
|
||||||
|
updated: vec![],
|
||||||
|
};
|
||||||
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
let src = session.path().join("src");
|
||||||
|
let base = src.join("X_Y.luau");
|
||||||
|
let dedup1 = src.join("X_Y~1.luau");
|
||||||
|
let dedup2 = src.join("X_Y~2.luau");
|
||||||
|
|
||||||
|
poll_file_exists(&base, "X_Y.luau after add");
|
||||||
|
poll_file_exists(&dedup1, "X_Y~1.luau after add");
|
||||||
|
poll_file_exists(&dedup2, "X_Y~2.luau after add");
|
||||||
|
|
||||||
|
// Wait for VFS to settle
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Re-read tree and identify which instance maps to the base file
|
||||||
|
let rs_read = session.get_api_read(rs_id).unwrap();
|
||||||
|
let base_meta = src.join("X_Y.meta.json5");
|
||||||
|
let base_meta_content = fs::read_to_string(&base_meta).unwrap();
|
||||||
|
|
||||||
|
// Extract the real name from the base meta file
|
||||||
|
let base_inst_name = if base_meta_content.contains("\"X/Y\"") {
|
||||||
|
"X/Y"
|
||||||
|
} else if base_meta_content.contains("\"X:Y\"") {
|
||||||
|
"X:Y"
|
||||||
|
} else {
|
||||||
|
"X|Y"
|
||||||
|
};
|
||||||
|
|
||||||
|
let (base_id, _) = find_by_name(&rs_read.instances, base_inst_name);
|
||||||
|
|
||||||
|
// Delete the base instance
|
||||||
|
send_removal(&session, &info.session_id, vec![base_id]);
|
||||||
|
|
||||||
|
// After base-name promotion: ~1 should be promoted to base
|
||||||
|
// X_Y.luau should exist (promoted from ~1)
|
||||||
|
// X_Y~2.luau should still exist (unchanged)
|
||||||
|
// X_Y~1.luau should NOT exist (promoted away)
|
||||||
|
poll_not_exists(&dedup1, "X_Y~1.luau after promotion");
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
assert_file_exists(&base, "X_Y.luau should exist (promoted)");
|
||||||
|
assert_file_exists(&dedup2, "X_Y~2.luau should remain");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add three colliding ModuleScripts, then delete the ~1 instance.
|
||||||
|
/// Gap should be tolerated: X_Y.luau and X_Y~2.luau remain unchanged.
|
||||||
|
#[test]
|
||||||
|
fn delete_middle_deduped_instance_tolerates_gap() {
|
||||||
|
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||||
|
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||||
|
|
||||||
|
let names = ["X/Y", "X:Y", "X|Y"];
|
||||||
|
let mut added_map = HashMap::new();
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
let mut props = HashMap::new();
|
||||||
|
props.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String(format!("-- inst {}", i + 1)),
|
||||||
|
);
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: name.to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: props,
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let write_request = WriteRequest {
|
||||||
|
session_id: info.session_id,
|
||||||
|
removed: vec![],
|
||||||
|
added: added_map,
|
||||||
|
updated: vec![],
|
||||||
|
};
|
||||||
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
let src = session.path().join("src");
|
||||||
|
let base = src.join("X_Y.luau");
|
||||||
|
let dedup1 = src.join("X_Y~1.luau");
|
||||||
|
let dedup2 = src.join("X_Y~2.luau");
|
||||||
|
|
||||||
|
poll_file_exists(&base, "X_Y.luau after add");
|
||||||
|
poll_file_exists(&dedup1, "X_Y~1.luau after add");
|
||||||
|
poll_file_exists(&dedup2, "X_Y~2.luau after add");
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Find which instance maps to ~1
|
||||||
|
let rs_read = session.get_api_read(rs_id).unwrap();
|
||||||
|
let dedup1_meta = src.join("X_Y~1.meta.json5");
|
||||||
|
let dedup1_meta_content = fs::read_to_string(&dedup1_meta).unwrap();
|
||||||
|
let middle_name = if dedup1_meta_content.contains("\"X/Y\"") {
|
||||||
|
"X/Y"
|
||||||
|
} else if dedup1_meta_content.contains("\"X:Y\"") {
|
||||||
|
"X:Y"
|
||||||
|
} else {
|
||||||
|
"X|Y"
|
||||||
|
};
|
||||||
|
|
||||||
|
let (middle_id, _) = find_by_name(&rs_read.instances, middle_name);
|
||||||
|
|
||||||
|
// Delete the ~1 instance
|
||||||
|
send_removal(&session, &info.session_id, vec![middle_id]);
|
||||||
|
|
||||||
|
// Gap tolerance: base and ~2 remain, ~1 is gone
|
||||||
|
poll_not_exists(&dedup1, "X_Y~1.luau after deletion");
|
||||||
|
// Also verify the ~1 meta is gone
|
||||||
|
poll_not_exists(&dedup1_meta, "X_Y~1.meta.json5 after deletion");
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
assert_file_exists(&base, "X_Y.luau should remain");
|
||||||
|
assert_file_exists(&dedup2, "X_Y~2.luau should remain (gap tolerated)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests: Folder dedup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Add two Folders with the same name. Both should become directories with
|
||||||
|
/// dedup suffixes, each containing their respective children.
|
||||||
|
#[test]
|
||||||
|
fn add_same_named_folders_deduplicates() {
|
||||||
|
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||||
|
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||||
|
|
||||||
|
// Add two Folders named "Data", each with a distinct child
|
||||||
|
let child1 = AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "ChildA".to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: {
|
||||||
|
let mut p = HashMap::new();
|
||||||
|
p.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String("-- child A".to_string()),
|
||||||
|
);
|
||||||
|
p
|
||||||
|
},
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
let child2 = AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "ChildB".to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: {
|
||||||
|
let mut p = HashMap::new();
|
||||||
|
p.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String("-- child B".to_string()),
|
||||||
|
);
|
||||||
|
p
|
||||||
|
},
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut added_map = HashMap::new();
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "Data".to_string(),
|
||||||
|
class_name: "Folder".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![child1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "Data".to_string(),
|
||||||
|
class_name: "Folder".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![child2],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let write_request = WriteRequest {
|
||||||
|
session_id: info.session_id,
|
||||||
|
removed: vec![],
|
||||||
|
added: added_map,
|
||||||
|
updated: vec![],
|
||||||
|
};
|
||||||
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
let src = session.path().join("src");
|
||||||
|
let data_dir = src.join("Data");
|
||||||
|
let data_dedup = src.join("Data~1");
|
||||||
|
|
||||||
|
// Both directories should exist
|
||||||
|
assert!(
|
||||||
|
data_dir.is_dir(),
|
||||||
|
"Data/ directory should exist at {}",
|
||||||
|
data_dir.display()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
data_dedup.is_dir(),
|
||||||
|
"Data~1/ directory should exist at {}",
|
||||||
|
data_dedup.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The dedup'd directory should have a meta with name: "Data"
|
||||||
|
let dedup_meta = data_dedup.join("init.meta.json5");
|
||||||
|
assert!(
|
||||||
|
dedup_meta.exists(),
|
||||||
|
"Data~1/init.meta.json5 should exist for name override"
|
||||||
|
);
|
||||||
|
let meta_content = fs::read_to_string(&dedup_meta).unwrap();
|
||||||
|
assert!(
|
||||||
|
meta_content.contains("\"Data\""),
|
||||||
|
"Dedup meta should contain name \"Data\", got: {}",
|
||||||
|
meta_content
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each directory should have its own child script
|
||||||
|
let has_child_a =
|
||||||
|
data_dir.join("ChildA.luau").exists() || data_dedup.join("ChildA.luau").exists();
|
||||||
|
let has_child_b =
|
||||||
|
data_dir.join("ChildB.luau").exists() || data_dedup.join("ChildB.luau").exists();
|
||||||
|
assert!(has_child_a, "ChildA.luau should exist in one of the dirs");
|
||||||
|
assert!(has_child_b, "ChildB.luau should exist in one of the dirs");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests: Ref paths through dedup'd instances
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Add a Model with children that have slug-colliding names, and set a Ref
|
||||||
|
/// property (PrimaryPart) to one of them. Verify the Rojo_Ref_* attribute
|
||||||
|
/// path includes the dedup suffix.
|
||||||
|
#[test]
|
||||||
|
fn ref_through_deduped_instance() {
|
||||||
|
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||||
|
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||||
|
|
||||||
|
// Add a Model "Container" with two Part children that slug-collide
|
||||||
|
let target_guid = Ref::new();
|
||||||
|
let child_target = AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "X/Y".to_string(),
|
||||||
|
class_name: "Part".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
let child_other = AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "X:Y".to_string(),
|
||||||
|
class_name: "Part".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut model_props = HashMap::new();
|
||||||
|
model_props.insert("PrimaryPart".to_string(), Variant::Ref(target_guid));
|
||||||
|
|
||||||
|
let mut added_map = HashMap::new();
|
||||||
|
added_map.insert(
|
||||||
|
target_guid,
|
||||||
|
AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "X/Y".to_string(),
|
||||||
|
class_name: "Part".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Clear and re-build: add Model with children and PrimaryPart ref
|
||||||
|
added_map.clear();
|
||||||
|
|
||||||
|
// Use Ref::new() for the child that is NOT the target
|
||||||
|
let container_guid = Ref::new();
|
||||||
|
added_map.insert(
|
||||||
|
container_guid,
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "Container".to_string(),
|
||||||
|
class_name: "Model".to_string(),
|
||||||
|
properties: model_props,
|
||||||
|
children: vec![child_target, child_other],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// The target_guid ref in PrimaryPart points to an instance added in
|
||||||
|
// the same batch — but since children use inline nesting (no separate
|
||||||
|
// Ref), the Ref property needs to point to a top-level added instance.
|
||||||
|
// For simplicity, add the target as a separate instance under Container.
|
||||||
|
added_map.clear();
|
||||||
|
|
||||||
|
// Simpler approach: add the Model first, then set PrimaryPart via update
|
||||||
|
let child1 = AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "X/Y".to_string(),
|
||||||
|
class_name: "Part".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
let child2 = AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "X:Y".to_string(),
|
||||||
|
class_name: "Part".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "Container".to_string(),
|
||||||
|
class_name: "Model".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![child1, child2],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let write_request = WriteRequest {
|
||||||
|
session_id: info.session_id,
|
||||||
|
removed: vec![],
|
||||||
|
added: added_map,
|
||||||
|
updated: vec![],
|
||||||
|
};
|
||||||
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Verify Container directory exists with dedup'd children
|
||||||
|
let src = session.path().join("src");
|
||||||
|
let container = src.join("Container");
|
||||||
|
assert!(container.is_dir(), "Container/ should be a directory");
|
||||||
|
|
||||||
|
// Wait for VFS and re-read tree
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
let rs_read = session.get_api_read(rs_id).unwrap();
|
||||||
|
let (container_id, _container_inst) = find_by_name(&rs_read.instances, "Container");
|
||||||
|
|
||||||
|
// Find one of the Part children to use as PrimaryPart target
|
||||||
|
let container_read = session.get_api_read(container_id).unwrap();
|
||||||
|
let (target_part_id, _) = find_by_name(&container_read.instances, "X/Y");
|
||||||
|
|
||||||
|
// Set PrimaryPart on the container model
|
||||||
|
let mut props = UstrMap::default();
|
||||||
|
props.insert(ustr("PrimaryPart"), Some(Variant::Ref(target_part_id)));
|
||||||
|
send_update(
|
||||||
|
&session,
|
||||||
|
&info.session_id,
|
||||||
|
InstanceUpdate {
|
||||||
|
id: container_id,
|
||||||
|
changed_name: None,
|
||||||
|
changed_class_name: None,
|
||||||
|
changed_properties: props,
|
||||||
|
changed_metadata: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Container's init.meta.json5 should have a Rojo_Ref_PrimaryPart
|
||||||
|
// attribute with a path that includes X_Y (base or ~1 suffix)
|
||||||
|
let init_meta = container.join("init.meta.json5");
|
||||||
|
poll_file_exists(&init_meta, "Container init.meta.json5 after ref set");
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
let meta_content = fs::read_to_string(&init_meta).unwrap();
|
||||||
|
assert!(
|
||||||
|
meta_content.contains("Rojo_Ref_PrimaryPart"),
|
||||||
|
"Meta should contain Rojo_Ref_PrimaryPart, got: {}",
|
||||||
|
meta_content
|
||||||
|
);
|
||||||
|
// The ref path should contain "X_Y" (the slugified child name)
|
||||||
|
assert!(
|
||||||
|
meta_content.contains("X_Y"),
|
||||||
|
"Ref path should contain slugified name X_Y, got: {}",
|
||||||
|
meta_content
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a dedup group where one instance is a ref target, then delete a
|
||||||
|
/// member to trigger cleanup rename. Verify the ref path in the pointing
|
||||||
|
/// meta file is updated.
|
||||||
|
#[test]
|
||||||
|
fn ref_path_updated_after_dedup_cleanup() {
|
||||||
|
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||||
|
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||||
|
|
||||||
|
// Step 1: Add a Model "Pointer" and two colliding ModuleScripts
|
||||||
|
let child_ptr = AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "Ptr".to_string(),
|
||||||
|
class_name: "ObjectValue".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
let mut added_map = HashMap::new();
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "Pointer".to_string(),
|
||||||
|
class_name: "Model".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![child_ptr],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut props1 = HashMap::new();
|
||||||
|
props1.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String("-- target".to_string()),
|
||||||
|
);
|
||||||
|
let mut props2 = HashMap::new();
|
||||||
|
props2.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String("-- other".to_string()),
|
||||||
|
);
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "X/Y".to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: props1,
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "X:Y".to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: props2,
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let write_request = WriteRequest {
|
||||||
|
session_id: info.session_id,
|
||||||
|
removed: vec![],
|
||||||
|
added: added_map,
|
||||||
|
updated: vec![],
|
||||||
|
};
|
||||||
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
let src = session.path().join("src");
|
||||||
|
let base = src.join("X_Y.luau");
|
||||||
|
let deduped = src.join("X_Y~1.luau");
|
||||||
|
|
||||||
|
poll_file_exists(&base, "X_Y.luau after add");
|
||||||
|
poll_file_exists(&deduped, "X_Y~1.luau after add");
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Step 2: Find the ~1 instance and point Pointer's child ObjectValue at it
|
||||||
|
let rs_read = session.get_api_read(rs_id).unwrap();
|
||||||
|
let dedup_meta_path = src.join("X_Y~1.meta.json5");
|
||||||
|
let dedup_meta_content = fs::read_to_string(&dedup_meta_path).unwrap();
|
||||||
|
let target_name = if dedup_meta_content.contains("\"X/Y\"") {
|
||||||
|
"X/Y"
|
||||||
|
} else {
|
||||||
|
"X:Y"
|
||||||
|
};
|
||||||
|
let other_name = if target_name == "X/Y" { "X:Y" } else { "X/Y" };
|
||||||
|
|
||||||
|
let (target_id, _) = find_by_name(&rs_read.instances, target_name);
|
||||||
|
let (other_id, _) = find_by_name(&rs_read.instances, other_name);
|
||||||
|
|
||||||
|
// Find the Pointer model and its ObjectValue child
|
||||||
|
let (pointer_id, _) = find_by_name(&rs_read.instances, "Pointer");
|
||||||
|
let pointer_read = session.get_api_read(pointer_id).unwrap();
|
||||||
|
let (ptr_child_id, _) = find_by_name(&pointer_read.instances, "Ptr");
|
||||||
|
|
||||||
|
// Set ObjectValue.Value to the ~1 target
|
||||||
|
let mut props = UstrMap::default();
|
||||||
|
props.insert(ustr("Value"), Some(Variant::Ref(target_id)));
|
||||||
|
send_update(
|
||||||
|
&session,
|
||||||
|
&info.session_id,
|
||||||
|
InstanceUpdate {
|
||||||
|
id: ptr_child_id,
|
||||||
|
changed_name: None,
|
||||||
|
changed_class_name: None,
|
||||||
|
changed_properties: props,
|
||||||
|
changed_metadata: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the ref was written with the ~1 path
|
||||||
|
let pointer_dir = src.join("Pointer");
|
||||||
|
let ptr_model_path = pointer_dir.join("Ptr.model.json5");
|
||||||
|
poll_file_exists(&ptr_model_path, "Ptr.model.json5 after ref set");
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
let ref_content = fs::read_to_string(&ptr_model_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
ref_content.contains("X_Y~1"),
|
||||||
|
"Before cleanup: ref should point to X_Y~1, got: {}",
|
||||||
|
ref_content
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Delete the OTHER instance (not the target) to trigger
|
||||||
|
// group-to-1 cleanup, which renames X_Y~1 → X_Y
|
||||||
|
send_removal(&session, &info.session_id, vec![other_id]);
|
||||||
|
|
||||||
|
// After cleanup: X_Y~1.luau renamed to X_Y.luau
|
||||||
|
poll_not_exists(&deduped, "X_Y~1.luau after cleanup");
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
assert_file_exists(&base, "X_Y.luau after cleanup");
|
||||||
|
|
||||||
|
// The ref in Ptr.model.json5 should now point to the non-suffixed path
|
||||||
|
let updated_ref = fs::read_to_string(&ptr_model_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
!updated_ref.contains("X_Y~1"),
|
||||||
|
"After cleanup: ref should NOT contain X_Y~1 (old path), got: {}",
|
||||||
|
updated_ref
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
updated_ref.contains("X_Y"),
|
||||||
|
"After cleanup: ref should contain X_Y (new path), got: {}",
|
||||||
|
updated_ref
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename an instance that is a ref target via two-way sync. Verify
|
||||||
|
/// Rojo_Ref_* paths pointing to it are updated in meta files.
|
||||||
|
#[test]
|
||||||
|
fn rename_ref_target_updates_ref_paths() {
|
||||||
|
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||||
|
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||||
|
|
||||||
|
// Step 1: Add a target ModuleScript and a Pointer model
|
||||||
|
let child_obj = AddedInstance {
|
||||||
|
parent: None,
|
||||||
|
name: "Ref".to_string(),
|
||||||
|
class_name: "ObjectValue".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
let mut added_map = HashMap::new();
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "Target".to_string(),
|
||||||
|
class_name: "ModuleScript".to_string(),
|
||||||
|
properties: {
|
||||||
|
let mut p = HashMap::new();
|
||||||
|
p.insert(
|
||||||
|
"Source".to_string(),
|
||||||
|
Variant::String("-- target".to_string()),
|
||||||
|
);
|
||||||
|
p
|
||||||
|
},
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
added_map.insert(
|
||||||
|
Ref::new(),
|
||||||
|
AddedInstance {
|
||||||
|
parent: Some(rs_id),
|
||||||
|
name: "Ptr".to_string(),
|
||||||
|
class_name: "Model".to_string(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
children: vec![child_obj],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let write_request = WriteRequest {
|
||||||
|
session_id: info.session_id,
|
||||||
|
removed: vec![],
|
||||||
|
added: added_map,
|
||||||
|
updated: vec![],
|
||||||
|
};
|
||||||
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
let src = session.path().join("src");
|
||||||
|
poll_file_exists(&src.join("Target.luau"), "Target.luau after add");
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Step 2: Set the ObjectValue.Value to Target
|
||||||
|
let rs_read = session.get_api_read(rs_id).unwrap();
|
||||||
|
let (target_id, _) = find_by_name(&rs_read.instances, "Target");
|
||||||
|
let (ptr_model_id, _) = find_by_name(&rs_read.instances, "Ptr");
|
||||||
|
let ptr_read = session.get_api_read(ptr_model_id).unwrap();
|
||||||
|
let (objval_id, _) = find_by_name(&ptr_read.instances, "Ref");
|
||||||
|
|
||||||
|
let mut props = UstrMap::default();
|
||||||
|
props.insert(ustr("Value"), Some(Variant::Ref(target_id)));
|
||||||
|
send_update(
|
||||||
|
&session,
|
||||||
|
&info.session_id,
|
||||||
|
InstanceUpdate {
|
||||||
|
id: objval_id,
|
||||||
|
changed_name: None,
|
||||||
|
changed_class_name: None,
|
||||||
|
changed_properties: props,
|
||||||
|
changed_metadata: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify ref was written
|
||||||
|
let obj_model_path = src.join("Ptr").join("Ref.model.json5");
|
||||||
|
poll_file_exists(&obj_model_path, "Ref.model.json5 after ref set");
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
let ref_content = fs::read_to_string(&obj_model_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
ref_content.contains("Target.luau"),
|
||||||
|
"Ref should point to Target.luau, got: {}",
|
||||||
|
ref_content
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Rename the target
|
||||||
|
// Re-read to get current IDs (may have changed after VFS rebuild)
|
||||||
|
let rs_read = session.get_api_read(rs_id).unwrap();
|
||||||
|
let (target_id, _) = find_by_name(&rs_read.instances, "Target");
|
||||||
|
|
||||||
|
send_update(
|
||||||
|
&session,
|
||||||
|
&info.session_id,
|
||||||
|
InstanceUpdate {
|
||||||
|
id: target_id,
|
||||||
|
changed_name: Some("Renamed".to_string()),
|
||||||
|
changed_class_name: None,
|
||||||
|
changed_properties: UstrMap::default(),
|
||||||
|
changed_metadata: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for rename to propagate
|
||||||
|
let renamed_path = src.join("Renamed.luau");
|
||||||
|
poll_file_exists(&renamed_path, "Renamed.luau after rename");
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
|
||||||
|
// Step 4: Verify the ref path was updated
|
||||||
|
let updated_ref = fs::read_to_string(&obj_model_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
updated_ref.contains("Renamed.luau"),
|
||||||
|
"After rename: ref should point to Renamed.luau, got: {}",
|
||||||
|
updated_ref
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!updated_ref.contains("Target.luau"),
|
||||||
|
"After rename: ref should NOT point to old Target.luau, got: {}",
|
||||||
|
updated_ref
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue