mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-22 21:41:37 +00:00
Remove float-cmp dependency and implement custom float comparison logic
- Eliminated the `float-cmp` crate from the project, replacing its functionality with custom float comparison functions that match Lua's epsilon-based equality checks. - Updated the `variant_eq` function to utilize the new fuzzy equality methods for `Float32` and `Float64` variants, ensuring consistent behavior across Rust and Lua implementations. - Enhanced the matching algorithms in both forward sync and syncback processes to account for refined scoring of `Ref`, `Tags`, and `Attributes`, improving scoring accuracy and parity between implementations. - Introduced a new plan document detailing the changes made to the matching algorithms, including the rationale for the updates and the expected impact on performance and correctness. These changes aim to improve the reliability and maintainability of the matching algorithms while ensuring parity with Lua's behavior.
This commit is contained in:
parent
a283fd1dc9
commit
7e0538b9f5
10 changed files with 1276 additions and 87 deletions
275
.cursor/plans/matching_algorithm_parity_01524d4c.plan.md
Normal file
275
.cursor/plans/matching_algorithm_parity_01524d4c.plan.md
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
---
|
||||
name: matching algorithm parity
|
||||
overview: Fix scoring parity between the 3 matching algorithm implementations (Rust forward sync, Rust syncback, Lua plugin) and add parity/round-trip tests.
|
||||
todos:
|
||||
- id: fix-1-ref-scoring
|
||||
content: "Fix 1: Add Ref-target-aware scoring to Rust countOwnDiffs in both forward sync and syncback matching"
|
||||
status: completed
|
||||
- id: fix-2-tags-attrs
|
||||
content: "Fix 2: Add granular Tags/Attributes scoring to Rust countOwnDiffs in both implementations"
|
||||
status: completed
|
||||
- id: fix-3-epsilon
|
||||
content: "Fix 3: Replace float_cmp::approx_eq! with custom fuzzy_eq matching Lua's 0.0001 epsilon in variant_eq.rs"
|
||||
status: completed
|
||||
- id: fix-4-sort
|
||||
content: "Fix 4: Add matched_indices sort to forward sync build_result; remove TODO from syncback"
|
||||
status: completed
|
||||
- id: fix-5-vcache
|
||||
content: "Fix 5: Cache VCache at session level in Lua matching.lua to avoid redundant decoding"
|
||||
status: completed
|
||||
- id: fix-6-tests
|
||||
content: "Fix 6: Add parity tests (Rust forward vs syncback), round-trip matching test, and Lua parity spec test"
|
||||
status: completed
|
||||
- id: run-ci
|
||||
content: Run /ci after all fixes to verify CI passes
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Matching Algorithm Parity Audit Fixes
|
||||
|
||||
> This plan was generated by `/audit` from a Plan-mode session.
|
||||
> Refer to `.cursor/rules/atlas.mdc` for full project standards.
|
||||
|
||||
## Standards
|
||||
|
||||
These standards govern every fix in this plan. An implementer MUST read `.cursor/rules/atlas.mdc` before starting.
|
||||
|
||||
### Round-Trip Identity (from `atlas.mdc` Quality Standard)
|
||||
|
||||
Syncback (or two-way sync) writes a directory tree. Building an rbxl from that directory tree and forward-syncing it back must produce a **bit-identical instance tree** -- same names, same classes, same properties, same hierarchy, same ref targets. Any deviation is a bug.
|
||||
|
||||
### CLI Syncback Parity (from `atlas.mdc` Two-Way Sync Strategy)
|
||||
|
||||
Plugin-based sync must produce **exactly the same filesystem output** that `atlas syncback` would give for the same input. Byte-for-byte identical files, identical directory structures, identical naming. Any divergence is a bug until proven otherwise.
|
||||
|
||||
### Code Quality (from `atlas.mdc` Code Quality Standard)
|
||||
|
||||
DRY code that is easy to reason about and hard to get wrong. The same slugify/dedup/meta-update pattern should not be copy-pasted across files. Small refactors (2-3 call sites) are included as fixes. Major rewrites (10+ call sites) are deferred.
|
||||
|
||||
## Context
|
||||
|
||||
The matching algorithm was audited across all 3 parallel implementations:
|
||||
|
||||
- `src/snapshot/matching.rs` (Rust forward sync)
|
||||
- `src/syncback/matching.rs` (Rust syncback)
|
||||
- `plugin/src/Reconciler/matching.lua` (Lua plugin)
|
||||
|
||||
The audit found **scoring parity issues** that could cause different implementations to produce different pairings for the same logical input. The most critical is Ref property scoring (UNMATCHED_PENALTY in Lua vs +1 in Rust), followed by Tags/Attributes granularity and float epsilon differences.
|
||||
|
||||
## Fixes
|
||||
|
||||
### Fix 1: Add Ref-Target-Aware Scoring to Rust `countOwnDiffs`
|
||||
|
||||
- **Status:** Approved
|
||||
- **Finding:** Step 11l: Matching Algorithm Parity -- Ref property scoring
|
||||
- **Files:** `src/snapshot/matching.rs`, `src/syncback/matching.rs`
|
||||
- **Problem:** Lua `countOwnDiffs` (matching.lua lines 309-325) scores Ref property mismatches at `UNMATCHED_PENALTY = 10000` by comparing target Name+ClassName. Rust `count_own_diffs` in both files treats Ref properties as regular Variant properties scoring +1 per mismatch. In syncback matching, Ref IDs are always different between WeakDom instances, so ALL Ref properties score +1 regardless of logical equivalence.
|
||||
- **Solution:**
|
||||
- In **forward sync** `count_own_diffs` (matching.rs line 452-496): When comparing properties, detect `Variant::Ref` values. For each Ref property:
|
||||
- If both sides have a Ref value, resolve both Refs to their target instances (via `tree.get_instance(ref)` for tree side, look up snapshot children by snapshot_id for snap side). Compare target Name+ClassName. If different, add `UNMATCHED_PENALTY` instead of 1.
|
||||
- If one side has Ref and other doesn't (or Ref::none vs valid ref), add `UNMATCHED_PENALTY`.
|
||||
- Skip `Variant::Ref` from regular property comparison to avoid double-counting.
|
||||
- In **syncback** `count_own_diffs` (matching.rs line 501-548): Same approach using `new_dom.get_by_ref(ref)` and `old_dom.get_by_ref(ref)` to resolve targets.
|
||||
- Both functions need access to the DOM/tree for Ref resolution -- add the necessary parameters.
|
||||
- **Risk:** Adding DOM access to `count_own_diffs` increases its parameter count. The function is `#[inline]`, so ensure the extra parameter doesn't cause performance regression. The Ref resolution is a simple hash lookup (O(1)).
|
||||
- **Verify round-trip:** Create 3 Models named "Weapon" with different PrimaryPart targets. Syncback, then forward sync. Verify all 3 match to their correct counterparts.
|
||||
- **Verify syncback parity:** Compare CLI syncback output with two-way sync output for a tree with Ref-differentiated instances.
|
||||
- **Tests required:**
|
||||
- Rust unit test: `ref_scoring_forward_sync` -- 2 Models with different PrimaryPart targets, verify correct pairing
|
||||
- Rust unit test: `ref_scoring_syncback` -- same scenario for syncback matching
|
||||
- Lua spec test: already has ref scoring test in matching.spec.lua, verify it passes
|
||||
|
||||
### Fix 2: Add Granular Tags/Attributes Scoring to Rust `countOwnDiffs`
|
||||
|
||||
- **Status:** Approved
|
||||
- **Finding:** Step 11l: Matching Algorithm Parity -- Tags/Attributes granularity
|
||||
- **Files:** `src/snapshot/matching.rs`, `src/syncback/matching.rs`
|
||||
- **Problem:** Lua `countOwnDiffs` counts per-tag (+1 each in symmetric difference) and per-attribute (+1 each diff). Rust counts Tags as one Variant property (+1 total) and Attributes as one Variant property (+1 total). An instance with 5 extra tags scores +5 in Lua but +1 in Rust.
|
||||
- **Solution:**
|
||||
- In both Rust `count_own_diffs` functions:
|
||||
- When encountering `Variant::Tags(tags)`, extract and compare granularly: +1 per tag in symmetric difference. Skip from regular property comparison.
|
||||
- When encountering `Variant::Attributes(attrs)`, extract and compare granularly: +1 per attribute whose value differs (using `variant_eq`), +1 per attribute present only on one side. Skip from regular property comparison.
|
||||
- The key check is `key == "Tags"` (for `Variant::Tags`) and `key == "Attributes"` (for `Variant::Attributes`).
|
||||
- **Risk:** Low. Only changes the cost value for Tags/Attributes, making it more discriminating. Existing tests should still pass since they don't depend on exact cost values, only on correct pairings.
|
||||
- **Verify round-trip:** Instances differing by tags should be paired correctly.
|
||||
- **Verify syncback parity:** N/A -- Tags/Attributes rarely distinguish instances in typical syncback scenarios.
|
||||
- **Tests required:**
|
||||
- Rust unit test: `tags_granular_scoring` -- 2 Parts with different tag sets, verify scoring matches Lua behavior
|
||||
- Rust unit test: `attributes_granular_scoring` -- 2 Parts with different attribute maps, verify scoring
|
||||
|
||||
### Fix 3: Loosen Rust Float Epsilon to Match Lua
|
||||
|
||||
- **Status:** Approved (Modified: loosen Rust to match Lua)
|
||||
- **Finding:** Step 11l: Matching Algorithm Parity -- Float epsilon
|
||||
- **Files:** `src/variant_eq.rs`
|
||||
- **Problem:** Lua `trueEquals` uses epsilon=0.0001 with absolute+relative check. Rust `variant_eq` uses `float_cmp::approx_eq!` defaults (epsilon~1.19e-7, ulps=4), which is ~1000x tighter. This means Rust considers values different that Lua considers equal, potentially causing different match scores and different pairings.
|
||||
- **Solution:**
|
||||
- Replace the `approx_eq!` macro wrapper in `variant_eq.rs` with a custom `fuzzy_eq` function that matches Lua's dual epsilon approach:
|
||||
|
||||
```rust
|
||||
const EPSILON: f32 = 0.0001;
|
||||
fn fuzzy_eq_f32(a: f32, b: f32) -> bool {
|
||||
if a.is_nan() && b.is_nan() { return true; }
|
||||
let diff = (a - b).abs();
|
||||
let max_val = a.abs().max(b.abs()).max(1.0);
|
||||
diff < EPSILON || diff < max_val * EPSILON
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
- Apply similar `fuzzy_eq_f64` for f64 comparisons.
|
||||
- Update all float comparison call sites in `variant_eq` (Color3, CFrame, Vector3, UDim, etc.) to use the new function instead of `approx_eq!`.
|
||||
- Remove the `float-cmp` dependency from `Cargo.toml` if no other code uses it.
|
||||
- **Risk:** Medium. Loosening epsilon means values that were previously considered different are now equal. This affects:
|
||||
- `variant_eq` used in matching (intended -- this fix)
|
||||
- `variant_eq` used in `patch_compute.rs` for property diff detection (side effect -- could suppress legitimate property patches for near-equal values)
|
||||
- Verify that the 0.0001 epsilon doesn't suppress meaningful Studio property changes.
|
||||
- **Verify round-trip:** Float properties should round-trip without phantom diffs (the looser epsilon should REDUCE phantom diffs, not create them).
|
||||
- **Verify syncback parity:** N/A
|
||||
- **Tests required:**
|
||||
- Rust unit test: update existing `variant_eq` float tests to verify the new epsilon behavior
|
||||
- Rust unit test: `fuzzy_eq_matches_lua` -- verify same boundary values as `trueEquals.spec.lua` (0.0001 threshold, relative scaling, NaN handling)
|
||||
|
||||
### Fix 4: Add Sort to Forward Sync `build_result` for Consistency
|
||||
|
||||
- **Status:** Approved
|
||||
- **Finding:** Code quality -- inconsistent sort between implementations
|
||||
- **Files:** `src/snapshot/matching.rs`, `src/syncback/matching.rs`
|
||||
- **Problem:** Syncback `build_result` (line 217) sorts `matched_indices` by new_ref index. Forward sync `build_result` (line 206) does NOT sort. The Lua `matchChildren` returns matched pairs in insertion order (which is group iteration order, then sorted within groups).
|
||||
- **Solution:**
|
||||
- Add `matched_indices.sort_by_key(|&(si, _)| si);` to the forward sync `build_result` function (line 203, before the matched Vec construction).
|
||||
- Remove the TODO comment from the syncback version (line 217): the sort is intentional for consistent downstream iteration order.
|
||||
- **Risk:** Very low. Only changes the ORDER of matched pairs in the result, not WHICH pairs are matched. Downstream code iterates matched pairs for patch computation, and order should not affect correctness.
|
||||
- **Verify round-trip:** N/A -- order of matched pairs doesn't affect round-trip identity.
|
||||
- **Verify syncback parity:** N/A
|
||||
- **Tests required:**
|
||||
- No new tests needed. Existing tests verify correct pairings, not ordering.
|
||||
|
||||
### Fix 5: Cache VCache at Session Level in Lua Matching
|
||||
|
||||
- **Status:** Approved
|
||||
- **Finding:** Performance -- redundant `cacheVirtual` calls
|
||||
- **Files:** `plugin/src/Reconciler/matching.lua`
|
||||
- **Problem:** `cacheVirtual` is called during ambiguous group scoring (line 492) AND again inside `computePairCost` (line 362). The same virtual instance's properties are decoded multiple times per session.
|
||||
- **Solution:**
|
||||
- Add a `vCacheStore: { [string]: VCache }` field to `MatchingSession`.
|
||||
- In `cacheVirtual`, check `session.vCacheStore[virtualId]` first. If present, return cached. Otherwise, compute and store.
|
||||
- Update `computePairCost` to use the session-level cache instead of re-calling `cacheVirtual`.
|
||||
- The `classKeys` parameter varies by group, but for a given virtualId the classKeys is always the same (determined by ClassName). So caching by virtualId is safe.
|
||||
- **Risk:** Very low. Pure performance optimization. The VCache content is deterministic for a given virtualId.
|
||||
- **Verify round-trip:** N/A
|
||||
- **Verify syncback parity:** N/A
|
||||
- **Tests required:**
|
||||
- No new tests needed. Existing matching.spec.lua tests verify correctness. Could add a timing test but not required.
|
||||
|
||||
### Fix 6: Add Parity Tests Between All 3 Implementations
|
||||
|
||||
- **Status:** Approved
|
||||
- **Finding:** Steps 5-6: Missing parity and round-trip tests
|
||||
- **Files:** `tests/tests/matching_fixtures.rs` (extend), `plugin/src/Reconciler/matching.spec.lua` (extend)
|
||||
- **Problem:** No tests verify that the 3 implementations produce identical pairings for the same input. Given Fixes 1-3 close the scoring gaps, parity tests are needed to prevent future divergence.
|
||||
- **Solution:**
|
||||
- **Rust integration test** (`tests/tests/matching_fixtures.rs`): Add a test `parity_forward_vs_syncback` that:
|
||||
1. Creates a WeakDom with an ambiguous group (e.g., 4 Parts named "Line" with different Transparency values, in reversed order)
|
||||
2. Runs syncback matching on `(new_children, old_children)`
|
||||
3. Builds a RojoTree from `new_children` snapshots, runs forward sync matching on `(old_children_as_snapshots, tree_children)`
|
||||
4. Verifies both produce the same pairings (by comparing matched Name+Property values)
|
||||
- **Lua spec test** (`matching.spec.lua`): Add a test `parity_with_rust_fixture` that:
|
||||
1. Uses the same input data as the Rust test (4 Parts with specific Transparency values)
|
||||
2. Runs Lua matching
|
||||
3. Verifies the same pairings as the Rust tests (by property comparison)
|
||||
- **Round-trip matching test** (`tests/tests/matching_fixtures.rs`): Add `round_trip_matching_identity`:
|
||||
1. Uses the UFOWave fixture (12 Textures with default-omitted Face)
|
||||
2. Runs syncback to produce filesystem
|
||||
3. Runs forward sync on the filesystem output
|
||||
4. Verifies all 12 Textures are matched to their correct counterparts (by Face value)
|
||||
- **Risk:** None. Tests only.
|
||||
- **Verify round-trip:** This IS the round-trip test.
|
||||
- **Verify syncback parity:** N/A (these are matching-level tests, not filesystem-level)
|
||||
- **Tests required:** (this fix IS the test)
|
||||
|
||||
## Skipped Fixes
|
||||
|
||||
None. All identified fixes were approved.
|
||||
|
||||
## Accepted Limitations
|
||||
|
||||
### Greedy Matching Not Globally Optimal
|
||||
|
||||
- **Limitation:** The greedy assignment algorithm is not globally optimal. Edge cases exist where a locally-best pick forces a globally-worse assignment.
|
||||
- **Scenario:** Large ambiguous groups (10+ instances) with specific cost patterns where the Hungarian algorithm would produce different (better) pairings.
|
||||
- **User decision:** Accepted for all releases (documented in `atlas-dedup.mdc`).
|
||||
- **Mitigation:** Ambiguous groups are typically small (2-10 instances). The depth limit (MAX_SCORING_DEPTH=3) bounds worst-case performance. Monitoring via test coverage for large groups.
|
||||
|
||||
## Deferred Refactors
|
||||
|
||||
- **Shared matching core:** The 3 implementations share the same algorithm but differ in input types (InstanceSnapshot vs WeakDom vs Studio Instance). A generic/trait-based approach could consolidate the duplicated logic (~2700 total lines), but the type differences are fundamental to each context. Estimated scope: 10+ files, significant API redesign. Deferred.
|
||||
- **Duplicated grouping pattern:** All 3 implementations repeat the same HashMap-based `(Name, ClassName) -> Vec<index>` grouping. Could be factored into a shared helper, but types differ. Minor DRY violation. Deferred.
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Rust Unit Tests (`#[cfg(test)]` blocks)
|
||||
|
||||
- `ref_scoring_forward_sync` -- 2 Models with different PrimaryPart targets, verify correct pairing (Fix 1)
|
||||
- `ref_scoring_syncback` -- same scenario for syncback matching (Fix 1)
|
||||
- `tags_granular_scoring` -- 2 Parts with different tag sets, verify scoring (Fix 2)
|
||||
- `attributes_granular_scoring` -- 2 Parts with different attribute maps, verify scoring (Fix 2)
|
||||
- Update existing `variant_eq` float tests for new epsilon behavior (Fix 3)
|
||||
- `fuzzy_eq_matches_lua` -- verify same boundary values as `trueEquals.spec.lua` (Fix 3)
|
||||
|
||||
### Rust Integration Tests (`tests/tests/`)
|
||||
|
||||
- `parity_forward_vs_syncback` -- same ambiguous group through both Rust matchers, verify identical pairings (Fix 6)
|
||||
- `round_trip_matching_identity` -- UFOWave fixture through syncback then forward sync, verify correct pairings (Fix 6)
|
||||
|
||||
### Lua Spec Tests (`.spec.lua` files)
|
||||
|
||||
- `parity_with_rust_fixture` -- same input as Rust parity test, verify identical pairings (Fix 6)
|
||||
|
||||
### Snapshot Tests (`insta` crate)
|
||||
|
||||
- None needed for matching fixes.
|
||||
|
||||
### Failing Tests for Known Limitations
|
||||
|
||||
- None. The greedy limitation is accepted and no test is needed to demonstrate it.
|
||||
|
||||
## Test Rules
|
||||
|
||||
The implementer MUST follow these rules when writing tests for this plan:
|
||||
|
||||
### For bugs found and fixed
|
||||
|
||||
Every bug fix MUST have a test that:
|
||||
|
||||
1. Would have **FAILED** before the fix
|
||||
2. **PASSES** after the fix
|
||||
3. Prevents the bug from regressing
|
||||
|
||||
### For missing coverage identified
|
||||
|
||||
For each "missing test coverage" gap:
|
||||
|
||||
1. Write the test
|
||||
2. Verify it passes against the current implementation
|
||||
3. If it fails, investigate -- it may have found another bug
|
||||
|
||||
### For known limitations (Accepted Limitations section)
|
||||
|
||||
No known-limitation tests needed for this plan.
|
||||
|
||||
### Scope: test every affected layer
|
||||
|
||||
For each finding, determine which test layers are affected and write tests in ALL of them:
|
||||
|
||||
- Does the bug manifest at the plugin level? -> Lua spec test
|
||||
- Does it manifest at the server level? -> Rust unit test or integration test
|
||||
- Does it affect the round-trip? -> Integration test with `/api/write` + filesystem verification
|
||||
- Does it affect forward-sync? -> Serve test with `recv_socket_packet`
|
||||
- Could it regress from a different code path? -> Test that code path too
|
||||
|
||||
## Final Step: Run CI
|
||||
|
||||
After ALL fixes and tests are implemented, run the `/ci` command (see `.cursor/commands/ci.md`) to execute the full CI pipeline. Every fix must pass CI before the plan is considered complete. Do not skip this step.
|
||||
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -186,7 +186,6 @@ dependencies = [
|
|||
"data-encoding",
|
||||
"embed-resource",
|
||||
"env_logger 0.11.9",
|
||||
"float-cmp",
|
||||
"fs-err",
|
||||
"futures",
|
||||
"globset",
|
||||
|
|
@ -1033,15 +1032,6 @@ dependencies = [
|
|||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ pathdiff = "0.2.3"
|
|||
|
||||
blake3 = "1.8.3"
|
||||
sha1 = "0.10"
|
||||
float-cmp = "0.10.0"
|
||||
indexmap = { version = "2.13.0", features = ["serde"] }
|
||||
rmp-serde = "1.3.1"
|
||||
serde_bytes = "0.11.19"
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ type MatchResult = {
|
|||
type MatchingSession = {
|
||||
matchCache: { [string]: { [Instance]: MatchResult } },
|
||||
costCache: { [string]: { [Instance]: number } },
|
||||
vCacheStore: { [string]: VCache },
|
||||
}
|
||||
|
||||
type RefIdentity = {
|
||||
|
|
@ -103,6 +104,7 @@ local function newSession(): MatchingSession
|
|||
return {
|
||||
matchCache = {},
|
||||
costCache = {},
|
||||
vCacheStore = {},
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -359,7 +361,11 @@ local function computePairCost(
|
|||
end
|
||||
|
||||
local classKeys = RbxDom.getClassComparisonKeys(vInst.ClassName)
|
||||
local vCache = cacheVirtual(vInst, classKeys, virtualInstances)
|
||||
local vCache = session.vCacheStore[virtualId]
|
||||
if not vCache then
|
||||
vCache = cacheVirtual(vInst, classKeys, virtualInstances)
|
||||
session.vCacheStore[virtualId] = vCache
|
||||
end
|
||||
local refPropNames: { string } = {}
|
||||
for propName, _ in pairs(vCache.refs) do
|
||||
table.insert(refPropNames, propName)
|
||||
|
|
@ -485,11 +491,16 @@ matchChildren = function(
|
|||
local allExtraProps: { [string]: boolean } = {}
|
||||
local allRefProps: { [string]: boolean } = {}
|
||||
for _, vi in ipairs(vIndices) do
|
||||
local vInst = virtualInstances[virtualChildren[vi]]
|
||||
local vid = virtualChildren[vi]
|
||||
local vInst = virtualInstances[vid]
|
||||
if not vInst then
|
||||
continue
|
||||
end
|
||||
local vCache = cacheVirtual(vInst, classKeys, virtualInstances)
|
||||
local vCache = session.vCacheStore[vid]
|
||||
if not vCache then
|
||||
vCache = cacheVirtual(vInst, classKeys, virtualInstances)
|
||||
session.vCacheStore[vid] = vCache
|
||||
end
|
||||
vCaches[vi] = vCache
|
||||
for _, propName in ipairs(vCache.extraProps) do
|
||||
allExtraProps[propName] = true
|
||||
|
|
|
|||
|
|
@ -544,4 +544,44 @@ return function()
|
|||
expect(result.totalCost).to.equal(0)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("parity with Rust fixture", function()
|
||||
it("4 Parts with different Transparency values in reversed order", function()
|
||||
local session = Matching.newSession()
|
||||
local transparencies = { 0, 0.3, 0.6, 0.9 }
|
||||
|
||||
local virtualInstances = {}
|
||||
local virtualChildren = {}
|
||||
for i, t in ipairs(transparencies) do
|
||||
local id = "V" .. tostring(i)
|
||||
virtualInstances[id] = makeVirtualInstance("Line", "Part", {
|
||||
Transparency = { Float32 = t },
|
||||
}, {})
|
||||
table.insert(virtualChildren, id)
|
||||
end
|
||||
|
||||
local studioChildren = {}
|
||||
for i = #transparencies, 1, -1 do
|
||||
local inst = Instance.new("Part")
|
||||
inst.Name = "Line"
|
||||
inst.Transparency = transparencies[i]
|
||||
inst.Parent = container
|
||||
table.insert(studioChildren, inst)
|
||||
end
|
||||
|
||||
local result =
|
||||
Matching.matchChildren(session, virtualChildren, studioChildren, virtualInstances, "ROOT", container)
|
||||
|
||||
expect(#result.matched).to.equal(4)
|
||||
expect(#result.unmatchedVirtual).to.equal(0)
|
||||
expect(#result.unmatchedStudio).to.equal(0)
|
||||
|
||||
for _, pair in ipairs(result.matched) do
|
||||
local vInst = virtualInstances[pair.virtualId]
|
||||
local vT = vInst.Properties.Transparency.Float32
|
||||
local sT = pair.studioInstance.Transparency
|
||||
expect(math.abs(vT - sT) < 0.001).to.equal(true)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
15
src/git.rs
15
src/git.rs
|
|
@ -786,10 +786,7 @@ mod tests {
|
|||
fs::write(dir.path().join("a.luau"), "content a").unwrap();
|
||||
git_commit_all(dir.path(), "init");
|
||||
|
||||
let refs = vec![
|
||||
"HEAD:a.luau".to_string(),
|
||||
":0:a.luau".to_string(),
|
||||
];
|
||||
let refs = vec!["HEAD:a.luau".to_string(), ":0:a.luau".to_string()];
|
||||
let results = git_batch_check_hashes(dir.path(), &refs);
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].is_some());
|
||||
|
|
@ -809,19 +806,13 @@ mod tests {
|
|||
fs::write(dir.path().join("b.luau"), "staged version").unwrap();
|
||||
git_stage(dir.path(), "b.luau");
|
||||
|
||||
let refs = vec![
|
||||
"HEAD:b.luau".to_string(),
|
||||
":0:b.luau".to_string(),
|
||||
];
|
||||
let refs = vec!["HEAD:b.luau".to_string(), ":0:b.luau".to_string()];
|
||||
let results = git_batch_check_hashes(dir.path(), &refs);
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].is_some());
|
||||
assert!(results[1].is_some());
|
||||
assert_ne!(results[0], results[1]);
|
||||
assert_eq!(
|
||||
results[0].as_ref().unwrap(),
|
||||
&compute_blob_sha1("original")
|
||||
);
|
||||
assert_eq!(results[0].as_ref().unwrap(), &compute_blob_sha1("original"));
|
||||
assert_eq!(
|
||||
results[1].as_ref().unwrap(),
|
||||
&compute_blob_sha1("staged version")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rbx_dom_weak::types::Ref;
|
||||
use rbx_dom_weak::types::{Ref, Variant};
|
||||
use rbx_dom_weak::Ustr;
|
||||
|
||||
use crate::variant_eq::variant_eq;
|
||||
|
|
@ -200,8 +200,9 @@ fn build_result(
|
|||
mut snap_available: Vec<Option<InstanceSnapshot>>,
|
||||
tree_children: &[Ref],
|
||||
tree_available: &[bool],
|
||||
matched_indices: Vec<(usize, usize)>,
|
||||
mut matched_indices: Vec<(usize, usize)>,
|
||||
) -> ForwardMatchResult {
|
||||
matched_indices.sort_by_key(|&(si, _)| si);
|
||||
let mut matched = Vec::with_capacity(matched_indices.len());
|
||||
for (si, ti) in matched_indices {
|
||||
if let Some(snap) = snap_available[si].take() {
|
||||
|
|
@ -389,7 +390,7 @@ fn compute_change_count(
|
|||
None => return UNMATCHED_PENALTY,
|
||||
};
|
||||
|
||||
let mut cost = count_own_diffs(snap, &inst);
|
||||
let mut cost = count_own_diffs(snap, &inst, tree);
|
||||
if cost >= best_so_far || depth >= MAX_SCORING_DEPTH {
|
||||
return cost;
|
||||
}
|
||||
|
|
@ -440,8 +441,9 @@ fn compute_change_count(
|
|||
}
|
||||
|
||||
/// Count own property diffs between a snapshot and a tree instance.
|
||||
/// Each differing property = +1. Tags and Attributes counted granularly.
|
||||
/// Children count diff = +1.
|
||||
/// Each differing property = +1. Tags and Attributes counted granularly
|
||||
/// (per-tag and per-attribute diffs). Ref properties compared by target
|
||||
/// Name+ClassName at UNMATCHED_PENALTY per mismatch. Children count diff = +1.
|
||||
///
|
||||
/// Syncback strips default-valued properties from model/meta files, so
|
||||
/// filesystem snapshots may omit properties that the tree (populated from
|
||||
|
|
@ -449,7 +451,7 @@ fn compute_change_count(
|
|||
/// match costs with phantom diffs, properties present on only one side are
|
||||
/// skipped when their value matches the class default.
|
||||
#[inline]
|
||||
fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta) -> u32 {
|
||||
fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta, tree: &RojoTree) -> u32 {
|
||||
let mut cost: u32 = 0;
|
||||
|
||||
let snap_props = &snap.properties;
|
||||
|
|
@ -459,35 +461,30 @@ fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta) -> u32 {
|
|||
.ok()
|
||||
.and_then(|db| db.classes.get(snap.class_name.as_str()));
|
||||
|
||||
// Properties present on the snapshot side
|
||||
for (key, snap_val) in snap_props.iter() {
|
||||
if let Some(inst_val) = inst_props.get(key) {
|
||||
if !variant_eq(snap_val, inst_val) {
|
||||
cost += 1;
|
||||
}
|
||||
cost += diff_variant_pair(snap_val, inst_val, tree);
|
||||
} else {
|
||||
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;
|
||||
cost += count_variant_one_sided(snap_val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Properties present only on the tree side
|
||||
for (key, inst_val) in inst_props.iter() {
|
||||
if !snap_props.contains_key(key) {
|
||||
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;
|
||||
cost += count_variant_one_sided(inst_val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Children count diff
|
||||
if snap.children.len() != inst.children().len() {
|
||||
cost += 1;
|
||||
}
|
||||
|
|
@ -495,6 +492,107 @@ fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta) -> u32 {
|
|||
cost
|
||||
}
|
||||
|
||||
/// Compare two Variant values with type-aware scoring:
|
||||
/// - Ref: compare target Name+ClassName, cost UNMATCHED_PENALTY if different
|
||||
/// - Tags: +1 per tag in symmetric difference
|
||||
/// - Attributes: +1 per differing or one-sided attribute
|
||||
/// - Other: +1 if not equal
|
||||
#[inline]
|
||||
fn diff_variant_pair(a: &Variant, b: &Variant, tree: &RojoTree) -> u32 {
|
||||
match (a, b) {
|
||||
(Variant::Ref(ref_a), Variant::Ref(ref_b)) => {
|
||||
if ref_a == ref_b {
|
||||
return 0;
|
||||
}
|
||||
let a_none = !ref_a.is_some();
|
||||
let b_none = !ref_b.is_some();
|
||||
if a_none != b_none {
|
||||
return UNMATCHED_PENALTY;
|
||||
}
|
||||
if a_none && b_none {
|
||||
return 0;
|
||||
}
|
||||
let target_a = tree.get_instance(*ref_a);
|
||||
let target_b = tree.get_instance(*ref_b);
|
||||
match (target_a, target_b) {
|
||||
(Some(ta), Some(tb)) => {
|
||||
if ta.name() == tb.name() && ta.class_name() == tb.class_name() {
|
||||
0
|
||||
} else {
|
||||
UNMATCHED_PENALTY
|
||||
}
|
||||
}
|
||||
(None, None) => 0,
|
||||
_ => UNMATCHED_PENALTY,
|
||||
}
|
||||
}
|
||||
(Variant::Tags(tags_a), Variant::Tags(tags_b)) => count_tags_diff(tags_a, tags_b),
|
||||
(Variant::Attributes(attrs_a), Variant::Attributes(attrs_b)) => {
|
||||
count_attributes_diff(attrs_a, attrs_b)
|
||||
}
|
||||
_ => {
|
||||
if variant_eq(a, b) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cost for a one-sided Variant (property present on only one side).
|
||||
/// Tags/Attributes contribute their element count; Refs cost UNMATCHED_PENALTY;
|
||||
/// other types cost 1.
|
||||
#[inline]
|
||||
fn count_variant_one_sided(val: &Variant) -> u32 {
|
||||
match val {
|
||||
Variant::Ref(r) => {
|
||||
if r.is_some() {
|
||||
UNMATCHED_PENALTY
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Variant::Tags(tags) => tags.len() as u32,
|
||||
Variant::Attributes(attrs) => attrs.len() as u32,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Count tag diffs as symmetric difference (matching Lua per-tag scoring).
|
||||
fn count_tags_diff(a: &rbx_dom_weak::types::Tags, b: &rbx_dom_weak::types::Tags) -> u32 {
|
||||
use std::collections::HashSet;
|
||||
let set_a: HashSet<&str> = a.iter().collect();
|
||||
let set_b: HashSet<&str> = b.iter().collect();
|
||||
let only_a = set_a.difference(&set_b).count();
|
||||
let only_b = set_b.difference(&set_a).count();
|
||||
(only_a + only_b) as u32
|
||||
}
|
||||
|
||||
/// Count attribute diffs granularly (matching Lua per-attribute scoring).
|
||||
fn count_attributes_diff(
|
||||
a: &rbx_dom_weak::types::Attributes,
|
||||
b: &rbx_dom_weak::types::Attributes,
|
||||
) -> u32 {
|
||||
let mut cost: u32 = 0;
|
||||
for (key, a_val) in a.iter() {
|
||||
match b.get(key.as_str()) {
|
||||
Some(b_val) => {
|
||||
if !variant_eq(a_val, b_val) {
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
None => cost += 1,
|
||||
}
|
||||
}
|
||||
for (key, _) in b.iter() {
|
||||
if a.get(key.as_str()).is_none() {
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1295,4 +1393,207 @@ mod tests {
|
|||
assert_eq!(r1.matched.len(), r2.matched.len());
|
||||
assert_eq!(r1.unmatched_snapshot.len(), r2.unmatched_snapshot.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_granular_scoring() {
|
||||
use rbx_dom_weak::types::{Tags, Variant};
|
||||
|
||||
fn make_tags(names: &[&str]) -> Tags {
|
||||
let mut t = Tags::new();
|
||||
for &n in names {
|
||||
t.push(n);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
let snap_a = make_snapshot_with_props(
|
||||
"Item",
|
||||
"Part",
|
||||
vec![("Tags", Variant::Tags(make_tags(&["A", "B", "C"])))],
|
||||
);
|
||||
let snap_b = make_snapshot_with_props(
|
||||
"Item",
|
||||
"Part",
|
||||
vec![("Tags", Variant::Tags(make_tags(&["X", "Y"])))],
|
||||
);
|
||||
|
||||
let tree_a = make_snapshot_with_props(
|
||||
"Item",
|
||||
"Part",
|
||||
vec![("Tags", Variant::Tags(make_tags(&["A", "B", "C"])))],
|
||||
);
|
||||
let tree_b = make_snapshot_with_props(
|
||||
"Item",
|
||||
"Part",
|
||||
vec![("Tags", Variant::Tags(make_tags(&["X", "Y"])))],
|
||||
);
|
||||
|
||||
let (tree, tree_refs) = make_tree_from_snapshots(vec![tree_b, tree_a]);
|
||||
let result = match_forward(
|
||||
vec![snap_a, snap_b],
|
||||
&tree_refs,
|
||||
&tree,
|
||||
&MatchingSession::new(),
|
||||
);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
for (snap, tree_ref) in &result.matched {
|
||||
let inst = tree.get_instance(*tree_ref).unwrap();
|
||||
let snap_tags = snap.properties.get(&ustr("Tags"));
|
||||
let tree_tags = inst.properties().get(&ustr("Tags"));
|
||||
assert!(
|
||||
variant_eq(snap_tags.unwrap(), tree_tags.unwrap()),
|
||||
"Tags mismatch: snap={:?}, tree={:?}",
|
||||
snap_tags,
|
||||
tree_tags
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributes_granular_scoring() {
|
||||
use rbx_dom_weak::types::{Attributes, Variant};
|
||||
|
||||
let mut attrs_a = Attributes::new();
|
||||
attrs_a.insert("Health".into(), Variant::Float32(100.0));
|
||||
attrs_a.insert("Speed".into(), Variant::Float32(16.0));
|
||||
|
||||
let mut attrs_b = Attributes::new();
|
||||
attrs_b.insert("Health".into(), Variant::Float32(50.0));
|
||||
attrs_b.insert("Damage".into(), Variant::Float32(25.0));
|
||||
|
||||
let snap_a = make_snapshot_with_props(
|
||||
"Mob",
|
||||
"Part",
|
||||
vec![("Attributes", Variant::Attributes(attrs_a.clone()))],
|
||||
);
|
||||
let snap_b = make_snapshot_with_props(
|
||||
"Mob",
|
||||
"Part",
|
||||
vec![("Attributes", Variant::Attributes(attrs_b.clone()))],
|
||||
);
|
||||
|
||||
let tree_a = make_snapshot_with_props(
|
||||
"Mob",
|
||||
"Part",
|
||||
vec![("Attributes", Variant::Attributes(attrs_a))],
|
||||
);
|
||||
let tree_b = make_snapshot_with_props(
|
||||
"Mob",
|
||||
"Part",
|
||||
vec![("Attributes", Variant::Attributes(attrs_b))],
|
||||
);
|
||||
|
||||
let (tree, tree_refs) = make_tree_from_snapshots(vec![tree_b, tree_a]);
|
||||
let result = match_forward(
|
||||
vec![snap_a, snap_b],
|
||||
&tree_refs,
|
||||
&tree,
|
||||
&MatchingSession::new(),
|
||||
);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
for (snap, tree_ref) in &result.matched {
|
||||
let inst = tree.get_instance(*tree_ref).unwrap();
|
||||
let snap_attrs = snap.properties.get(&ustr("Attributes"));
|
||||
let tree_attrs = inst.properties().get(&ustr("Attributes"));
|
||||
assert!(
|
||||
variant_eq(snap_attrs.unwrap(), tree_attrs.unwrap()),
|
||||
"Attributes mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_scoring_forward_sync() {
|
||||
use rbx_dom_weak::types::Variant;
|
||||
|
||||
// Build a tree with 2 Models ("Weapon"), each containing a differently
|
||||
// named child Part. A Ref property on each Model points to its child.
|
||||
// The matching must pair each snapshot Model to the tree Model whose
|
||||
// Ref target has the same Name+ClassName.
|
||||
|
||||
// Build a tree with 2 Models ("Weapon"), each containing a
|
||||
// differently-named child Part. Ref property on each Model points
|
||||
// to its child. Tree order is reversed relative to snapshots.
|
||||
let tree_child_handle = {
|
||||
let mut s = make_snapshot("Handle", "Part");
|
||||
s.properties
|
||||
.insert(ustr("Transparency"), Variant::Float32(0.0));
|
||||
s
|
||||
};
|
||||
let tree_child_grip = {
|
||||
let mut s = make_snapshot("Grip", "Part");
|
||||
s.properties
|
||||
.insert(ustr("Transparency"), Variant::Float32(0.0));
|
||||
s
|
||||
};
|
||||
let tree_model_grip_snap = make_snapshot_with_children(
|
||||
"Weapon",
|
||||
"Model",
|
||||
vec![tree_child_grip],
|
||||
);
|
||||
let tree_model_handle_snap = make_snapshot_with_children(
|
||||
"Weapon",
|
||||
"Model",
|
||||
vec![tree_child_handle],
|
||||
);
|
||||
|
||||
let (tree2, tree_refs2) =
|
||||
make_tree_from_snapshots(vec![tree_model_grip_snap, tree_model_handle_snap]);
|
||||
|
||||
let tree2_model_0 = tree2.get_instance(tree_refs2[0]).unwrap();
|
||||
let tree2_model_1 = tree2.get_instance(tree_refs2[1]).unwrap();
|
||||
let tree2_grip_ref = tree2_model_0.children()[0];
|
||||
let tree2_handle_ref = tree2_model_1.children()[0];
|
||||
|
||||
// Rebuild snapshots with Refs pointing into tree2
|
||||
let snap_handle2 = {
|
||||
let mut s = make_snapshot_with_children(
|
||||
"Weapon",
|
||||
"Model",
|
||||
vec![make_snapshot("Handle", "Part")],
|
||||
);
|
||||
s.properties
|
||||
.insert(ustr("PrimaryPart"), Variant::Ref(tree2_handle_ref));
|
||||
s
|
||||
};
|
||||
let snap_grip2 = {
|
||||
let mut s = make_snapshot_with_children(
|
||||
"Weapon",
|
||||
"Model",
|
||||
vec![make_snapshot("Grip", "Part")],
|
||||
);
|
||||
s.properties
|
||||
.insert(ustr("PrimaryPart"), Variant::Ref(tree2_grip_ref));
|
||||
s
|
||||
};
|
||||
|
||||
let result = match_forward(
|
||||
vec![snap_handle2, snap_grip2],
|
||||
&tree_refs2,
|
||||
&tree2,
|
||||
&MatchingSession::new(),
|
||||
);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_snapshot.is_empty());
|
||||
assert!(result.unmatched_tree.is_empty());
|
||||
|
||||
// Verify correct pairing: each snap should match the tree model
|
||||
// whose child has the same name as the snap's child.
|
||||
for (snap, tree_ref) in &result.matched {
|
||||
let snap_child_name = &snap.children[0].name;
|
||||
let tree_inst = tree2.get_instance(*tree_ref).unwrap();
|
||||
let tree_child_ref = tree_inst.children()[0];
|
||||
let tree_child = tree2.get_instance(tree_child_ref).unwrap();
|
||||
assert_eq!(
|
||||
snap_child_name.as_ref(),
|
||||
tree_child.name(),
|
||||
"Ref scoring failed: snap with child '{}' matched tree with child '{}'",
|
||||
snap_child_name,
|
||||
tree_child.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use std::cell::RefCell;
|
|||
use std::collections::HashMap;
|
||||
|
||||
use blake3::Hash;
|
||||
use rbx_dom_weak::types::Ref;
|
||||
use rbx_dom_weak::types::{Ref, Variant};
|
||||
use rbx_dom_weak::{Ustr, WeakDom};
|
||||
|
||||
use crate::variant_eq::variant_eq;
|
||||
|
|
@ -214,7 +214,7 @@ fn build_result(
|
|||
old_matched: &[bool],
|
||||
mut matched_indices: Vec<(usize, usize)>,
|
||||
) -> MatchResult {
|
||||
matched_indices.sort_by_key(|&(ni, _)| ni); // TODO: this seems sketchy?
|
||||
matched_indices.sort_by_key(|&(ni, _)| ni);
|
||||
let matched: Vec<(Ref, Ref)> = matched_indices
|
||||
.into_iter()
|
||||
.map(|(ni, oi)| (new_children[ni], old_children[oi]))
|
||||
|
|
@ -492,7 +492,9 @@ fn compute_change_count(
|
|||
}
|
||||
|
||||
/// Count own property diffs between two WeakDom instances (flat, non-recursive).
|
||||
/// Each differing property = +1. Children count diff = +1.
|
||||
/// Tags and Attributes counted granularly (per-tag and per-attribute diffs).
|
||||
/// Ref properties compared by target Name+ClassName at UNMATCHED_PENALTY per
|
||||
/// mismatch. Children count diff = +1.
|
||||
/// Does NOT include hash fast-path (handled by `compute_change_count`).
|
||||
///
|
||||
/// Properties present on only one side are skipped when their value matches
|
||||
|
|
@ -516,15 +518,13 @@ fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &Weak
|
|||
|
||||
for (key, new_val) in new_inst.properties.iter() {
|
||||
if let Some(old_val) = old_inst.properties.get(key) {
|
||||
if !variant_eq(new_val, old_val) {
|
||||
cost += 1;
|
||||
}
|
||||
cost += diff_variant_pair(new_val, old_val, new_dom, old_dom);
|
||||
} else {
|
||||
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;
|
||||
cost += count_variant_one_sided(new_val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -535,7 +535,7 @@ fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &Weak
|
|||
.and_then(|cd| cd.default_properties.get(key.as_str()))
|
||||
.is_some_and(|default| variant_eq(old_val, default));
|
||||
if !is_default {
|
||||
cost += 1;
|
||||
cost += count_variant_one_sided(old_val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -547,6 +547,89 @@ fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &Weak
|
|||
cost
|
||||
}
|
||||
|
||||
/// Compare two Variant values with type-aware scoring for syncback.
|
||||
/// Ref targets resolved by Name+ClassName from their respective DOMs.
|
||||
#[inline]
|
||||
fn diff_variant_pair(a: &Variant, b: &Variant, a_dom: &WeakDom, b_dom: &WeakDom) -> u32 {
|
||||
match (a, b) {
|
||||
(Variant::Ref(ref_a), Variant::Ref(ref_b)) => {
|
||||
if ref_a == ref_b {
|
||||
return 0;
|
||||
}
|
||||
let a_none = !ref_a.is_some();
|
||||
let b_none = !ref_b.is_some();
|
||||
if a_none != b_none {
|
||||
return UNMATCHED_PENALTY;
|
||||
}
|
||||
if a_none && b_none {
|
||||
return 0;
|
||||
}
|
||||
let target_a = a_dom.get_by_ref(*ref_a);
|
||||
let target_b = b_dom.get_by_ref(*ref_b);
|
||||
match (target_a, target_b) {
|
||||
(Some(ta), Some(tb)) => {
|
||||
if ta.name == tb.name && ta.class == tb.class {
|
||||
0
|
||||
} else {
|
||||
UNMATCHED_PENALTY
|
||||
}
|
||||
}
|
||||
(None, None) => 0,
|
||||
_ => UNMATCHED_PENALTY,
|
||||
}
|
||||
}
|
||||
(Variant::Tags(tags_a), Variant::Tags(tags_b)) => {
|
||||
use std::collections::HashSet;
|
||||
let set_a: HashSet<&str> = tags_a.iter().collect();
|
||||
let set_b: HashSet<&str> = tags_b.iter().collect();
|
||||
(set_a.difference(&set_b).count() + set_b.difference(&set_a).count()) as u32
|
||||
}
|
||||
(Variant::Attributes(attrs_a), Variant::Attributes(attrs_b)) => {
|
||||
let mut cost: u32 = 0;
|
||||
for (key, a_val) in attrs_a.iter() {
|
||||
match attrs_b.get(key.as_str()) {
|
||||
Some(b_val) => {
|
||||
if !variant_eq(a_val, b_val) {
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
None => cost += 1,
|
||||
}
|
||||
}
|
||||
for (key, _) in attrs_b.iter() {
|
||||
if attrs_a.get(key.as_str()).is_none() {
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
cost
|
||||
}
|
||||
_ => {
|
||||
if variant_eq(a, b) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cost for a one-sided Variant (property present on only one side).
|
||||
#[inline]
|
||||
fn count_variant_one_sided(val: &Variant) -> u32 {
|
||||
match val {
|
||||
Variant::Ref(r) => {
|
||||
if r.is_some() {
|
||||
UNMATCHED_PENALTY
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Variant::Tags(tags) => tags.len() as u32,
|
||||
Variant::Attributes(attrs) => attrs.len() as u32,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -784,4 +867,113 @@ mod tests {
|
|||
);
|
||||
assert_eq!(r1.matched.len(), r2.matched.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_scoring_syncback() {
|
||||
use rbx_dom_weak::types::Variant;
|
||||
|
||||
// 2 Models named "Weapon" in each DOM, each with a differently-named
|
||||
// child Part. A Ref property on each Model points to its own child.
|
||||
// Syncback matching must pair by Ref target Name+ClassName, not by
|
||||
// raw Ref identity (which always differs between DOMs).
|
||||
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
|
||||
let new_model_a = new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Model").with_name("Weapon"),
|
||||
);
|
||||
let new_handle = new_dom.insert(
|
||||
new_model_a,
|
||||
InstanceBuilder::new("Part").with_name("Handle"),
|
||||
);
|
||||
new_dom
|
||||
.get_by_ref_mut(new_model_a)
|
||||
.unwrap()
|
||||
.properties
|
||||
.insert("PrimaryPart".into(), Variant::Ref(new_handle));
|
||||
|
||||
let new_model_b = new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Model").with_name("Weapon"),
|
||||
);
|
||||
let new_grip = new_dom.insert(
|
||||
new_model_b,
|
||||
InstanceBuilder::new("Part").with_name("Grip"),
|
||||
);
|
||||
new_dom
|
||||
.get_by_ref_mut(new_model_b)
|
||||
.unwrap()
|
||||
.properties
|
||||
.insert("PrimaryPart".into(), Variant::Ref(new_grip));
|
||||
|
||||
// Old DOM: reversed order (Grip-model first, Handle-model second)
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
|
||||
let old_model_b = old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Model").with_name("Weapon"),
|
||||
);
|
||||
let old_grip = old_dom.insert(
|
||||
old_model_b,
|
||||
InstanceBuilder::new("Part").with_name("Grip"),
|
||||
);
|
||||
old_dom
|
||||
.get_by_ref_mut(old_model_b)
|
||||
.unwrap()
|
||||
.properties
|
||||
.insert("PrimaryPart".into(), Variant::Ref(old_grip));
|
||||
|
||||
let old_model_a = old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Model").with_name("Weapon"),
|
||||
);
|
||||
let old_handle = old_dom.insert(
|
||||
old_model_a,
|
||||
InstanceBuilder::new("Part").with_name("Handle"),
|
||||
);
|
||||
old_dom
|
||||
.get_by_ref_mut(old_model_a)
|
||||
.unwrap()
|
||||
.properties
|
||||
.insert("PrimaryPart".into(), Variant::Ref(old_handle));
|
||||
|
||||
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
|
||||
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
|
||||
|
||||
let result = match_children(
|
||||
&new_children,
|
||||
&old_children,
|
||||
&new_dom,
|
||||
&old_dom,
|
||||
None,
|
||||
None,
|
||||
&MatchingSession::new(),
|
||||
);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
|
||||
for (new_ref, old_ref) in &result.matched {
|
||||
let new_inst = new_dom.get_by_ref(*new_ref).unwrap();
|
||||
let old_inst = old_dom.get_by_ref(*old_ref).unwrap();
|
||||
|
||||
let new_child_name = &new_dom
|
||||
.get_by_ref(new_inst.children()[0])
|
||||
.unwrap()
|
||||
.name;
|
||||
let old_child_name = &old_dom
|
||||
.get_by_ref(old_inst.children()[0])
|
||||
.unwrap()
|
||||
.name;
|
||||
assert_eq!(
|
||||
new_child_name, old_child_name,
|
||||
"Ref scoring failed: new model with child '{}' matched old model with child '{}'",
|
||||
new_child_name, old_child_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,34 @@
|
|||
use rbx_dom_weak::types::{PhysicalProperties, Variant, Vector3};
|
||||
|
||||
/// Accepts three argumets: a float type and two values to compare.
|
||||
///
|
||||
/// Returns a bool indicating whether they're equal. This accounts for NaN such
|
||||
/// that `approx_eq!(f32, f32::NAN, f32::NAN)` is `true`.
|
||||
macro_rules! approx_eq {
|
||||
($Ty:ty, $a:expr, $b:expr) => {
|
||||
float_cmp::approx_eq!($Ty, $a, $b) || $a.is_nan() && $b.is_nan()
|
||||
};
|
||||
const EPSILON_F32: f32 = 0.0001;
|
||||
const EPSILON_F64: f64 = 0.0001;
|
||||
|
||||
/// Fuzzy float equality matching Lua trueEquals: absolute OR relative epsilon.
|
||||
/// NaN == NaN is true.
|
||||
#[inline(always)]
|
||||
fn fuzzy_eq_f32(a: f32, b: f32) -> bool {
|
||||
if a.is_nan() {
|
||||
return b.is_nan();
|
||||
}
|
||||
if b.is_nan() {
|
||||
return false;
|
||||
}
|
||||
let diff = (a - b).abs();
|
||||
let max_val = a.abs().max(b.abs()).max(1.0);
|
||||
diff < EPSILON_F32 || diff < max_val * EPSILON_F32
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn fuzzy_eq_f64(a: f64, b: f64) -> bool {
|
||||
if a.is_nan() {
|
||||
return b.is_nan();
|
||||
}
|
||||
if b.is_nan() {
|
||||
return false;
|
||||
}
|
||||
let diff = (a - b).abs();
|
||||
let max_val = a.abs().max(b.abs()).max(1.0);
|
||||
diff < EPSILON_F64 || diff < max_val * EPSILON_F64
|
||||
}
|
||||
|
||||
/// Compares two variants to determine if they're equal. This correctly takes
|
||||
|
|
@ -46,7 +67,7 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
&& vector_eq(&a.orientation.z, &b.orientation.z)
|
||||
}
|
||||
(Variant::Color3(a), Variant::Color3(b)) => {
|
||||
approx_eq!(f32, a.r, b.r) && approx_eq!(f32, a.g, b.g) && approx_eq!(f32, a.b, b.b)
|
||||
fuzzy_eq_f32(a.r, b.r) && fuzzy_eq_f32(a.g, b.g) && fuzzy_eq_f32(a.b, b.b)
|
||||
}
|
||||
(Variant::Color3uint8(a), Variant::Color3uint8(b)) => a == b,
|
||||
(Variant::ColorSequence(a), Variant::ColorSequence(b)) => {
|
||||
|
|
@ -59,10 +80,10 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
b_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap());
|
||||
|
||||
for (a_kp, b_kp) in a_keypoints.iter().zip(b_keypoints) {
|
||||
if !(approx_eq!(f32, a_kp.time, b_kp.time)
|
||||
&& approx_eq!(f32, a_kp.color.r, b_kp.color.r)
|
||||
&& approx_eq!(f32, a_kp.color.g, b_kp.color.g)
|
||||
&& approx_eq!(f32, a_kp.color.b, b_kp.color.b))
|
||||
if !(fuzzy_eq_f32(a_kp.time, b_kp.time)
|
||||
&& fuzzy_eq_f32(a_kp.color.r, b_kp.color.r)
|
||||
&& fuzzy_eq_f32(a_kp.color.g, b_kp.color.g)
|
||||
&& fuzzy_eq_f32(a_kp.color.b, b_kp.color.b))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -74,8 +95,8 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
(Variant::Enum(a), Variant::Enum(b)) => a == b,
|
||||
(Variant::EnumItem(a), Variant::EnumItem(b)) => a == b,
|
||||
(Variant::Faces(a), Variant::Faces(b)) => a == b,
|
||||
(Variant::Float32(a), Variant::Float32(b)) => approx_eq!(f32, *a, *b),
|
||||
(Variant::Float64(a), Variant::Float64(b)) => approx_eq!(f64, *a, *b),
|
||||
(Variant::Float32(a), Variant::Float32(b)) => fuzzy_eq_f32(*a, *b),
|
||||
(Variant::Float64(a), Variant::Float64(b)) => fuzzy_eq_f64(*a, *b),
|
||||
(Variant::Font(a), Variant::Font(b)) => {
|
||||
a.weight == b.weight
|
||||
&& a.style == b.style
|
||||
|
|
@ -87,7 +108,7 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
(Variant::MaterialColors(a), Variant::MaterialColors(b)) => a.encode() == b.encode(),
|
||||
(Variant::NetAssetRef(a), Variant::NetAssetRef(b)) => a == b,
|
||||
(Variant::NumberRange(a), Variant::NumberRange(b)) => {
|
||||
approx_eq!(f32, a.max, b.max) && approx_eq!(f32, a.min, b.min)
|
||||
fuzzy_eq_f32(a.max, b.max) && fuzzy_eq_f32(a.min, b.min)
|
||||
}
|
||||
(Variant::NumberSequence(a), Variant::NumberSequence(b)) => {
|
||||
if a.keypoints.len() != b.keypoints.len() {
|
||||
|
|
@ -99,9 +120,9 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
b_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap());
|
||||
|
||||
for (a_kp, b_kp) in a_keypoints.iter().zip(b_keypoints) {
|
||||
if !(approx_eq!(f32, a_kp.time, b_kp.time)
|
||||
&& approx_eq!(f32, a_kp.value, b_kp.value)
|
||||
&& approx_eq!(f32, a_kp.envelope, b_kp.envelope))
|
||||
if !(fuzzy_eq_f32(a_kp.time, b_kp.time)
|
||||
&& fuzzy_eq_f32(a_kp.value, b_kp.value)
|
||||
&& fuzzy_eq_f32(a_kp.envelope, b_kp.envelope))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -121,12 +142,12 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
(Variant::PhysicalProperties(a), Variant::PhysicalProperties(b)) => match (a, b) {
|
||||
(PhysicalProperties::Default, PhysicalProperties::Default) => true,
|
||||
(PhysicalProperties::Custom(a2), PhysicalProperties::Custom(b2)) => {
|
||||
approx_eq!(f32, a2.density(), b2.density())
|
||||
&& approx_eq!(f32, a2.elasticity(), b2.elasticity())
|
||||
&& approx_eq!(f32, a2.friction(), b2.friction())
|
||||
&& approx_eq!(f32, a2.elasticity_weight(), b2.elasticity_weight())
|
||||
&& approx_eq!(f32, a2.friction_weight(), b2.friction_weight())
|
||||
&& approx_eq!(f32, a2.acoustic_absorption(), b2.acoustic_absorption())
|
||||
fuzzy_eq_f32(a2.density(), b2.density())
|
||||
&& fuzzy_eq_f32(a2.elasticity(), b2.elasticity())
|
||||
&& fuzzy_eq_f32(a2.friction(), b2.friction())
|
||||
&& fuzzy_eq_f32(a2.elasticity_weight(), b2.elasticity_weight())
|
||||
&& fuzzy_eq_f32(a2.friction_weight(), b2.friction_weight())
|
||||
&& fuzzy_eq_f32(a2.acoustic_absorption(), b2.acoustic_absorption())
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
|
|
@ -134,10 +155,10 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
vector_eq(&a.direction, &b.direction) && vector_eq(&a.origin, &b.origin)
|
||||
}
|
||||
(Variant::Rect(a), Variant::Rect(b)) => {
|
||||
approx_eq!(f32, a.max.x, b.max.x)
|
||||
&& approx_eq!(f32, a.max.y, b.max.y)
|
||||
&& approx_eq!(f32, a.min.x, b.min.x)
|
||||
&& approx_eq!(f32, a.min.y, b.min.y)
|
||||
fuzzy_eq_f32(a.max.x, b.max.x)
|
||||
&& fuzzy_eq_f32(a.max.y, b.max.y)
|
||||
&& fuzzy_eq_f32(a.min.x, b.min.x)
|
||||
&& fuzzy_eq_f32(a.min.y, b.min.y)
|
||||
}
|
||||
(Variant::Ref(a), Variant::Ref(b)) => a == b,
|
||||
(Variant::Region3(a), Variant::Region3(b)) => {
|
||||
|
|
@ -163,18 +184,18 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
}
|
||||
}
|
||||
(Variant::UDim(a), Variant::UDim(b)) => {
|
||||
approx_eq!(f32, a.scale, b.scale) && a.offset == b.offset
|
||||
fuzzy_eq_f32(a.scale, b.scale) && a.offset == b.offset
|
||||
}
|
||||
(Variant::UDim2(a), Variant::UDim2(b)) => {
|
||||
approx_eq!(f32, a.x.scale, b.x.scale)
|
||||
fuzzy_eq_f32(a.x.scale, b.x.scale)
|
||||
&& a.x.offset == b.x.offset
|
||||
&& approx_eq!(f32, a.y.scale, b.y.scale)
|
||||
&& fuzzy_eq_f32(a.y.scale, b.y.scale)
|
||||
&& a.y.offset == b.y.offset
|
||||
}
|
||||
(Variant::UniqueId(a), Variant::UniqueId(b)) => a == b,
|
||||
(Variant::String(a), Variant::String(b)) => a == b,
|
||||
(Variant::Vector2(a), Variant::Vector2(b)) => {
|
||||
approx_eq!(f32, a.x, b.x) && approx_eq!(f32, a.y, b.y)
|
||||
fuzzy_eq_f32(a.x, b.x) && fuzzy_eq_f32(a.y, b.y)
|
||||
}
|
||||
(Variant::Vector2int16(a), Variant::Vector2int16(b)) => a == b,
|
||||
(Variant::Vector3(a), Variant::Vector3(b)) => vector_eq(a, b),
|
||||
|
|
@ -189,5 +210,53 @@ pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
|||
|
||||
#[inline(always)]
|
||||
fn vector_eq(a: &Vector3, b: &Vector3) -> bool {
|
||||
approx_eq!(f32, a.x, b.x) && approx_eq!(f32, a.y, b.y) && approx_eq!(f32, a.z, b.z)
|
||||
fuzzy_eq_f32(a.x, b.x) && fuzzy_eq_f32(a.y, b.y) && fuzzy_eq_f32(a.z, b.z)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fuzzy_eq_matches_lua_absolute_epsilon() {
|
||||
assert!(fuzzy_eq_f32(1.0, 1.0 + 0.000099));
|
||||
assert!(!fuzzy_eq_f32(1.0, 1.0 + 0.00011));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_eq_matches_lua_relative_epsilon() {
|
||||
assert!(fuzzy_eq_f32(10000.0, 10000.0 + 0.9));
|
||||
assert!(!fuzzy_eq_f32(10000.0, 10000.0 + 1.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_eq_nan_handling() {
|
||||
assert!(fuzzy_eq_f32(f32::NAN, f32::NAN));
|
||||
assert!(!fuzzy_eq_f32(f32::NAN, 0.0));
|
||||
assert!(!fuzzy_eq_f32(0.0, f32::NAN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_eq_zero_and_negative_zero() {
|
||||
assert!(fuzzy_eq_f32(0.0, -0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_eq_f64_basic() {
|
||||
assert!(fuzzy_eq_f64(1.0, 1.0 + 0.000099));
|
||||
assert!(!fuzzy_eq_f64(1.0, 1.0 + 0.00011));
|
||||
assert!(fuzzy_eq_f64(f64::NAN, f64::NAN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_eq_float32_with_new_epsilon() {
|
||||
assert!(variant_eq(
|
||||
&Variant::Float32(1.0),
|
||||
&Variant::Float32(1.0 + 0.000099)
|
||||
));
|
||||
assert!(!variant_eq(
|
||||
&Variant::Float32(1.0),
|
||||
&Variant::Float32(1.0 + 0.00011)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use std::collections::HashMap;
|
|||
use std::path::Path;
|
||||
|
||||
use rbx_dom_weak::types::{Ref, Variant};
|
||||
use rbx_dom_weak::{ustr, WeakDom};
|
||||
use rbx_dom_weak::{ustr, HashMapExt as _, WeakDom};
|
||||
|
||||
use crate::rojo_test::io_util::SYNCBACK_TESTS_PATH;
|
||||
|
||||
|
|
@ -339,19 +339,340 @@ fn forward_matching_ufowave_textures() {
|
|||
);
|
||||
}
|
||||
(None, Some(tv)) => {
|
||||
// Snapshot omitted Face (default = Front)
|
||||
assert!(
|
||||
librojo::variant_eq::variant_eq(tv, &face_front),
|
||||
"Omitted Face should match Front, got {:?}",
|
||||
tv
|
||||
);
|
||||
}
|
||||
(None, None) => {
|
||||
// Both omitted: both at default, fine
|
||||
}
|
||||
(None, None) => {}
|
||||
(Some(sv), None) => {
|
||||
panic!("Snap has Face={:?} but tree has none", sv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Parity: forward sync vs syncback produce same pairings
|
||||
// ================================================================
|
||||
|
||||
#[test]
|
||||
fn parity_forward_vs_syncback() {
|
||||
use rbx_dom_weak::types::Variant;
|
||||
use rbx_dom_weak::InstanceBuilder;
|
||||
use std::borrow::Cow;
|
||||
|
||||
let transparency_values = [0.0_f32, 0.3, 0.6, 0.9];
|
||||
|
||||
// Build "new" WeakDom (for syncback) with parts in forward order
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
for &t in &transparency_values {
|
||||
let builder = InstanceBuilder::new("Part")
|
||||
.with_name("Line")
|
||||
.with_property("Transparency", Variant::Float32(t));
|
||||
new_dom.insert(new_root, builder);
|
||||
}
|
||||
|
||||
// Build "old" WeakDom (for syncback) with parts in REVERSED order
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
for &t in transparency_values.iter().rev() {
|
||||
let builder = InstanceBuilder::new("Part")
|
||||
.with_name("Line")
|
||||
.with_property("Transparency", Variant::Float32(t));
|
||||
old_dom.insert(old_root, builder);
|
||||
}
|
||||
|
||||
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
|
||||
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
|
||||
|
||||
// Run syncback matching
|
||||
let syncback_session = librojo::syncback::matching::MatchingSession::new();
|
||||
let syncback_result = librojo::syncback::matching::match_children(
|
||||
&new_children,
|
||||
&old_children,
|
||||
&new_dom,
|
||||
&old_dom,
|
||||
None,
|
||||
None,
|
||||
&syncback_session,
|
||||
);
|
||||
|
||||
// Build equivalent InstanceSnapshots for forward sync
|
||||
let snap_children: Vec<librojo::InstanceSnapshot> = transparency_values
|
||||
.iter()
|
||||
.map(|&t| {
|
||||
let mut properties = rbx_dom_weak::UstrMap::new();
|
||||
properties.insert(ustr("Transparency"), Variant::Float32(t));
|
||||
librojo::InstanceSnapshot {
|
||||
snapshot_id: Ref::none(),
|
||||
metadata: librojo::snapshot::InstanceMetadata::default(),
|
||||
name: Cow::Borrowed("Line"),
|
||||
class_name: ustr("Part"),
|
||||
properties,
|
||||
children: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build RojoTree from old_dom data (reversed order)
|
||||
let tree_snap_children: Vec<librojo::InstanceSnapshot> = transparency_values
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|&t| {
|
||||
let mut properties = rbx_dom_weak::UstrMap::new();
|
||||
properties.insert(ustr("Transparency"), Variant::Float32(t));
|
||||
librojo::InstanceSnapshot {
|
||||
snapshot_id: Ref::none(),
|
||||
metadata: librojo::snapshot::InstanceMetadata::default(),
|
||||
name: Cow::Borrowed("Line"),
|
||||
class_name: ustr("Part"),
|
||||
properties,
|
||||
children: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let root_snap = librojo::InstanceSnapshot {
|
||||
snapshot_id: Ref::none(),
|
||||
metadata: librojo::snapshot::InstanceMetadata::default(),
|
||||
name: Cow::Borrowed("DataModel"),
|
||||
class_name: ustr("DataModel"),
|
||||
properties: Default::default(),
|
||||
children: tree_snap_children,
|
||||
};
|
||||
let tree = librojo::RojoTree::new(root_snap);
|
||||
let tree_root = tree.get_root_id();
|
||||
let tree_children_refs: Vec<Ref> = tree.get_instance(tree_root).unwrap().children().to_vec();
|
||||
|
||||
// Run forward sync matching
|
||||
let forward_session = librojo::snapshot::matching::MatchingSession::new();
|
||||
let forward_result = librojo::snapshot::matching::match_forward(
|
||||
snap_children,
|
||||
&tree_children_refs,
|
||||
&tree,
|
||||
&forward_session,
|
||||
);
|
||||
|
||||
// Both should match all 4
|
||||
assert_eq!(syncback_result.matched.len(), 4);
|
||||
assert_eq!(forward_result.matched.len(), 4);
|
||||
|
||||
// Verify both produce the same pairing (by Transparency value)
|
||||
let mut syncback_pairs: Vec<(f32, f32)> = syncback_result
|
||||
.matched
|
||||
.iter()
|
||||
.map(|(nr, or)| {
|
||||
let nt = match new_dom
|
||||
.get_by_ref(*nr)
|
||||
.unwrap()
|
||||
.properties
|
||||
.get(&ustr("Transparency"))
|
||||
{
|
||||
Some(Variant::Float32(t)) => *t,
|
||||
_ => panic!("Missing Transparency on new"),
|
||||
};
|
||||
let ot = match old_dom
|
||||
.get_by_ref(*or)
|
||||
.unwrap()
|
||||
.properties
|
||||
.get(&ustr("Transparency"))
|
||||
{
|
||||
Some(Variant::Float32(t)) => *t,
|
||||
_ => panic!("Missing Transparency on old"),
|
||||
};
|
||||
(nt, ot)
|
||||
})
|
||||
.collect();
|
||||
syncback_pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
|
||||
let mut forward_pairs: Vec<(f32, f32)> = forward_result
|
||||
.matched
|
||||
.iter()
|
||||
.map(|(snap, tree_ref)| {
|
||||
let st = match snap.properties.get(&ustr("Transparency")) {
|
||||
Some(Variant::Float32(t)) => *t,
|
||||
_ => panic!("Missing Transparency on snap"),
|
||||
};
|
||||
let tt = match tree
|
||||
.get_instance(*tree_ref)
|
||||
.unwrap()
|
||||
.properties()
|
||||
.get(&ustr("Transparency"))
|
||||
{
|
||||
Some(Variant::Float32(t)) => *t,
|
||||
_ => panic!("Missing Transparency on tree"),
|
||||
};
|
||||
(st, tt)
|
||||
})
|
||||
.collect();
|
||||
forward_pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
|
||||
assert_eq!(
|
||||
syncback_pairs, forward_pairs,
|
||||
"Syncback and forward sync produced different pairings"
|
||||
);
|
||||
|
||||
// Verify correct pairing: each pair should have matching Transparency
|
||||
for (nt, ot) in &syncback_pairs {
|
||||
assert_eq!(
|
||||
nt, ot,
|
||||
"Incorrect pairing: new Transparency={} matched to old Transparency={}",
|
||||
nt, ot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Round-trip: syncback matching + forward sync matching agree
|
||||
// ================================================================
|
||||
|
||||
#[test]
|
||||
fn round_trip_matching_identity() {
|
||||
let _ = env_logger::try_init();
|
||||
|
||||
// Load the input rbxm (simulates Studio with full properties)
|
||||
let input_dom = load_input_rbxm();
|
||||
let input_root = input_dom.root_ref();
|
||||
let input_tsunami = find_descendant(&input_dom, input_root, "TsunamiWave")
|
||||
.expect("TsunamiWave not found in input.rbxm");
|
||||
let input_textures = get_texture_children(&input_dom, input_tsunami);
|
||||
assert_eq!(input_textures.len(), 12, "Expected 12 Textures in input");
|
||||
|
||||
// Load the expected/ filesystem as a WeakDom (syncback output)
|
||||
let expected_dom = load_expected_dom();
|
||||
let expected_root = expected_dom.root_ref();
|
||||
let expected_tsunami = find_descendant(&expected_dom, expected_root, "TsunamiWave")
|
||||
.expect("TsunamiWave not found in expected/");
|
||||
let expected_textures = get_texture_children(&expected_dom, expected_tsunami);
|
||||
assert_eq!(
|
||||
expected_textures.len(),
|
||||
12,
|
||||
"Expected 12 Textures in expected/"
|
||||
);
|
||||
|
||||
// Step 1: Syncback matching (input rbxm vs expected filesystem)
|
||||
let syncback_session = librojo::syncback::matching::MatchingSession::new();
|
||||
let syncback_result = librojo::syncback::matching::match_children(
|
||||
&input_textures,
|
||||
&expected_textures,
|
||||
&input_dom,
|
||||
&expected_dom,
|
||||
None,
|
||||
None,
|
||||
&syncback_session,
|
||||
);
|
||||
assert_eq!(
|
||||
syncback_result.matched.len(),
|
||||
12,
|
||||
"Syncback should match all 12"
|
||||
);
|
||||
|
||||
// Step 2: Forward sync matching (expected/ snapshots vs input-as-tree)
|
||||
let expected_path = Path::new(SYNCBACK_TESTS_PATH)
|
||||
.join(FIXTURE_DIR)
|
||||
.join("expected");
|
||||
let vfs = memofs::Vfs::new_default();
|
||||
let ctx = librojo::InstanceContext::default();
|
||||
let full_snapshot = librojo::snapshot_from_vfs(&ctx, &vfs, &expected_path)
|
||||
.expect("Failed to snapshot expected/")
|
||||
.expect("snapshot_from_vfs returned None");
|
||||
let snap_tsunami = full_snapshot
|
||||
.children
|
||||
.iter()
|
||||
.find(|c| c.name == "TsunamiWave")
|
||||
.expect("TsunamiWave not found in snapshot");
|
||||
let snap_textures: Vec<librojo::InstanceSnapshot> = snap_tsunami
|
||||
.children
|
||||
.iter()
|
||||
.filter(|c| c.class_name.as_str() == "Texture")
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(snap_textures.len(), 12, "Expected 12 snapshot Textures");
|
||||
|
||||
let input_dom2 = load_input_rbxm();
|
||||
let root_ref2 = input_dom2.root_ref();
|
||||
let input_snapshot = librojo::InstanceSnapshot::from_tree(input_dom2, root_ref2);
|
||||
let tree = librojo::RojoTree::new(input_snapshot);
|
||||
let tree_root = tree.get_root_id();
|
||||
let tree_tsunami = find_descendant_by_name(&tree, tree_root, "TsunamiWave")
|
||||
.expect("TsunamiWave not found in tree");
|
||||
let tree_textures: Vec<Ref> = tree
|
||||
.get_instance(tree_tsunami)
|
||||
.unwrap()
|
||||
.children()
|
||||
.iter()
|
||||
.filter(|&&r| {
|
||||
tree.get_instance(r)
|
||||
.is_some_and(|i| i.class_name().as_str() == "Texture")
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
assert_eq!(tree_textures.len(), 12, "Expected 12 tree Textures");
|
||||
|
||||
let forward_session = librojo::snapshot::matching::MatchingSession::new();
|
||||
let forward_result = librojo::snapshot::matching::match_forward(
|
||||
snap_textures,
|
||||
&tree_textures,
|
||||
&tree,
|
||||
&forward_session,
|
||||
);
|
||||
assert_eq!(
|
||||
forward_result.matched.len(),
|
||||
12,
|
||||
"Forward sync should match all 12"
|
||||
);
|
||||
|
||||
// Step 3: Verify both directions paired by the same Face value.
|
||||
// Syncback pairs: (input_face, expected_face)
|
||||
let face_front = 5_u32;
|
||||
let mut syncback_faces: Vec<(u32, u32)> = syncback_result
|
||||
.matched
|
||||
.iter()
|
||||
.map(|(input_ref, expected_ref)| {
|
||||
let iface = get_face(&input_dom, *input_ref).unwrap_or(face_front);
|
||||
let eface = get_face(&expected_dom, *expected_ref).unwrap_or(face_front);
|
||||
(iface, eface)
|
||||
})
|
||||
.collect();
|
||||
syncback_faces.sort();
|
||||
|
||||
// Forward pairs: (snap_face, tree_face)
|
||||
let mut forward_faces: Vec<(u32, u32)> = forward_result
|
||||
.matched
|
||||
.iter()
|
||||
.map(|(snap, tree_ref)| {
|
||||
let sface = snap
|
||||
.properties
|
||||
.get(&ustr("Face"))
|
||||
.and_then(|v| match v {
|
||||
Variant::Enum(e) => Some(e.to_u32()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(face_front);
|
||||
let tface = tree
|
||||
.get_instance(*tree_ref)
|
||||
.and_then(|i| i.properties().get(&ustr("Face")))
|
||||
.and_then(|v| match v {
|
||||
Variant::Enum(e) => Some(e.to_u32()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(face_front);
|
||||
(sface, tface)
|
||||
})
|
||||
.collect();
|
||||
forward_faces.sort();
|
||||
|
||||
// Both directions must produce identical (face, face) pairs
|
||||
assert_eq!(
|
||||
syncback_faces, forward_faces,
|
||||
"Round-trip mismatch: syncback and forward sync disagree on Face pairings"
|
||||
);
|
||||
|
||||
// Every pair must have matching Face values
|
||||
for (a, b) in &syncback_faces {
|
||||
assert_eq!(a, b, "Face mismatch in round-trip: {} vs {}", a, b);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue