diff --git a/.llm/context.md b/.llm/context.md index 13578f003..5b63764ba 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -50,7 +50,7 @@ See [create-csharp-file](./skills/create-csharp-file.md) for detailed C# rules. 1. `using` directives INSIDE namespace; `#if` blocks INSIDE namespace; `#define` at file top 2. NO underscores in method names (including tests) -3. Explicit types over `var` +3. Explicit types over `var`. Prefer compiler-proven definite assignment or direct initialization over placeholder `default` assignments; retain `default` when it expresses a real return or failure contract. 4. **NEVER use `#region` or `#endregion`** (see [no-regions](./skills/no-regions.md)) 5. NEVER use nullable reference types (`string?`) 6. One file per MonoBehaviour/ScriptableObject (production AND tests). One member ordering across the codebase (#672): const, events, delegates, static properties, static fields, properties, fields, constructors, static methods, methods -- each tier ordered public → protected → internal → private, including const. A nested type goes at the END of its containing type or in its own file, never between members. `npm run lint:nested-type-placement` enforces both and `:fix` reorders what it can; a member whose move would cross a `#if` boundary or a directive is reported, never moved ([#575](https://github.com/Ambiguous-Interactive/unity-helpers/issues/575), [#672](https://github.com/Ambiguous-Interactive/unity-helpers/issues/672); see [create-csharp-file](./skills/create-csharp-file.md)) @@ -58,7 +58,7 @@ See [create-csharp-file](./skills/create-csharp-file.md) for detailed C# rules. 8. **Aim for zero comments.** Reach for a better name before a better sentence, and spell names out rather than abbreviating. A comment that survives that explains **why**, never **what**; a non-doc comment INSIDE a type or member spanning more than one line uses the `/* ... */` block form, the two-line license header excepted (see [create-csharp-file](./skills/create-csharp-file.md)). `npm run lint:comment-block-form` enforces the block form and `:fix` converts what it can; `Runtime/`, `Editor/`, `Tests/` and `Generator~/` are enforced without baseline exemptions ([#635](https://github.com/Ambiguous-Interactive/unity-helpers/issues/635)). Delete before converting -- a run that only restates the code says **what** 9. Generate `.meta` files with `./scripts/generate-meta.sh ` after creating ANY file/folder -- never commit Unity's auto-written stub, which omits the importer block. And **an `AddComponent`-able MonoBehaviour belongs in a runtime-capable test assembly**: Unity refuses one it can identify as an editor script, and a type with no `MonoScript` merely escapes that policy until someone gives it a correctly-named file (12 red tests, session 244). `npm run lint:editor-assembly-monobehaviours` now holds it statically from `includePlatforms` plus asmdef ownership, over the 14 that legitimately live in an Editor-only assembly -- each carrying a reason and a FROZEN `AddComponent` site count, so a new call site reds the excuse instead of hiding behind it ([#678](https://github.com/Ambiguous-Interactive/unity-helpers/issues/678)). Exception: no `.meta` for dot folders (`.llm/`, `.github/`, `.git/`, `.vscode/`). See [create-unity-meta](./skills/create-unity-meta.md) 10. Enums: explicit values, `None`/`Unknown` = 0 with `[Obsolete]` (see [create-enum](./skills/create-enum.md)) -11. Never reflect on our own code; use `internal` + `[InternalsVisibleTo]` (see [avoid-reflection](./skills/avoid-reflection.md)) +11. Never reflect on our own code; expose actual implementation members through `internal` + `[InternalsVisibleTo]`. Production code must contain no test-only methods, wrappers, state, callbacks, branches or instrumentation. Do not add or rename `ForTest`, `ForTesting`, `ForTests`, `TestHooks` or `TestOnly` members. Keep test setup and simulated failures in test assemblies; use real implementation phases and filesystem/lifecycle controls. Legitimate production discovery of test assemblies remains supported (see [avoid-reflection](./skills/avoid-reflection.md)). 12. Never use magic strings; use `nameof()` (see [avoid-magic-strings](./skills/avoid-magic-strings.md)) 13. All code must follow [high-performance-csharp](./skills/high-performance-csharp.md) and [defensive-programming](./skills/defensive-programming.md) (never throw from public APIs; use `TryXxx` patterns; handle all inputs gracefully) 14. For forbidden patterns and alternatives, see [forbidden-patterns reference](./references/forbidden-patterns.md) diff --git a/.llm/references/avoid-reflection-part-1.md b/.llm/references/avoid-reflection-part-1.md index 517a8c3f2..95f0e28a5 100644 --- a/.llm/references/avoid-reflection-part-1.md +++ b/.llm/references/avoid-reflection-part-1.md @@ -35,6 +35,10 @@ Do not use these reflection APIs to access WallstopStudios code members: Change `private` members to `internal` and rely on the `InternalsVisibleTo` infrastructure already in place. +Expose the actual implementation, not a test-only wrapper. Shipped production code must contain no test-only APIs, state, callbacks, branches or instrumentation, including code hidden behind `UNITY_INCLUDE_TESTS`. Never introduce `ForTest`, `ForTesting`, `ForTests`, `TestHooks` or `TestOnly` members, and do not merely rename such hooks. Test setup and simulated failures belong in test assemblies. Prefer the real lifecycle method, algorithm phase, state field or filesystem primitive; preserve failure and cleanup controls while changing their setup. Legitimate production discovery of test assemblies and authoring types remains supported. + +`npm run lint:production-test-hooks` scans shipped C# under Runtime, Editor, production Generator projects, Samples and Styles, including generator source literals. It rejects forbidden naming tokens in ordinary source and emitted literal code, plus explicit test-only hook/code marker forms; its contract tests retain rejecting and accepting controls. Test projects and the enumerated host check tools are excluded. Every expected production root must exist as a directory, and the scan must find production C#; missing roots and empty scans fail rather than accepting a subset. This naming/marker gate is not a complete C# parser. Interpolation expressions inside strings are masked; references or local functions found only inside those expressions are not covered. It does not prove semantic absence of test-only code: review production call sites and behavior, including suffix-free injected delegates and conditional instrumentation. Do not claim end-to-end scheduling coverage when a test exercises only separately exposed implementation phases. + ```csharp // ❌ FORBIDDEN - Reflection on our code var field = typeof(OurClass).GetField("_someField", BindingFlags.NonPublic | BindingFlags.Instance); diff --git a/.llm/references/context-ci-and-testing.md b/.llm/references/context-ci-and-testing.md index 648f1e097..e63a13233 100644 --- a/.llm/references/context-ci-and-testing.md +++ b/.llm/references/context-ci-and-testing.md @@ -51,7 +51,7 @@ redacted, uploaded, and required by the aggregate gate (#813). for. Session 251 shipped a `CS0103` in both and cost the whole eight-leg matrix. `typecheck:editor-tests` is the FOURTH tree, `Tests/Editor/**`, and the only gate that compiles it ([#616](https://github.com/Ambiguous-Interactive/unity-helpers/issues/616)); two ways, default and - `:odin`. It inherits the editor pin and so EditorCheck's exclusions -- 42 of 820 files, one line + `:odin`. It inherits the editor pin and so EditorCheck's exclusions -- 45 of 856 files, one line with its reason each in the csproj. `typecheck:integrations` is the FIFTH tree: `Runtime/Integrations/**`, the 19 Reflex/VContainer/Zenject files EVERY other project named in an `Exclude`, so no `WUH###` rule ever ran there and four `??`-on-a-`ScriptableObject` sites shipped; no DI package is on nuget.org, so it takes the Odin route -- three shims declaring only what those 19 name -- and builds default, `:legacy-reflex` and `:player` ([#687](https://github.com/Ambiguous-Interactive/unity-helpers/issues/687)). - `dotnet test -c Release -p:ProtobufNetOracle=v3` and then diff --git a/.llm/references/unity-mcp-fixture-runner-part-3.md b/.llm/references/unity-mcp-fixture-runner-part-3.md index 9c0055536..7c58d3af8 100644 --- a/.llm/references/unity-mcp-fixture-runner-part-3.md +++ b/.llm/references/unity-mcp-fixture-runner-part-3.md @@ -157,3 +157,21 @@ namespace 'Unity.CompilationPipeline'` -- the name bound to a NAMESPACE, not to ([#569](https://github.com/Ambiguous-Interactive/unity-helpers/issues/569)). Before filing such a failure as an artifact OR as a regression, grep the code under test for `Application.isPlaying`. It is a third category: a real defect that only this harness can see. + +### Native asynchronous runner limits (session 348) + +The pipeline backend rejects `mode: "all"` with `async_tests: true` before clearing its +previous result. A response can therefore contain an old completed run. Run `editor` and +`playmode` separately and verify a fresh job identity and status, its filter and discovered case count. +A request timeout does not end the native job; poll that job until terminal before editing +C# sources, refreshing assets or submitting another run. + +Capture growing validation logs and generated host-only C# samples outside the Unity +package, for example under `/tmp`. Unity imports even gitignored package files. A changing +log caused file-size assertions during `AssetDatabase.Refresh`; generated documentation +samples also entered authored-script validation. Preserve those artifacts outside the +package before a native run, and copy completed logs into ignored progress afterward. + +Compare summary totals with individual results. This backend can report more summary cases +than its callback result list contains. Preserve both counts and investigate missing failed +identities; an incomplete leaf list cannot justify accepting a failed summary. diff --git a/.llm/skill-splits.json b/.llm/skill-splits.json index 9ee19bedf..f956eee2a 100644 --- a/.llm/skill-splits.json +++ b/.llm/skill-splits.json @@ -29,7 +29,7 @@ }, { "name": "avoid-reflection", - "sha256": "8b083202f8331761e9159f7f317f087a9a6ce8aef1860d43b4282230dcf2358d", + "sha256": "3a8ce96749ecfefdf8864388b691fe1b39a496e32d14af5df21b226ce28bf4ce", "parts": ["avoid-reflection-part-1.md", "avoid-reflection-part-2.md"], "routerSha256": "d9d0748a7f8782677f897b396994d260daea9240daff339b3c6588c46e59da3e" }, @@ -428,13 +428,13 @@ }, { "name": "unity-mcp-fixture-runner", - "sha256": "c870473c8d0b849846ad569b11e1bca1824782bab93b62ad6184a38136789392", + "sha256": "a4e69aad560a6e06836d3640c8f0b20f712badfdfe0b69a271e961cbd449c10e", "parts": [ "unity-mcp-fixture-runner-part-1.md", "unity-mcp-fixture-runner-part-2.md", "unity-mcp-fixture-runner-part-3.md" ], - "routerSha256": "eb5d855ae47ea6901443ccbdb70a53c16825d013834996ae6291718855a2b12f" + "routerSha256": "0aabdfc757a7a062cb60843685662b97e513d94104bdfd8d53beedaa8668b1ca" }, { "name": "unity-performance-patterns", diff --git a/.llm/skills/unity-mcp-fixture-runner.md b/.llm/skills/unity-mcp-fixture-runner.md index 45d7be78a..852a3894a 100644 --- a/.llm/skills/unity-mcp-fixture-runner.md +++ b/.llm/skills/unity-mcp-fixture-runner.md @@ -37,3 +37,5 @@ ### [Three things the probe itself gets wrong (session 224)](../references/unity-mcp-fixture-runner-part-3.md#three-things-the-probe-itself-gets-wrong-session-224) ### [Three the bridge itself gets wrong (session 225)](../references/unity-mcp-fixture-runner-part-3.md#three-the-bridge-itself-gets-wrong-session-225) + +### [Native asynchronous runner limits (session 348)](../references/unity-mcp-fixture-runner-part-3.md#native-asynchronous-runner-limits-session-348) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f1f50cf9..050036198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `WProtoMember.IsPacked = false` to write unpacked numeric and enum collections matching protobuf-net defaults, while preserving existing packed writes and both read forms ([#937](https://github.com/Ambiguous-Interactive/unity-helpers/issues/937)). - Add `SystemArrayPool.TryWithBuffer` for synchronous borrowed spans with explicit state, optional results, and reported callback or cleanup errors ([#924](https://github.com/Ambiguous-Interactive/unity-helpers/issues/924)). - Add `WUH022` to report boxing disposable structs and suggest concrete or constrained generic disposal ([#924](https://github.com/Ambiguous-Interactive/unity-helpers/issues/924)). - Add opt-in `WUH021` to flag repeated size observations, including interface `Count`, known `Length`/`LongLength` properties, and non-predicate LINQ `Count()`/`LongCount()`, when one snapshot is intended. @@ -27,6 +28,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fix singleton asset recreation after stale GUID cleanup by requesting synchronous cleanup and pausing batching during creation and import ([#942](https://github.com/Ambiguous-Interactive/unity-helpers/issues/942)). +- Fix unexpected singleton asset creation failures blocking later attempts until a domain reload ([#936](https://github.com/Ambiguous-Interactive/unity-helpers/issues/936)). +- Stop WallstopProto `WPROTO044` inheritance diagnostics at an intermediate `[WProtoNotSerialized]` boundary, including referenced assemblies, while retaining contradictory declaration errors ([#937](https://github.com/Ambiguous-Interactive/unity-helpers/issues/937)). +- Fix WallstopProto map keys to match protobuf-net 3.2.56: write zero enum keys and omit zero floating-point keys while preserving reads of older payloads ([#937](https://github.com/Ambiguous-Interactive/unity-helpers/issues/937)). +- Fix periodic effects repeating ticks before their cadence at large clocks; initial delays, tick limits, refresh phase, and catch-up limits are preserved ([#934](https://github.com/Ambiguous-Interactive/unity-helpers/issues/934)). +- Fix cache and effect lifetimes at large clocks, including small timing offsets and jitter; preserve remaining effect time and disabled cache expiry ([#932](https://github.com/Ambiguous-Interactive/unity-helpers/issues/932)). +- Fix discarded worker jobs retaining captured references after shutdown, including submissions racing disposal ([#844](https://github.com/Ambiguous-Interactive/unity-helpers/issues/844)). +- Fix pool acquisition and warm-up failures leaking items or leaving phantom active rentals; acquisition callbacks can query pool state across threads ([#827](https://github.com/Ambiguous-Interactive/unity-helpers/issues/827)). +- Fix timed cache refresh failures changing cached values or consuming initial jitter, and preserve expiry when finite lifetime plus jitter exceeds float range ([#817](https://github.com/Ambiguous-Interactive/unity-helpers/issues/817)). +- Fix singleton loading to use the Resources root when configured subfolders contain only slashes and whitespace; spaces inside valid folders are preserved ([#867](https://github.com/Ambiguous-Interactive/unity-helpers/issues/867)). +- Fix invisible whitespace-only toggle captions by showing `(Unnamed)`; literal string values and padded captions stay unchanged ([#867](https://github.com/Ambiguous-Interactive/unity-helpers/issues/867)). - Fix sprite sheet automatic config lookup throwing for invalid paths; invalid paths and missing sidecars preserve loaded settings ([#929](https://github.com/Ambiguous-Interactive/unity-helpers/issues/929)). - Fix sprite sheet configuration save/load accepting blank texture paths; valid filenames containing spaces and serialized hashes keep their exact values ([#867](https://github.com/Ambiguous-Interactive/unity-helpers/issues/867)). - Fix blank custom validation messages hiding the default warning in standard and Odin inspectors; nonblank messages and serialized string values retain their exact text ([#867](https://github.com/Ambiguous-Interactive/unity-helpers/issues/867)). @@ -68,6 +80,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve `JesseSort` for mixed regions, sparse disorder, and natural runs using the upstream live-phase design ([#747](https://github.com/Ambiguous-Interactive/unity-helpers/issues/747)). - Improve parent and child collection transfers with bulk copying for `Component` fields while retaining typed enumeration for derived components ([#781](https://github.com/Ambiguous-Interactive/unity-helpers/issues/781)). +### Removed + +- Remove production test-only APIs and injected callbacks; package tests now access real implementation internals ([#935](https://github.com/Ambiguous-Interactive/unity-helpers/issues/935)). + ## [3.6.2] - 2026-09-28 ### Added diff --git a/Editor/AssetProcessors/AssetPostprocessorDeferral.cs b/Editor/AssetProcessors/AssetPostprocessorDeferral.cs index a312f8a05..82704c23e 100644 --- a/Editor/AssetProcessors/AssetPostprocessorDeferral.cs +++ b/Editor/AssetProcessors/AssetPostprocessorDeferral.cs @@ -27,26 +27,9 @@ namespace WallstopStudios.UnityHelpers.Editor.AssetProcessors /// internal static class AssetPostprocessorDeferral { - /// - /// Safety cap on iterations. A handler whose - /// drain re-schedules itself (directly or transitively) would loop forever; - /// bounds that to the smallest number that - /// still absorbs realistic reentrant fan-out (tests that create N assets, - /// each of whose handlers re-schedules a cleanup). Reaching the cap surfaces - /// a warning so the caller can investigate rather than silently leaking drains. - /// - private const int FlushIterationCap = 32; - - /// - /// Test-only snapshot of the pending-drain count. Used by regression - /// tests that verify cap/drain behavior without pulling in the full - /// reflection machinery. - /// - internal static int PendingDrainCountForTesting => PendingDrains.Count; - - private static readonly List PendingDrains = new(); - private static bool _scheduled; - private static bool _draining; + internal static readonly List PendingDrains = new(); + internal static bool _scheduled; + internal static bool _draining; private static int? _mainThreadId; /// @@ -105,106 +88,13 @@ internal static void Schedule(Action drain) EditorApplication.delayCall += DrainScheduled; } - /// - /// Synchronously drains any pending actions, iterating until the queue is - /// stable so a drain that reentrantly calls does - /// not leave items in the queue for the next test's setup to inherit. - /// Intended for tests to avoid yielding an editor frame. - /// - /// Bounded by iterations to prevent a - /// buggy handler that re-schedules itself from hanging the test run; if - /// the cap is hit, the method returns with drains still pending, logs a - /// warning, and those drains fire on the next editor tick (potentially - /// polluting the next test — the warning is the caller's signal to - /// investigate). - /// - /// Note on dormant delayCalls: when a drain appends to - /// during its execution, - /// re-arms an - /// subscription for the next editor tick. This loop then drains that - /// queue synchronously in the next iteration, so the delayCall (when it - /// eventually fires) observes an empty queue and returns as a harmless - /// no-op. Within a single reentrant iteration, at most ONE dormant - /// delayCall is registered: both and - /// gate on _scheduled and will not - /// double-register. Across a full flush cycle, however, the top of each - /// iteration clears _scheduled = false, so up to - /// dormant DrainScheduled - /// callbacks can accumulate on - /// — each one a harmless no-op when it fires. Editor-tick telemetry may - /// therefore show between zero and - /// no-op DrainScheduled invocations per flush cycle (zero when - /// no reentrant appends happened, one per iteration that had them). - /// - internal static void FlushForTesting() - { - if (_draining) - { - // Reentrant flush cannot drain its own active queue; warn without aborting the outer batch. - Debug.LogWarning( - "FlushForTesting called reentrantly during drain — flush is a no-op; " - + "ensure tests don't call FlushForTesting from a handler callback." - ); - return; - } - - for (int iteration = 0; iteration < FlushIterationCap; ++iteration) - { - // Clear scheduling state before callbacks can reenter and enqueue another batch. - _scheduled = false; - DrainPending(); - - if (PendingDrains.Count == 0) - { - _scheduled = false; - return; - } - // Take synchronous ownership of the reentrant batch instead of racing its scheduled tick. - } - - Debug.LogWarning( - "FlushForTesting hit the iteration cap (" - + FlushIterationCap - + ") with " - + PendingDrains.Count - + " drain(s) still pending. A drain handler is likely re-scheduling itself. " - + "Remaining drains will fire on the next editor tick, which may pollute the next test." - ); - } - - /// - /// Test-only reset hook. Wipes and the - /// scheduling flags, mirroring . Tests - /// that deliberately exercise edge cases (e.g. hitting - /// ) may leave drains queued; calling - /// this from a TearDown guarantees the next test starts with a - /// quiescent deferral. - /// - /// Caveat — dormant subscriptions - /// are NOT purged by this reset. Each call to or - /// 's fallback appends - /// to Unity's multicast delayCall, and Unity does not expose a - /// safe way to dequeue a specific subscription mid-flight. Those - /// subscriptions remain pending and fire on subsequent editor ticks — - /// but because early-returns on an empty - /// , each dormant fire is a harmless no-op. - /// Consequence: do NOT treat - /// as a proxy for "no delayCall callback is pending". It only reflects - /// the drain queue; the delayCall multicast may still hold stale - /// subscriptions that will quietly no-op when they fire. - /// - internal static void ResetForTesting() - { - ResetForDomainReload(); - } - - private static void DrainScheduled() + internal static void DrainScheduled() { _scheduled = false; DrainPending(); } - private static void DrainPending() + internal static void DrainPending() { if (PendingDrains.Count == 0) { @@ -240,6 +130,13 @@ private static void DrainPending() } } + internal static void ResetForDomainReload() + { + PendingDrains.Clear(); + _scheduled = false; + _draining = false; + } + private static void RunSafely(Action drain) { try @@ -276,13 +173,6 @@ private static void RegisterDomainCleanup() AssemblyReloadEvents.beforeAssemblyReload += ResetForDomainReload; } - private static void ResetForDomainReload() - { - PendingDrains.Clear(); - _scheduled = false; - _draining = false; - } - [System.Diagnostics.Conditional("UNITY_ASSERTIONS")] [System.Diagnostics.Conditional("DEBUG")] private static void AssertOnMainThread() diff --git a/Editor/AssetProcessors/DetectAssetChangeProcessor.cs b/Editor/AssetProcessors/DetectAssetChangeProcessor.cs index 75bff5475..07d8431dd 100644 --- a/Editor/AssetProcessors/DetectAssetChangeProcessor.cs +++ b/Editor/AssetProcessors/DetectAssetChangeProcessor.cs @@ -22,44 +22,11 @@ internal sealed class DetectAssetChangeProcessor : AssetPostprocessor internal const int MaxPendingChangeSetsPerCycle = 32; internal const int MaxConsecutiveChangeSetsWithinWindow = 128; - private const string TestAssetFolderMarker = "__DetectAssetChangedTests__"; private const string SupportedSignatureDescription = "Supported signatures: () with no parameters; (AssetChangeContext context); or (TAsset[] createdAssets, string[] deletedAssetPaths) where TAsset derives from UnityEngine.Object."; private const string InfiniteLoopWarning = "[DetectAssetChanged] Detected a potentially infinite asset change loop triggered by DetectAssetChanged handlers. Additional change batches will be skipped to prevent recursion until the editor domain reloads. Please fix the offending callbacks."; - internal static Func TimeProvider - { - get => _timeProvider; - set => _timeProvider = value ?? DefaultTimeProvider; - } - - internal static double? LoopWindowSecondsOverride - { - get => _loopWindowSecondsOverride; - set => _loopWindowSecondsOverride = value; - } - - internal static bool IncludeTestAssets - { - get => _includeTestAssets; - set => _includeTestAssets = value; - } - - /// - /// Test-only: when non-null AND is , - /// asset paths not starting with any of the listed prefixes are skipped. Lets each - /// test fixture declare its own folder so cross-fixture pollution is structurally - /// impossible even if Clear() is forgotten. Production code never sets this, - /// and treats as "no allowlist — - /// preserve legacy include-all-test-assets behavior." - /// - internal static IReadOnlyList TestAssetFolderAllowlist - { - get => _testAssetFolderAllowlist; - set => _testAssetFolderAllowlist = value == null ? null : new List(value); - } - /// /// Enables diagnostic logging for debugging asset change detection behavior. /// When enabled, logs detailed information about instance enumeration and search options. @@ -97,22 +64,15 @@ internal static bool? EnabledOverride /// internal static bool IsEnabled => _enabledOverride ?? !Application.isBatchMode; - private static readonly Func DefaultTimeProvider = () => - EditorApplication.timeSinceStartup; - - private static readonly Dictionary WatchersByAssetType = new(); - private static readonly Queue PendingAssetChanges = new(); - private static bool _initialized; - private static bool _includeTestAssets; - private static List _testAssetFolderAllowlist; - private static bool _processingAssetChanges; - private static bool _loopProtectionActive; - private static int _consecutiveChangeBatches; - private static double _lastChangeProcessTimestamp; - private static Func _timeProvider = DefaultTimeProvider; - private static double? _loopWindowSecondsOverride; - private static bool _diagnosticsEnabled; - private static bool? _enabledOverride; + internal static readonly Dictionary WatchersByAssetType = new(); + internal static readonly Queue PendingAssetChanges = new(); + internal static bool _initialized; + internal static bool _processingAssetChanges; + internal static bool _loopProtectionActive; + internal static int _consecutiveChangeBatches; + internal static double _lastChangeProcessTimestamp; + internal static bool _diagnosticsEnabled; + internal static bool? _enabledOverride; private static readonly Action DrainPendingChangesAction = ProcessPendingAssetChangesCore; @@ -121,85 +81,6 @@ static DetectAssetChangeProcessor() EditorApplication.delayCall += EnsureInitialized; } - internal static void ProcessChangesForTesting( - string[] imported, - string[] deleted, - string[] moved, - string[] movedFrom - ) - { - EnsureInitialized(force: true); - EnqueueAssetChanges( - imported ?? Array.Empty(), - deleted ?? Array.Empty(), - moved ?? Array.Empty(), - movedFrom ?? Array.Empty(), - deferProcessing: false - ); - } - - internal static AssetWatcherSettings GetSettingsForTesting() - { - return new AssetWatcherSettings - { - Initialized = _initialized, - IncludeTestAssets = _includeTestAssets, - TestAssetFolderAllowlist = - _testAssetFolderAllowlist == null - ? null - : new List(_testAssetFolderAllowlist), - WatchersByAssetType = CloneWatchers(WatchersByAssetType), - PendingAssetChanges = ClonePendingChanges(PendingAssetChanges), - ProcessingAssetChanges = _processingAssetChanges, - LoopProtectionActive = _loopProtectionActive, - ConsecutiveChangeBatches = _consecutiveChangeBatches, - LastChangeProcessTimestamp = _lastChangeProcessTimestamp, - TimeProvider = _timeProvider, - LoopWindowSecondsOverride = _loopWindowSecondsOverride, - DiagnosticsEnabled = _diagnosticsEnabled, - EnabledOverride = _enabledOverride, - }; - } - - internal static void ResetForTesting(AssetWatcherSettings settings = null) - { - _initialized = settings?.Initialized ?? false; - IncludeTestAssets = settings?.IncludeTestAssets ?? false; - _testAssetFolderAllowlist = - settings?.TestAssetFolderAllowlist == null - ? null - : new List(settings.TestAssetFolderAllowlist); - WatchersByAssetType.Clear(); - if (settings?.WatchersByAssetType != null) - { - foreach (KeyValuePair pair in settings.WatchersByAssetType) - { - WatchersByAssetType.Add(pair.Key, CloneWatcher(pair.Value)); - } - } - PendingAssetChanges.Clear(); - if (settings?.PendingAssetChanges != null) - { - foreach (PendingAssetChangeSet pendingChange in settings.PendingAssetChanges) - { - PendingAssetChanges.Enqueue(ClonePendingChange(pendingChange)); - } - } - _processingAssetChanges = settings?.ProcessingAssetChanges ?? false; - _loopProtectionActive = settings?.LoopProtectionActive ?? false; - _consecutiveChangeBatches = settings?.ConsecutiveChangeBatches ?? 0; - _lastChangeProcessTimestamp = settings?.LastChangeProcessTimestamp ?? 0; - TimeProvider = settings?.TimeProvider ?? DefaultTimeProvider; - LoopWindowSecondsOverride = settings?.LoopWindowSecondsOverride; - DiagnosticsEnabled = settings?.DiagnosticsEnabled ?? false; - _enabledOverride = settings?.EnabledOverride; - } - - internal static void EnsureInitializedForTesting() - { - EnsureInitialized(); - } - internal static void ResetLoopProtection() { _loopProtectionActive = false; @@ -208,77 +89,11 @@ internal static void ResetLoopProtection() PendingAssetChanges.Clear(); } - internal static bool ValidateMethodSignatureForTesting( - Type declaringType, - string methodName - ) - { - if (declaringType == null) - { - throw new ArgumentNullException(nameof(declaringType)); - } - - if (string.IsNullOrWhiteSpace(methodName)) - { - throw new ArgumentException(nameof(methodName)); - } - - BindingFlags flags = - BindingFlags.Instance - | BindingFlags.Static - | BindingFlags.Public - | BindingFlags.NonPublic; - MethodInfo method = declaringType.GetMethod(methodName, flags); - if (method == null) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "Method {0}.{1} was not found.", - declaringType.FullName, - methodName - ), - nameof(methodName) - ); - } - - return TryResolveParameterMode(declaringType, method, out _, out _); - } - - private static void OnPostprocessAllAssets( - string[] importedAssets, - string[] deletedAssets, - string[] movedAssets, - string[] movedFromAssetPaths - ) - { - // Play-mode asset changes must not trigger a reflection scan recursively inside Unity import. - if (EditorApplication.isPlayingOrWillChangePlaymode) - { - return; - } - - EnsureInitialized(); - if (WatchersByAssetType.Count == 0) - { - return; - } - - EnqueueAssetChanges( - importedAssets, - deletedAssets, - movedAssets, - movedFromAssetPaths, - deferProcessing: true - ); - } - - private static void EnqueueAssetChanges( + internal static void EnqueueAssetChanges( IReadOnlyList importedAssets, IReadOnlyList deletedAssets, IReadOnlyList movedAssets, - IReadOnlyList movedFromAssetPaths, - bool deferProcessing + IReadOnlyList movedFromAssetPaths ) { if (_loopProtectionActive) @@ -296,17 +111,11 @@ bool deferProcessing ) ); - if (deferProcessing) - { - // Defer beyond import guards; type-only questions must still use metadata because loading invokes consumer OnValidate. - AssetPostprocessorDeferral.Schedule(DrainPendingChangesAction); - return; - } - - ProcessPendingAssetChangesCore(); + // Defer beyond import guards; type-only questions must still use metadata. + AssetPostprocessorDeferral.Schedule(DrainPendingChangesAction); } - private static void ProcessPendingAssetChangesCore() + internal static void ProcessPendingAssetChangesCore() { if (_loopProtectionActive) { @@ -348,127 +157,422 @@ private static void ProcessPendingAssetChangesCore() _processingAssetChanges = false; if (!_loopProtectionActive && 0 < processedBatches) { - UpdateLoopWindow(processedBatches); + UpdateLoopWindow( + processedBatches, + EditorApplication.timeSinceStartup, + ResolveLoopWindowSeconds() + ); } } } - private static bool HandleAssetChanges( - IReadOnlyList importedAssets, - IReadOnlyList deletedAssets, - IReadOnlyList movedAssets, - IReadOnlyList movedFromAssetPaths - ) + internal static void EnsureInitialized() { - if (_loopProtectionActive) + if (_initialized || !IsEnabled) { - return false; + return; } + _initialized = true; + BuildWatchers(); + } - bool handledChange = false; - string[] importedSearchFolders = null; - string[] movedSearchFolders = null; - foreach (AssetWatcher watcher in WatchersByAssetType.Values) - { - using PooledResource> createdPathsLease = Buffers.List.Get( - out List createdPaths - ); - using PooledResource> deletedPathsLease = Buffers.List.Get( - out List deletedPaths - ); - CollectCreatedAssets( - watcher, - importedAssets, - movedAssets, - createdPaths, - ref importedSearchFolders, - ref movedSearchFolders - ); - CollectDeletedAssets(watcher, deletedAssets, movedFromAssetPaths, deletedPaths); + internal static void BuildWatchers() + { + WatchersByAssetType.Clear(); - AssetChangeFlags triggeredFlags = AssetChangeFlags.None; - if (0 < createdPaths.Count) + List loadedTypes = new(); + IEnumerable discoveredTypes = ReflectionHelpers.GetAllLoadedTypes(); + if (discoveredTypes != null) + { + foreach (Type discoveredType in discoveredTypes) { - triggeredFlags |= AssetChangeFlags.Created; + if (discoveredType != null) + { + loadedTypes.Add(discoveredType); + } } - - if (0 < deletedPaths.Count) + } + List attributedMethods = new(); + HashSet discoveredMethods = new(); + HashSet inheritedHandlerBases = new(); + foreach ( + MethodInfo method in TypeCache.GetMethodsWithAttribute() + ) + { + if (discoveredMethods.Add(method)) { - triggeredFlags |= AssetChangeFlags.Deleted; + attributedMethods.Add(method); } - - if (triggeredFlags == AssetChangeFlags.None) + if (method.IsVirtual && method.DeclaringType != null) { - continue; + inheritedHandlerBases.Add(method.DeclaringType); } + } - handledChange = true; - List createdAssetInstances = null; - PooledResource> createdAssetInstancesLease = default; - Dictionary createdAssetArrays = null; - PooledResource> createdAssetArraysLease = default; - string[] createdPathsArray = null; - string[] deletedPathsContextArray = null; - string[] deletedPathsArray = null; - try + if (0 < inheritedHandlerBases.Count) + { + BindingFlags inheritedFlags = + BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly; + foreach (Type type in loadedTypes) { - foreach (MethodSubscription subscription in watcher.Subscriptions) + if (type.IsAbstract && !type.IsSealed) { - AssetChangeFlags relevant = subscription._flags & triggeredFlags; - if (relevant == AssetChangeFlags.None) + continue; + } + bool inheritsHandler = false; + for ( + Type ancestor = type.BaseType; + ancestor != null; + ancestor = ancestor.BaseType + ) + { + if ( + inheritedHandlerBases.Contains(ancestor) + || ( + ancestor.IsGenericType + && inheritedHandlerBases.Contains( + ancestor.GetGenericTypeDefinition() + ) + ) + ) { - continue; + inheritsHandler = true; + break; } - - object[] args = BuildInvocationArguments( - subscription, - watcher.AssetType, - relevant, - createdPaths, - deletedPaths, - ref createdAssetInstances, - ref createdAssetInstancesLease, - ref createdAssetArrays, - ref createdAssetArraysLease, - ref createdPathsArray, - ref deletedPathsContextArray, - ref deletedPathsArray - ); - - InvokeSubscription(subscription, args); } - } - finally - { - createdAssetArraysLease.Dispose(); - createdAssetInstancesLease.Dispose(); - } - - if (0 < createdPaths.Count) - { - foreach (string assetPath in createdPaths) + if (!inheritsHandler) { - watcher.KnownAssetPaths.Add(assetPath); + continue; } - } - - if (0 < deletedPaths.Count) - { - foreach (string deletedPath in deletedPaths) + foreach (MethodInfo method in type.GetMethods(inheritedFlags)) { - watcher.KnownAssetPaths.Remove(deletedPath); + if ( + method.IsVirtual + && discoveredMethods.Add(method) + && method.GetAllAttributesSafe().Length + != 0 + ) + { + attributedMethods.Add(method); + } } } } - return handledChange; - } + foreach (MethodInfo method in attributedMethods) + { + Type type = method.DeclaringType; + // Static classes are abstract sealed and can still declare handlers. + if (type == null || (type.IsAbstract && !type.IsSealed)) + { + continue; + } - private static object[] BuildInvocationArguments( - MethodSubscription subscription, - Type assetType, - AssetChangeFlags relevantFlags, - IReadOnlyList createdPaths, + DetectAssetChangedAttribute[] attributes = + method.GetAllAttributesSafe(); + if (attributes.Length == 0) + { + continue; + } + + if ( + !TryResolveParameterMode( + type, + method, + out SubscriptionParameterMode parameterMode, + out Type createdElementType + ) + ) + { + continue; + } + + foreach (DetectAssetChangedAttribute attribute in attributes) + { + if ( + parameterMode == SubscriptionParameterMode.CreatedAndDeleted + && !ResolutionSupportsAssetType(createdElementType, attribute.AssetType) + ) + { + Debug.LogWarning( + $"[DetectAssetChanged] {type.FullName}.{method.Name} expects created asset parameter type {createdElementType.FullName}, which is not compatible with watched asset type {attribute.AssetType.FullName}." + ); + continue; + } + + bool includeAssignableTypes = attribute.IncludeAssignableTypes; + if ( + !WatchersByAssetType.TryGetValue( + attribute.AssetType, + out AssetWatcher watcher + ) + ) + { + watcher = new AssetWatcher(attribute.AssetType, includeAssignableTypes); + PopulateKnownAssetPaths(watcher, loadedTypes); + WatchersByAssetType.Add(attribute.AssetType, watcher); + } + else if (includeAssignableTypes && !watcher.IncludeAssignableTypes) + { + watcher.EnableAssignableMatching(); + PopulateKnownAssetPaths(watcher, loadedTypes); + } + + MethodSubscription subscription = new() + { + _declaringType = type, + _method = method, + _flags = attribute.Flags, + _parameterMode = parameterMode, + _createdParameterElementType = createdElementType, + _searchPrefabs = attribute.SearchPrefabs, + _searchSceneObjects = attribute.SearchSceneObjects, + }; + + if (attribute.SearchPrefabs && !watcher.SearchPrefabs) + { + watcher.EnablePrefabSearch(); + } + + if (attribute.SearchSceneObjects && !watcher.SearchSceneObjects) + { + watcher.EnableSceneObjectSearch(); + } + + bool alreadyExists = false; + foreach (MethodSubscription existing in watcher.Subscriptions) + { + if (existing._declaringType == type && existing._method == method) + { + alreadyExists = true; + break; + } + } + if (!alreadyExists) + { + watcher.Subscriptions.Add(subscription); + } + } + } + } + + internal static bool TryResolveParameterMode( + Type declaringType, + MethodInfo method, + out SubscriptionParameterMode mode, + out Type createdElementType + ) + { + if (method.ReturnType != typeof(void)) + { + LogUnsupportedSignature( + declaringType, + method, + "must return void to receive DetectAssetChanged notifications." + ); + mode = SubscriptionParameterMode.None; + createdElementType = null; + return false; + } + + ParameterInfo[] parameters = method.GetParameters(); + if (parameters.Length == 0) + { + mode = SubscriptionParameterMode.None; + createdElementType = null; + return true; + } + + if (parameters.Length == 1 && parameters[0].ParameterType == typeof(AssetChangeContext)) + { + mode = SubscriptionParameterMode.Context; + createdElementType = null; + return true; + } + + if ( + parameters.Length == 2 + && TryResolveCreatedParameterType(parameters[0].ParameterType, out Type elementType) + && parameters[1].ParameterType == typeof(string[]) + ) + { + mode = SubscriptionParameterMode.CreatedAndDeleted; + createdElementType = elementType; + return true; + } + + LogUnsupportedSignature( + declaringType, + method, + "has an unsupported parameter signature for DetectAssetChanged." + ); + mode = SubscriptionParameterMode.None; + createdElementType = null; + return false; + } + + internal static void UpdateLoopWindow(int processedBatches, double now, double loopWindow) + { + if (loopWindow <= 0d) + { + loopWindow = UnityHelpersSettings.DefaultDetectAssetChangeLoopWindowSeconds; + } + + if (loopWindow < now - _lastChangeProcessTimestamp) + { + _consecutiveChangeBatches = 0; + } + + _lastChangeProcessTimestamp = now; + _consecutiveChangeBatches += processedBatches; + if (MaxConsecutiveChangeSetsWithinWindow <= _consecutiveChangeBatches) + { + EnterLoopProtection(); + } + } + + private static void OnPostprocessAllAssets( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths + ) + { + // Play-mode asset changes must not trigger a reflection scan recursively inside Unity import. + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + return; + } + + EnsureInitialized(); + if (WatchersByAssetType.Count == 0) + { + return; + } + + EnqueueAssetChanges(importedAssets, deletedAssets, movedAssets, movedFromAssetPaths); + } + + private static bool HandleAssetChanges( + IReadOnlyList importedAssets, + IReadOnlyList deletedAssets, + IReadOnlyList movedAssets, + IReadOnlyList movedFromAssetPaths + ) + { + if (_loopProtectionActive) + { + return false; + } + + bool handledChange = false; + string[] importedSearchFolders = null; + string[] movedSearchFolders = null; + foreach (AssetWatcher watcher in WatchersByAssetType.Values) + { + using PooledResource> createdPathsLease = Buffers.List.Get( + out List createdPaths + ); + using PooledResource> deletedPathsLease = Buffers.List.Get( + out List deletedPaths + ); + CollectCreatedAssets( + watcher, + importedAssets, + movedAssets, + createdPaths, + ref importedSearchFolders, + ref movedSearchFolders + ); + CollectDeletedAssets(watcher, deletedAssets, movedFromAssetPaths, deletedPaths); + + AssetChangeFlags triggeredFlags = AssetChangeFlags.None; + if (0 < createdPaths.Count) + { + triggeredFlags |= AssetChangeFlags.Created; + } + + if (0 < deletedPaths.Count) + { + triggeredFlags |= AssetChangeFlags.Deleted; + } + + if (triggeredFlags == AssetChangeFlags.None) + { + continue; + } + + handledChange = true; + List createdAssetInstances = null; + PooledResource> createdAssetInstancesLease = default; + Dictionary createdAssetArrays = null; + PooledResource> createdAssetArraysLease = default; + string[] createdPathsArray = null; + string[] deletedPathsContextArray = null; + string[] deletedPathsArray = null; + try + { + foreach (MethodSubscription subscription in watcher.Subscriptions) + { + AssetChangeFlags relevant = subscription._flags & triggeredFlags; + if (relevant == AssetChangeFlags.None) + { + continue; + } + + object[] args = BuildInvocationArguments( + subscription, + watcher.AssetType, + relevant, + createdPaths, + deletedPaths, + ref createdAssetInstances, + ref createdAssetInstancesLease, + ref createdAssetArrays, + ref createdAssetArraysLease, + ref createdPathsArray, + ref deletedPathsContextArray, + ref deletedPathsArray + ); + + InvokeSubscription(subscription, args); + } + } + finally + { + createdAssetArraysLease.Dispose(); + createdAssetInstancesLease.Dispose(); + } + + if (0 < createdPaths.Count) + { + foreach (string assetPath in createdPaths) + { + watcher.KnownAssetPaths.Add(assetPath); + } + } + + if (0 < deletedPaths.Count) + { + foreach (string deletedPath in deletedPaths) + { + watcher.KnownAssetPaths.Remove(deletedPath); + } + } + } + + return handledChange; + } + + private static object[] BuildInvocationArguments( + MethodSubscription subscription, + Type assetType, + AssetChangeFlags relevantFlags, + IReadOnlyList createdPaths, IReadOnlyList deletedPaths, ref List createdAssetInstances, ref PooledResource> createdAssetInstancesLease, @@ -732,64 +836,11 @@ out HashSet yieldedInstanceIds UnityEngine.Object instance = AssetDatabase.LoadAssetAtPath( path, declaringType - ); - if (instance != null) - { - yieldedPaths.Add(path); - yield return instance; - } - } - - // Test types without matching filenames can evade Unity type filters; scan only fixture folders as fallback. - if (_includeTestAssets) - { - string testFolder = "Assets/" + TestAssetFolderMarker; - if (!AssetDatabase.IsValidFolder(testFolder)) - { - yield break; - } - - // Check the filesystem too because Unity can log before throwing for a deleted folder. - string fullTestFolderPath = Path.Combine( - Path.GetDirectoryName(Application.dataPath) ?? string.Empty, - testFolder - ); - if (!Directory.Exists(fullTestFolderPath)) - { - yield break; - } - - // The folder may disappear between validation and search. - string[] testGuids; - try - { - testGuids = AssetDatabase.FindAssets( - "t:ScriptableObject", - new[] { testFolder } - ); - } - catch (Exception ex) - when (ex is not OutOfMemoryException and not StackOverflowException) - { - // Fixture cleanup can delete the folder between validation and search. - testGuids = Array.Empty(); - } - - foreach (string testGuidsElement in testGuids) - { - string path = AssetDatabase.GUIDToAssetPath(testGuidsElement); - if (yieldedPaths.Contains(path)) - { - continue; - } - - UnityEngine.Object asset = - AssetDatabase.LoadAssetAtPath(path); - if (asset != null && declaringType.IsInstanceOfType(asset)) - { - yieldedPaths.Add(path); - yield return asset; - } + ); + if (instance != null) + { + yieldedPaths.Add(path); + yield return instance; } } } @@ -845,586 +896,279 @@ UnityEngine.Object component in EnumerateSceneComponents( sceneCount ) ); - } - } - } - - private static IEnumerable EnumeratePrefabComponents( - Type declaringType, - HashSet yieldedInstanceIds - ) - { - string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab"); - foreach (string prefabGuidsElement in prefabGuids) - { - string path = AssetDatabase.GUIDToAssetPath(prefabGuidsElement); - if (ShouldSkipPath(path)) - { - continue; - } - - GameObject prefab = AssetDatabase.LoadAssetAtPath(path); - if (prefab == null) - { - continue; - } - - Component[] components = prefab.GetComponentsInChildren(declaringType, true); - foreach (Component component in components) - { - if (component == null) - { - continue; - } - - long instanceId = component.GetUnityObjectId(); - if (yieldedInstanceIds.Add(instanceId)) - { - yield return component; - } - } - } - } - - private static IEnumerable EnumerateSceneComponents( - Type declaringType, - HashSet yieldedInstanceIds - ) - { - int sceneCount = UnityEngine.SceneManagement.SceneManager.sceneCount; - for (int sceneIndex = 0; sceneIndex < sceneCount; ++sceneIndex) - { - UnityEngine.SceneManagement.Scene scene = - UnityEngine.SceneManagement.SceneManager.GetSceneAt(sceneIndex); - if (!scene.isLoaded) - { - continue; - } - - GameObject[] rootObjects = scene.GetRootGameObjects(); - foreach (GameObject root in rootObjects) - { - if (root == null) - { - continue; - } - - Component[] components = root.GetComponentsInChildren(declaringType, true); - foreach (Component component in components) - { - if (component == null) - { - continue; - } - - long instanceId = component.GetUnityObjectId(); - if (yieldedInstanceIds.Add(instanceId)) - { - yield return component; - } - } - } - } - } - - private static void CollectCreatedAssets( - AssetWatcher watcher, - IReadOnlyList importedAssets, - IReadOnlyList movedAssets, - List buffer, - ref string[] importedSearchFolders, - ref string[] movedSearchFolders - ) - { - AppendCreatedAssets(watcher, importedAssets, buffer, ref importedSearchFolders); - AppendCreatedAssets(watcher, movedAssets, buffer, ref movedSearchFolders); - } - - private static void AppendCreatedAssets( - AssetWatcher watcher, - IReadOnlyList candidatePaths, - List buffer, - ref string[] searchFolders - ) - { - if (candidatePaths == null) - { - return; - } - - HashSet matchingSubAssetGuids = null; - PooledResource> matchingGuidLease = default; - int candidateCount = candidatePaths.Count; - try - { - for (int i = 0; i < candidateCount; ++i) - { - string path = candidatePaths[i]; - if (ShouldSkipPath(path)) - { - continue; - } - - Type mainType = AssetDatabase.GetMainAssetTypeAtPath(path); - if (mainType != null && watcher.AssetType.IsAssignableFrom(mainType)) - { - buffer.Add(path); - continue; - } - - if ( - mainType != null - && HasMatchingSubAsset( - path, - watcher, - candidatePaths, - candidateCount, - ref searchFolders, - ref matchingSubAssetGuids, - ref matchingGuidLease - ) - ) - { - buffer.Add(path); - continue; - } - - // Only fixture-scoped test assets may be loaded to resolve missing metadata; production queries must use metadata. - if ( - _includeTestAssets - && 0 - <= path.IndexOf( - TestAssetFolderMarker, - StringComparison.OrdinalIgnoreCase - ) - ) - { - UnityEngine.Object loadedAsset = - AssetDatabase.LoadAssetAtPath(path); - if (loadedAsset != null && watcher.AssetType.IsInstanceOfType(loadedAsset)) - { - buffer.Add(path); - } - } - } - } - finally - { - matchingGuidLease.Dispose(); - } - } - - private static bool HasMatchingSubAsset( - string path, - AssetWatcher watcher, - IReadOnlyList candidatePaths, - int candidateCount, - ref string[] searchFolders, - ref HashSet matchingGuids, - ref PooledResource> matchingGuidLease - ) - { - if (IsScenePath(path) || IsPrefabPath(path)) - { - return false; - } - - if (string.IsNullOrEmpty(watcher.SubAssetSearchFilter)) - { - return false; - } - - searchFolders ??= CollectSearchFolders(candidatePaths, candidateCount); - if (searchFolders.Length == 0) - { - return false; - } - - if (matchingGuids == null) - { - matchingGuidLease = SetBuffers - .GetHashSetPool(StringComparer.OrdinalIgnoreCase) - .Get(out matchingGuids); - string[] foundGuids = AssetDatabase.FindAssets( - watcher.SubAssetSearchFilter, - searchFolders - ); - foreach (string foundGuid in foundGuids) - { - matchingGuids.Add(foundGuid); - } - } - - string guid = AssetDatabase.AssetPathToGUID(path); - return !string.IsNullOrEmpty(guid) && matchingGuids.Contains(guid); - } - - private static string[] CollectSearchFolders( - IReadOnlyList candidatePaths, - int candidateCount - ) - { - using PooledResource> folderLease = SetBuffers - .GetHashSetPool(StringComparer.OrdinalIgnoreCase) - .Get(out HashSet folders); - for (int i = 0; i < candidateCount; ++i) - { - string candidate = candidatePaths[i]; - if ( - string.IsNullOrWhiteSpace(candidate) - || IsScenePath(candidate) - || IsPrefabPath(candidate) - ) - { - continue; - } - - int separator = candidate.LastIndexOf('/'); - if (0 < separator) - { - folders.Add(candidate.Substring(0, separator)); - } - } - - if (folders.Count == 0) - { - return Array.Empty(); - } - - string[] searchFolders = new string[folders.Count]; - folders.CopyTo(searchFolders); - return searchFolders; - } - - private static void CollectDeletedAssets( - AssetWatcher watcher, - IReadOnlyList deletedAssets, - IReadOnlyList movedFromAssetPaths, - List buffer - ) - { - AppendDeletedAssets(watcher, deletedAssets, buffer); - AppendDeletedAssets(watcher, movedFromAssetPaths, buffer); + } + } } - private static void AppendDeletedAssets( - AssetWatcher watcher, - IReadOnlyList candidatePaths, - List buffer + private static IEnumerable EnumeratePrefabComponents( + Type declaringType, + HashSet yieldedInstanceIds ) { - if (candidatePaths == null) - { - return; - } - - int candidateCount = candidatePaths.Count; - for (int i = 0; i < candidateCount; ++i) + string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab"); + foreach (string prefabGuidsElement in prefabGuids) { - string path = candidatePaths[i]; + string path = AssetDatabase.GUIDToAssetPath(prefabGuidsElement); if (ShouldSkipPath(path)) { continue; } - if (!watcher.KnownAssetPaths.Contains(path)) + GameObject prefab = AssetDatabase.LoadAssetAtPath(path); + if (prefab == null) { continue; } - buffer.Add(path); - } - } - - private static void EnsureInitialized() - { - EnsureInitialized(force: false); - } - - // Guard every entry to watcher construction; only explicit test requests bypass batch-mode suppression. - private static void EnsureInitialized(bool force) - { - if (_initialized) - { - return; - } - - if (!force && !IsEnabled) - { - return; - } - - _initialized = true; - BuildWatchers(); - } - - private static void BuildWatchers() - { - WatchersByAssetType.Clear(); - - List loadedTypes = new(); - IEnumerable discoveredTypes = ReflectionHelpers.GetAllLoadedTypes(); - if (discoveredTypes != null) - { - foreach (Type discoveredType in discoveredTypes) + Component[] components = prefab.GetComponentsInChildren(declaringType, true); + foreach (Component component in components) { - if (discoveredType != null) + if (component == null) { - loadedTypes.Add(discoveredType); + continue; + } + + long instanceId = component.GetUnityObjectId(); + if (yieldedInstanceIds.Add(instanceId)) + { + yield return component; } } } - List attributedMethods = new(); - HashSet discoveredMethods = new(); - HashSet inheritedHandlerBases = new(); - foreach ( - MethodInfo method in TypeCache.GetMethodsWithAttribute() - ) + } + + private static IEnumerable EnumerateSceneComponents( + Type declaringType, + HashSet yieldedInstanceIds + ) + { + int sceneCount = UnityEngine.SceneManagement.SceneManager.sceneCount; + for (int sceneIndex = 0; sceneIndex < sceneCount; ++sceneIndex) { - if (discoveredMethods.Add(method)) - { - attributedMethods.Add(method); - } - if (method.IsVirtual && method.DeclaringType != null) + UnityEngine.SceneManagement.Scene scene = + UnityEngine.SceneManagement.SceneManager.GetSceneAt(sceneIndex); + if (!scene.isLoaded) { - inheritedHandlerBases.Add(method.DeclaringType); + continue; } - } - if (0 < inheritedHandlerBases.Count) - { - BindingFlags inheritedFlags = - BindingFlags.Instance - | BindingFlags.Static - | BindingFlags.Public - | BindingFlags.NonPublic - | BindingFlags.DeclaredOnly; - foreach (Type type in loadedTypes) + GameObject[] rootObjects = scene.GetRootGameObjects(); + foreach (GameObject root in rootObjects) { - if (type.IsAbstract && !type.IsSealed) + if (root == null) { continue; } - bool inheritsHandler = false; - for ( - Type ancestor = type.BaseType; - ancestor != null; - ancestor = ancestor.BaseType - ) + + Component[] components = root.GetComponentsInChildren(declaringType, true); + foreach (Component component in components) { - if ( - inheritedHandlerBases.Contains(ancestor) - || ( - ancestor.IsGenericType - && inheritedHandlerBases.Contains( - ancestor.GetGenericTypeDefinition() - ) - ) - ) + if (component == null) { - inheritsHandler = true; - break; + continue; } - } - if (!inheritsHandler) - { - continue; - } - foreach (MethodInfo method in type.GetMethods(inheritedFlags)) - { - if ( - method.IsVirtual - && discoveredMethods.Add(method) - && method.GetAllAttributesSafe().Length - != 0 - ) + + long instanceId = component.GetUnityObjectId(); + if (yieldedInstanceIds.Add(instanceId)) { - attributedMethods.Add(method); + yield return component; } } } } + } - foreach (MethodInfo method in attributedMethods) - { - Type type = method.DeclaringType; - // Static classes are abstract sealed and can still declare handlers. - if (type == null || (type.IsAbstract && !type.IsSealed)) - { - continue; - } - - DetectAssetChangedAttribute[] attributes = - method.GetAllAttributesSafe(); - if (attributes.Length == 0) - { - continue; - } + private static void CollectCreatedAssets( + AssetWatcher watcher, + IReadOnlyList importedAssets, + IReadOnlyList movedAssets, + List buffer, + ref string[] importedSearchFolders, + ref string[] movedSearchFolders + ) + { + AppendCreatedAssets(watcher, importedAssets, buffer, ref importedSearchFolders); + AppendCreatedAssets(watcher, movedAssets, buffer, ref movedSearchFolders); + } - if ( - !TryResolveParameterMode( - type, - method, - out SubscriptionParameterMode parameterMode, - out Type createdElementType - ) - ) - { - continue; - } + private static void AppendCreatedAssets( + AssetWatcher watcher, + IReadOnlyList candidatePaths, + List buffer, + ref string[] searchFolders + ) + { + if (candidatePaths == null) + { + return; + } - foreach (DetectAssetChangedAttribute attribute in attributes) + HashSet matchingSubAssetGuids = null; + PooledResource> matchingGuidLease = default; + int candidateCount = candidatePaths.Count; + try + { + for (int i = 0; i < candidateCount; ++i) { - if ( - parameterMode == SubscriptionParameterMode.CreatedAndDeleted - && !ResolutionSupportsAssetType(createdElementType, attribute.AssetType) - ) + string path = candidatePaths[i]; + if (ShouldSkipPath(path)) { - Debug.LogWarning( - $"[DetectAssetChanged] {type.FullName}.{method.Name} expects created asset parameter type {createdElementType.FullName}, which is not compatible with watched asset type {attribute.AssetType.FullName}." - ); continue; } - bool includeAssignableTypes = attribute.IncludeAssignableTypes; - if ( - !WatchersByAssetType.TryGetValue( - attribute.AssetType, - out AssetWatcher watcher - ) - ) - { - watcher = new AssetWatcher(attribute.AssetType, includeAssignableTypes); - PopulateKnownAssetPaths(watcher, loadedTypes); - WatchersByAssetType.Add(attribute.AssetType, watcher); - } - else if (includeAssignableTypes && !watcher.IncludeAssignableTypes) - { - watcher.EnableAssignableMatching(); - PopulateKnownAssetPaths(watcher, loadedTypes); - } - - MethodSubscription subscription = new() - { - _declaringType = type, - _method = method, - _flags = attribute.Flags, - _parameterMode = parameterMode, - _createdParameterElementType = createdElementType, - _searchPrefabs = attribute.SearchPrefabs, - _searchSceneObjects = attribute.SearchSceneObjects, - }; - - if (attribute.SearchPrefabs && !watcher.SearchPrefabs) - { - watcher.EnablePrefabSearch(); - } - - if (attribute.SearchSceneObjects && !watcher.SearchSceneObjects) - { - watcher.EnableSceneObjectSearch(); - } - - bool alreadyExists = false; - foreach (MethodSubscription existing in watcher.Subscriptions) + Type mainType = AssetDatabase.GetMainAssetTypeAtPath(path); + if (mainType != null && watcher.AssetType.IsAssignableFrom(mainType)) { - if (existing._declaringType == type && existing._method == method) - { - alreadyExists = true; - break; - } + buffer.Add(path); + continue; } - if (!alreadyExists) + + if ( + mainType != null + && HasMatchingSubAsset( + path, + watcher, + candidatePaths, + candidateCount, + ref searchFolders, + ref matchingSubAssetGuids, + ref matchingGuidLease + ) + ) { - watcher.Subscriptions.Add(subscription); + buffer.Add(path); + continue; } + + // Only fixture-scoped test assets may be loaded to resolve missing metadata; production queries must use metadata. } } - } - - private static Dictionary CloneWatchers( - IReadOnlyDictionary source - ) - { - Dictionary clones = new(source.Count); - foreach (KeyValuePair pair in source) + finally { - clones.Add(pair.Key, CloneWatcher(pair.Value)); + matchingGuidLease.Dispose(); } - return clones; } - private static AssetWatcher CloneWatcher(AssetWatcher source) + private static bool HasMatchingSubAsset( + string path, + AssetWatcher watcher, + IReadOnlyList candidatePaths, + int candidateCount, + ref string[] searchFolders, + ref HashSet matchingGuids, + ref PooledResource> matchingGuidLease + ) { - if (source == null) + if (IsScenePath(path) || IsPrefabPath(path)) { - return null; + return false; } - AssetWatcher clone = new(source.AssetType, source.IncludeAssignableTypes); - clone.SubAssetSearchFilter = source.SubAssetSearchFilter; - if (source.SearchPrefabs) + if (string.IsNullOrEmpty(watcher.SubAssetSearchFilter)) { - clone.EnablePrefabSearch(); + return false; } - if (source.SearchSceneObjects) + + searchFolders ??= CollectSearchFolders(candidatePaths, candidateCount); + if (searchFolders.Length == 0) { - clone.EnableSceneObjectSearch(); + return false; } - clone.KnownAssetPaths.UnionWith(source.KnownAssetPaths); - foreach (MethodSubscription subscription in source.Subscriptions) + + if (matchingGuids == null) { - clone.Subscriptions.Add(subscription == null ? null : subscription.Clone()); + matchingGuidLease = SetBuffers + .GetHashSetPool(StringComparer.OrdinalIgnoreCase) + .Get(out matchingGuids); + string[] foundGuids = AssetDatabase.FindAssets( + watcher.SubAssetSearchFilter, + searchFolders + ); + foreach (string foundGuid in foundGuids) + { + matchingGuids.Add(foundGuid); + } } - return clone; + + string guid = AssetDatabase.AssetPathToGUID(path); + return !string.IsNullOrEmpty(guid) && matchingGuids.Contains(guid); } - private static Queue ClonePendingChanges( - IEnumerable source + private static string[] CollectSearchFolders( + IReadOnlyList candidatePaths, + int candidateCount ) { - Queue clones = new(); - foreach (PendingAssetChangeSet pendingChange in source) + using PooledResource> folderLease = SetBuffers + .GetHashSetPool(StringComparer.OrdinalIgnoreCase) + .Get(out HashSet folders); + for (int i = 0; i < candidateCount; ++i) { - clones.Enqueue(ClonePendingChange(pendingChange)); + string candidate = candidatePaths[i]; + if ( + string.IsNullOrWhiteSpace(candidate) + || IsScenePath(candidate) + || IsPrefabPath(candidate) + ) + { + continue; + } + + int separator = candidate.LastIndexOf('/'); + if (0 < separator) + { + folders.Add(candidate.Substring(0, separator)); + } } - return clones; - } - private static PendingAssetChangeSet ClonePendingChange(PendingAssetChangeSet source) - { - if (source == null) + if (folders.Count == 0) { - return null; + return Array.Empty(); } - return new PendingAssetChangeSet( - CopyPaths(source.Imported), - CopyPaths(source.Deleted), - CopyPaths(source.Moved), - CopyPaths(source.MovedFrom) - ); + string[] searchFolders = new string[folders.Count]; + folders.CopyTo(searchFolders); + return searchFolders; + } + + private static void CollectDeletedAssets( + AssetWatcher watcher, + IReadOnlyList deletedAssets, + IReadOnlyList movedFromAssetPaths, + List buffer + ) + { + AppendDeletedAssets(watcher, deletedAssets, buffer); + AppendDeletedAssets(watcher, movedFromAssetPaths, buffer); } - private static string[] CopyPaths(IReadOnlyList source) + private static void AppendDeletedAssets( + AssetWatcher watcher, + IReadOnlyList candidatePaths, + List buffer + ) { - int sourceCount = source.Count; - string[] copy = new string[sourceCount]; - if (source is string[] sourceArray) - { - Array.Copy(sourceArray, copy, sourceArray.Length); - return copy; - } - if (source is ICollection sourceCollection) + if (candidatePaths == null) { - sourceCollection.CopyTo(copy, 0); - return copy; + return; } - for (int index = 0; index < sourceCount; ++index) + + int candidateCount = candidatePaths.Count; + for (int i = 0; i < candidateCount; ++i) { - copy[index] = source[index]; + string path = candidatePaths[i]; + if (ShouldSkipPath(path)) + { + continue; + } + + if (!watcher.KnownAssetPaths.Contains(path)) + { + continue; + } + + buffer.Add(path); } - return copy; } private static void PopulateKnownAssetPaths( @@ -1473,58 +1217,6 @@ Type searchType in ResolveSearchableAssetTypes( } watcher.SubAssetSearchFilter = subAssetSearchFilter.ToString(); - - // Test types without matching filenames can evade Unity filters; this fallback stays in fixture folders. - if (_includeTestAssets) - { - string testFolder = "Assets/" + TestAssetFolderMarker; - if (!AssetDatabase.IsValidFolder(testFolder)) - { - return; - } - - // Check the filesystem too because Unity can log before throwing for a deleted folder. - string fullTestFolderPath = Path.Combine( - Path.GetDirectoryName(Application.dataPath) ?? string.Empty, - testFolder - ); - if (!Directory.Exists(fullTestFolderPath)) - { - return; - } - - // The folder may disappear between validation and search. - string[] testGuids; - try - { - testGuids = AssetDatabase.FindAssets( - "t:ScriptableObject", - new[] { testFolder } - ); - } - catch (Exception ex) - when (ex is not OutOfMemoryException and not StackOverflowException) - { - // Fixture cleanup can delete the folder between validation and search. - return; - } - - foreach (string testGuidsElement in testGuids) - { - string path = AssetDatabase.GUIDToAssetPath(testGuidsElement); - if (watcher.KnownAssetPaths.Contains(path)) - { - continue; - } - - // Metadata answers the main-asset type without deserializing consumer objects during import. - Type testAssetType = AssetDatabase.GetMainAssetTypeAtPath(path); - if (testAssetType != null && watcher.AssetType.IsAssignableFrom(testAssetType)) - { - watcher.KnownAssetPaths.Add(path); - } - } - } } private static IEnumerable ResolveSearchableAssetTypes( @@ -1598,61 +1290,6 @@ IReadOnlyList loadedTypes } } - private static bool TryResolveParameterMode( - Type declaringType, - MethodInfo method, - out SubscriptionParameterMode mode, - out Type createdElementType - ) - { - if (method.ReturnType != typeof(void)) - { - LogUnsupportedSignature( - declaringType, - method, - "must return void to receive DetectAssetChanged notifications." - ); - mode = SubscriptionParameterMode.None; - createdElementType = null; - return false; - } - - ParameterInfo[] parameters = method.GetParameters(); - if (parameters.Length == 0) - { - mode = SubscriptionParameterMode.None; - createdElementType = null; - return true; - } - - if (parameters.Length == 1 && parameters[0].ParameterType == typeof(AssetChangeContext)) - { - mode = SubscriptionParameterMode.Context; - createdElementType = null; - return true; - } - - if ( - parameters.Length == 2 - && TryResolveCreatedParameterType(parameters[0].ParameterType, out Type elementType) - && parameters[1].ParameterType == typeof(string[]) - ) - { - mode = SubscriptionParameterMode.CreatedAndDeleted; - createdElementType = elementType; - return true; - } - - LogUnsupportedSignature( - declaringType, - method, - "has an unsupported parameter signature for DetectAssetChanged." - ); - mode = SubscriptionParameterMode.None; - createdElementType = null; - return false; - } - private static bool TryResolveCreatedParameterType(Type parameterType, out Type elementType) { if (parameterType == null || !parameterType.IsArray) @@ -1692,35 +1329,8 @@ private static bool ResolutionSupportsAssetType(Type parameterElementType, Type return parameterElementType.IsAssignableFrom(assetType); } - private static void UpdateLoopWindow(int processedBatches) - { - double now = TimeProvider(); - double loopWindow = ResolveLoopWindowSeconds(); - if (loopWindow <= 0d) - { - loopWindow = UnityHelpersSettings.DefaultDetectAssetChangeLoopWindowSeconds; - } - - if (loopWindow < now - _lastChangeProcessTimestamp) - { - _consecutiveChangeBatches = 0; - } - - _lastChangeProcessTimestamp = now; - _consecutiveChangeBatches += processedBatches; - if (MaxConsecutiveChangeSetsWithinWindow <= _consecutiveChangeBatches) - { - EnterLoopProtection(); - } - } - private static double ResolveLoopWindowSeconds() { - if (_loopWindowSecondsOverride is > 0d) - { - return _loopWindowSecondsOverride.Value; - } - double configured; try { @@ -1762,41 +1372,7 @@ string detail private static bool ShouldSkipPath(string assetPath) { - if (string.IsNullOrWhiteSpace(assetPath)) - { - return true; - } - - // Fixture-scoped folders prevent one test's asset changes from invoking another test's handlers. - if (_includeTestAssets && _testAssetFolderAllowlist != null) - { - bool allowed = false; - foreach (string prefix in _testAssetFolderAllowlist) - { - if ( - !string.IsNullOrEmpty(prefix) - && assetPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - ) - { - allowed = true; - break; - } - } - if (!allowed) - { - return true; - } - } - - if ( - !_includeTestAssets - && 0 <= assetPath.IndexOf(TestAssetFolderMarker, StringComparison.OrdinalIgnoreCase) - ) - { - return true; - } - - return false; + return string.IsNullOrWhiteSpace(assetPath); } private static bool IsScenePath(string assetPath) @@ -1830,42 +1406,6 @@ internal sealed class MethodSubscription internal Type _createdParameterElementType; internal bool _searchPrefabs; internal bool _searchSceneObjects; - - internal MethodSubscription Clone() - { - return new MethodSubscription - { - _declaringType = _declaringType, - _method = _method, - _flags = _flags, - _parameterMode = _parameterMode, - _createdParameterElementType = _createdParameterElementType, - _searchPrefabs = _searchPrefabs, - _searchSceneObjects = _searchSceneObjects, - }; - } - } - - internal sealed class AssetWatcherSettings - { - internal bool IncludeAssignableTypes { get; set; } - internal bool SearchPrefabs { get; set; } - internal bool SearchSceneObjects { get; set; } - internal HashSet KnownAssetPaths { get; set; } - internal List Subscriptions { get; set; } - internal Dictionary WatchersByAssetType { get; set; } = new(); - internal Queue PendingAssetChanges { get; set; } = new(); - internal bool Initialized { get; set; } - internal bool IncludeTestAssets { get; set; } - internal IReadOnlyList TestAssetFolderAllowlist { get; set; } - internal bool ProcessingAssetChanges { get; set; } - internal bool LoopProtectionActive { get; set; } - internal int ConsecutiveChangeBatches { get; set; } - internal double LastChangeProcessTimestamp { get; set; } - internal Func TimeProvider { get; set; } = DefaultTimeProvider; - internal double? LoopWindowSecondsOverride { get; set; } - internal bool DiagnosticsEnabled { get; set; } - internal bool? EnabledOverride { get; set; } } internal sealed class AssetWatcher diff --git a/Editor/AssetProcessors/LlmArtifactCleaner.cs b/Editor/AssetProcessors/LlmArtifactCleaner.cs index 28519fb06..5352a4174 100644 --- a/Editor/AssetProcessors/LlmArtifactCleaner.cs +++ b/Editor/AssetProcessors/LlmArtifactCleaner.cs @@ -12,33 +12,15 @@ internal sealed class LlmArtifactCleaner : AssetPostprocessor private const string PackagePathPrefix = "Packages/com.wallstop-studios.unity-helpers/"; private const string LlmPrefix = "_llm_"; - internal static int PendingDeletionCountForTesting - { - get { return PendingDeletions.Count; } - } - - internal static Action DeleteAssetAction = DeleteAssetThroughDatabase; - - private static readonly string[] BlockedSegments = { LlmPrefix }; - private static readonly HashSet PendingDeletions = new( + internal static readonly HashSet PendingDeletions = new( StringComparer.OrdinalIgnoreCase ); - private static readonly Action DrainAction = DrainPendingDeletions; - private static bool _isDeleting; + internal static bool _isDeleting; - internal static void DeleteBlockedAssets(string[] assetPaths) - { - EnqueueBlockedAssets(assetPaths); - DrainPendingDeletions(); - } + private static readonly string[] BlockedSegments = { LlmPrefix }; - internal static void ResetForTesting() - { - PendingDeletions.Clear(); - DeleteAssetAction = DeleteAssetThroughDatabase; - _isDeleting = false; - } + private static readonly Action DrainAction = DrainPendingDeletions; internal static bool ShouldDelete(string assetPath) { @@ -75,29 +57,7 @@ internal static bool ShouldDelete(string assetPath) return false; } - private static void DeleteAssetThroughDatabase(string assetPath) - { - AssetDatabase.DeleteAsset(assetPath); - } - - private static void OnPostprocessAllAssets( - string[] importedAssets, - string[] deletedAssets, - string[] movedAssets, - string[] movedFromAssetPaths - ) - { - EnqueueBlockedAssets(importedAssets); - EnqueueBlockedAssets(movedAssets); - if (PendingDeletions.Count == 0) - { - return; - } - - AssetPostprocessorDeferral.Schedule(DrainAction); - } - - private static void EnqueueBlockedAssets(string[] assetPaths) + internal static void EnqueueBlockedAssets(string[] assetPaths) { if (assetPaths == null || assetPaths.Length == 0) { @@ -113,7 +73,7 @@ private static void EnqueueBlockedAssets(string[] assetPaths) } } - private static void DrainPendingDeletions() + internal static void DrainPendingDeletions() { if (PendingDeletions.Count == 0 || _isDeleting) { @@ -141,6 +101,23 @@ private static void DrainPendingDeletions() } } + private static void OnPostprocessAllAssets( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths + ) + { + EnqueueBlockedAssets(importedAssets); + EnqueueBlockedAssets(movedAssets); + if (PendingDeletions.Count == 0) + { + return; + } + + AssetPostprocessorDeferral.Schedule(DrainAction); + } + private static void DeleteAsset(string assetPath) { if (string.IsNullOrEmpty(assetPath)) @@ -148,7 +125,7 @@ private static void DeleteAsset(string assetPath) return; } - DeleteAssetAction(assetPath); + AssetDatabase.DeleteAsset(assetPath); } } } diff --git a/Editor/AssetProcessors/SpriteLabelProcessor.cs b/Editor/AssetProcessors/SpriteLabelProcessor.cs index 6a5c7a740..deb2d47b3 100644 --- a/Editor/AssetProcessors/SpriteLabelProcessor.cs +++ b/Editor/AssetProcessors/SpriteLabelProcessor.cs @@ -13,42 +13,26 @@ namespace WallstopStudios.UnityHelpers.Editor.AssetProcessors public sealed class SpriteLabelProcessor : AssetPostprocessor { - internal static int PendingImportedPathCountForTesting - { - get { return PendingImportedPaths.Count; } - } - - private static readonly HashSet PendingImportedPaths = new( + internal static readonly HashSet PendingImportedPaths = new( StringComparer.OrdinalIgnoreCase ); private static readonly Action DrainAction = DrainPendingImports; - internal static string[] SnapshotPendingImportedPathsForTesting() - { - string[] snapshot = new string[PendingImportedPaths.Count]; - PendingImportedPaths.CopyTo(snapshot); - return snapshot; - } - - internal static void EnqueueImportedPathsForTesting(string[] importedAssets) + internal static bool IsCandidatePath(string path) { - if (importedAssets == null || importedAssets.Length == 0) + if (string.IsNullOrEmpty(path)) { - return; + return false; } - foreach (string path in importedAssets) + if (!path.StartsWith("Assets", StringComparison.OrdinalIgnoreCase)) { - if (IsCandidatePath(path)) - { - PendingImportedPaths.Add(path); - } + return false; } - } - internal static void ResetForTesting() - { - PendingImportedPaths.Clear(); + return path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase); } private static void OnPostprocessAllAssets( @@ -86,23 +70,6 @@ string[] movedFromAssetPaths AssetPostprocessorDeferral.Schedule(DrainAction); } - private static bool IsCandidatePath(string path) - { - if (string.IsNullOrEmpty(path)) - { - return false; - } - - if (!path.StartsWith("Assets", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) - || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) - || path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase); - } - private static void DrainPendingImports() { if (PendingImportedPaths.Count == 0) diff --git a/Editor/CustomDrawers/Odin/WInLineEditorOdinDrawer.cs b/Editor/CustomDrawers/Odin/WInLineEditorOdinDrawer.cs index 82ad63f9a..fde2866fe 100644 --- a/Editor/CustomDrawers/Odin/WInLineEditorOdinDrawer.cs +++ b/Editor/CustomDrawers/Odin/WInLineEditorOdinDrawer.cs @@ -25,30 +25,6 @@ namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers /// public sealed class WInLineEditorOdinDrawer : OdinAttributeDrawer { - /// - /// Clears cached editors and state. Primarily for testing purposes. - /// - internal static void ClearCachedStateForTesting() - { - InLineEditorShared.ClearCachedStateForTesting(); - } - - /// - /// Test hook to set the foldout state for a given key. - /// - internal static void SetFoldoutStateForTesting(string key, bool expanded) - { - InLineEditorShared.SetFoldoutStateForTesting(key, expanded); - } - - /// - /// Test hook to get the foldout state for a given key. - /// - internal static bool GetFoldoutStateForTesting(string key) - { - return InLineEditorShared.GetFoldoutStateForTesting(key); - } - private static bool DrawHeader( Object value, GUIContent label, diff --git a/Editor/CustomDrawers/Odin/WShowIfOdinDrawer.cs b/Editor/CustomDrawers/Odin/WShowIfOdinDrawer.cs index eb2943605..7e3b0255c 100644 --- a/Editor/CustomDrawers/Odin/WShowIfOdinDrawer.cs +++ b/Editor/CustomDrawers/Odin/WShowIfOdinDrawer.cs @@ -35,21 +35,7 @@ internal static void ClearCache() MemberCache.Clear(); } - /// - /// Gets the value of the condition field from the parent object. - /// - /// - /// This method is internal to allow testing without reflection. - /// - /// The parent object containing the condition field. - /// The name of the condition field. - /// The value of the condition field, or null if not found. - internal static object GetConditionValueForTest(object parent, string conditionField) - { - return GetConditionValue(parent, conditionField); - } - - private static object GetConditionValue(object parent, string conditionField) + internal static object GetConditionValue(object parent, string conditionField) { if (parent == null || string.IsNullOrEmpty(conditionField)) { diff --git a/Editor/CustomDrawers/SerializableDictionaryPropertyDrawer.cs b/Editor/CustomDrawers/SerializableDictionaryPropertyDrawer.cs index 8797455a7..bddf74984 100644 --- a/Editor/CustomDrawers/SerializableDictionaryPropertyDrawer.cs +++ b/Editor/CustomDrawers/SerializableDictionaryPropertyDrawer.cs @@ -597,7 +597,7 @@ public sealed class SerializableDictionaryPropertyDrawer : PropertyDrawer internal const float PendingExpandableValueFoldoutGutter = 7f; internal const float RowExpandableValueFoldoutGutter = 24f; - private const float PendingSectionPadding = 6f; + internal const float PendingSectionPadding = 6f; private const float PendingSectionPaddingProjectSettings = 2f; private const float PendingFoldoutInspectorLabelShift = 2.5f; private const float DictionaryRowSimpleValueWidthRatio = 0.54f; @@ -648,31 +648,6 @@ internal delegate void PaletteValueDrawHandler( ref object value ); - internal static bool HasLastPendingHeaderRect { get; private set; } - internal static Rect LastPendingHeaderRect { get; private set; } - internal static Rect LastPendingFoldoutToggleRect { get; private set; } - internal static Rect LastPendingLabelHitRect { get; private set; } - internal static Rect LastPendingAbsoluteLabelHitRect { get; private set; } - internal static bool HasLastMainFoldoutRect { get; private set; } - internal static Rect LastMainFoldoutRect { get; private set; } - internal static bool HasLastPendingFieldRects { get; private set; } - internal static Rect LastPendingKeyFieldRect { get; private set; } - internal static Rect LastPendingValueFieldRect { get; private set; } - internal static bool HasLastRowRects { get; private set; } - internal static Rect LastRowOriginalRect { get; private set; } - internal static Rect LastRowKeyRect { get; private set; } - internal static Rect LastRowValueRect { get; private set; } - internal static float LastRowValueBaseX { get; private set; } - internal static bool LastPendingValueUsedFoldoutLabel { get; private set; } - internal static bool LastRowValueUsedFoldoutLabel { get; private set; } - internal static float LastPendingValueFoldoutOffset { get; private set; } - internal static float LastRowValueFoldoutOffset { get; private set; } - internal static bool HasLastRowChildContentRect { get; private set; } - internal static Rect LastRowChildContentRect { get; private set; } - internal static bool HasRowChildLabelWidthData { get; private set; } - internal static float LastRowChildMinLabelWidth { get; private set; } - internal static float LastRowChildMaxLabelWidth { get; private set; } - private static GUIContent DuplicateIconTemplate => _duplicateIconTemplate ??= EditorGUIUtility.IconContent("console.warnicon.sml"); @@ -691,6 +666,21 @@ ref object value private static GUIContent PaginationLastContent => _paginationLastContent ??= EditorGUIUtility.TrTextContent(">>", "Jump to last page"); + /// + /// Frame number when a child property drawer signaled that its height changed. + /// When this matches the current frame, the row render cache should be invalidated. + /// + internal static int _childHeightChangedFrame = -1; + + internal static readonly Cache MainFoldoutAnimations = + CacheBuilder + .NewBuilder() + .MaximumSize(MaxFoldoutAnimations) + .InitialCapacity(16) + .OnEviction(static (_, anim, _) => Unsubscribe(anim)) + .TransferOwnershipOnRemoval() + .Build(); + private static readonly BindingFlags ReflectionBindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; private static readonly char[] PropertyPathSeparators = { '.' }; @@ -714,6 +704,7 @@ private static readonly ConcurrentDictionary< private static readonly Color NullKeyHighlightColor = new(0.84f, 0.2f, 0.2f, 0.6f); private static readonly Dictionary PaletteValueRenderers = BuildPaletteValueRenderers(); + private static readonly GUIContent DuplicateTooltipContent = new(); private static readonly GUIContent DuplicateIconContentCache = new(); private static readonly GUIContent NullKeyTooltipContent = new(); @@ -745,36 +736,18 @@ private static readonly ConcurrentDictionary< private static readonly ReorderableList.DrawNoneElementCallback EmptyDrawNoneCallback = static _ => { }; - /// - /// Frame number when a child property drawer signaled that its height changed. - /// When this matches the current frame, the row render cache should be invalidated. - /// - private static int _childHeightChangedFrame = -1; - private static readonly Dictionary SingleTargetPropertyKeyCache = new(); - private static readonly Cache MainFoldoutAnimations = - CacheBuilder - .NewBuilder() - .MaximumSize(MaxFoldoutAnimations) - .InitialCapacity(16) - .OnEviction(static (_, anim, _) => Unsubscribe(anim)) - .TransferOwnershipOnRemoval() - .Build(); - - internal Rect LastResolvedPosition { get; private set; } - internal Rect LastListRect { get; private set; } - internal bool HasLastListRect { get; private set; } + internal readonly Dictionary _pendingEntries = new(); + internal readonly Dictionary _rowValueFoldoutStates = new(); private readonly Dictionary _lists = new(); - private readonly Dictionary _pendingEntries = new(); private readonly Dictionary _paginationStates = new(); private readonly Dictionary _pageCaches = new(); private readonly Dictionary _keyIndexCaches = new(); private readonly Dictionary _duplicateStates = new(); private readonly Dictionary _nullKeyStates = new(); - private readonly Dictionary _rowValueFoldoutStates = new(); private readonly Dictionary _valueTypes = new(); private readonly Dictionary _sortedOrderHashes = new(); private readonly Dictionary _heightCache = new(); @@ -792,6 +765,7 @@ private static readonly ConcurrentDictionary< private int _lastPrimedFoldoutFrame = -1; private bool _currentDrawInsideWGroup; + private float _currentListContentWidth; /// /// Signals that a child property drawer's height has changed and the parent @@ -804,78 +778,6 @@ internal static void SignalChildHeightChanged() InternalEditorUtility.RepaintAllViews(); } - /// - /// Gets the frame number when the child height changed signal was last set. - /// For testing purposes only. - /// - internal static int GetChildHeightChangedFrameForTests() - { - return _childHeightChangedFrame; - } - - /// - /// Resets the child height changed frame to -1 for testing purposes. - /// - internal static void ResetChildHeightChangedFrameForTests() - { - _childHeightChangedFrame = -1; - } - - internal static void ResetLayoutTrackingForTests() - { - HasLastPendingHeaderRect = false; - LastPendingHeaderRect = default; - LastPendingFoldoutToggleRect = default; - LastPendingLabelHitRect = default; - LastPendingAbsoluteLabelHitRect = default; - HasLastPendingFieldRects = false; - HasLastMainFoldoutRect = false; - LastMainFoldoutRect = default; - LastPendingValueUsedFoldoutLabel = false; - LastRowValueUsedFoldoutLabel = false; - LastPendingValueFoldoutOffset = 0f; - LastRowValueFoldoutOffset = 0f; - HasLastRowRects = false; - HasLastRowChildContentRect = false; - HasRowChildLabelWidthData = false; - LastRowChildMinLabelWidth = 0f; - LastRowChildMaxLabelWidth = 0f; - LastRowChildContentRect = default; - LastPendingKeyFieldRect = default; - LastPendingValueFieldRect = default; - } - - /// - /// Returns the resolved pending section padding value for test diagnostics. - /// - internal static float GetPendingSectionPaddingForTests() - { - return PendingSectionPadding; - } - - internal static EventType GetEffectiveMouseEventTypeForTests( - EventType eventType, - EventType rawEventType - ) - { - return GetEffectiveMouseEventType(eventType, rawEventType); - } - - internal static bool TryTogglePendingFoldoutLabelForTests( - Event currentEvent, - Rect labelHitRect, - Rect absoluteLabelHitRect, - ref bool expanded - ) - { - return TryTogglePendingFoldoutLabel( - currentEvent, - labelHitRect, - absoluteLabelHitRect, - ref expanded - ); - } - internal static float CalculateDictionaryRowHeight( SerializedProperty keyProperty, SerializedProperty valueProperty, @@ -952,20 +854,6 @@ int cycleLimit return Mathf.Sin(phase + seed) * DuplicateShakeAmplitude; } - internal static bool HasDroppedValuesArrayForTests(SerializedProperty dictionaryProperty) - { - return HasDroppedValuesArray( - new CachedPropertyPair - { - keysProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Keys - ), - valuesProperty = ResolveValuesArray(dictionaryProperty), - }, - dictionaryProperty - ); - } - internal static void SyncListSelectionWithPagination( ReorderableList list, PaginationState pagination, @@ -1290,87 +1178,6 @@ PendingEntry pending return matches; } - internal static bool IsTweeningEnabledForTests(bool isSortedDictionary) - { - return ShouldTweenPendingFoldout(isSortedDictionary); - } - - /// - /// Returns the expected static foldout progress without animation. - /// This static method cannot access instance animation state. - /// Use for actual animation testing. - /// - internal static float GetPendingFoldoutProgressForTests( - SerializedProperty property, - bool expanded, - bool isSorted - ) - { - return expanded ? 1f : 0f; - } - - /// - /// Clears the main foldout animation cache. Used for testing purposes. - /// - internal static void ClearMainFoldoutAnimCacheForTests() - { - MainFoldoutAnimations.Clear(); - } - - /// - /// Returns true if a main foldout AnimBool exists for the given property path and target object. - /// - internal static bool HasMainFoldoutAnimBoolForTests( - SerializedObject serializedObject, - string propertyPath - ) - { - MainFoldoutCacheKey cacheKey = GetMainFoldoutCacheKey(serializedObject, propertyPath); - return MainFoldoutAnimations.ContainsKey(cacheKey); - } - - /// - /// Gets the main foldout progress for testing purposes. - /// - internal static float GetMainFoldoutProgressForTests( - SerializedObject serializedObject, - string propertyPath, - bool isExpanded, - bool isSortedDictionary - ) - { - return GetMainFoldoutProgress( - serializedObject, - propertyPath, - isExpanded, - isSortedDictionary - ); - } - - /// - /// Gets the main foldout cache key for testing purposes. - /// Returns the string representation of the struct key. - /// - internal static string GetMainFoldoutCacheKeyForTests( - SerializedObject serializedObject, - string propertyPath - ) - { - return GetMainFoldoutCacheKey(serializedObject, propertyPath).ToString(); - } - - /// - /// Resolves the content rect for testing purposes, applying WGroup padding and indentation - /// without requiring a full OnGUI context. - /// - /// The original position rect. - /// Whether to skip standard Unity indentation. - /// The resolved content rect. - internal static Rect ResolveContentRectForTests(Rect position, bool skipIndentation = false) - { - return ResolveContentRect(position, skipIndentation); - } - internal static object DrawFieldForType( Rect rect, string label, @@ -2090,7 +1897,23 @@ internal static void SyncRuntimeDictionary(SerializedProperty dictionaryProperty serializedObject.UpdateIfRequiredOrScript(); } - private static Rect ResolveContentRect(Rect position, bool skipIndentation = false) + internal static float ResolveRowContentWidth(float rowWidth, float listContentWidth) + { + float minContentWidth = + DictionaryRowKeyColumnMinWidth + + DictionaryRowValueColumnMinWidth + + DictionaryRowKeyValueGap + + DictionaryRowFieldPadding * 4f; + if (rowWidth < minContentWidth) + { + return 0f < listContentWidth + ? Mathf.Max(minContentWidth, listContentWidth) + : minContentWidth; + } + return rowWidth; + } + + internal static Rect ResolveContentRect(Rect position, bool skipIndentation = false) { float leftPadding = GroupGUIWidthUtility.CurrentLeftPadding; float rightPadding = GroupGUIWidthUtility.CurrentRightPadding; @@ -2198,12 +2021,7 @@ private static Rect ResolveContentRect(Rect position, bool skipIndentation = fal return final; } - private static Rect ConvertGroupRectToAbsolute(Rect rect, Rect groupRect) - { - return new Rect(rect.x + groupRect.x, rect.y + groupRect.y, rect.width, rect.height); - } - - private static EventType GetEffectiveMouseEventType(Event currentEvent) + internal static EventType GetEffectiveMouseEventType(Event currentEvent) { if (currentEvent == null) { @@ -2213,7 +2031,7 @@ private static EventType GetEffectiveMouseEventType(Event currentEvent) return GetEffectiveMouseEventType(currentEvent.type, currentEvent.rawType); } - private static EventType GetEffectiveMouseEventType( + internal static EventType GetEffectiveMouseEventType( EventType eventType, EventType rawEventType ) @@ -2226,7 +2044,7 @@ EventType rawEventType return eventType; } - private static bool TryTogglePendingFoldoutLabel( + internal static bool TryTogglePendingFoldoutLabel( Event currentEvent, Rect labelHitRect, Rect absoluteLabelHitRect, @@ -2261,214 +2079,492 @@ ref bool expanded return true; } - private static void DrawDuplicateTooltip(Rect rect, string tooltip) + // Collection values use boxed wrappers when Unity refuses the plain array; ordinary values resolve first. + internal static SerializedProperty ResolveValuesArray(SerializedProperty dictionaryProperty) { - if (string.IsNullOrEmpty(tooltip) || Event.current.type != EventType.Repaint) - { - return; - } - - DuplicateTooltipContent.text = string.Empty; - DuplicateTooltipContent.image = null; - DuplicateTooltipContent.tooltip = tooltip; - GUI.Label(rect, DuplicateTooltipContent, GUIStyle.none); + return dictionaryProperty.FindPropertyRelative( + SerializableDictionarySerializedPropertyNames.Values + ) + ?? dictionaryProperty.FindPropertyRelative( + SerializableDictionarySerializedPropertyNames.BoxedValues + ); } - private static GUIContent GetDuplicateIconContent(string tooltip) + // Resolved keys with unresolved values indicate serialization failure; neither resolved means the property is unknown. + internal static bool HasDroppedValuesArray( + CachedPropertyPair propertyPair, + SerializedProperty dictionaryProperty + ) { - if (DuplicateIconTemplate != null) - { - DuplicateIconContentCache.image = DuplicateIconTemplate.image; - DuplicateIconContentCache.text = string.Empty; - } - else + if (propertyPair.keysProperty == null) { - DuplicateIconContentCache.image = null; - DuplicateIconContentCache.text = "!"; + return false; } - DuplicateIconContentCache.tooltip = tooltip; - return DuplicateIconContentCache; - } - - private static void DrawNullTooltip(Rect rect, string tooltip) - { - if (string.IsNullOrEmpty(tooltip) || Event.current.type != EventType.Repaint) + SerializedProperty valuesProperty = propertyPair.valuesProperty; + if (valuesProperty == null) { - return; + return true; } - NullKeyTooltipContent.text = string.Empty; - NullKeyTooltipContent.image = null; - NullKeyTooltipContent.tooltip = tooltip; - GUI.Label(rect, NullKeyTooltipContent, GUIStyle.none); - } - - private static float CalculateFoldoutValueHeight(SerializedProperty valueProperty) - { - float height = EditorGUIUtility.singleLineHeight; - if (!valueProperty.isExpanded || !valueProperty.hasVisibleChildren) + if ( + !string.Equals( + valuesProperty.name, + SerializableDictionarySerializedPropertyNames.BoxedValues, + StringComparison.Ordinal + ) + ) { - return height; + return false; } - float spacing = EditorGUIUtility.standardVerticalSpacing; - SerializedProperty iterator = valueProperty.Copy(); - SerializedProperty endProperty = iterator.GetEndProperty(); - bool enterChildren = true; - int baseDepth = valueProperty.depth; - - while ( - iterator.NextVisible(enterChildren) - && !SerializedProperty.EqualContents(iterator, endProperty) - ) + // An empty boxed array cannot prove serializability; runtime type metadata distinguishes unsupported values from empty dictionaries. + if (valuesProperty.arraySize == 0) { - enterChildren = false; - if (iterator.depth <= baseDepth) - { - break; - } - - float childHeight = EditorGUI.GetPropertyHeight(iterator, true); - height += spacing + childHeight; + // Unresolvable instances are unknown, not evidence that an empty dictionary cannot serialize. + return GetDictionaryInstance(dictionaryProperty) + is ISerializableDictionaryBoxedValues boxing + && !boxing.UsesBoxedValues; } - return height; + // Boxes only help if Unity can serialize their Data fields; inspect a real element when one exists. + SerializedProperty firstBox = valuesProperty.GetArrayElementAtIndex(0); + return firstBox == null + || firstBox.FindPropertyRelative( + SerializableDictionarySerializedPropertyNames.BoxedValueData + ) == null; } - // Collection values use boxed wrappers when Unity refuses the plain array; ordinary values resolve first. - private static SerializedProperty ResolveValuesArray(SerializedProperty dictionaryProperty) + internal static RowFoldoutKey BuildRowFoldoutStateKey(string cacheKey, int globalIndex) { - return dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ) - ?? dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.BoxedValues - ); + return new RowFoldoutKey(cacheKey, globalIndex); } - // Identify wrappers by the array field, so consumer types with a Data member are not accidentally unwrapped. - private static SerializedProperty GetValueElement( - SerializedProperty valuesProperty, - int index + internal static float ResolvePendingFoldoutToggleOffset( + SerializedProperty dictionaryProperty ) { - SerializedProperty element = valuesProperty.GetArrayElementAtIndex(index); - if ( - element == null - || !string.Equals( - valuesProperty.name, - SerializableDictionarySerializedPropertyNames.BoxedValues, - StringComparison.Ordinal - ) - ) + SerializedObject serializedObject = dictionaryProperty?.serializedObject; + if (!TargetsUnityHelpersSettings(serializedObject)) { - return element; + return PendingFoldoutToggleOffset; } - return element.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.BoxedValueData - ) ?? element; + return PendingFoldoutToggleOffsetProjectSettings; } - private static GUIContent GetNullKeyIconContent(string tooltip) + internal static float ResolvePendingFoldoutLabelContentOffset( + SerializedProperty dictionaryProperty + ) { - if (DuplicateIconTemplate != null) + SerializedObject serializedObject = dictionaryProperty?.serializedObject; + if (TargetsUnityHelpersSettings(serializedObject)) { - NullKeyIconContentCache.image = DuplicateIconTemplate.image; - NullKeyIconContentCache.text = string.Empty; - } - else - { - NullKeyIconContentCache.image = null; - NullKeyIconContentCache.text = "!"; + return PendingFoldoutLabelContentOffset; } - NullKeyIconContentCache.tooltip = tooltip; - return NullKeyIconContentCache; + return PendingFoldoutLabelContentOffset - PendingFoldoutInspectorLabelShift; } - private static void DrawDuplicateOutline(Rect rect) + internal static float CalculateRowChildLabelWidth( + float valueColumnWidth, + SerializedProperty property + ) { - Rect top = new(rect.x, rect.y, rect.width, DuplicateBorderThickness); - Rect bottom = new( - rect.x, - rect.yMax - DuplicateBorderThickness, - rect.width, - DuplicateBorderThickness + float ratioWidth = Mathf.Max(0f, valueColumnWidth * DictionaryRowChildLabelWidthRatio); + float measuredWidth = ratioWidth; + if (property != null) + { + RowChildLabelContent.text = property.displayName ?? string.Empty; + RowChildLabelContent.image = null; + RowChildLabelContent.tooltip = null; + Vector2 labelSize = GetRowChildLabelStyle().CalcSize(RowChildLabelContent); + measuredWidth = Mathf.Max(0f, labelSize.x + DictionaryRowChildLabelTextPadding); + } + + float availableWidth = Mathf.Max( + 0f, + valueColumnWidth - DictionaryRowChildHorizontalPadding * 2f ); - Rect left = new(rect.x, rect.y, DuplicateBorderThickness, rect.height); - Rect right = new( - rect.xMax - DuplicateBorderThickness, - rect.y, - DuplicateBorderThickness, - rect.height + float maxAllowed = Mathf.Min( + DictionaryRowChildLabelWidthMax, + Mathf.Max(DictionaryRowChildLabelWidthMin, availableWidth) ); - EditorGUI.DrawRect(top, DuplicateOutlineColor); - EditorGUI.DrawRect(bottom, DuplicateOutlineColor); - EditorGUI.DrawRect(left, DuplicateOutlineColor); - EditorGUI.DrawRect(right, DuplicateOutlineColor); + float targetWidth = 0f < measuredWidth ? measuredWidth : ratioWidth; + return Mathf.Clamp(targetWidth, DictionaryRowChildLabelWidthMin, maxAllowed); } /// - /// Gets high-resolution time in seconds using for accurate animation timing. - /// This is more reliable than for animations - /// that need consistent frame-to-frame timing. + /// Computes the cache key for main foldout animations. + /// Includes the target object's instance ID to prevent collisions between different objects. /// - private static double GetHighResolutionTime() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static MainFoldoutCacheKey GetMainFoldoutCacheKey( + SerializedObject serializedObject, + string propertyPath + ) { - return (double)Stopwatch.GetTimestamp() / Stopwatch.Frequency; + long instanceId = + serializedObject?.targetObject != null + ? serializedObject.targetObject.GetUnityObjectId() + : 0; + return new MainFoldoutCacheKey(instanceId, propertyPath); } - private static float GetWarningBarHeight() + internal static float GetMainFoldoutProgress( + SerializedObject serializedObject, + string propertyPath, + bool isExpanded, + bool isSortedDictionary + ) { - return EditorGUIUtility.singleLineHeight * 1.6f; + bool shouldTween = ShouldTweenMainFoldout(isSortedDictionary); + if (!shouldTween) + { + float immediateProgress = isExpanded ? 1f : 0f; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetMainFoldoutProgress_NoTween", + propertyPath ?? "(unknown)", + false, + isExpanded, + immediateProgress, + false + ); + + return immediateProgress; + } + + AnimBool anim = EnsureMainFoldoutAnim( + serializedObject, + propertyPath, + isExpanded, + isSortedDictionary + ); + if (anim == null) + { + float fallbackProgress = isExpanded ? 1f : 0f; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetMainFoldoutProgress_NoAnimBool", + propertyPath ?? "(unknown)", + true, + isExpanded, + fallbackProgress, + false + ); + + return fallbackProgress; + } + + if (anim.isAnimating) + { + RequestRepaint(); + } + + float animatedProgress = anim.faded; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetMainFoldoutProgress_Animated", + propertyPath ?? "(unknown)", + true, + isExpanded, + animatedProgress, + true + ); + + return animatedProgress; } - // Resolved keys with unresolved values indicate serialization failure; neither resolved means the property is unknown. - private static bool HasDroppedValuesArray( - CachedPropertyPair propertyPair, - SerializedProperty dictionaryProperty + internal static bool ShouldTweenPendingFoldout(bool isSortedDictionary) + { + return isSortedDictionary + ? UnityHelpersSettings.ShouldTweenSerializableSortedDictionaryFoldouts() + : UnityHelpersSettings.ShouldTweenSerializableDictionaryFoldouts(); + } + + internal static AnimBool EnsurePendingFoldoutAnim( + PendingEntry pending, + string propertyPath = null ) { - if (propertyPair.keysProperty == null) + if (pending == null) { - return false; + return null; } - SerializedProperty valuesProperty = propertyPair.valuesProperty; - if (valuesProperty == null) + bool shouldTween = ShouldTweenPendingFoldout(pending.isSorted); + float speed = GetPendingFoldoutAnimationSpeed(pending.isSorted); + + SerializableCollectionTweenDiagnostics.LogTweenSettingsQuery( + "EnsurePendingFoldoutAnim", + propertyPath ?? "(unknown)", + pending.isSorted, + shouldTween, + speed + ); + + if (!shouldTween) { - return true; + if (pending.foldoutAnim != null) + { + pending.foldoutAnim.valueChanged.RemoveListener(RequestRepaint); + pending.foldoutAnim = null; + + SerializableCollectionTweenDiagnostics.LogAnimBoolDestroyed( + propertyPath ?? "(unknown)", + "TweeningDisabled" + ); + } + + return null; } + if (pending.foldoutAnim == null) + { + pending.foldoutAnim = CreatePendingFoldoutAnim( + pending.isExpanded, + pending.isSorted, + propertyPath + ); + } + else + { + pending.foldoutAnim.speed = speed; + + if (pending.foldoutAnim.target != pending.isExpanded) + { + pending.foldoutAnim.target = pending.isExpanded; + } + } + + return pending.foldoutAnim; + } + + internal static float GetPendingFoldoutProgress( + PendingEntry pending, + string propertyPath = null + ) + { + if (pending == null) + { + return 0f; + } + + bool shouldTween = ShouldTweenPendingFoldout(pending.isSorted); + + // EnsurePendingFoldoutAnim also disposes animation state when tweening is disabled. + AnimBool anim = EnsurePendingFoldoutAnim(pending, propertyPath); + + if (!shouldTween) + { + float immediateProgress = pending.isExpanded ? 1f : 0f; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetPendingFoldoutProgress_NoTween", + propertyPath ?? "(unknown)", + false, + pending.isExpanded, + immediateProgress, + pending.foldoutAnim != null + ); + + return immediateProgress; + } + + if (anim == null) + { + float fallbackProgress = pending.isExpanded ? 1f : 0f; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetPendingFoldoutProgress_NoAnimBool", + propertyPath ?? "(unknown)", + true, + pending.isExpanded, + fallbackProgress, + false + ); + + return fallbackProgress; + } + + anim.target = pending.isExpanded; + if (anim.isAnimating) + { + RequestRepaint(); + } + float animatedProgress = anim.faded; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetPendingFoldoutProgress_Animated", + propertyPath ?? "(unknown)", + true, + pending.isExpanded, + animatedProgress, + true + ); + + return animatedProgress; + } + + private static Rect ConvertGroupRectToAbsolute(Rect rect, Rect groupRect) + { + return new Rect(rect.x + groupRect.x, rect.y + groupRect.y, rect.width, rect.height); + } + + private static void DrawDuplicateTooltip(Rect rect, string tooltip) + { + if (string.IsNullOrEmpty(tooltip) || Event.current.type != EventType.Repaint) + { + return; + } + + DuplicateTooltipContent.text = string.Empty; + DuplicateTooltipContent.image = null; + DuplicateTooltipContent.tooltip = tooltip; + GUI.Label(rect, DuplicateTooltipContent, GUIStyle.none); + } + + private static GUIContent GetDuplicateIconContent(string tooltip) + { + if (DuplicateIconTemplate != null) + { + DuplicateIconContentCache.image = DuplicateIconTemplate.image; + DuplicateIconContentCache.text = string.Empty; + } + else + { + DuplicateIconContentCache.image = null; + DuplicateIconContentCache.text = "!"; + } + + DuplicateIconContentCache.tooltip = tooltip; + return DuplicateIconContentCache; + } + + private static void DrawNullTooltip(Rect rect, string tooltip) + { + if (string.IsNullOrEmpty(tooltip) || Event.current.type != EventType.Repaint) + { + return; + } + + NullKeyTooltipContent.text = string.Empty; + NullKeyTooltipContent.image = null; + NullKeyTooltipContent.tooltip = tooltip; + GUI.Label(rect, NullKeyTooltipContent, GUIStyle.none); + } + + private static float CalculateFoldoutValueHeight(SerializedProperty valueProperty) + { + float height = EditorGUIUtility.singleLineHeight; + if (!valueProperty.isExpanded || !valueProperty.hasVisibleChildren) + { + return height; + } + + float spacing = EditorGUIUtility.standardVerticalSpacing; + SerializedProperty iterator = valueProperty.Copy(); + SerializedProperty endProperty = iterator.GetEndProperty(); + bool enterChildren = true; + int baseDepth = valueProperty.depth; + + while ( + iterator.NextVisible(enterChildren) + && !SerializedProperty.EqualContents(iterator, endProperty) + ) + { + enterChildren = false; + if (iterator.depth <= baseDepth) + { + break; + } + + float childHeight = EditorGUI.GetPropertyHeight(iterator, true); + height += spacing + childHeight; + } + + return height; + } + + // Identify wrappers by the array field, so consumer types with a Data member are not accidentally unwrapped. + private static SerializedProperty GetValueElement( + SerializedProperty valuesProperty, + int index + ) + { + SerializedProperty element = valuesProperty.GetArrayElementAtIndex(index); if ( - !string.Equals( + element == null + || !string.Equals( valuesProperty.name, SerializableDictionarySerializedPropertyNames.BoxedValues, StringComparison.Ordinal ) ) { - return false; + return element; + } + + return element.FindPropertyRelative( + SerializableDictionarySerializedPropertyNames.BoxedValueData + ) ?? element; + } + + private static GUIContent GetNullKeyIconContent(string tooltip) + { + if (DuplicateIconTemplate != null) + { + NullKeyIconContentCache.image = DuplicateIconTemplate.image; + NullKeyIconContentCache.text = string.Empty; + } + else + { + NullKeyIconContentCache.image = null; + NullKeyIconContentCache.text = "!"; } - // An empty boxed array cannot prove serializability; runtime type metadata distinguishes unsupported values from empty dictionaries. - if (valuesProperty.arraySize == 0) - { - // Unresolvable instances are unknown, not evidence that an empty dictionary cannot serialize. - return GetDictionaryInstance(dictionaryProperty) - is ISerializableDictionaryBoxedValues boxing - && !boxing.UsesBoxedValues; - } + NullKeyIconContentCache.tooltip = tooltip; + return NullKeyIconContentCache; + } + + private static void DrawDuplicateOutline(Rect rect) + { + Rect top = new(rect.x, rect.y, rect.width, DuplicateBorderThickness); + Rect bottom = new( + rect.x, + rect.yMax - DuplicateBorderThickness, + rect.width, + DuplicateBorderThickness + ); + Rect left = new(rect.x, rect.y, DuplicateBorderThickness, rect.height); + Rect right = new( + rect.xMax - DuplicateBorderThickness, + rect.y, + DuplicateBorderThickness, + rect.height + ); + + EditorGUI.DrawRect(top, DuplicateOutlineColor); + EditorGUI.DrawRect(bottom, DuplicateOutlineColor); + EditorGUI.DrawRect(left, DuplicateOutlineColor); + EditorGUI.DrawRect(right, DuplicateOutlineColor); + } + + /// + /// Gets high-resolution time in seconds using for accurate animation timing. + /// This is more reliable than for animations + /// that need consistent frame-to-frame timing. + /// + private static double GetHighResolutionTime() + { + return (double)Stopwatch.GetTimestamp() / Stopwatch.Frequency; + } - // Boxes only help if Unity can serialize their Data fields; inspect a real element when one exists. - SerializedProperty firstBox = valuesProperty.GetArrayElementAtIndex(0); - return firstBox == null - || firstBox.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.BoxedValueData - ) == null; + private static float GetWarningBarHeight() + { + return EditorGUIUtility.singleLineHeight * 1.6f; } // Height measurement has no rect, so wrap the rare serialization error using Inspector width. @@ -2660,11 +2756,6 @@ PaginationState pagination } } - private static RowFoldoutKey BuildRowFoldoutStateKey(string cacheKey, int globalIndex) - { - return new RowFoldoutKey(cacheKey, globalIndex); - } - private static int GetRelativeIndex(ListPageCache cache, int globalIndex) { if (globalIndex < 0) @@ -2804,19 +2895,6 @@ private static void SetPageIndex( } } - private static float ResolvePendingFoldoutToggleOffset( - SerializedProperty dictionaryProperty - ) - { - SerializedObject serializedObject = dictionaryProperty?.serializedObject; - if (!TargetsUnityHelpersSettings(serializedObject)) - { - return PendingFoldoutToggleOffset; - } - - return PendingFoldoutToggleOffsetProjectSettings; - } - private static float ResolvePendingSectionPadding(SerializedProperty dictionaryProperty) { SerializedObject serializedObject = dictionaryProperty?.serializedObject; @@ -2828,19 +2906,6 @@ private static float ResolvePendingSectionPadding(SerializedProperty dictionaryP return PendingSectionPaddingProjectSettings; } - private static float ResolvePendingFoldoutLabelContentOffset( - SerializedProperty dictionaryProperty - ) - { - SerializedObject serializedObject = dictionaryProperty?.serializedObject; - if (TargetsUnityHelpersSettings(serializedObject)) - { - return PendingFoldoutLabelContentOffset; - } - - return PendingFoldoutLabelContentOffset - PendingFoldoutInspectorLabelShift; - } - private static bool TargetsUnityHelpersSettings(SerializedObject serializedObject) { if (serializedObject == null) @@ -3303,8 +3368,6 @@ out float renderedHeight } } - TrackRowChildLayout(childRect, childLabelWidth); - childY = childRect.yMax + EditorGUIUtility.standardVerticalSpacing; } @@ -3315,35 +3378,6 @@ out float renderedHeight return changed; } - private static float CalculateRowChildLabelWidth( - float valueColumnWidth, - SerializedProperty property - ) - { - float ratioWidth = Mathf.Max(0f, valueColumnWidth * DictionaryRowChildLabelWidthRatio); - float measuredWidth = ratioWidth; - if (property != null) - { - RowChildLabelContent.text = property.displayName ?? string.Empty; - RowChildLabelContent.image = null; - RowChildLabelContent.tooltip = null; - Vector2 labelSize = GetRowChildLabelStyle().CalcSize(RowChildLabelContent); - measuredWidth = Mathf.Max(0f, labelSize.x + DictionaryRowChildLabelTextPadding); - } - - float availableWidth = Mathf.Max( - 0f, - valueColumnWidth - DictionaryRowChildHorizontalPadding * 2f - ); - float maxAllowed = Mathf.Min( - DictionaryRowChildLabelWidthMax, - Mathf.Max(DictionaryRowChildLabelWidthMin, availableWidth) - ); - - float targetWidth = 0f < measuredWidth ? measuredWidth : ratioWidth; - return Mathf.Clamp(targetWidth, DictionaryRowChildLabelWidthMin, maxAllowed); - } - private static GUIStyle GetRowChildLabelStyle() { if (_rowChildLabelStyle == null) @@ -3358,30 +3392,6 @@ private static GUIStyle GetRowChildLabelStyle() return _rowChildLabelStyle; } - private static void TrackRowChildLayout(Rect childRect, float labelWidth) - { - HasLastRowChildContentRect = true; - LastRowChildContentRect = childRect; - - if (!HasRowChildLabelWidthData) - { - HasRowChildLabelWidthData = true; - LastRowChildMinLabelWidth = labelWidth; - LastRowChildMaxLabelWidth = labelWidth; - return; - } - - LastRowChildMinLabelWidth = Mathf.Min(LastRowChildMinLabelWidth, labelWidth); - LastRowChildMaxLabelWidth = Mathf.Max(LastRowChildMaxLabelWidth, labelWidth); - } - - private static void TrackPendingFieldRects(Rect keyRect, Rect valueRect) - { - HasLastPendingFieldRects = true; - LastPendingKeyFieldRect = keyRect; - LastPendingValueFieldRect = valueRect; - } - private static Func CreateManualKeyComparisonDelegate(Type keyType) { if (!KeyTypeSupportsManualSorting(keyType)) @@ -3779,341 +3789,128 @@ private static int ComputeKeyOrderHash(SerializedProperty keysProperty, Type key return Objects.EnumerableHashCode(EnumerateKeyPairs(keysProperty, keyType)); static IEnumerable<(int index, object keyValue)> EnumerateKeyPairs( - SerializedProperty property, - Type type - ) - { - int count = property.arraySize; - for (int index = 0; index < count; ++index) - { - SerializedProperty keyProperty = property.GetArrayElementAtIndex(index); - yield return (index, GetPropertyValue(keyProperty, type)); - } - } - } - - /// - /// Computes the cache key for main foldout animations. - /// Includes the target object's instance ID to prevent collisions between different objects. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static MainFoldoutCacheKey GetMainFoldoutCacheKey( - SerializedObject serializedObject, - string propertyPath - ) - { - long instanceId = - serializedObject?.targetObject != null - ? serializedObject.targetObject.GetUnityObjectId() - : 0; - return new MainFoldoutCacheKey(instanceId, propertyPath); - } - - private static bool ShouldTweenMainFoldout(bool isSortedDictionary) - { - return isSortedDictionary - ? UnityHelpersSettings.ShouldTweenSerializableSortedDictionaryFoldouts() - : UnityHelpersSettings.ShouldTweenSerializableDictionaryFoldouts(); - } - - private static float GetMainFoldoutAnimationSpeed(bool isSortedDictionary) - { - return isSortedDictionary - ? UnityHelpersSettings.GetSerializableSortedDictionaryFoldoutSpeed() - : UnityHelpersSettings.GetSerializableDictionaryFoldoutSpeed(); - } - - private static AnimBool EnsureMainFoldoutAnim( - SerializedObject serializedObject, - string propertyPath, - bool isExpanded, - bool isSortedDictionary - ) - { - bool shouldTween = ShouldTweenMainFoldout(isSortedDictionary); - float speed = GetMainFoldoutAnimationSpeed(isSortedDictionary); - MainFoldoutCacheKey cacheKey = GetMainFoldoutCacheKey(serializedObject, propertyPath); - - SerializableCollectionTweenDiagnostics.LogTweenSettingsQuery( - "EnsureMainFoldoutAnim", - propertyPath ?? "(unknown)", - isSortedDictionary, - shouldTween, - speed - ); - - if (!shouldTween) - { - if (MainFoldoutAnimations.TryRemove(cacheKey, out AnimBool existing)) - { - Unsubscribe(existing); - - SerializableCollectionTweenDiagnostics.LogAnimBoolDestroyed( - propertyPath, - "MainFoldout_TweeningDisabled" - ); - } - - return null; - } - - if (!MainFoldoutAnimations.TryGet(cacheKey, out AnimBool anim) || anim == null) - { - anim = new AnimBool(isExpanded) { speed = speed }; - anim.valueChanged.AddListener(RequestRepaint); - MainFoldoutAnimations.Set(cacheKey, anim); - - SerializableCollectionTweenDiagnostics.LogAnimBoolCreation( - propertyPath, - isExpanded, - isSortedDictionary, - speed - ); - } - else - { - anim.speed = speed; - } - - anim.target = isExpanded; - return anim; - } - - private static float GetMainFoldoutProgress( - SerializedObject serializedObject, - string propertyPath, - bool isExpanded, - bool isSortedDictionary - ) - { - bool shouldTween = ShouldTweenMainFoldout(isSortedDictionary); - if (!shouldTween) - { - float immediateProgress = isExpanded ? 1f : 0f; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetMainFoldoutProgress_NoTween", - propertyPath ?? "(unknown)", - false, - isExpanded, - immediateProgress, - false - ); - - return immediateProgress; - } - - AnimBool anim = EnsureMainFoldoutAnim( - serializedObject, - propertyPath, - isExpanded, - isSortedDictionary - ); - if (anim == null) - { - float fallbackProgress = isExpanded ? 1f : 0f; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetMainFoldoutProgress_NoAnimBool", - propertyPath ?? "(unknown)", - true, - isExpanded, - fallbackProgress, - false - ); - - return fallbackProgress; - } - - if (anim.isAnimating) - { - RequestRepaint(); - } - - float animatedProgress = anim.faded; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetMainFoldoutProgress_Animated", - propertyPath ?? "(unknown)", - true, - isExpanded, - animatedProgress, - true - ); - - return animatedProgress; + SerializedProperty property, + Type type + ) + { + int count = property.arraySize; + for (int index = 0; index < count; ++index) + { + SerializedProperty keyProperty = property.GetArrayElementAtIndex(index); + yield return (index, GetPropertyValue(keyProperty, type)); + } + } } - private static bool ShouldTweenPendingFoldout(bool isSortedDictionary) + private static bool ShouldTweenMainFoldout(bool isSortedDictionary) { return isSortedDictionary ? UnityHelpersSettings.ShouldTweenSerializableSortedDictionaryFoldouts() : UnityHelpersSettings.ShouldTweenSerializableDictionaryFoldouts(); } - private static float GetPendingFoldoutAnimationSpeed(bool isSortedDictionary) + private static float GetMainFoldoutAnimationSpeed(bool isSortedDictionary) { return isSortedDictionary ? UnityHelpersSettings.GetSerializableSortedDictionaryFoldoutSpeed() : UnityHelpersSettings.GetSerializableDictionaryFoldoutSpeed(); } - private static AnimBool CreatePendingFoldoutAnim( - bool initialValue, - bool isSortedDictionary, - string propertyPath = null - ) - { - float speed = GetPendingFoldoutAnimationSpeed(isSortedDictionary); - AnimBool anim = new(initialValue) { speed = speed }; - anim.valueChanged.AddListener(RequestRepaint); - - SerializableCollectionTweenDiagnostics.LogAnimBoolCreation( - propertyPath ?? "(unknown)", - initialValue, - isSortedDictionary, - speed - ); - - return anim; - } - - private static AnimBool EnsurePendingFoldoutAnim( - PendingEntry pending, - string propertyPath = null + private static AnimBool EnsureMainFoldoutAnim( + SerializedObject serializedObject, + string propertyPath, + bool isExpanded, + bool isSortedDictionary ) { - if (pending == null) - { - return null; - } - - bool shouldTween = ShouldTweenPendingFoldout(pending.isSorted); - float speed = GetPendingFoldoutAnimationSpeed(pending.isSorted); + bool shouldTween = ShouldTweenMainFoldout(isSortedDictionary); + float speed = GetMainFoldoutAnimationSpeed(isSortedDictionary); + MainFoldoutCacheKey cacheKey = GetMainFoldoutCacheKey(serializedObject, propertyPath); SerializableCollectionTweenDiagnostics.LogTweenSettingsQuery( - "EnsurePendingFoldoutAnim", + "EnsureMainFoldoutAnim", propertyPath ?? "(unknown)", - pending.isSorted, + isSortedDictionary, shouldTween, speed ); if (!shouldTween) { - if (pending.foldoutAnim != null) + if (MainFoldoutAnimations.TryRemove(cacheKey, out AnimBool existing)) { - pending.foldoutAnim.valueChanged.RemoveListener(RequestRepaint); - pending.foldoutAnim = null; + Unsubscribe(existing); SerializableCollectionTweenDiagnostics.LogAnimBoolDestroyed( - propertyPath ?? "(unknown)", - "TweeningDisabled" + propertyPath, + "MainFoldout_TweeningDisabled" ); } return null; } - if (pending.foldoutAnim == null) + if (!MainFoldoutAnimations.TryGet(cacheKey, out AnimBool anim) || anim == null) { - pending.foldoutAnim = CreatePendingFoldoutAnim( - pending.isExpanded, - pending.isSorted, - propertyPath + anim = new AnimBool(isExpanded) { speed = speed }; + anim.valueChanged.AddListener(RequestRepaint); + MainFoldoutAnimations.Set(cacheKey, anim); + + SerializableCollectionTweenDiagnostics.LogAnimBoolCreation( + propertyPath, + isExpanded, + isSortedDictionary, + speed ); } else { - pending.foldoutAnim.speed = speed; - - if (pending.foldoutAnim.target != pending.isExpanded) - { - pending.foldoutAnim.target = pending.isExpanded; - } + anim.speed = speed; } - return pending.foldoutAnim; - } - - private static void RequestRepaint() - { - // Repaint all views because SettingsProvider and Inspector can both host this drawer. - InternalEditorUtility.RepaintAllViews(); + anim.target = isExpanded; + return anim; } - private static void Unsubscribe(AnimBool anim) + private static float GetPendingFoldoutAnimationSpeed(bool isSortedDictionary) { - if (anim != null) - { - anim.valueChanged.RemoveListener(RequestRepaint); - } + return isSortedDictionary + ? UnityHelpersSettings.GetSerializableSortedDictionaryFoldoutSpeed() + : UnityHelpersSettings.GetSerializableDictionaryFoldoutSpeed(); } - private static float GetPendingFoldoutProgress( - PendingEntry pending, + private static AnimBool CreatePendingFoldoutAnim( + bool initialValue, + bool isSortedDictionary, string propertyPath = null ) { - if (pending == null) - { - return 0f; - } - - bool shouldTween = ShouldTweenPendingFoldout(pending.isSorted); - - // EnsurePendingFoldoutAnim also disposes animation state when tweening is disabled. - AnimBool anim = EnsurePendingFoldoutAnim(pending, propertyPath); - - if (!shouldTween) - { - float immediateProgress = pending.isExpanded ? 1f : 0f; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetPendingFoldoutProgress_NoTween", - propertyPath ?? "(unknown)", - false, - pending.isExpanded, - immediateProgress, - pending.foldoutAnim != null - ); - - return immediateProgress; - } + float speed = GetPendingFoldoutAnimationSpeed(isSortedDictionary); + AnimBool anim = new(initialValue) { speed = speed }; + anim.valueChanged.AddListener(RequestRepaint); - if (anim == null) - { - float fallbackProgress = pending.isExpanded ? 1f : 0f; + SerializableCollectionTweenDiagnostics.LogAnimBoolCreation( + propertyPath ?? "(unknown)", + initialValue, + isSortedDictionary, + speed + ); - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetPendingFoldoutProgress_NoAnimBool", - propertyPath ?? "(unknown)", - true, - pending.isExpanded, - fallbackProgress, - false - ); + return anim; + } - return fallbackProgress; - } + private static void RequestRepaint() + { + // Repaint all views because SettingsProvider and Inspector can both host this drawer. + InternalEditorUtility.RepaintAllViews(); + } - anim.target = pending.isExpanded; - if (anim.isAnimating) + private static void Unsubscribe(AnimBool anim) + { + if (anim != null) { - RequestRepaint(); + anim.valueChanged.RemoveListener(RequestRepaint); } - float animatedProgress = anim.faded; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetPendingFoldoutProgress_Animated", - propertyPath ?? "(unknown)", - true, - pending.isExpanded, - animatedProgress, - true - ); - - return animatedProgress; } private static GUIStyle GetFooterLabelStyle() @@ -6067,25 +5864,7 @@ out DuplicateKeyState duplicateState return; } - float minContentWidth = - DictionaryRowKeyColumnMinWidth - + DictionaryRowValueColumnMinWidth - + DictionaryRowKeyValueGap - + DictionaryRowFieldPadding * 4f; - if (rect.width < minContentWidth) - { - float fallbackWidth = minContentWidth; - if (HasLastListRect && 0f < LastListRect.width) - { - fallbackWidth = Mathf.Max(fallbackWidth, LastListRect.width); - } - else if (0f < LastResolvedPosition.width) - { - fallbackWidth = Mathf.Max(fallbackWidth, LastResolvedPosition.width); - } - - rect.width = fallbackWidth; - } + rect.width = ResolveRowContentWidth(rect.width, _currentListContentWidth); int globalIndex = currentCache.entries[index].arrayIndex; CachedPropertyPair elementPair = GetOrCreateCachedPropertyPair( @@ -6221,9 +6000,6 @@ out DuplicateKeyState duplicateState Mathf.Max(0f, rowRightEdge - valueRect.x) ); - LastRowOriginalRect = rect; - LastRowValueBaseX = valueRect.x; - float rowFieldPadding = DictionaryRowFieldPadding; keyRect.x += rowFieldPadding; keyRect.width = Mathf.Max(0f, keyRect.width - rowFieldPadding); @@ -6261,9 +6037,6 @@ out DuplicateKeyState duplicateState } } - HasLastRowRects = true; - LastRowKeyRect = keyRect; - EditorGUI.BeginChangeCheck(); EditorGUI.PropertyField(keyRect, keyProperty, GUIContent.none, true); if (EditorGUI.EndChangeCheck()) @@ -6284,7 +6057,7 @@ out DuplicateKeyState duplicateState float foldoutOffset = RowExpandableValueFoldoutGutter; valueRect.x += foldoutOffset; valueRect.width = Mathf.Max(0f, valueRect.width - foldoutOffset); - LastRowValueFoldoutOffset = foldoutOffset; + valueChanged = DrawRowFoldoutValue( valueRect, valueProperty, @@ -6296,14 +6069,11 @@ out float renderedValueHeight } else { - LastRowValueFoldoutOffset = 0f; EditorGUI.BeginChangeCheck(); EditorGUI.PropertyField(valueRect, valueProperty, valueLabel, true); valueChanged = EditorGUI.EndChangeCheck(); } - LastRowValueRect = valueRect; - if (valueChanged) { MarkListCacheDirty(key); @@ -6332,7 +6102,6 @@ out float renderedValueHeight MarkListCacheDirty(key); RequestRepaint(); } - LastRowValueUsedFoldoutLabel = valueSupportsFoldout; }; list.onRemoveCallback = reorderableList => @@ -6733,17 +6502,6 @@ internal void MarkListCacheDirty(string cacheKey) keysToRemoveLease.Dispose(); } - internal void SetRowFoldoutStateForTests(string cacheKey, int globalIndex, bool isExpanded) - { - RowFoldoutKey key = BuildRowFoldoutStateKey(cacheKey, globalIndex); - if (!key.IsValid) - { - return; - } - - _rowValueFoldoutStates[key] = isExpanded; - } - internal void RemoveEntryAtIndex( int removeIndex, ReorderableList list, @@ -7070,94 +6828,6 @@ internal void InvalidatePendingDuplicateCache(string cacheKey) } } - /// - /// Gets the actual animated foldout progress from the drawer instance's pending entry. - /// Use this for testing that animations are actually progressing over time. - /// - /// The serialized property for the dictionary. - /// - /// The current animation progress (0 to 1), or -1 if no pending entry exists for this property. - /// When tweening is disabled, returns 0 or 1 immediately based on expanded state. - /// - internal float GetPendingFoldoutProgressFromInstance(SerializedProperty property) - { - if (property == null) - { - return -1f; - } - - string cacheKey = GetListKey(property); - if (!_pendingEntries.TryGetValue(cacheKey, out PendingEntry pending) || pending == null) - { - return -1f; - } - - return GetPendingFoldoutProgress(pending); - } - - /// - /// Gets the pending entry's expanded state and animation information for testing. - /// - /// The serialized property for the dictionary. - /// Output: whether the pending section is logically expanded. - /// Output: the current animation progress (0 to 1). - /// Output: whether an AnimBool is active for this entry. - /// True if a pending entry was found, false otherwise. - internal bool TryGetPendingAnimationStateForTests( - SerializedProperty property, - out bool isExpanded, - out float animProgress, - out bool hasAnimBool - ) - { - if (property == null) - { - isExpanded = false; - animProgress = 0f; - hasAnimBool = false; - return false; - } - - string cacheKey = GetListKey(property); - if (!_pendingEntries.TryGetValue(cacheKey, out PendingEntry pending) || pending == null) - { - isExpanded = false; - animProgress = 0f; - hasAnimBool = false; - return false; - } - - isExpanded = pending.isExpanded; - hasAnimBool = pending.foldoutAnim != null; - animProgress = GetPendingFoldoutProgress(pending); - return true; - } - - /// - /// Sets the pending entry's expanded state for testing purposes. - /// This properly triggers animation state updates. - /// - internal void SetPendingExpandedStateForTests(SerializedProperty property, bool expanded) - { - if (property == null) - { - return; - } - - string cacheKey = GetListKey(property); - if (!_pendingEntries.TryGetValue(cacheKey, out PendingEntry pending) || pending == null) - { - return; - } - - pending.isExpanded = expanded; - AnimBool anim = EnsurePendingFoldoutAnim(pending, property.propertyPath); - if (anim != null) - { - anim.target = expanded; - } - } - internal void InvokeClearDictionary( SerializedProperty dictionaryProperty, SerializedProperty keysProperty, @@ -7185,8 +6855,7 @@ private void OnGUIInternal(Rect position, SerializedProperty property, GUIConten SerializableDictionaryIndentDiagnostics.LogGroupPaddingState("OnGUI entry"); Rect contentPosition = ResolveContentRect(originalPosition, targetsSettings); - LastResolvedPosition = contentPosition; - HasLastListRect = false; + _currentListContentWidth = contentPosition.width; EditorGUI.BeginProperty(originalPosition, label, property); // WGroup already provides SettingsProvider indentation; applying EditorGUI indentation would double it. @@ -7287,9 +6956,6 @@ out bool runtimeSorted true ); - HasLastMainFoldoutRect = true; - LastMainFoldoutRect = foldoutRect; - float mainFoldoutProgress = GetMainFoldoutProgress( property.serializedObject, property.propertyPath, @@ -7410,8 +7076,6 @@ out bool runtimeSorted y = pendingY + EditorGUIUtility.standardVerticalSpacing; Rect listRect = new(position.x, y, position.width, list.GetHeight()); - LastListRect = listRect; - HasLastListRect = true; // Opaque backgrounds cover Unity's defaults instead of tinting them. if (_currentDrawInsideWGroup && Event.current.type == EventType.Repaint) @@ -8532,19 +8196,11 @@ Type valueType headerRect.height ); - HasLastPendingHeaderRect = true; - LastPendingHeaderRect = ConvertGroupRectToAbsolute(headerRect, containerRect); - LastPendingFoldoutToggleRect = ConvertGroupRectToAbsolute( - foldoutToggleRect, - containerRect - ); - Event currentEvent = Event.current; bool expanded = pending.isExpanded; Rect absoluteLabelHitRect = ConvertGroupRectToAbsolute(labelHitRect, containerRect); - LastPendingLabelHitRect = labelHitRect; - LastPendingAbsoluteLabelHitRect = absoluteLabelHitRect; + bool mouseInLabelRect = labelHitRect.Contains(currentEvent.mousePosition) || absoluteLabelHitRect.Contains(currentEvent.mousePosition); @@ -8641,11 +8297,7 @@ ref expanded { GUI.EndGroup(); y = containerRect.yMax; - LastPendingValueUsedFoldoutLabel = false; - LastPendingValueFoldoutOffset = 0f; - HasLastPendingFieldRects = false; - LastPendingKeyFieldRect = default; - LastPendingValueFieldRect = default; + return; } @@ -8692,7 +8344,6 @@ ref expanded { InvalidatePendingDuplicateCache(pending); } - Rect absoluteKeyRect = ConvertGroupRectToAbsolute(keyRect, containerRect); float valueHeight = pendingMetrics.ValueHeight; // Key and value share one column; identical origins and gutters keep stacked fields aligned. @@ -8724,8 +8375,7 @@ ref expanded { MarkPendingValueChanged(pending); } - LastPendingValueUsedFoldoutLabel = pendingValueSupportsFoldout; - LastPendingValueFoldoutOffset = pendingValueFoldoutOffset; + if ( pendingValueSupportsFoldout && pending.valueWrapperProperty is { isExpanded: false } @@ -8733,8 +8383,7 @@ ref expanded { pending.valueWrapperProperty.isExpanded = true; } - Rect absoluteValueRect = ConvertGroupRectToAbsolute(valueRect, containerRect); - TrackPendingFieldRects(absoluteKeyRect, absoluteValueRect); + innerY += valueHeight + spacing; bool keySupported = IsTypeSupported(keyType); @@ -9335,7 +8984,7 @@ private void MarkNullKeyStateDirty(string cacheKey) state.MarkDirty(); } - private sealed class CachedPropertyPair + internal sealed class CachedPropertyPair { public SerializedProperty keysProperty; public SerializedProperty valuesProperty; @@ -9505,7 +9154,7 @@ public override int GetHashCode() /// Struct key for MainFoldoutAnimations cache to avoid string allocations. /// Uses (instanceId, propertyPath) pair for cache identity. /// - private readonly struct MainFoldoutCacheKey : IEquatable + internal readonly struct MainFoldoutCacheKey : IEquatable { public readonly long InstanceId; public readonly string PropertyPath; diff --git a/Editor/CustomDrawers/SerializableSetPropertyDrawer.cs b/Editor/CustomDrawers/SerializableSetPropertyDrawer.cs index ff92b8957..04a6f3282 100644 --- a/Editor/CustomDrawers/SerializableSetPropertyDrawer.cs +++ b/Editor/CustomDrawers/SerializableSetPropertyDrawer.cs @@ -34,7 +34,7 @@ public sealed class SerializableSetPropertyDrawer : PropertyDrawer { internal const float WGroupFoldoutAlignmentOffset = 2.5f; - private const float SectionSpacing = 4f; + internal const float SectionSpacing = 4f; private const float ButtonSpacing = 4f; private const float PaginationButtonWidth = 28f; private const int DefaultPageSize = 15; @@ -74,24 +74,6 @@ public sealed class SerializableSetPropertyDrawer : PropertyDrawer private const int MaxFoldoutAnimations = 256; private const int MaxSingleTargetPropertyKeyCacheSize = 512; - internal static bool HasLastManualEntryHeaderRect { get; private set; } - internal static Rect LastManualEntryHeaderRect { get; private set; } - internal static Rect LastManualEntryToggleRect { get; private set; } - internal static bool HasLastMainFoldoutRect { get; private set; } - internal static Rect LastMainFoldoutRect { get; private set; } - internal static bool HasLastManualEntryValueRect { get; private set; } - internal static Rect LastManualEntryValueRect { get; private set; } - internal static bool LastManualEntryValueUsedFoldoutLabel { get; private set; } - internal static float LastManualEntryValueFoldoutOffset { get; private set; } - internal static bool HasLastRowContentRect { get; private set; } - internal static Rect LastRowContentRect { get; private set; } - - internal static float LastFooterWGroupLeftPadding { get; private set; } - internal static float LastFooterWGroupRightPadding { get; private set; } - internal static float LastFooterAvailableWidth { get; private set; } - internal static float LastFooterRangeWidth { get; private set; } - internal static bool LastFooterRangeLabelWasDrawn { get; private set; } - internal static Rect LastFooterRangeLabelRect { get; private set; } private static GUIStyle AddButtonStyle => _addButtonStyle ??= BuildButtonStyle(new Color(0.22f, 0.62f, 0.29f)); @@ -113,6 +95,21 @@ public sealed class SerializableSetPropertyDrawer : PropertyDrawer private static GUIContent ManualEntryAddContent => _manualEntryAddContent ??= EditorGUIUtility.TrTextContent("Add"); + internal static readonly Cache MainFoldoutAnimations = + CacheBuilder + .NewBuilder() + .MaximumSize(MaxFoldoutAnimations) + .InitialCapacity(16) + .OnEviction(static (_, anim, _) => Unsubscribe(anim)) + .TransferOwnershipOnRemoval() + .Build(); + + /// + /// Frame number when a child property drawer signaled that its height changed. + /// When this matches the current frame, the row render cache should be invalidated. + /// + internal static int _childHeightChangedFrame = -1; + private static readonly GUIContent AddEntryContent = new("Add"); private static readonly GUIContent ClearAllContent = new("Clear All"); private static readonly GUIContent SortContent = new("Sort"); @@ -162,32 +159,13 @@ public sealed class SerializableSetPropertyDrawer : PropertyDrawer private static readonly ConcurrentDictionary UnsupportedParameterlessTypes = new(); - private static readonly Cache MainFoldoutAnimations = - CacheBuilder - .NewBuilder() - .MaximumSize(MaxFoldoutAnimations) - .InitialCapacity(16) - .OnEviction(static (_, anim, _) => Unsubscribe(anim)) - .TransferOwnershipOnRemoval() - .Build(); - - /// - /// Frame number when a child property drawer signaled that its height changed. - /// When this matches the current frame, the row render cache should be invalidated. - /// - private static int _childHeightChangedFrame = -1; - private static readonly Dictionary SingleTargetPropertyKeyCache = new(); - - internal Rect LastResolvedPosition { get; private set; } - internal Rect LastItemsContainerRect { get; private set; } - internal bool HasItemsContainerRect { get; private set; } + internal readonly Dictionary _pendingEntries = new(); private readonly Dictionary _paginationStates = new(); private readonly Dictionary _duplicateStates = new(); private readonly Dictionary _nullEntryStates = new(); - private readonly Dictionary _pendingEntries = new(); private readonly Dictionary _lists = new(); private readonly Dictionary _pageCaches = new(); private readonly Dictionary _listContexts = new(); @@ -205,59 +183,6 @@ public sealed class SerializableSetPropertyDrawer : PropertyDrawer private int _lastRowRenderCacheFrame = -1; private int _lastItemsPropertyCacheFrame = -1; - /// - /// Resolves the content rect for testing purposes, applying WGroup padding and indentation - /// without requiring a full OnGUI context. - /// - /// The original position rect. - /// Whether to skip standard Unity indentation. - /// The resolved content rect. - internal static Rect ResolveContentRectForTests(Rect position, bool skipIndentation = false) - { - return ResolveContentRect(position, skipIndentation); - } - - internal static void ResetLayoutTrackingForTests() - { - HasLastManualEntryHeaderRect = false; - LastManualEntryHeaderRect = default; - LastManualEntryToggleRect = default; - HasLastMainFoldoutRect = false; - LastMainFoldoutRect = default; - HasLastManualEntryValueRect = false; - LastManualEntryValueRect = default; - LastManualEntryValueUsedFoldoutLabel = false; - LastManualEntryValueFoldoutOffset = 0f; - HasLastRowContentRect = false; - LastRowContentRect = default; - - LastFooterWGroupLeftPadding = 0f; - LastFooterWGroupRightPadding = 0f; - LastFooterAvailableWidth = 0f; - LastFooterRangeWidth = 0f; - LastFooterRangeLabelWasDrawn = false; - LastFooterRangeLabelRect = default; - - MainFoldoutAnimations.Clear(); - } - - internal static bool TryToggleManualEntryFoldoutLabelForTests( - Event currentEvent, - Rect labelHitRect, - ref bool expanded - ) - { - return TryToggleManualEntryFoldoutLabel(currentEvent, labelHitRect, ref expanded); - } - - internal static EventType GetEffectiveMouseEventTypeForTests( - EventType eventType, - EventType rawEventType - ) - { - return GetEffectiveMouseEventType(eventType, rawEventType); - } - /// /// Signals that a child property drawer's height has changed and the parent /// SerializableSetPropertyDrawer should invalidate its row height cache. @@ -269,31 +194,6 @@ internal static void SignalChildHeightChanged() InternalEditorUtility.RepaintAllViews(); } - /// - /// Gets the frame number when the child height changed signal was last set. - /// For testing purposes only. - /// - internal static int GetChildHeightChangedFrameForTests() - { - return _childHeightChangedFrame; - } - - /// - /// Resets the child height changed frame to -1 for testing purposes. - /// - internal static void ResetChildHeightChangedFrameForTests() - { - _childHeightChangedFrame = -1; - } - - internal static bool HasDroppedItemsArrayForTests( - bool hasInspector, - SerializedProperty itemsProperty - ) - { - return HasDroppedItemsArray(hasInspector, itemsProperty); - } - internal static PendingWrapperContext EnsurePendingWrapper( PendingEntry pending, Type elementType @@ -381,70 +281,6 @@ internal static void ReleasePendingWrapper(PendingEntry pending) } } - internal static bool IsTweeningEnabledForTests(bool isSortedSet) - { - return ShouldTweenManualEntryFoldout(isSortedSet); - } - - /// - /// Returns the expected static foldout progress without animation. - /// This static method cannot access instance animation state. - /// Use for actual animation testing. - /// - internal static float GetPendingFoldoutProgressForTests( - SerializedProperty property, - bool expanded, - bool isSorted - ) - { - return expanded ? 1f : 0f; - } - - /// - /// Clears the main foldout animation cache. Used for testing purposes. - /// - internal static void ClearMainFoldoutAnimCacheForTests() - { - MainFoldoutAnimations.Clear(); - } - - /// - /// Returns true if a main foldout AnimBool exists for the given property path and target object. - /// - internal static bool HasMainFoldoutAnimBoolForTests( - SerializedObject serializedObject, - string propertyPath - ) - { - MainFoldoutCacheKey cacheKey = GetMainFoldoutCacheKey(serializedObject, propertyPath); - return MainFoldoutAnimations.ContainsKey(cacheKey); - } - - /// - /// Gets the main foldout progress for testing purposes. - /// - internal static float GetMainFoldoutProgressForTests( - SerializedObject serializedObject, - string propertyPath, - bool isExpanded, - bool isSortedSet - ) - { - return GetMainFoldoutProgress(serializedObject, propertyPath, isExpanded, isSortedSet); - } - - /// - /// Gets the main foldout cache key for testing purposes. - /// Returns the string representation of the struct key. - /// - internal static string GetMainFoldoutCacheKeyForTests( - SerializedObject serializedObject, - string propertyPath - ) - { - return GetMainFoldoutCacheKey(serializedObject, propertyPath).ToString(); - } - internal static object DrawFieldForType( Rect rect, GUIContent content, @@ -1038,7 +874,7 @@ internal static void SaveScriptableSingleton(Object target) /// Includes the target object's instance ID to prevent collisions between different objects. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static MainFoldoutCacheKey GetMainFoldoutCacheKey( + internal static MainFoldoutCacheKey GetMainFoldoutCacheKey( SerializedObject serializedObject, string propertyPath ) @@ -1050,148 +886,7 @@ string propertyPath return new MainFoldoutCacheKey(instanceId, propertyPath); } - private static float GetFooterHeight() - { - float lineHeight = EditorGUIUtility.singleLineHeight; - return lineHeight + EditorGUIUtility.standardVerticalSpacing * 2f; - } - - private static float GetPaginationHeaderHeight() - { - return EditorGUIUtility.singleLineHeight + PaginationHeaderHeightPadding; - } - - private static float GetEmptySetDrawerHeight() - { - float lineHeight = EditorGUIUtility.singleLineHeight; - float padding = 6f; - float verticalSpacing = EditorGUIUtility.standardVerticalSpacing; - float contentHeight = lineHeight + verticalSpacing + lineHeight; - return padding * 2f + contentHeight; - } - - private static void DrawSetBodyTopBorder(Rect rect) - { - Color borderColor = EditorGUIUtility.isProSkin - ? new Color(0.25f, 0.25f, 0.25f, 1f) - : new Color(0.7f, 0.7f, 0.7f, 1f); - EditorGUI.DrawRect(rect, borderColor); - } - - private static GUIStyle BuildButtonStyle(Color baseColor) - { - GUIStyle style = CreateSolidButtonStyle(baseColor); - ConfigureButtonStyle(style, EditorGUIUtility.singleLineHeight); - return style; - } - - private static GUIStyle BuildMoveButtonStyle(Color baseColor) - { - GUIStyle style = BuildButtonStyle(baseColor); - SetButtonTextColor(style, Color.black); - return style; - } - - private static GUIStyle BuildRemoveButtonStyle(Color baseColor) - { - GUIStyle style = BuildButtonStyle(baseColor); - style.fixedWidth = 0f; - style.padding = new RectOffset(3, 3, 1, 1); - style.margin = new RectOffset(0, 0, 1, 1); - return style; - } - - private static GUIStyle CreateSolidButtonStyle(Color baseColor) - { - Color hoverColor = AdjustColorBrightness(baseColor, 0.12f); - Color activeColor = AdjustColorBrightness(baseColor, -0.18f); - - GUIStyle style = new(EditorStyles.miniButton); - Texture2D normalTexture = CreateSolidTexture(baseColor); - Texture2D hoverTexture = CreateSolidTexture(hoverColor); - Texture2D activeTexture = CreateSolidTexture(activeColor); - - style.normal.background = normalTexture; - style.normal.textColor = Color.white; - style.hover.background = hoverTexture; - style.hover.textColor = Color.white; - style.active.background = activeTexture; - style.active.textColor = Color.white; - - style.onNormal.background = normalTexture; - style.onNormal.textColor = Color.white; - style.onHover.background = hoverTexture; - style.onHover.textColor = Color.white; - style.onActive.background = activeTexture; - style.onActive.textColor = Color.white; - - style.focused.background = normalTexture; - style.focused.textColor = Color.white; - style.onFocused.background = normalTexture; - style.onFocused.textColor = Color.white; - - return style; - } - - private static void ConfigureButtonStyle(GUIStyle style, float lineHeight) - { - if (style == null) - { - return; - } - - style.fixedHeight = lineHeight; - style.margin = new RectOffset(1, 1, 1, 1); - style.padding = new RectOffset(6, 6, 2, 2); - } - - private static void SetButtonTextColor(GUIStyle style, Color color) - { - if (style == null) - { - return; - } - - style.normal.textColor = color; - style.hover.textColor = color; - style.active.textColor = color; - style.focused.textColor = color; - style.onNormal.textColor = color; - style.onHover.textColor = color; - style.onActive.textColor = color; - style.onFocused.textColor = color; - } - - private static Texture2D CreateSolidTexture(Color color) - { - Texture2D texture = new(1, 1) - { - hideFlags = HideFlags.HideAndDontSave, - wrapMode = TextureWrapMode.Repeat, - filterMode = FilterMode.Point, - }; - Color opaque = new(color.r, color.g, color.b, 1f); - texture.SetPixel(0, 0, opaque); - texture.Apply(); - return texture; - } - - private static Color AdjustColorBrightness(Color color, float amount) - { - if (0f < amount) - { - return Color.Lerp(color, Color.white, Mathf.Clamp01(amount)); - } - - if (amount < 0f) - { - return Color.Lerp(color, Color.black, Mathf.Clamp01(-amount)); - } - - return color; - } - - private static Rect ResolveContentRect(Rect position, bool skipIndentation = false) + internal static Rect ResolveContentRect(Rect position, bool skipIndentation = false) { float leftPadding = GroupGUIWidthUtility.CurrentLeftPadding; float rightPadding = GroupGUIWidthUtility.CurrentRightPadding; @@ -1269,7 +964,7 @@ private static Rect ResolveContentRect(Rect position, bool skipIndentation = fal return final; } - private static bool TryToggleManualEntryFoldoutLabel( + internal static bool TryToggleManualEntryFoldoutLabel( Event currentEvent, Rect labelHitRect, ref bool expanded @@ -1300,7 +995,7 @@ ref bool expanded return true; } - private static EventType GetEffectiveMouseEventType(Event currentEvent) + internal static EventType GetEffectiveMouseEventType(Event currentEvent) { if (currentEvent == null) { @@ -1310,7 +1005,7 @@ private static EventType GetEffectiveMouseEventType(Event currentEvent) return GetEffectiveMouseEventType(currentEvent.type, currentEvent.rawType); } - private static EventType GetEffectiveMouseEventType( + internal static EventType GetEffectiveMouseEventType( EventType eventType, EventType rawEventType ) @@ -1323,10 +1018,368 @@ EventType rawEventType return eventType; } - private static string BuildPropertyCacheKey(SerializedProperty property) + // A resolved set with no items array indicates refused element serialization; an unresolved property remains unknown. + internal static bool HasDroppedItemsArray( + bool hasInspector, + SerializedProperty itemsProperty + ) { - if (property == null) - { + return hasInspector && itemsProperty == null; + } + + internal static float ResolveManualEntryFoldoutToggleOffset(SerializedProperty property) + { + SerializedObject serializedObject = property?.serializedObject; + return TargetsUnityHelpersSettings(serializedObject) + ? ManualEntryFoldoutToggleOffsetSettings + : ManualEntryFoldoutToggleOffsetInspector; + } + + internal static float ResolveManualEntrySectionPadding(SerializedProperty property) + { + SerializedObject serializedObject = property?.serializedObject; + return TargetsUnityHelpersSettings(serializedObject) + ? ManualEntrySectionPaddingSettings + : ManualEntrySectionPadding; + } + + internal static float GetMainFoldoutProgress( + SerializedObject serializedObject, + string propertyPath, + bool isExpanded, + bool isSortedSet + ) + { + bool shouldTween = ShouldTweenMainFoldout(isSortedSet); + if (!shouldTween) + { + float immediateProgress = isExpanded ? 1f : 0f; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetMainFoldoutProgress_NoTween", + propertyPath ?? "(unknown)", + false, + isExpanded, + immediateProgress, + false + ); + + return immediateProgress; + } + + AnimBool anim = EnsureMainFoldoutAnim( + serializedObject, + propertyPath, + isExpanded, + isSortedSet + ); + if (anim == null) + { + float fallbackProgress = isExpanded ? 1f : 0f; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetMainFoldoutProgress_NoAnimBool", + propertyPath ?? "(unknown)", + true, + isExpanded, + fallbackProgress, + false + ); + + return fallbackProgress; + } + + if (anim.isAnimating) + { + RequestRepaint(); + } + + float animatedProgress = anim.faded; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetMainFoldoutProgress_Animated", + propertyPath ?? "(unknown)", + true, + isExpanded, + animatedProgress, + true + ); + + return animatedProgress; + } + + internal static bool ShouldTweenManualEntryFoldout(bool isSortedSet) + { + return isSortedSet + ? UnityHelpersSettings.ShouldTweenSerializableSortedSetFoldouts() + : UnityHelpersSettings.ShouldTweenSerializableSetFoldouts(); + } + + internal static AnimBool EnsureManualEntryFoldoutAnim( + PendingEntry pending, + string propertyPath = null + ) + { + if (pending == null) + { + return null; + } + + bool shouldTween = ShouldTweenManualEntryFoldout(pending.isSorted); + float speed = GetManualEntryFoldoutSpeed(pending.isSorted); + + SerializableCollectionTweenDiagnostics.LogTweenSettingsQuery( + "EnsureManualEntryFoldoutAnim", + propertyPath ?? "(unknown)", + pending.isSorted, + shouldTween, + speed + ); + + if (!shouldTween) + { + if (pending.foldoutAnim != null) + { + pending.foldoutAnim.valueChanged.RemoveListener(RequestRepaint); + pending.foldoutAnim = null; + + SerializableCollectionTweenDiagnostics.LogAnimBoolDestroyed( + propertyPath ?? "(unknown)", + "TweeningDisabled" + ); + } + + return null; + } + + if (pending.foldoutAnim == null) + { + pending.foldoutAnim = CreateManualEntryFoldoutAnim( + pending.isExpanded, + pending.isSorted, + propertyPath + ); + } + else + { + pending.foldoutAnim.speed = speed; + + if (pending.foldoutAnim.target != pending.isExpanded) + { + pending.foldoutAnim.target = pending.isExpanded; + } + } + + return pending.foldoutAnim; + } + + internal static float GetPendingFoldoutProgress( + PendingEntry pending, + string propertyPath = null + ) + { + if (pending == null) + { + return 0f; + } + + bool shouldTween = ShouldTweenManualEntryFoldout(pending.isSorted); + + // EnsureManualEntryFoldoutAnim also cleans up animation state when tweening is disabled. + AnimBool anim = EnsureManualEntryFoldoutAnim(pending, propertyPath); + + if (!shouldTween) + { + float immediateProgress = pending.isExpanded ? 1f : 0f; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetPendingFoldoutProgress_NoTween", + propertyPath ?? "(unknown)", + false, + pending.isExpanded, + immediateProgress, + pending.foldoutAnim != null + ); + + return immediateProgress; + } + + if (anim == null) + { + float fallbackProgress = pending.isExpanded ? 1f : 0f; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetPendingFoldoutProgress_NoAnimBool", + propertyPath ?? "(unknown)", + true, + pending.isExpanded, + fallbackProgress, + false + ); + + return fallbackProgress; + } + + anim.target = pending.isExpanded; + float animatedProgress = anim.faded; + + SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( + "GetPendingFoldoutProgress_Animated", + propertyPath ?? "(unknown)", + true, + pending.isExpanded, + animatedProgress, + true + ); + + return animatedProgress; + } + + private static float GetFooterHeight() + { + float lineHeight = EditorGUIUtility.singleLineHeight; + return lineHeight + EditorGUIUtility.standardVerticalSpacing * 2f; + } + + private static float GetPaginationHeaderHeight() + { + return EditorGUIUtility.singleLineHeight + PaginationHeaderHeightPadding; + } + + private static float GetEmptySetDrawerHeight() + { + float lineHeight = EditorGUIUtility.singleLineHeight; + float padding = 6f; + float verticalSpacing = EditorGUIUtility.standardVerticalSpacing; + float contentHeight = lineHeight + verticalSpacing + lineHeight; + return padding * 2f + contentHeight; + } + + private static void DrawSetBodyTopBorder(Rect rect) + { + Color borderColor = EditorGUIUtility.isProSkin + ? new Color(0.25f, 0.25f, 0.25f, 1f) + : new Color(0.7f, 0.7f, 0.7f, 1f); + EditorGUI.DrawRect(rect, borderColor); + } + + private static GUIStyle BuildButtonStyle(Color baseColor) + { + GUIStyle style = CreateSolidButtonStyle(baseColor); + ConfigureButtonStyle(style, EditorGUIUtility.singleLineHeight); + return style; + } + + private static GUIStyle BuildMoveButtonStyle(Color baseColor) + { + GUIStyle style = BuildButtonStyle(baseColor); + SetButtonTextColor(style, Color.black); + return style; + } + + private static GUIStyle BuildRemoveButtonStyle(Color baseColor) + { + GUIStyle style = BuildButtonStyle(baseColor); + style.fixedWidth = 0f; + style.padding = new RectOffset(3, 3, 1, 1); + style.margin = new RectOffset(0, 0, 1, 1); + return style; + } + + private static GUIStyle CreateSolidButtonStyle(Color baseColor) + { + Color hoverColor = AdjustColorBrightness(baseColor, 0.12f); + Color activeColor = AdjustColorBrightness(baseColor, -0.18f); + + GUIStyle style = new(EditorStyles.miniButton); + Texture2D normalTexture = CreateSolidTexture(baseColor); + Texture2D hoverTexture = CreateSolidTexture(hoverColor); + Texture2D activeTexture = CreateSolidTexture(activeColor); + + style.normal.background = normalTexture; + style.normal.textColor = Color.white; + style.hover.background = hoverTexture; + style.hover.textColor = Color.white; + style.active.background = activeTexture; + style.active.textColor = Color.white; + + style.onNormal.background = normalTexture; + style.onNormal.textColor = Color.white; + style.onHover.background = hoverTexture; + style.onHover.textColor = Color.white; + style.onActive.background = activeTexture; + style.onActive.textColor = Color.white; + + style.focused.background = normalTexture; + style.focused.textColor = Color.white; + style.onFocused.background = normalTexture; + style.onFocused.textColor = Color.white; + + return style; + } + + private static void ConfigureButtonStyle(GUIStyle style, float lineHeight) + { + if (style == null) + { + return; + } + + style.fixedHeight = lineHeight; + style.margin = new RectOffset(1, 1, 1, 1); + style.padding = new RectOffset(6, 6, 2, 2); + } + + private static void SetButtonTextColor(GUIStyle style, Color color) + { + if (style == null) + { + return; + } + + style.normal.textColor = color; + style.hover.textColor = color; + style.active.textColor = color; + style.focused.textColor = color; + style.onNormal.textColor = color; + style.onHover.textColor = color; + style.onActive.textColor = color; + style.onFocused.textColor = color; + } + + private static Texture2D CreateSolidTexture(Color color) + { + Texture2D texture = new(1, 1) + { + hideFlags = HideFlags.HideAndDontSave, + wrapMode = TextureWrapMode.Repeat, + filterMode = FilterMode.Point, + }; + Color opaque = new(color.r, color.g, color.b, 1f); + texture.SetPixel(0, 0, opaque); + texture.Apply(); + return texture; + } + + private static Color AdjustColorBrightness(Color color, float amount) + { + if (0f < amount) + { + return Color.Lerp(color, Color.white, Mathf.Clamp01(amount)); + } + + if (amount < 0f) + { + return Color.Lerp(color, Color.black, Mathf.Clamp01(-amount)); + } + + return color; + } + + private static string BuildPropertyCacheKey(SerializedProperty property) + { + if (property == null) + { return string.Empty; } @@ -1394,15 +1447,6 @@ out StringBuilder keyBuilder return keyBuilder.ToString(); } - // A resolved set with no items array indicates refused element serialization; an unresolved property remains unknown. - private static bool HasDroppedItemsArray( - bool hasInspector, - SerializedProperty itemsProperty - ) - { - return hasInspector && itemsProperty == null; - } - // Height measurement has no rect, so wrap the rare serialization error using Inspector width. private static float GetDroppedBackingArrayHeight(string message) { @@ -1553,27 +1597,11 @@ out StringBuilder builder } if (isNullObjectValue) - { - return "Adding null object reference."; - } - - return string.Empty; - } - - private static float ResolveManualEntryFoldoutToggleOffset(SerializedProperty property) - { - SerializedObject serializedObject = property?.serializedObject; - return TargetsUnityHelpersSettings(serializedObject) - ? ManualEntryFoldoutToggleOffsetSettings - : ManualEntryFoldoutToggleOffsetInspector; - } + { + return "Adding null object reference."; + } - private static float ResolveManualEntrySectionPadding(SerializedProperty property) - { - SerializedObject serializedObject = property?.serializedObject; - return TargetsUnityHelpersSettings(serializedObject) - ? ManualEntrySectionPaddingSettings - : ManualEntrySectionPadding; + return string.Empty; } private static GUIStyle GetManualEntryFoldoutLabelStyle() @@ -1768,78 +1796,6 @@ bool isSortedSet return anim; } - private static float GetMainFoldoutProgress( - SerializedObject serializedObject, - string propertyPath, - bool isExpanded, - bool isSortedSet - ) - { - bool shouldTween = ShouldTweenMainFoldout(isSortedSet); - if (!shouldTween) - { - float immediateProgress = isExpanded ? 1f : 0f; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetMainFoldoutProgress_NoTween", - propertyPath ?? "(unknown)", - false, - isExpanded, - immediateProgress, - false - ); - - return immediateProgress; - } - - AnimBool anim = EnsureMainFoldoutAnim( - serializedObject, - propertyPath, - isExpanded, - isSortedSet - ); - if (anim == null) - { - float fallbackProgress = isExpanded ? 1f : 0f; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetMainFoldoutProgress_NoAnimBool", - propertyPath ?? "(unknown)", - true, - isExpanded, - fallbackProgress, - false - ); - - return fallbackProgress; - } - - if (anim.isAnimating) - { - RequestRepaint(); - } - - float animatedProgress = anim.faded; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetMainFoldoutProgress_Animated", - propertyPath ?? "(unknown)", - true, - isExpanded, - animatedProgress, - true - ); - - return animatedProgress; - } - - private static bool ShouldTweenManualEntryFoldout(bool isSortedSet) - { - return isSortedSet - ? UnityHelpersSettings.ShouldTweenSerializableSortedSetFoldouts() - : UnityHelpersSettings.ShouldTweenSerializableSetFoldouts(); - } - private static float GetManualEntryFoldoutSpeed(bool isSortedSet) { return isSortedSet @@ -1867,126 +1823,6 @@ private static AnimBool CreateManualEntryFoldoutAnim( return anim; } - private static AnimBool EnsureManualEntryFoldoutAnim( - PendingEntry pending, - string propertyPath = null - ) - { - if (pending == null) - { - return null; - } - - bool shouldTween = ShouldTweenManualEntryFoldout(pending.isSorted); - float speed = GetManualEntryFoldoutSpeed(pending.isSorted); - - SerializableCollectionTweenDiagnostics.LogTweenSettingsQuery( - "EnsureManualEntryFoldoutAnim", - propertyPath ?? "(unknown)", - pending.isSorted, - shouldTween, - speed - ); - - if (!shouldTween) - { - if (pending.foldoutAnim != null) - { - pending.foldoutAnim.valueChanged.RemoveListener(RequestRepaint); - pending.foldoutAnim = null; - - SerializableCollectionTweenDiagnostics.LogAnimBoolDestroyed( - propertyPath ?? "(unknown)", - "TweeningDisabled" - ); - } - - return null; - } - - if (pending.foldoutAnim == null) - { - pending.foldoutAnim = CreateManualEntryFoldoutAnim( - pending.isExpanded, - pending.isSorted, - propertyPath - ); - } - else - { - pending.foldoutAnim.speed = speed; - - if (pending.foldoutAnim.target != pending.isExpanded) - { - pending.foldoutAnim.target = pending.isExpanded; - } - } - - return pending.foldoutAnim; - } - - private static float GetPendingFoldoutProgress( - PendingEntry pending, - string propertyPath = null - ) - { - if (pending == null) - { - return 0f; - } - - bool shouldTween = ShouldTweenManualEntryFoldout(pending.isSorted); - - // EnsureManualEntryFoldoutAnim also cleans up animation state when tweening is disabled. - AnimBool anim = EnsureManualEntryFoldoutAnim(pending, propertyPath); - - if (!shouldTween) - { - float immediateProgress = pending.isExpanded ? 1f : 0f; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetPendingFoldoutProgress_NoTween", - propertyPath ?? "(unknown)", - false, - pending.isExpanded, - immediateProgress, - pending.foldoutAnim != null - ); - - return immediateProgress; - } - - if (anim == null) - { - float fallbackProgress = pending.isExpanded ? 1f : 0f; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetPendingFoldoutProgress_NoAnimBool", - propertyPath ?? "(unknown)", - true, - pending.isExpanded, - fallbackProgress, - false - ); - - return fallbackProgress; - } - - anim.target = pending.isExpanded; - float animatedProgress = anim.faded; - - SerializableCollectionTweenDiagnostics.LogFoldoutProgressCalculation( - "GetPendingFoldoutProgress_Animated", - propertyPath ?? "(unknown)", - true, - pending.isExpanded, - animatedProgress, - true - ); - - return animatedProgress; - } - private static float GetPendingSectionHeight(PendingEntry pending) { return GetPendingSectionHeight(pending, null); @@ -3799,8 +3635,6 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten Rect originalPosition = position; bool targetsSettings = TargetsUnityHelpersSettings(property?.serializedObject); Rect contentPosition = ResolveContentRect(originalPosition, targetsSettings); - LastResolvedPosition = contentPosition; - HasItemsContainerRect = false; if (0 < GroupGUIWidthUtility.CurrentScopeDepth) { @@ -3878,9 +3712,6 @@ out ISerializableSetInspector inspector true ); - HasLastMainFoldoutRect = true; - LastMainFoldoutRect = foldoutRect; - float mainFoldoutProgress = GetMainFoldoutProgress( property.serializedObject, property.propertyPath, @@ -4011,8 +3842,7 @@ out ISerializableSetInspector inspector { float emptyHeight = GetEmptySetDrawerHeight(); Rect emptyRect = new(position.x, y, position.width, emptyHeight); - LastItemsContainerRect = emptyRect; - HasItemsContainerRect = true; + DrawEmptySetDrawer( emptyRect, property, @@ -4078,8 +3908,7 @@ out ISerializableSetInspector inspector ); } } - LastItemsContainerRect = listRect; - HasItemsContainerRect = true; + if (list != null) { list.DoList(listRect); @@ -4595,94 +4424,6 @@ ISerializableSetInspector inspector return true; } - /// - /// Gets the actual animated foldout progress from the drawer instance's pending entry. - /// Use this for testing that animations are actually progressing over time. - /// - /// The serialized property for the set. - /// - /// The current animation progress (0 to 1), or -1 if no pending entry exists for this property. - /// When tweening is disabled, returns 0 or 1 immediately based on expanded state. - /// - internal float GetPendingFoldoutProgressFromInstance(SerializedProperty property) - { - if (property == null) - { - return -1f; - } - - string cacheKey = GetPropertyCacheKey(property); - if (!_pendingEntries.TryGetValue(cacheKey, out PendingEntry pending) || pending == null) - { - return -1f; - } - - return GetPendingFoldoutProgress(pending); - } - - /// - /// Gets the pending entry's expanded state and animation information for testing. - /// - /// The serialized property for the set. - /// Output: whether the pending section is logically expanded. - /// Output: the current animation progress (0 to 1). - /// Output: whether an AnimBool is active for this entry. - /// True if a pending entry was found, false otherwise. - internal bool TryGetPendingAnimationStateForTests( - SerializedProperty property, - out bool isExpanded, - out float animProgress, - out bool hasAnimBool - ) - { - if (property == null) - { - isExpanded = false; - animProgress = 0f; - hasAnimBool = false; - return false; - } - - string cacheKey = GetPropertyCacheKey(property); - if (!_pendingEntries.TryGetValue(cacheKey, out PendingEntry pending) || pending == null) - { - isExpanded = false; - animProgress = 0f; - hasAnimBool = false; - return false; - } - - isExpanded = pending.isExpanded; - hasAnimBool = pending.foldoutAnim != null; - animProgress = GetPendingFoldoutProgress(pending); - return true; - } - - /// - /// Sets the pending entry's expanded state for testing purposes. - /// This properly triggers animation state updates. - /// - internal void SetPendingExpandedStateForTests(SerializedProperty property, bool expanded) - { - if (property == null) - { - return; - } - - string cacheKey = GetPropertyCacheKey(property); - if (!_pendingEntries.TryGetValue(cacheKey, out PendingEntry pending) || pending == null) - { - return; - } - - pending.isExpanded = expanded; - AnimBool anim = EnsureManualEntryFoldoutAnim(pending, property.propertyPath); - if (anim != null) - { - anim.target = expanded; - } - } - internal NullEntryState EvaluateNullEntryState( SerializedProperty property, SerializedProperty itemsProperty, @@ -5760,24 +5501,12 @@ out ISerializableSetInspector inspector float rangeWidth = rangeStyle.CalcSize(RangeLabelGUIContent).x; float availableWidth = Mathf.Max(0f, rightCursor - leftCursor); - LastFooterWGroupLeftPadding = GroupGUIWidthUtility.CurrentLeftPadding; - LastFooterWGroupRightPadding = GroupGUIWidthUtility.CurrentRightPadding; - LastFooterAvailableWidth = availableWidth; - LastFooterRangeWidth = rangeWidth; - if (rangeWidth <= availableWidth) { float rangeHeight = lineHeight + 4f; float rangeY = rect.y + Mathf.Max(0f, (rect.height - rangeHeight) * 0.5f) - 2.5f; Rect rangeRect = new(leftCursor, rangeY, rangeWidth, rangeHeight); EditorGUI.LabelField(rangeRect, rangeText, rangeStyle); - LastFooterRangeLabelWasDrawn = true; - LastFooterRangeLabelRect = rangeRect; - } - else - { - LastFooterRangeLabelWasDrawn = false; - LastFooterRangeLabelRect = default; } } @@ -6004,10 +5733,6 @@ Type elementType headerRect.height ); - HasLastManualEntryHeaderRect = true; - LastManualEntryHeaderRect = headerRect; - LastManualEntryToggleRect = foldoutToggleRect; - Event currentEvent = Event.current; bool expanded = pending.isExpanded; @@ -6154,13 +5879,6 @@ ref expanded valueRect.width = Mathf.Max(0f, valueRect.width - rightShift); } - HasLastManualEntryValueRect = true; - LastManualEntryValueRect = valueRect; - LastManualEntryValueUsedFoldoutLabel = valueSupportsFoldout; - LastManualEntryValueFoldoutOffset = valueSupportsFoldout - ? ManualEntryExpandableValueFoldoutGutter - : 0f; - object previousValue = pending.value; object updatedValue = DrawFieldForType( valueRect, @@ -6689,9 +6407,6 @@ out float renderedHeight contentRect.height = Mathf.Max(0f, maxContentBottom - contentRect.y); } - HasLastRowContentRect = true; - LastRowContentRect = contentRect; - if (valueSupportsFoldout && hasFoldoutKey) { _rowFoldoutStates[foldoutKey] = element.isExpanded; @@ -7031,7 +6746,7 @@ out ISerializableSetInspector inspector /// Struct key for MainFoldoutAnimations cache to avoid string allocations. /// Uses (instanceId, propertyPath) pair for cache identity. /// - private readonly struct MainFoldoutCacheKey : IEquatable + internal readonly struct MainFoldoutCacheKey : IEquatable { public readonly long InstanceId; public readonly string PropertyPath; diff --git a/Editor/CustomDrawers/StringInListDrawer.cs b/Editor/CustomDrawers/StringInListDrawer.cs index 1ed7652ed..fad0c7b6f 100644 --- a/Editor/CustomDrawers/StringInListDrawer.cs +++ b/Editor/CustomDrawers/StringInListDrawer.cs @@ -23,21 +23,136 @@ namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers [CustomPropertyDrawer(typeof(StringInListAttribute))] public sealed class StringInListDrawer : PropertyDrawer { + internal const float PaginationButtonHeight = DropDownShared.PaginationButtonHeight; + internal const float PopupWidth = DropDownShared.PopupWidth; + internal const float OptionBottomPadding = DropDownShared.OptionBottomPadding; + internal const float EmptySearchHorizontalPadding = + DropDownShared.EmptySearchHorizontalPadding; + internal const float EmptySearchExtraPadding = DropDownShared.EmptySearchExtraPadding; + internal const string EmptyResultsMessage = DropDownShared.EmptyResultsMessage; + private const float ButtonWidth = DropDownShared.ButtonWidth; private const float PageLabelWidth = DropDownShared.PageLabelWidth; - private const float PaginationButtonHeight = DropDownShared.PaginationButtonHeight; - private const float PopupWidth = DropDownShared.PopupWidth; - private const float OptionBottomPadding = DropDownShared.OptionBottomPadding; private const float OptionRowExtraHeight = DropDownShared.OptionRowExtraHeight; - private const float EmptySearchHorizontalPadding = - DropDownShared.EmptySearchHorizontalPadding; - private const float EmptySearchExtraPadding = DropDownShared.EmptySearchExtraPadding; - private const string EmptyResultsMessage = DropDownShared.EmptyResultsMessage; private static readonly GUIContent EmptyResultsContent = DropDownShared.EmptyResultsContent; private static float s_cachedOptionControlHeight = -1f; private static float s_cachedOptionRowHeight = -1f; private static readonly GUIContent ReusableDropDownButtonContent = new(); + internal static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) + { + if (filteredCount <= 0 || pageSize <= 0) + { + return 1; + } + + int maxPageIndex = CalculatePageCount(pageSize, filteredCount) - 1; + int clampedPage = Mathf.Clamp(currentPage, 0, Mathf.Max(0, maxPageIndex)); + int startIndex = clampedPage * pageSize; + int remaining = filteredCount - startIndex; + if (remaining <= 0) + { + return 1; + } + + return Mathf.Min(pageSize, remaining); + } + + internal static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) + { + int clampedRows = Mathf.Max(1, rowsOnPage); + float chromeHeight = CalculatePopupChromeHeight(includePagination); + float optionListHeight = clampedRows * GetOptionRowHeight(); + float unclampedHeight = chromeHeight + optionListHeight; + return unclampedHeight; + } + + internal static float CalculatePopupChromeHeight(bool includePagination) + { + float searchHeight = EditorGUIUtility.singleLineHeight; + float paginationHeight = includePagination + ? PopupStyles.PaginationButtonLeft.fixedHeight + : EditorGUIUtility.standardVerticalSpacing; + float footerHeight = EditorGUIUtility.standardVerticalSpacing + OptionBottomPadding; + return searchHeight + paginationHeight + footerHeight; + } + + internal static float CalculateEmptySearchHeight(float measuredHelpBoxHeight = -1f) + { + GUIStyle helpStyle = EditorStyles.helpBox; + int helpMargin = helpStyle.margin?.horizontal ?? 0; + float availableWidth = PopupWidth - EmptySearchHorizontalPadding - helpMargin; + availableWidth = Mathf.Max(32f, availableWidth); + float helpBoxHeight; + if (0f < measuredHelpBoxHeight) + { + helpBoxHeight = measuredHelpBoxHeight; + } + else + { + float calculated = helpStyle.CalcHeight(EmptyResultsContent, availableWidth); + float marginVertical = helpStyle.margin?.vertical ?? 0; + helpBoxHeight = calculated + marginVertical; + } + + float searchRow = + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; + float topSpacer = EditorGUIUtility.standardVerticalSpacing; + float bottomSpacer = EditorGUIUtility.standardVerticalSpacing; + float footer = + EditorGUIUtility.standardVerticalSpacing + + OptionBottomPadding + + EmptySearchExtraPadding; + + float result = searchRow + topSpacer + helpBoxHeight + bottomSpacer + footer; + return result; + } + + internal static float GetOptionRowHeight() + { + if (0f < s_cachedOptionRowHeight) + { + return s_cachedOptionRowHeight; + } + + float controlHeight = GetOptionControlHeight(); + RectOffset margin = PopupStyles.OptionButton.margin; + float adjustedMargin = 0f; + if (margin != null) + { + // GUILayout absorbs some margin; subtract standard spacing to match repaint height. + adjustedMargin = Mathf.Max( + 0f, + margin.vertical - EditorGUIUtility.standardVerticalSpacing + ); + } + else + { + adjustedMargin = EditorGUIUtility.standardVerticalSpacing; + } + + s_cachedOptionRowHeight = controlHeight + adjustedMargin; + return s_cachedOptionRowHeight; + } + + internal static float GetOptionControlHeight() + { + if (0f < s_cachedOptionControlHeight) + { + return s_cachedOptionControlHeight; + } + + float width = PopupWidth - 32f; + float measured = PopupStyles.OptionButton.CalcHeight(GUIContent.none, width); + if (measured <= 0f || float.IsNaN(measured)) + { + measured = EditorGUIUtility.singleLineHeight + OptionRowExtraHeight; + } + + s_cachedOptionControlHeight = measured; + return measured; + } + private static string GetPaginationLabel(int page, int totalPages) { return DropDownShared.GetPaginationLabel(page, totalPages); @@ -168,25 +283,6 @@ private static int CalculatePageCount(int pageSize, int filteredCount) return (filteredCount + pageSize - 1) / pageSize; } - private static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) - { - if (filteredCount <= 0 || pageSize <= 0) - { - return 1; - } - - int maxPageIndex = CalculatePageCount(pageSize, filteredCount) - 1; - int clampedPage = Mathf.Clamp(currentPage, 0, Mathf.Max(0, maxPageIndex)); - int startIndex = clampedPage * pageSize; - int remaining = filteredCount - startIndex; - if (remaining <= 0) - { - return 1; - } - - return Mathf.Min(pageSize, remaining); - } - private static void DrawPopupDropDown( Rect position, SerializedProperty property, @@ -379,101 +475,6 @@ private static bool IsSerializableTypeProvider(StringInListAttribute attribute) ); } - private static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) - { - int clampedRows = Mathf.Max(1, rowsOnPage); - float chromeHeight = CalculatePopupChromeHeight(includePagination); - float optionListHeight = clampedRows * GetOptionRowHeight(); - float unclampedHeight = chromeHeight + optionListHeight; - return unclampedHeight; - } - - private static float CalculatePopupChromeHeight(bool includePagination) - { - float searchHeight = EditorGUIUtility.singleLineHeight; - float paginationHeight = includePagination - ? PopupStyles.PaginationButtonLeft.fixedHeight - : EditorGUIUtility.standardVerticalSpacing; - float footerHeight = EditorGUIUtility.standardVerticalSpacing + OptionBottomPadding; - return searchHeight + paginationHeight + footerHeight; - } - - private static float CalculateEmptySearchHeight(float measuredHelpBoxHeight = -1f) - { - GUIStyle helpStyle = EditorStyles.helpBox; - int helpMargin = helpStyle.margin?.horizontal ?? 0; - float availableWidth = PopupWidth - EmptySearchHorizontalPadding - helpMargin; - availableWidth = Mathf.Max(32f, availableWidth); - float helpBoxHeight; - if (0f < measuredHelpBoxHeight) - { - helpBoxHeight = measuredHelpBoxHeight; - } - else - { - float calculated = helpStyle.CalcHeight(EmptyResultsContent, availableWidth); - float marginVertical = helpStyle.margin?.vertical ?? 0; - helpBoxHeight = calculated + marginVertical; - } - - float searchRow = - EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; - float topSpacer = EditorGUIUtility.standardVerticalSpacing; - float bottomSpacer = EditorGUIUtility.standardVerticalSpacing; - float footer = - EditorGUIUtility.standardVerticalSpacing - + OptionBottomPadding - + EmptySearchExtraPadding; - - float result = searchRow + topSpacer + helpBoxHeight + bottomSpacer + footer; - return result; - } - - private static float GetOptionRowHeight() - { - if (0f < s_cachedOptionRowHeight) - { - return s_cachedOptionRowHeight; - } - - float controlHeight = GetOptionControlHeight(); - RectOffset margin = PopupStyles.OptionButton.margin; - float adjustedMargin = 0f; - if (margin != null) - { - // GUILayout absorbs some margin; subtract standard spacing to match repaint height. - adjustedMargin = Mathf.Max( - 0f, - margin.vertical - EditorGUIUtility.standardVerticalSpacing - ); - } - else - { - adjustedMargin = EditorGUIUtility.standardVerticalSpacing; - } - - s_cachedOptionRowHeight = controlHeight + adjustedMargin; - return s_cachedOptionRowHeight; - } - - private static float GetOptionControlHeight() - { - if (0f < s_cachedOptionControlHeight) - { - return s_cachedOptionControlHeight; - } - - float width = PopupWidth - 32f; - float measured = PopupStyles.OptionButton.CalcHeight(GUIContent.none, width); - if (measured <= 0f || float.IsNaN(measured)) - { - measured = EditorGUIUtility.singleLineHeight + OptionRowExtraHeight; - } - - s_cachedOptionControlHeight = measured; - return measured; - } - private static string[] GetOptionDisplayArray( StringInListAttribute attribute, string[] options @@ -648,7 +649,7 @@ public override VisualElement CreatePropertyGUI(SerializedProperty property) return selector; } - private sealed class StringInListPopupSelectorElement : WDropDownPopupSelectorBase + internal sealed class StringInListPopupSelectorElement : WDropDownPopupSelectorBase { protected override int OptionCount => _options.Length; @@ -693,7 +694,7 @@ int pageSize } } - private sealed class StringInListSelector : WDropDownSelectorBase + internal sealed class StringInListSelector : WDropDownSelectorBase { protected override int OptionCount => _options.Length; @@ -1077,61 +1078,7 @@ private void Refresh() } } - internal static class TestHooks - { - public static int OptionButtonMarginVertical => - PopupStyles.OptionButton.margin?.vertical ?? 0; - - public static float OptionFooterPadding => OptionBottomPadding; - - public static float PaginationButtonHeight => - PopupStyles.PaginationButtonLeft.fixedHeight; - - public static float PopupWidthValue => PopupWidth; - - public static float EmptySearchHorizontalPaddingValue => EmptySearchHorizontalPadding; - - public static string EmptyResultsMessageValue => EmptyResultsMessage; - - public static float EmptySearchExtraPaddingValue => EmptySearchExtraPadding; - - public static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) - { - return StringInListDrawer.CalculatePopupTargetHeight(rowsOnPage, includePagination); - } - - public static float CalculatePopupChromeHeight(bool includePagination) - { - return StringInListDrawer.CalculatePopupChromeHeight(includePagination); - } - - public static float GetOptionRowHeight() - { - return StringInListDrawer.GetOptionRowHeight(); - } - - public static float GetOptionControlHeight() - { - return StringInListDrawer.GetOptionControlHeight(); - } - - public static float CalculateEmptySearchHeight() - { - return StringInListDrawer.CalculateEmptySearchHeight(); - } - - public static float CalculateEmptySearchHeightWithMeasurement(float measuredHelpHeight) - { - return StringInListDrawer.CalculateEmptySearchHeight(measuredHelpHeight); - } - - public static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) - { - return StringInListDrawer.CalculateRowsOnPage(filteredCount, pageSize, currentPage); - } - } - - private static class PopupStyles + internal static class PopupStyles { public static GUIStyle OptionButton => _optionButton ??= new GUIStyle("Button") diff --git a/Editor/CustomDrawers/Utils/DropDownShared.cs b/Editor/CustomDrawers/Utils/DropDownShared.cs index 090cfa4e6..ab3ba0e83 100644 --- a/Editor/CustomDrawers/Utils/DropDownShared.cs +++ b/Editor/CustomDrawers/Utils/DropDownShared.cs @@ -144,7 +144,7 @@ public static GUIStyle PaginationLabel /// public static readonly GUIContent EmptyResultsContent = new(EmptyResultsMessage); - private static readonly Cache FormattedOptionCache = CacheBuilder< + internal static readonly Cache FormattedOptionCache = CacheBuilder< object, string > @@ -152,12 +152,12 @@ public static GUIStyle PaginationLabel .MaximumSize(MaxFormattedOptionCacheEntries) .InitialCapacity(16) .Build(); + internal static GUIStyle s_optionButton; private static readonly Dictionary EnumDisplayNameCache = new(); private static readonly Dictionary FallbackOptionLabelCache = new(); private static float s_cachedOptionControlHeight = -1f; private static float s_cachedOptionRowHeight = -1f; - private static GUIStyle s_optionButton; private static GUIStyle s_selectedOptionButton; private static GUIStyle s_paginationButtonLeft; private static GUIStyle s_paginationButtonRight; @@ -565,7 +565,7 @@ public static string GetFallbackOptionLabel(int optionIndex) return cached; } - private static void EnsureStylesInitialized() + internal static void EnsureStylesInitialized() { if (s_optionButton != null) { @@ -595,54 +595,6 @@ private static void EnsureStylesInitialized() padding = new RectOffset(0, 0, 0, 0), }; } - - /// - /// Test hooks for unit testing internal functionality. - /// - internal static class TestHooks - { - /// - /// Gets the number of formatted option labels currently retained, for testing. - /// - public static int FormattedOptionCacheCount => FormattedOptionCache.Count; - - /// - /// Gets the option button margin vertical value for testing. - /// - public static int OptionButtonMarginVertical - { - get - { - EnsureStylesInitialized(); - return s_optionButton.margin?.vertical ?? 0; - } - } - - /// - /// Gets the option footer padding for testing. - /// - public static float OptionFooterPadding => OptionBottomPadding; - - /// - /// Gets the popup width value for testing. - /// - public static float PopupWidthValue => PopupWidth; - - /// - /// Gets the empty search horizontal padding value for testing. - /// - public static float EmptySearchHorizontalPaddingValue => EmptySearchHorizontalPadding; - - /// - /// Gets the empty results message value for testing. - /// - public static string EmptyResultsMessageValue => EmptyResultsMessage; - - /// - /// Gets the empty search extra padding value for testing. - /// - public static float EmptySearchExtraPaddingValue => EmptySearchExtraPadding; - } } #endif } diff --git a/Editor/CustomDrawers/Utils/EnumToggleButtonsShared.cs b/Editor/CustomDrawers/Utils/EnumToggleButtonsShared.cs index cfdd547bc..a82b7c4c7 100644 --- a/Editor/CustomDrawers/Utils/EnumToggleButtonsShared.cs +++ b/Editor/CustomDrawers/Utils/EnumToggleButtonsShared.cs @@ -758,13 +758,13 @@ public readonly struct ToggleOption /// /// Creates a new toggle option. /// - /// The display label. + /// The display label. Null, empty, or whitespace-only captions use "(Unnamed)"; nonblank captions remain exact. /// The enum value. /// The numeric flag value. /// Whether this is a zero flag. public ToggleOption(string label, object value, ulong flagValue, bool isZeroFlag) { - Label = string.IsNullOrEmpty(label) ? "(Unnamed)" : label; + Label = string.IsNullOrWhiteSpace(label) ? "(Unnamed)" : label; Value = value; diff --git a/Editor/CustomDrawers/Utils/InLineEditorShared.cs b/Editor/CustomDrawers/Utils/InLineEditorShared.cs index 546979f2a..f4e11adbc 100644 --- a/Editor/CustomDrawers/Utils/InLineEditorShared.cs +++ b/Editor/CustomDrawers/Utils/InLineEditorShared.cs @@ -108,7 +108,7 @@ public static class InLineEditorShared /// to properly destroy Editor instances when they are evicted from the cache. /// Lazy-initialized to prevent Unity Editor hangs during static initialization. /// - private static Cache EditorCache => + internal static Cache EditorCache => _editorCache ??= CacheBuilder .NewBuilder() .MaximumSize(MaxEditorCacheSize) @@ -132,7 +132,7 @@ public static class InLineEditorShared /// Cache for foldout expansion states, keyed by a unique foldout identifier. /// Limited to entries to prevent unbounded memory growth. /// - private static readonly Dictionary FoldoutStates = new Dictionary< + internal static readonly Dictionary FoldoutStates = new Dictionary< string, bool >(StringComparer.Ordinal); @@ -141,7 +141,7 @@ public static class InLineEditorShared /// Cache for scroll positions, keyed by a unique scroll identifier. /// Limited to entries to prevent unbounded memory growth. /// - private static readonly Dictionary ScrollPositions = new Dictionary< + internal static readonly Dictionary ScrollPositions = new Dictionary< string, Vector2 >(StringComparer.Ordinal); @@ -604,69 +604,6 @@ internal static void ClearCache() _editorCache?.Clear(); } - /// - /// Clears all cached state. Primarily for testing purposes. - /// - internal static void ClearCachedStateForTesting() - { - ClearCache(); - } - - /// - /// Test hook to set the foldout state for a given key. - /// - /// The foldout key. - /// Whether the foldout should be expanded. - internal static void SetFoldoutStateForTesting(string key, bool expanded) - { - if (!string.IsNullOrEmpty(key)) - { - FoldoutStates[key] = expanded; - } - } - - /// - /// Test hook to get the foldout state for a given key. - /// - /// The foldout key. - /// True if expanded; false otherwise. - internal static bool GetFoldoutStateForTesting(string key) - { - if (string.IsNullOrEmpty(key)) - { - return false; - } - - return FoldoutStates.TryGetValue(key, out bool value) && value; - } - - /// - /// Test hook to get the number of cached editors. - /// - /// The number of cached editors. - internal static int GetEditorCacheCountForTesting() - { - return EditorCache.Count; - } - - /// - /// Test hook to get the number of cached foldout states. - /// - /// The number of cached foldout states. - internal static int GetFoldoutStateCacheCountForTesting() - { - return FoldoutStates.Count; - } - - /// - /// Test hook to get the number of cached scroll positions. - /// - /// The number of cached scroll positions. - internal static int GetScrollPositionCacheCountForTesting() - { - return ScrollPositions.Count; - } - /// /// Callback invoked when an Editor is evicted from the cache. /// Properly destroys the Editor instance to prevent memory leaks. diff --git a/Editor/CustomDrawers/ValidateAssignmentPropertyDrawer.cs b/Editor/CustomDrawers/ValidateAssignmentPropertyDrawer.cs index 5757d9e59..83db57636 100644 --- a/Editor/CustomDrawers/ValidateAssignmentPropertyDrawer.cs +++ b/Editor/CustomDrawers/ValidateAssignmentPropertyDrawer.cs @@ -45,14 +45,6 @@ ValidateAssignmentAttribute validateAttribute ); } - /// - /// Clears the height cache. Useful for tests or when font settings change. - /// - internal static void ClearHeightCache() - { - ValidationShared.ClearHeightCache(); - } - /// /// Gets the total property height including the help box when the field is invalid. /// diff --git a/Editor/CustomDrawers/WEnumToggleButtonsDrawer.cs b/Editor/CustomDrawers/WEnumToggleButtonsDrawer.cs index 27cba016a..7c734b924 100644 --- a/Editor/CustomDrawers/WEnumToggleButtonsDrawer.cs +++ b/Editor/CustomDrawers/WEnumToggleButtonsDrawer.cs @@ -1385,9 +1385,10 @@ internal readonly struct ToggleOption internal bool IsZeroFlag { get; } + // Caption fallback does not normalize the independently stored option value. internal ToggleOption(string label, object value, ulong flagValue, bool isZeroFlag) { - Label = string.IsNullOrEmpty(label) ? "(Unnamed)" : label; + Label = string.IsNullOrWhiteSpace(label) ? "(Unnamed)" : label; Value = value; FlagValue = flagValue; IsZeroFlag = isZeroFlag; diff --git a/Editor/CustomDrawers/WInLineEditorDrawer.cs b/Editor/CustomDrawers/WInLineEditorDrawer.cs index 68306f819..298e56b8f 100644 --- a/Editor/CustomDrawers/WInLineEditorDrawer.cs +++ b/Editor/CustomDrawers/WInLineEditorDrawer.cs @@ -63,75 +63,14 @@ Inspired by the Unity Editor Toolbox inline editor drawer (MIT): /// private const int MaxFoldoutAnimations = 256; - /// - /// Test hook to get detailed height calculation info for diagnostics. - /// - internal static ( - float baseHeight, - float inlineHeight, - bool showHeader, - bool showBody, - float displayHeight - ) GetHeightCalculationDetailsForTesting( - SerializedProperty property, - WInLineEditorAttribute inlineAttribute, - Object value, - float availableWidth - ) - { - if (value == null || property == null) - { - return (0f, 0f, false, false, 0f); - } - - float baseHeight = inlineAttribute.DrawObjectField - ? EditorGUI.GetPropertyHeight(property, GUIContent.none, false) - : EditorGUIUtility.singleLineHeight; - - WInLineEditorMode mode = InLineEditorShared.ResolveMode(inlineAttribute); - bool useStandaloneHeader = InLineEditorShared.ShouldDrawStandaloneHeader( - inlineAttribute - ); - bool showHeader = - useStandaloneHeader - && (inlineAttribute.DrawHeader || mode != WInLineEditorMode.AlwaysExpanded); - bool foldoutState = GetFoldoutState(property, inlineAttribute, mode); - bool showBody = mode == WInLineEditorMode.AlwaysExpanded || foldoutState; - - float inlineHeight = 0f; - float displayHeight = 0f; - if (showHeader) - { - inlineHeight += InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing; - } - - if (showBody) - { - InspectorHeightInfo inspectorHeightInfo = ResolveInspectorHeightInfo( - value, - inlineAttribute, - availableWidth - ); - displayHeight = inspectorHeightInfo.DisplayHeight; - inlineHeight += displayHeight; - } - - return (baseHeight, inlineHeight, showHeader, showBody, displayHeight); - } - - /// - /// Test hook to check if the force serialized inspector flag is enabled. - /// - internal static bool ForceSerializedInspectorForTesting => _forceSerializedInspector; - - private static readonly Cache PropertyWidths = CacheBuilder + internal static readonly Cache PropertyWidths = CacheBuilder .NewBuilder() .MaximumSize(MaxMemoizedPropertyEntries) .InitialCapacity(16) .KeyComparer(System.StringComparer.Ordinal) .Build(); - private static readonly Cache< + internal static readonly Cache< (long instanceId, string propertyPath), string > FoldoutKeyCache = CacheBuilder<(long instanceId, string propertyPath), string> @@ -139,7 +78,7 @@ private static readonly Cache< .MaximumSize(MaxMemoizedPropertyEntries) .InitialCapacity(16) .Build(); - private static readonly Cache< + internal static readonly Cache< (long instanceId, string propertyPath), string > ScrollKeyCache = CacheBuilder<(long instanceId, string propertyPath), string> @@ -148,13 +87,13 @@ private static readonly Cache< .InitialCapacity(16) .Build(); - private static readonly Dictionary< + internal static readonly Dictionary< (long instanceId, float width), InspectorHeightInfoCacheEntry > InspectorHeightCache = new Dictionary<(long, float), InspectorHeightInfoCacheEntry>(); - private static int _lastInspectorHeightCacheFrame = -1; + internal static int _lastInspectorHeightCacheFrame = -1; - private static readonly Cache FoldoutAnimations = CacheBuilder< + internal static readonly Cache FoldoutAnimations = CacheBuilder< string, AnimBool > @@ -166,290 +105,301 @@ private static readonly Dictionary< .TransferOwnershipOnRemoval() .Build(); + // Use serialized layout because reflected width overrides differ across Unity versions. + internal static bool _forceSerializedInspector = true; + // GetPropertyHeight can recursively invoke this drawer through EditorGUI. [System.ThreadStatic] private static bool _isCalculatingHeight; - // Use serialized layout because reflected width overrides differ across Unity versions. - private static bool _forceSerializedInspector = true; - - internal static Rect GetInlineContentRectForTesting(Rect backgroundRect) + internal static bool ShouldShowPingButton(Object value) { - return GetInlineContentRect(backgroundRect); + return InLineEditorShared.ShouldShowPingButton(value); } - /// - /// Test hook to get extensive diagnostic info for debugging height calculation issues. - /// - internal static string GetExtensiveDiagnosticsForTesting( - SerializedProperty property, - WInLineEditorAttribute inlineAttribute, + internal static InspectorHeightInfo ResolveInspectorHeightInfo( Object value, + WInLineEditorAttribute inlineAttribute, float availableWidth ) { - if (value == null || property == null) + if (value == null) { - return "null property or value"; + return InspectorHeightInfo.Empty; } - System.Text.StringBuilder sb = new(); - sb.AppendLine($"=== Extensive Diagnostics ==="); - sb.AppendLine($"Property path: {property.propertyPath}"); - sb.AppendLine($"Value type: {value.GetType().Name}"); - sb.AppendLine($"Available width: {availableWidth}"); - - sb.AppendLine($"--- Attribute ---"); - sb.AppendLine($" Mode: {inlineAttribute.Mode}"); - sb.AppendLine($" DrawObjectField: {inlineAttribute.DrawObjectField}"); - sb.AppendLine($" DrawHeader: {inlineAttribute.DrawHeader}"); - sb.AppendLine($" EnableScrolling: {inlineAttribute.EnableScrolling}"); - sb.AppendLine($" InspectorHeight: {inlineAttribute.InspectorHeight}"); - sb.AppendLine($" MinInspectorWidth: {inlineAttribute.MinInspectorWidth}"); - sb.AppendLine( - $" HasExplicitMinInspectorWidth: {inlineAttribute.HasExplicitMinInspectorWidth}" - ); - - WInLineEditorMode resolvedMode = InLineEditorShared.ResolveMode(inlineAttribute); - sb.AppendLine($"--- Mode Resolution ---"); - sb.AppendLine($" Resolved mode: {resolvedMode}"); - if (inlineAttribute.Mode == WInLineEditorMode.UseSettings) + int currentFrame = Time.frameCount; + if (_lastInspectorHeightCacheFrame != currentFrame) { - UnityHelpersSettings.InlineEditorFoldoutBehavior behavior = - UnityHelpersSettings.GetInlineEditorFoldoutBehavior(); - sb.AppendLine($" Settings behavior: {behavior}"); + InspectorHeightCache.Clear(); + _lastInspectorHeightCacheFrame = currentFrame; } - string foldoutKey = BuildFoldoutKey(property); - bool foldoutInCache = InLineEditorShared.GetFoldoutStateForTesting(foldoutKey); - bool foldoutState = GetFoldoutState(property, inlineAttribute, resolvedMode); - sb.AppendLine($"--- Foldout State ---"); - sb.AppendLine($" Foldout key: {foldoutKey}"); - sb.AppendLine($" In cache before GetFoldoutState: {foldoutInCache}"); - sb.AppendLine($" GetFoldoutState result: {foldoutState}"); - - bool useStandaloneHeader = InLineEditorShared.ShouldDrawStandaloneHeader( - inlineAttribute - ); - bool showHeader = - useStandaloneHeader - && (inlineAttribute.DrawHeader || resolvedMode != WInLineEditorMode.AlwaysExpanded); - bool showBody = resolvedMode == WInLineEditorMode.AlwaysExpanded || foldoutState; - sb.AppendLine($"--- Visibility ---"); - sb.AppendLine($" useStandaloneHeader: {useStandaloneHeader}"); - sb.AppendLine($" showHeader: {showHeader}"); - sb.AppendLine($" showBody: {showBody}"); - - sb.AppendLine($"--- Inspector Height ---"); - Editor editor = InLineEditorShared.GetOrCreateEditor(value); - SerializedObject analysisObject = GetSerializedObjectForAnalysis(editor, value); - using SerializedObject ownedAnalysisObject = editor == null ? analysisObject : null; - bool hasSerializedData = analysisObject != null; - bool hasSimpleLayout = - hasSerializedData && SerializedObjectHasOnlySimpleProperties(analysisObject); - bool canUseSerializedInspector = - hasSerializedData && ShouldUseSerializedInspector(editor); - sb.AppendLine( - $" Editor type: {(editor != null ? editor.GetType().FullName : "null")}" - ); - sb.AppendLine($" hasSerializedData: {hasSerializedData}"); - sb.AppendLine($" hasSimpleLayout: {hasSimpleLayout}"); - sb.AppendLine($" canUseSerializedInspector: {canUseSerializedInspector}"); + long instanceId = value.GetUnityObjectId(); + // Round width to avoid cache misses from floating point variations + float roundedWidth = Mathf.Round(availableWidth); + (long, float) cacheKey = (instanceId, roundedWidth); - if (hasSerializedData) + if ( + InspectorHeightCache.TryGetValue(cacheKey, out InspectorHeightInfoCacheEntry cached) + ) { - float serializedHeight = CalculateSerializedInspectorHeight(analysisObject); - sb.AppendLine($" Serialized inspector height: {serializedHeight}"); - - sb.AppendLine($" --- Properties ---"); - analysisObject.UpdateIfRequiredOrScript(); - SerializedProperty iterator = analysisObject.GetIterator(); - bool enterChildren = true; - while (iterator.NextVisible(enterChildren)) - { - float propHeight = EditorGUI.GetPropertyHeight(iterator, true); - bool isScript = string.Equals( - iterator.propertyPath, - InLineEditorShared.ScriptPropertyPath, - System.StringComparison.Ordinal - ); - sb.AppendLine( - $" {iterator.propertyPath}: {propHeight}px (type: {iterator.propertyType}){(isScript ? " [SCRIPT - skipped]" : "")}" - ); - enterChildren = false; - } + return cached.heightInfo; } - InspectorHeightInfo heightInfo = ResolveInspectorHeightInfo( + InspectorHeightInfo result = CalculateInspectorHeightInfoUncached( value, inlineAttribute, availableWidth ); - sb.AppendLine($"--- Height Info Result ---"); - sb.AppendLine($" ContentHeight: {heightInfo.ContentHeight}"); - sb.AppendLine($" DisplayHeight: {heightInfo.DisplayHeight}"); - sb.AppendLine($" UsesSerializedInspector: {heightInfo.UsesSerializedInspector}"); - sb.AppendLine($" HorizontalScrollbarHeight: {heightInfo.HorizontalScrollbarHeight}"); - sb.AppendLine( - $" RequiresHorizontalScrollbar: {heightInfo.RequiresHorizontalScrollbar}" - ); - sb.AppendLine($" PaddingHeight: {heightInfo.PaddingHeight}"); - float inlineHeight = 0f; - if (showHeader) - { - inlineHeight += InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing; - } - if (showBody) + if (!InspectorHeightCache.TryGetValue(cacheKey, out cached)) { - inlineHeight += heightInfo.DisplayHeight; + cached = new InspectorHeightInfoCacheEntry(); + InspectorHeightCache[cacheKey] = cached; } - sb.AppendLine($"--- Final Inline Height ---"); - sb.AppendLine( - $" Header contribution: {(showHeader ? InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing : 0f)}" - ); - sb.AppendLine($" Body contribution: {(showBody ? heightInfo.DisplayHeight : 0f)}"); - sb.AppendLine($" Total inline height: {inlineHeight}"); + cached.heightInfo = result; - return sb.ToString(); + return result; } - internal static bool ShouldShowPingButton(Object value) + internal static bool ShouldUseSerializedInspector(Editor editor) { - return InLineEditorShared.ShouldShowPingButton(value); - } + if (editor == null) + { + return true; + } - internal static void ClearAnimationCacheForTesting() - { - FoldoutAnimations.Clear(); + return editor.GetType() == typeof(Editor); } - /// - /// Test hook to get the number of cached animation entries. - /// - internal static int GetAnimationCacheCountForTesting() + internal static float CalculateSerializedInspectorHeight(SerializedObject serializedObject) { - return FoldoutAnimations.Count; - } + if (serializedObject == null) + { + return 0f; + } - /// - /// Test hook to check if an animation entry exists for a specific key. - /// - internal static bool HasAnimationCacheEntryForTesting(string foldoutKey) - { - return FoldoutAnimations.ContainsKey(foldoutKey); - } + serializedObject.UpdateIfRequiredOrScript(); + SerializedProperty iterator = serializedObject.GetIterator(); + bool enterChildren = true; + float height = 0f; + bool firstPropertyMeasured = false; + while (iterator.NextVisible(enterChildren)) + { + if ( + string.Equals( + iterator.propertyPath, + InLineEditorShared.ScriptPropertyPath, + System.StringComparison.Ordinal + ) + ) + { + enterChildren = false; + continue; + } - /// - /// Test hook to get or create a foldout animation for testing purposes. - /// - internal static AnimBool GetOrCreateFoldoutAnimForTesting(string foldoutKey, bool expanded) - { - return GetOrCreateFoldoutAnim(foldoutKey, expanded); + if (firstPropertyMeasured) + { + height += EditorGUIUtility.standardVerticalSpacing; + } + + height += EditorGUI.GetPropertyHeight(iterator, true); + enterChildren = false; + firstPropertyMeasured = true; + } + + return Mathf.Max(0f, height); } - /// - /// Test hook to get the fade progress for a foldout. - /// - internal static float GetFadeProgressForTesting(string foldoutKey, bool expanded) + internal static SerializedObject GetSerializedObjectForAnalysis(Editor editor, Object value) { - return GetFadeProgress(foldoutKey, expanded); + if (!SupportsSerializedInspectorTarget(value)) + { + return null; + } + + SerializedObject serializedObject = + editor != null ? editor.serializedObject : new SerializedObject(value); + return serializedObject; } - /// - /// Test hook to build a foldout key from a serialized property. - /// - internal static string BuildFoldoutKeyForTesting(SerializedProperty property) + internal static bool SerializedObjectHasOnlySimpleProperties( + SerializedObject serializedObject + ) { - return BuildFoldoutKey(property); + if (serializedObject == null) + { + return false; + } + + SerializedProperty iterator = serializedObject.GetIterator(); + bool enterChildren = true; + bool hasAnyProperty = false; + while (iterator.NextVisible(enterChildren)) + { + if ( + string.Equals( + iterator.propertyPath, + InLineEditorShared.ScriptPropertyPath, + System.StringComparison.Ordinal + ) + ) + { + enterChildren = false; + continue; + } + + hasAnyProperty = true; + if (!IsSimpleSerializedProperty(iterator)) + { + return false; + } + + enterChildren = false; + } + + return hasAnyProperty; } - internal static void ClearCachedStateForTesting() + internal static Rect GetInlineContentRect(Rect backgroundRect) { - InLineEditorShared.ClearCachedStateForTesting(); - PropertyWidths.Clear(); - FoldoutKeyCache.Clear(); - ScrollKeyCache.Clear(); - InspectorHeightCache.Clear(); - _lastInspectorHeightCacheFrame = -1; - ClearAnimationCacheForTesting(); + return new Rect( + backgroundRect.x + InLineEditorShared.ContentPadding, + backgroundRect.y + InLineEditorShared.ContentPadding, + backgroundRect.width - (InLineEditorShared.ContentPadding * 2f), + Mathf.Max(0f, backgroundRect.height - (InLineEditorShared.ContentPadding * 2f)) + ); } - internal static void SetInlineFoldoutStateForTesting( + internal static bool DrawHeader( + Rect rect, SerializedProperty property, - bool expanded + Object value, + GUIContent label, + bool showFoldoutToggle, + bool foldoutState ) { - if (property == null) + float pingWidth = InLineEditorShared.GetPingButtonWidth(); + bool showPingButton = InLineEditorShared.ShouldShowPingButton(value); + float headerSpacing = 0f; + float headerRightMargin = 0f; + if (showPingButton) { - return; + headerSpacing = InLineEditorShared.HeaderPingSpacing; + headerRightMargin = InLineEditorShared.PingButtonRightMargin; + bool hasSpace = + InLineEditorShared.MinimumFoldoutLabelWidth + <= rect.width - pingWidth - headerSpacing - headerRightMargin; + if (!hasSpace) + { + showPingButton = false; + headerSpacing = 0f; + headerRightMargin = 0f; + } } + float labelWidth = showPingButton + ? Mathf.Max(0f, rect.width - pingWidth - headerSpacing - headerRightMargin) + : rect.width; + Rect labelRect = new Rect(rect.x, rect.y, labelWidth, rect.height); + Rect pingRect = new Rect( + rect.x + labelWidth + (showPingButton ? headerSpacing : 0f), + rect.y, + pingWidth, + rect.height + ); - string key = BuildFoldoutKey(property); - InLineEditorShared.SetFoldoutState(key, expanded); + GUIContent headerContent = InLineEditorShared.PrepareHeaderContent(value, label); + + if (showFoldoutToggle) + { + bool newState = EditorGUI.Foldout(labelRect, foldoutState, headerContent, true); + if (newState != foldoutState) + { + foldoutState = newState; + } + } + else + { + EditorGUI.LabelField(labelRect, headerContent, EditorStyles.boldLabel); + } + + if (showPingButton) + { + using (new EditorGUI.DisabledScope(value == null)) + { + if ( + GUI.Button( + pingRect, + InLineEditorShared.PingButtonContent, + EditorStyles.miniButton + ) + ) + { + EditorGUIUtility.PingObject(value); + } + } + } + + return foldoutState; } - internal static bool UsesHorizontalScrollbarForTesting( - Object value, - WInLineEditorAttribute inlineAttribute, - float availableWidth - ) + internal static string BuildFoldoutKey(SerializedProperty property) { - if (value == null || inlineAttribute == null) + Object target = + property.serializedObject != null ? property.serializedObject.targetObject : null; + long id = target != null ? target.GetUnityObjectId() : 0; + string propertyPath = property.propertyPath; + (long, string) cacheKey = (id, propertyPath); + if (!FoldoutKeyCache.TryGet(cacheKey, out string key)) { - return false; + key = InLineEditorShared.BuildFoldoutKey(id, propertyPath); + FoldoutKeyCache.Set(cacheKey, key); } + return key; + } - InspectorHeightInfo inspectorHeightInfo = ResolveInspectorHeightInfo( - value, - inlineAttribute, - availableWidth - ); - return inspectorHeightInfo.RequiresHorizontalScrollbar; + internal static bool GetFoldoutState( + SerializedProperty property, + WInLineEditorAttribute inlineAttribute, + WInLineEditorMode resolvedMode + ) + { + string key = BuildFoldoutKey(property); + return InLineEditorShared.GetFoldoutState(key, resolvedMode); } - /// - /// Test hook to directly check if a SerializedObject has only simple properties. - /// This allows unit testing the simple layout detection without full editor integration. - /// - internal static bool HasOnlySimplePropertiesForTesting(SerializedObject serializedObject) + internal static void SetFoldoutState(string key, bool value) { - return SerializedObjectHasOnlySimpleProperties(serializedObject); + InLineEditorShared.SetFoldoutState(key, value); } - /// - /// Test hook to directly check horizontal scrollbar requirement with explicit parameters. - /// This bypasses editor creation and allows testing the decision logic directly. - /// - internal static bool RequiresHorizontalScrollbarForTesting( - bool enableScrolling, - float minInspectorWidth, - bool hasExplicitMinInspectorWidth, - bool hasSimpleLayout, - float availableWidth - ) + internal static AnimBool GetOrCreateFoldoutAnim(string foldoutKey, bool expanded) { - float effectiveWidth = Mathf.Max( - 0f, - availableWidth - (InLineEditorShared.ContentPadding * 2f) - ); + float speed = UnityHelpersSettings.GetInlineEditorFoldoutSpeed(); - const float MinimumUsableWidth = 200f; - bool widthIsTooNarrow = effectiveWidth < MinimumUsableWidth; - bool shouldRespectMinWidth = - hasExplicitMinInspectorWidth || !hasSimpleLayout || widthIsTooNarrow; - return enableScrolling - && 0f < minInspectorWidth - && shouldRespectMinWidth - && 0.5f < minInspectorWidth - effectiveWidth; + if (!FoldoutAnimations.TryGet(foldoutKey, out AnimBool anim) || anim == null) + { + anim = new AnimBool(expanded) { speed = speed }; + anim.valueChanged.AddListener(RequestRepaint); + FoldoutAnimations.Set(foldoutKey, anim); + } + + anim.speed = speed; + anim.target = expanded; + return anim; } - /// - /// Test hook to calculate label width for a given available width. - /// - internal static float CalculateLabelWidthForTesting(float availableWidth) + internal static float GetFadeProgress(string foldoutKey, bool expanded) { - return availableWidth * InLineEditorShared.DefaultLabelWidthRatio; + if (!UnityHelpersSettings.ShouldTweenInlineEditorFoldouts()) + { + return expanded ? 1f : 0f; + } + + AnimBool anim = GetOrCreateFoldoutAnim(foldoutKey, expanded); + return anim.faded; } private static float GetHorizontalScrollbarHeight() @@ -931,72 +881,26 @@ Rect rect EditorGUIUtility.labelWidth = contentWidth * InLineEditorShared.DefaultLabelWidthRatio; EditorGUIUtility.fieldWidth = contentWidth * DefaultFieldWidthRatio; - try - { - GUILayout.BeginArea(rect); - using (InlineInspectorContext.Enter()) - { - editor.OnInspectorGUI(); - } - GUILayout.EndArea(); - } - finally - { - EditorGUIUtility.labelWidth = previousLabelWidth; - EditorGUIUtility.fieldWidth = previousFieldWidth; - } - } - - private static void DrawSerializedInspector(Rect rect, Editor editor) - { - SerializedObject serializedObject = editor.serializedObject; - InLineEditorShared.DrawSerializedObjectInRect(rect, serializedObject); - } - - private static InspectorHeightInfo ResolveInspectorHeightInfo( - Object value, - WInLineEditorAttribute inlineAttribute, - float availableWidth - ) - { - if (value == null) - { - return InspectorHeightInfo.Empty; - } - - int currentFrame = Time.frameCount; - if (_lastInspectorHeightCacheFrame != currentFrame) - { - InspectorHeightCache.Clear(); - _lastInspectorHeightCacheFrame = currentFrame; - } - - long instanceId = value.GetUnityObjectId(); - // Round width to avoid cache misses from floating point variations - float roundedWidth = Mathf.Round(availableWidth); - (long, float) cacheKey = (instanceId, roundedWidth); - - if ( - InspectorHeightCache.TryGetValue(cacheKey, out InspectorHeightInfoCacheEntry cached) - ) + try { - return cached.heightInfo; + GUILayout.BeginArea(rect); + using (InlineInspectorContext.Enter()) + { + editor.OnInspectorGUI(); + } + GUILayout.EndArea(); } - - InspectorHeightInfo result = CalculateInspectorHeightInfoUncached( - value, - inlineAttribute, - availableWidth - ); - - if (!InspectorHeightCache.TryGetValue(cacheKey, out cached)) + finally { - cached = new InspectorHeightInfoCacheEntry(); - InspectorHeightCache[cacheKey] = cached; + EditorGUIUtility.labelWidth = previousLabelWidth; + EditorGUIUtility.fieldWidth = previousFieldWidth; } - cached.heightInfo = result; + } - return result; + private static void DrawSerializedInspector(Rect rect, Editor editor) + { + SerializedObject serializedObject = editor.serializedObject; + InLineEditorShared.DrawSerializedObjectInRect(rect, serializedObject); } private static InspectorHeightInfo CalculateInspectorHeightInfoUncached( @@ -1095,110 +999,11 @@ out float contentHeight return true; } - private static bool ShouldUseSerializedInspector(Editor editor) - { - if (editor == null) - { - return true; - } - - return editor.GetType() == typeof(Editor); - } - - private static float CalculateSerializedInspectorHeight(SerializedObject serializedObject) - { - if (serializedObject == null) - { - return 0f; - } - - serializedObject.UpdateIfRequiredOrScript(); - SerializedProperty iterator = serializedObject.GetIterator(); - bool enterChildren = true; - float height = 0f; - bool firstPropertyMeasured = false; - while (iterator.NextVisible(enterChildren)) - { - if ( - string.Equals( - iterator.propertyPath, - InLineEditorShared.ScriptPropertyPath, - System.StringComparison.Ordinal - ) - ) - { - enterChildren = false; - continue; - } - - if (firstPropertyMeasured) - { - height += EditorGUIUtility.standardVerticalSpacing; - } - - height += EditorGUI.GetPropertyHeight(iterator, true); - enterChildren = false; - firstPropertyMeasured = true; - } - - return Mathf.Max(0f, height); - } - - private static SerializedObject GetSerializedObjectForAnalysis(Editor editor, Object value) - { - if (!SupportsSerializedInspectorTarget(value)) - { - return null; - } - - SerializedObject serializedObject = - editor != null ? editor.serializedObject : new SerializedObject(value); - return serializedObject; - } - private static bool SupportsSerializedInspectorTarget(Object value) { return value is ScriptableObject || value is MonoBehaviour; } - private static bool SerializedObjectHasOnlySimpleProperties( - SerializedObject serializedObject - ) - { - if (serializedObject == null) - { - return false; - } - - SerializedProperty iterator = serializedObject.GetIterator(); - bool enterChildren = true; - bool hasAnyProperty = false; - while (iterator.NextVisible(enterChildren)) - { - if ( - string.Equals( - iterator.propertyPath, - InLineEditorShared.ScriptPropertyPath, - System.StringComparison.Ordinal - ) - ) - { - enterChildren = false; - continue; - } - - hasAnyProperty = true; - if (!IsSimpleSerializedProperty(iterator)) - { - return false; - } - - enterChildren = false; - } - - return hasAnyProperty; - } - private static bool IsSimpleSerializedProperty(SerializedProperty property) { if (property == null) @@ -1226,104 +1031,6 @@ private static bool IsSimpleSerializedProperty(SerializedProperty property) return true; } - private static Rect GetInlineContentRect(Rect backgroundRect) - { - return new Rect( - backgroundRect.x + InLineEditorShared.ContentPadding, - backgroundRect.y + InLineEditorShared.ContentPadding, - backgroundRect.width - (InLineEditorShared.ContentPadding * 2f), - Mathf.Max(0f, backgroundRect.height - (InLineEditorShared.ContentPadding * 2f)) - ); - } - - private static bool DrawHeader( - Rect rect, - SerializedProperty property, - Object value, - GUIContent label, - bool showFoldoutToggle, - bool foldoutState - ) - { - float pingWidth = InLineEditorShared.GetPingButtonWidth(); - bool showPingButton = InLineEditorShared.ShouldShowPingButton(value); - float headerSpacing = 0f; - float headerRightMargin = 0f; - if (showPingButton) - { - headerSpacing = InLineEditorShared.HeaderPingSpacing; - headerRightMargin = InLineEditorShared.PingButtonRightMargin; - bool hasSpace = - InLineEditorShared.MinimumFoldoutLabelWidth - <= rect.width - pingWidth - headerSpacing - headerRightMargin; - if (!hasSpace) - { - showPingButton = false; - headerSpacing = 0f; - headerRightMargin = 0f; - } - } - float labelWidth = showPingButton - ? Mathf.Max(0f, rect.width - pingWidth - headerSpacing - headerRightMargin) - : rect.width; - Rect labelRect = new Rect(rect.x, rect.y, labelWidth, rect.height); - Rect pingRect = new Rect( - rect.x + labelWidth + (showPingButton ? headerSpacing : 0f), - rect.y, - pingWidth, - rect.height - ); - - GUIContent headerContent = InLineEditorShared.PrepareHeaderContent(value, label); - - if (showFoldoutToggle) - { - bool newState = EditorGUI.Foldout(labelRect, foldoutState, headerContent, true); - if (newState != foldoutState) - { - foldoutState = newState; - } - } - else - { - EditorGUI.LabelField(labelRect, headerContent, EditorStyles.boldLabel); - } - - if (showPingButton) - { - using (new EditorGUI.DisabledScope(value == null)) - { - if ( - GUI.Button( - pingRect, - InLineEditorShared.PingButtonContent, - EditorStyles.miniButton - ) - ) - { - EditorGUIUtility.PingObject(value); - } - } - } - - return foldoutState; - } - - private static string BuildFoldoutKey(SerializedProperty property) - { - Object target = - property.serializedObject != null ? property.serializedObject.targetObject : null; - long id = target != null ? target.GetUnityObjectId() : 0; - string propertyPath = property.propertyPath; - (long, string) cacheKey = (id, propertyPath); - if (!FoldoutKeyCache.TryGet(cacheKey, out string key)) - { - key = InLineEditorShared.BuildFoldoutKey(id, propertyPath); - FoldoutKeyCache.Set(cacheKey, key); - } - return key; - } - private static string BuildScrollKey(SerializedProperty property) { Object target = @@ -1339,48 +1046,6 @@ private static string BuildScrollKey(SerializedProperty property) return key; } - private static bool GetFoldoutState( - SerializedProperty property, - WInLineEditorAttribute inlineAttribute, - WInLineEditorMode resolvedMode - ) - { - string key = BuildFoldoutKey(property); - return InLineEditorShared.GetFoldoutState(key, resolvedMode); - } - - private static void SetFoldoutState(string key, bool value) - { - InLineEditorShared.SetFoldoutState(key, value); - } - - private static AnimBool GetOrCreateFoldoutAnim(string foldoutKey, bool expanded) - { - float speed = UnityHelpersSettings.GetInlineEditorFoldoutSpeed(); - - if (!FoldoutAnimations.TryGet(foldoutKey, out AnimBool anim) || anim == null) - { - anim = new AnimBool(expanded) { speed = speed }; - anim.valueChanged.AddListener(RequestRepaint); - FoldoutAnimations.Set(foldoutKey, anim); - } - - anim.speed = speed; - anim.target = expanded; - return anim; - } - - private static float GetFadeProgress(string foldoutKey, bool expanded) - { - if (!UnityHelpersSettings.ShouldTweenInlineEditorFoldouts()) - { - return expanded ? 1f : 0f; - } - - AnimBool anim = GetOrCreateFoldoutAnim(foldoutKey, expanded); - return anim.faded; - } - private static void RequestRepaint() { InternalEditorUtility.RepaintAllViews(); @@ -1545,12 +1210,12 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten EditorGUI.EndProperty(); } - private sealed class InspectorHeightInfoCacheEntry + internal sealed class InspectorHeightInfoCacheEntry { public InspectorHeightInfo heightInfo; } - private readonly struct InspectorHeightInfo + internal readonly struct InspectorHeightInfo { public static InspectorHeightInfo Empty => new InspectorHeightInfo(0f, 0f, false, 0f, false, 0f); diff --git a/Editor/CustomDrawers/WNotNullPropertyDrawer.cs b/Editor/CustomDrawers/WNotNullPropertyDrawer.cs index 75b3d73c8..e792b0c20 100644 --- a/Editor/CustomDrawers/WNotNullPropertyDrawer.cs +++ b/Editor/CustomDrawers/WNotNullPropertyDrawer.cs @@ -42,14 +42,6 @@ WNotNullAttribute notNullAttribute return ValidationShared.DrawNotNullHelpBoxIfNeeded(property, notNullAttribute); } - /// - /// Clears the height cache. Useful for tests or when font settings change. - /// - internal static void ClearHeightCache() - { - ValidationShared.ClearHeightCache(); - } - /// /// Gets the total property height including the help box when the field is null. /// diff --git a/Editor/CustomDrawers/WShowIfPropertyDrawer.cs b/Editor/CustomDrawers/WShowIfPropertyDrawer.cs index 2bd5f7226..351ef1362 100644 --- a/Editor/CustomDrawers/WShowIfPropertyDrawer.cs +++ b/Editor/CustomDrawers/WShowIfPropertyDrawer.cs @@ -87,7 +87,6 @@ private static readonly Dictionary< private static object[] _singleIndexArgs; private static int _lastConditionCacheFrame = -1; - private WShowIfAttribute _overrideAttribute; /// /// Checks whether a property with [WShowIf] attribute should be visible. @@ -802,11 +801,6 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten } } - internal void InitializeForTesting(WShowIfAttribute attributeOverride) - { - _overrideAttribute = attributeOverride; - } - internal bool ShouldShow(SerializedProperty property) { // Unity invokes collection drawers per element; hiding elements while the container remains visible corrupts layout. @@ -860,11 +854,6 @@ private bool ShouldShowInternal(SerializedProperty property) private WShowIfAttribute ResolveAttribute() { - if (_overrideAttribute != null) - { - return _overrideAttribute; - } - return attribute as WShowIfAttribute; } diff --git a/Editor/CustomDrawers/WValueDropDownDrawer.cs b/Editor/CustomDrawers/WValueDropDownDrawer.cs index b38d06b8d..cc2251a74 100644 --- a/Editor/CustomDrawers/WValueDropDownDrawer.cs +++ b/Editor/CustomDrawers/WValueDropDownDrawer.cs @@ -25,16 +25,13 @@ namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers [CustomPropertyDrawer(typeof(WValueDropDownAttribute))] public sealed class WValueDropDownDrawer : PropertyDrawer { - private const float ButtonWidth = DropDownShared.ButtonWidth; - private const float PageLabelWidth = DropDownShared.PageLabelWidth; - private const float PaginationButtonHeight = DropDownShared.PaginationButtonHeight; - private const float PopupWidth = DropDownShared.PopupWidth; - private const float OptionBottomPadding = DropDownShared.OptionBottomPadding; - private const float OptionRowExtraHeight = DropDownShared.OptionRowExtraHeight; - private const float EmptySearchHorizontalPadding = + internal const float PaginationButtonHeight = DropDownShared.PaginationButtonHeight; + internal const float PopupWidth = DropDownShared.PopupWidth; + internal const float OptionBottomPadding = DropDownShared.OptionBottomPadding; + internal const float EmptySearchHorizontalPadding = DropDownShared.EmptySearchHorizontalPadding; - private const float EmptySearchExtraPadding = DropDownShared.EmptySearchExtraPadding; - private const string EmptyResultsMessage = DropDownShared.EmptyResultsMessage; + internal const float EmptySearchExtraPadding = DropDownShared.EmptySearchExtraPadding; + internal const string EmptyResultsMessage = DropDownShared.EmptyResultsMessage; /// /// The number of property paths whose display labels are retained. @@ -46,7 +43,7 @@ public sealed class WValueDropDownDrawer : PropertyDrawer /// array of dropdowns contributes one path per element -- so the live set never reaches the /// bound. /// - private const int MaxDisplayLabelsCacheEntries = 512; + internal const int MaxDisplayLabelsCacheEntries = 512; /// /// The number of distinct option values whose formatted label is retained. @@ -56,19 +53,20 @@ public sealed class WValueDropDownDrawer : PropertyDrawer /// game's own dropdown source, so an unbounded cache roots every option ever rendered /// across scene changes and play sessions. /// - private const int MaxFormattedOptionCacheEntries = 2048; - private static readonly GUIContent EmptyResultsContent = DropDownShared.EmptyResultsContent; - private static float s_cachedOptionControlHeight = -1f; - private static float s_cachedOptionRowHeight = -1f; + internal const int MaxFormattedOptionCacheEntries = 2048; - private static readonly Cache DisplayLabelsCaches = + private const float ButtonWidth = DropDownShared.ButtonWidth; + private const float PageLabelWidth = DropDownShared.PageLabelWidth; + private const float OptionRowExtraHeight = DropDownShared.OptionRowExtraHeight; + internal static readonly Cache DisplayLabelsCaches = CacheBuilder .NewBuilder() .MaximumSize(MaxDisplayLabelsCacheEntries) .InitialCapacity(16) .KeyComparer(StringComparer.Ordinal) .Build(); - private static readonly Cache FormattedOptionCache = CacheBuilder< + + internal static readonly Cache FormattedOptionCache = CacheBuilder< object, string > @@ -76,6 +74,11 @@ public sealed class WValueDropDownDrawer : PropertyDrawer .MaximumSize(MaxFormattedOptionCacheEntries) .InitialCapacity(16) .Build(); + + private static readonly GUIContent EmptyResultsContent = DropDownShared.EmptyResultsContent; + private static float s_cachedOptionControlHeight = -1f; + private static float s_cachedOptionRowHeight = -1f; + private static readonly GUIContent ReusableDropDownButtonContent = new(); internal static void ApplyOption(SerializedProperty property, object selectedOption) @@ -158,6 +161,268 @@ internal static void ApplyOption(SerializedProperty property, object selectedOpt } } + internal static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) + { + if (filteredCount <= 0 || pageSize <= 0) + { + return 1; + } + + int maxPageIndex = CalculatePageCount(pageSize, filteredCount) - 1; + int clampedPage = Mathf.Clamp(currentPage, 0, Mathf.Max(0, maxPageIndex)); + int startIndex = clampedPage * pageSize; + int remaining = filteredCount - startIndex; + if (remaining <= 0) + { + return 1; + } + + return Mathf.Min(pageSize, remaining); + } + + internal static int ResolveSelectedIndex( + SerializedProperty property, + Type valueType, + object[] options + ) + { + int optionsLength = options.Length; + for (int index = 0; index < optionsLength; index += 1) + { + if (OptionMatches(property, valueType, options[index])) + { + return index; + } + } + + return -1; + } + + // Authored options can convert to serialized types; normalize them without weakening public equality contracts. + internal static bool MatchesAuthoredOption(object serializedValue, object option) + { + if (serializedValue.Equals(option)) + { + return true; + } + + object serializedUnderlying = UnderlyingValueOf(serializedValue); + object optionUnderlying = UnderlyingValueOf(option); + if (serializedUnderlying == null || optionUnderlying == null) + { + return false; + } + + if (serializedUnderlying.Equals(optionUnderlying)) + { + return true; + } + + return SharePlanarCoordinates(serializedUnderlying, optionUnderlying); + } + + internal static string[] GetOrCreateDisplayLabels(string cacheKey, object[] options) + { + if ( + DisplayLabelsCaches.TryGet(cacheKey, out DisplayLabelsCache cached) + && cached != null + ) + { + if (ReferenceEquals(cached.sourceOptions, options)) + { + return cached.labels; + } + + int optionsLength = options.Length; + if ( + cached.sourceOptions != null + && cached.sourceOptions.Length == optionsLength + && cached.labels != null + && cached.labels.Length == optionsLength + ) + { + bool match = true; + for (int i = 0; i < optionsLength && match; ++i) + { + if (!Equals(cached.sourceOptions[i], options[i])) + { + match = false; + } + } + if (match) + { + return cached.labels; + } + } + } + + string[] labels = BuildDisplayLabelsUncached(options); + DisplayLabelsCaches.Set( + cacheKey, + new DisplayLabelsCache { sourceOptions = options, labels = labels } + ); + return labels; + } + + internal static string[] BuildDisplayLabelsUncached(object[] options) + { + int optionsLength = options.Length; + string[] labels = new string[optionsLength]; + for (int index = 0; index < optionsLength; index += 1) + { + labels[index] = FormatOptionCached(options[index]); + } + + return labels; + } + + internal static string FormatOptionCached(object option) + { + if (option == null) + { + return "(null)"; + } + + if (FormattedOptionCache.TryGet(option, out string cached)) + { + return cached; + } + + string formatted; + if (option is Type type) + { + formatted = SerializableTypeCatalog.GetDisplayName(type); + } + else if (option is SerializableType serializableType) + { + formatted = serializableType.DisplayName; + } + else if (option is UnityEngine.Object unityObject) + { + if (unityObject == null) + { + formatted = "(None)"; + } + else + { + string objectName = unityObject.name; + formatted = string.IsNullOrWhiteSpace(objectName) + ? unityObject.GetType().Name + : objectName; + } + } + else if (option is IFormattable formattable) + { + formatted = formattable.ToString(null, CultureInfo.InvariantCulture); + } + else + { + formatted = option.ToString(); + } + + if (string.IsNullOrEmpty(formatted)) + { + formatted = $"({option.GetType().Name})"; + } + + FormattedOptionCache.Set(option, formatted); + return formatted; + } + + internal static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) + { + int clampedRows = Mathf.Max(1, rowsOnPage); + float chromeHeight = CalculatePopupChromeHeight(includePagination); + float optionListHeight = clampedRows * GetOptionRowHeight(); + float unclampedHeight = chromeHeight + optionListHeight; + return unclampedHeight; + } + + internal static float CalculatePopupChromeHeight(bool includePagination) + { + float searchHeight = EditorGUIUtility.singleLineHeight; + float paginationHeight = includePagination + ? PopupStyles.PaginationButtonLeft.fixedHeight + : EditorGUIUtility.standardVerticalSpacing; + float footerHeight = EditorGUIUtility.standardVerticalSpacing + OptionBottomPadding; + return searchHeight + paginationHeight + footerHeight; + } + + internal static float CalculateEmptySearchHeight(float measuredHelpBoxHeight = -1f) + { + GUIStyle helpStyle = EditorStyles.helpBox; + int helpMargin = helpStyle.margin?.horizontal ?? 0; + float availableWidth = PopupWidth - EmptySearchHorizontalPadding - helpMargin; + availableWidth = Mathf.Max(32f, availableWidth); + float helpBoxHeight; + if (0f < measuredHelpBoxHeight) + { + helpBoxHeight = measuredHelpBoxHeight; + } + else + { + float calculated = helpStyle.CalcHeight(EmptyResultsContent, availableWidth); + float marginVertical = helpStyle.margin?.vertical ?? 0; + helpBoxHeight = calculated + marginVertical; + } + + float searchRow = + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; + float topSpacer = EditorGUIUtility.standardVerticalSpacing; + float bottomSpacer = EditorGUIUtility.standardVerticalSpacing; + float footer = + EditorGUIUtility.standardVerticalSpacing + + OptionBottomPadding + + EmptySearchExtraPadding; + + float result = searchRow + topSpacer + helpBoxHeight + bottomSpacer + footer; + return result; + } + + internal static float GetOptionRowHeight() + { + if (0f < s_cachedOptionRowHeight) + { + return s_cachedOptionRowHeight; + } + + float controlHeight = GetOptionControlHeight(); + RectOffset margin = PopupStyles.OptionButton.margin; + float adjustedMargin = 0f; + if (margin != null) + { + adjustedMargin = Mathf.Max( + 0f, + margin.vertical - EditorGUIUtility.standardVerticalSpacing + ); + } + else + { + adjustedMargin = EditorGUIUtility.standardVerticalSpacing; + } + + s_cachedOptionRowHeight = controlHeight + adjustedMargin; + return s_cachedOptionRowHeight; + } + + internal static float GetOptionControlHeight() + { + if (0f < s_cachedOptionControlHeight) + { + return s_cachedOptionControlHeight; + } + + float width = PopupWidth - 32f; + float measured = PopupStyles.OptionButton.CalcHeight(GUIContent.none, width); + if (measured <= 0f || float.IsNaN(measured)) + { + measured = EditorGUIUtility.singleLineHeight + OptionRowExtraHeight; + } + + s_cachedOptionControlHeight = measured; + return measured; + } + private static string GetPaginationLabel(int page, int totalPages) { return DropDownShared.GetPaginationLabel(page, totalPages); @@ -277,25 +542,6 @@ private static int CalculatePageCount(int pageSize, int filteredCount) return (filteredCount + pageSize - 1) / pageSize; } - private static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) - { - if (filteredCount <= 0 || pageSize <= 0) - { - return 1; - } - - int maxPageIndex = CalculatePageCount(pageSize, filteredCount) - 1; - int clampedPage = Mathf.Clamp(currentPage, 0, Mathf.Max(0, maxPageIndex)); - int startIndex = clampedPage * pageSize; - int remaining = filteredCount - startIndex; - if (remaining <= 0) - { - return 1; - } - - return Mathf.Min(pageSize, remaining); - } - private static void DrawPopupDropDown( Rect position, SerializedProperty property, @@ -371,24 +617,6 @@ out string tooltip EditorGUI.EndProperty(); } - private static int ResolveSelectedIndex( - SerializedProperty property, - Type valueType, - object[] options - ) - { - int optionsLength = options.Length; - for (int index = 0; index < optionsLength; index += 1) - { - if (OptionMatches(property, valueType, options[index])) - { - return index; - } - } - - return -1; - } - private static string ResolveDisplayValue( SerializedProperty property, object[] options, @@ -511,36 +739,13 @@ object option return false; } - object boxedValue = GetBoxedPropertyValue(property, valueType); - if (boxedValue == null) - { - return false; - } - - return MatchesAuthoredOption(boxedValue, option); - } - - // Authored options can convert to serialized types; normalize them without weakening public equality contracts. - private static bool MatchesAuthoredOption(object serializedValue, object option) - { - if (serializedValue.Equals(option)) - { - return true; - } - - object serializedUnderlying = UnderlyingValueOf(serializedValue); - object optionUnderlying = UnderlyingValueOf(option); - if (serializedUnderlying == null || optionUnderlying == null) - { - return false; - } - - if (serializedUnderlying.Equals(optionUnderlying)) + object boxedValue = GetBoxedPropertyValue(property, valueType); + if (boxedValue == null) { - return true; + return false; } - return SharePlanarCoordinates(serializedUnderlying, optionUnderlying); + return MatchesAuthoredOption(boxedValue, option); } private static object UnderlyingValueOf(object value) @@ -860,114 +1065,6 @@ private static bool MatchesObjectReference(SerializedProperty property, object o return ReferenceEquals(currentValue, optionObject); } - private static string[] GetOrCreateDisplayLabels(string cacheKey, object[] options) - { - if ( - DisplayLabelsCaches.TryGet(cacheKey, out DisplayLabelsCache cached) - && cached != null - ) - { - if (ReferenceEquals(cached.sourceOptions, options)) - { - return cached.labels; - } - - int optionsLength = options.Length; - if ( - cached.sourceOptions != null - && cached.sourceOptions.Length == optionsLength - && cached.labels != null - && cached.labels.Length == optionsLength - ) - { - bool match = true; - for (int i = 0; i < optionsLength && match; ++i) - { - if (!Equals(cached.sourceOptions[i], options[i])) - { - match = false; - } - } - if (match) - { - return cached.labels; - } - } - } - - string[] labels = BuildDisplayLabelsUncached(options); - DisplayLabelsCaches.Set( - cacheKey, - new DisplayLabelsCache { sourceOptions = options, labels = labels } - ); - return labels; - } - - private static string[] BuildDisplayLabelsUncached(object[] options) - { - int optionsLength = options.Length; - string[] labels = new string[optionsLength]; - for (int index = 0; index < optionsLength; index += 1) - { - labels[index] = FormatOptionCached(options[index]); - } - - return labels; - } - - private static string FormatOptionCached(object option) - { - if (option == null) - { - return "(null)"; - } - - if (FormattedOptionCache.TryGet(option, out string cached)) - { - return cached; - } - - string formatted; - if (option is Type type) - { - formatted = SerializableTypeCatalog.GetDisplayName(type); - } - else if (option is SerializableType serializableType) - { - formatted = serializableType.DisplayName; - } - else if (option is UnityEngine.Object unityObject) - { - if (unityObject == null) - { - formatted = "(None)"; - } - else - { - string objectName = unityObject.name; - formatted = string.IsNullOrWhiteSpace(objectName) - ? unityObject.GetType().Name - : objectName; - } - } - else if (option is IFormattable formattable) - { - formatted = formattable.ToString(null, CultureInfo.InvariantCulture); - } - else - { - formatted = option.ToString(); - } - - if (string.IsNullOrEmpty(formatted)) - { - formatted = $"({option.GetType().Name})"; - } - - FormattedOptionCache.Set(option, formatted); - return formatted; - } - private static void ApplyBoolean(SerializedProperty property, object selectedOption) { if (selectedOption is bool boolValue) @@ -1366,100 +1463,6 @@ private static void ApplyEnum(SerializedProperty property, object selectedOption } } - private static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) - { - int clampedRows = Mathf.Max(1, rowsOnPage); - float chromeHeight = CalculatePopupChromeHeight(includePagination); - float optionListHeight = clampedRows * GetOptionRowHeight(); - float unclampedHeight = chromeHeight + optionListHeight; - return unclampedHeight; - } - - private static float CalculatePopupChromeHeight(bool includePagination) - { - float searchHeight = EditorGUIUtility.singleLineHeight; - float paginationHeight = includePagination - ? PopupStyles.PaginationButtonLeft.fixedHeight - : EditorGUIUtility.standardVerticalSpacing; - float footerHeight = EditorGUIUtility.standardVerticalSpacing + OptionBottomPadding; - return searchHeight + paginationHeight + footerHeight; - } - - private static float CalculateEmptySearchHeight(float measuredHelpBoxHeight = -1f) - { - GUIStyle helpStyle = EditorStyles.helpBox; - int helpMargin = helpStyle.margin?.horizontal ?? 0; - float availableWidth = PopupWidth - EmptySearchHorizontalPadding - helpMargin; - availableWidth = Mathf.Max(32f, availableWidth); - float helpBoxHeight; - if (0f < measuredHelpBoxHeight) - { - helpBoxHeight = measuredHelpBoxHeight; - } - else - { - float calculated = helpStyle.CalcHeight(EmptyResultsContent, availableWidth); - float marginVertical = helpStyle.margin?.vertical ?? 0; - helpBoxHeight = calculated + marginVertical; - } - - float searchRow = - EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; - float topSpacer = EditorGUIUtility.standardVerticalSpacing; - float bottomSpacer = EditorGUIUtility.standardVerticalSpacing; - float footer = - EditorGUIUtility.standardVerticalSpacing - + OptionBottomPadding - + EmptySearchExtraPadding; - - float result = searchRow + topSpacer + helpBoxHeight + bottomSpacer + footer; - return result; - } - - private static float GetOptionRowHeight() - { - if (0f < s_cachedOptionRowHeight) - { - return s_cachedOptionRowHeight; - } - - float controlHeight = GetOptionControlHeight(); - RectOffset margin = PopupStyles.OptionButton.margin; - float adjustedMargin = 0f; - if (margin != null) - { - adjustedMargin = Mathf.Max( - 0f, - margin.vertical - EditorGUIUtility.standardVerticalSpacing - ); - } - else - { - adjustedMargin = EditorGUIUtility.standardVerticalSpacing; - } - - s_cachedOptionRowHeight = controlHeight + adjustedMargin; - return s_cachedOptionRowHeight; - } - - private static float GetOptionControlHeight() - { - if (0f < s_cachedOptionControlHeight) - { - return s_cachedOptionControlHeight; - } - - float width = PopupWidth - 32f; - float measured = PopupStyles.OptionButton.CalcHeight(GUIContent.none, width); - if (measured <= 0f || float.IsNaN(measured)) - { - measured = EditorGUIUtility.singleLineHeight + OptionRowExtraHeight; - } - - s_cachedOptionControlHeight = measured; - return measured; - } - private static string GetTypeMismatchMessage( SerializedProperty property, WValueDropDownAttribute dropdownAttribute @@ -1697,13 +1700,14 @@ public override VisualElement CreatePropertyGUI(SerializedProperty property) return selector; } - private sealed class DisplayLabelsCache + internal sealed class DisplayLabelsCache { public object[] sourceOptions; public string[] labels; } - private sealed class WValueDropDownPopupSelectorElement : WDropDownPopupSelectorBase + internal sealed class WValueDropDownPopupSelectorElement + : WDropDownPopupSelectorBase { protected override int OptionCount => _options.Length; @@ -1777,7 +1781,7 @@ int pageSize } } - private sealed class WValueDropDownSelector : WDropDownSelectorBase + internal sealed class WValueDropDownSelector : WDropDownSelectorBase { protected override int OptionCount => _options.Length; @@ -1826,129 +1830,7 @@ protected override string GetDefaultValue() } } - internal static class TestHooks - { - /// - /// Gets the number of display-label sets currently retained, for testing. - /// - public static int DisplayLabelsCacheCount => DisplayLabelsCaches.Count; - - /// - /// Gets the bound the display-label cache evicts at, for testing. - /// - public static int MaxDisplayLabelsCacheCount => MaxDisplayLabelsCacheEntries; - - /// - /// Gets the number of formatted option labels currently retained, for testing. - /// - public static int FormattedOptionCacheCount => FormattedOptionCache.Count; - - /// - /// Gets the bound the formatted option cache evicts at, for testing. - /// - public static int MaxFormattedOptionCacheCount => MaxFormattedOptionCacheEntries; - - public static int OptionButtonMarginVertical => - PopupStyles.OptionButton.margin?.vertical ?? 0; - - public static float OptionFooterPadding => OptionBottomPadding; - - public static float PaginationButtonHeight => - PopupStyles.PaginationButtonLeft.fixedHeight; - - public static float PopupWidthValue => PopupWidth; - - public static float EmptySearchHorizontalPaddingValue => EmptySearchHorizontalPadding; - - public static string EmptyResultsMessageValue => EmptyResultsMessage; - - public static float EmptySearchExtraPaddingValue => EmptySearchExtraPadding; - - /// - /// Reads the cached display labels for a property path, populating them when absent. - /// - public static string[] GetOrCreateDisplayLabels(string cacheKey, object[] options) - { - return WValueDropDownDrawer.GetOrCreateDisplayLabels(cacheKey, options); - } - - /// - /// Drops every cached display-label set and formatted option label, for testing. - /// - public static void ClearCaches() - { - DisplayLabelsCaches.Clear(); - FormattedOptionCache.Clear(); - } - - public static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) - { - return WValueDropDownDrawer.CalculatePopupTargetHeight( - rowsOnPage, - includePagination - ); - } - - public static float CalculatePopupChromeHeight(bool includePagination) - { - return WValueDropDownDrawer.CalculatePopupChromeHeight(includePagination); - } - - public static float GetOptionRowHeight() - { - return WValueDropDownDrawer.GetOptionRowHeight(); - } - - public static float GetOptionControlHeight() - { - return WValueDropDownDrawer.GetOptionControlHeight(); - } - - public static float CalculateEmptySearchHeight() - { - return WValueDropDownDrawer.CalculateEmptySearchHeight(); - } - - public static float CalculateEmptySearchHeightWithMeasurement(float measuredHelpHeight) - { - return WValueDropDownDrawer.CalculateEmptySearchHeight(measuredHelpHeight); - } - - public static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) - { - return WValueDropDownDrawer.CalculateRowsOnPage( - filteredCount, - pageSize, - currentPage - ); - } - - public static int ResolveSelectedIndex( - SerializedProperty property, - Type valueType, - object[] options - ) - { - return WValueDropDownDrawer.ResolveSelectedIndex(property, valueType, options); - } - - public static bool MatchesAuthoredOption(object serializedValue, object option) - { - return WValueDropDownDrawer.MatchesAuthoredOption(serializedValue, option); - } - - public static string FormatOptionCached(object option) - { - return WValueDropDownDrawer.FormatOptionCached(option); - } - - public static string[] BuildDisplayLabelsUncached(object[] options) - { - return WValueDropDownDrawer.BuildDisplayLabelsUncached(options); - } - } - - private static class PopupStyles + internal static class PopupStyles { public static GUIStyle OptionButton => _optionButton ??= new GUIStyle("Button") diff --git a/Editor/FitTextureSizeWindow.cs b/Editor/FitTextureSizeWindow.cs index 9dfbbcea8..3bc33673c 100644 --- a/Editor/FitTextureSizeWindow.cs +++ b/Editor/FitTextureSizeWindow.cs @@ -18,7 +18,6 @@ namespace WallstopStudios.UnityHelpers.Editor public sealed class FitTextureSizeWindow : EditorWindow { private static bool SuppressUserPrompts { get; set; } - internal SerializedObject SerializedStateForTesting => _serializedObject; internal FitMode _fitMode = FitMode.GrowAndShrink; @@ -65,8 +64,8 @@ public sealed class FitTextureSizeWindow : EditorWindow [SerializeField] internal string _labelFilterCsv = string.Empty; + internal SerializedObject _serializedObject; private Vector2 _scrollPosition = Vector2.zero; - private SerializedObject _serializedObject; private SerializedProperty _textureSourcePathsProperty; private int _potentialChangeCount = -1; private int _potentialGrowCount; diff --git a/Editor/PersistentDirectorySettings.cs b/Editor/PersistentDirectorySettings.cs index f573474a8..79101f980 100644 --- a/Editor/PersistentDirectorySettings.cs +++ b/Editor/PersistentDirectorySettings.cs @@ -453,10 +453,7 @@ private static void EnsureSingletonAndMigrate() EditorApplication.delayCall += () => { // Automatic migration can open modal failure dialogs during tests; require explicit test opt-in. - if ( - Utils.EditorUi.Suppress - && !Utils.ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression - ) + if (Utils.EditorUi.Suppress) { return; } diff --git a/Editor/Settings/ColorKeyChangeNotifier.cs b/Editor/Settings/ColorKeyChangeNotifier.cs index 21890b456..b92d30ff8 100644 --- a/Editor/Settings/ColorKeyChangeNotifier.cs +++ b/Editor/Settings/ColorKeyChangeNotifier.cs @@ -31,32 +31,32 @@ internal static class ColorKeyChangeNotifier /// internal static event Action> OnWEnumToggleButtonsColorKeysChanged; - private static readonly Dictionary PreviousWButtonButtonColors = new( + internal static readonly Dictionary PreviousWButtonButtonColors = new( StringComparer.OrdinalIgnoreCase ); - private static readonly Dictionary PreviousWButtonTextColors = new( + internal static readonly Dictionary PreviousWButtonTextColors = new( StringComparer.OrdinalIgnoreCase ); - private static readonly Dictionary PreviousWEnumSelectedBackgrounds = new( + internal static readonly Dictionary PreviousWEnumSelectedBackgrounds = new( StringComparer.OrdinalIgnoreCase ); - private static readonly Dictionary PreviousWEnumSelectedTexts = new( + internal static readonly Dictionary PreviousWEnumSelectedTexts = new( StringComparer.OrdinalIgnoreCase ); - private static readonly Dictionary PreviousWEnumInactiveBackgrounds = new( + internal static readonly Dictionary PreviousWEnumInactiveBackgrounds = new( StringComparer.OrdinalIgnoreCase ); - private static readonly Dictionary PreviousWEnumInactiveTexts = new( + internal static readonly Dictionary PreviousWEnumInactiveTexts = new( StringComparer.OrdinalIgnoreCase ); - private static HashSet _changedWButtonKeys; - private static HashSet _changedWEnumKeys; + internal static HashSet _changedWButtonKeys; + internal static HashSet _changedWEnumKeys; /// /// Captures the current state of custom color dictionaries so we can detect changes later. @@ -126,25 +126,9 @@ internal static void RepaintAffectedInspectors() } } - /// - /// Clears all cached state. Useful for tests or domain reload scenarios. - /// - internal static void ClearCache() + internal static HashSet DetectWButtonChanges(SerializedObject settingsObject) { - PreviousWButtonButtonColors.Clear(); - PreviousWButtonTextColors.Clear(); - PreviousWEnumSelectedBackgrounds.Clear(); - PreviousWEnumSelectedTexts.Clear(); - PreviousWEnumInactiveBackgrounds.Clear(); - PreviousWEnumInactiveTexts.Clear(); - _changedWButtonKeys = null; - _changedWEnumKeys = null; - } - - private static void CaptureWButtonColors(SerializedObject settingsObject) - { - PreviousWButtonButtonColors.Clear(); - PreviousWButtonTextColors.Clear(); + HashSet changedKeys = null; SerializedProperty customColors = settingsObject.FindProperty( UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors @@ -152,7 +136,7 @@ private static void CaptureWButtonColors(SerializedObject settingsObject) if (customColors == null) { - return; + return changedKeys; } SerializedProperty keys = customColors.FindPropertyRelative( @@ -164,9 +148,11 @@ private static void CaptureWButtonColors(SerializedObject settingsObject) if (keys == null || values == null) { - return; + return changedKeys; } + HashSet currentKeys = new(StringComparer.OrdinalIgnoreCase); + int count = Mathf.Min(keys.arraySize, values.arraySize); for (int index = 0; index < count; ++index) { @@ -184,6 +170,8 @@ private static void CaptureWButtonColors(SerializedObject settingsObject) continue; } + currentKeys.Add(key); + SerializedProperty buttonColor = valueProp.FindPropertyRelative( UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColorButton ); @@ -191,24 +179,50 @@ private static void CaptureWButtonColors(SerializedObject settingsObject) UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColorText ); + bool buttonChanged = false; + bool textChanged = false; + if (buttonColor != null) { - PreviousWButtonButtonColors[key] = buttonColor.colorValue; + if ( + !PreviousWButtonButtonColors.TryGetValue(key, out Color previousButton) + || !ColorsEqual(previousButton, buttonColor.colorValue) + ) + { + buttonChanged = true; + } } if (textColor != null) { - PreviousWButtonTextColors[key] = textColor.colorValue; + if ( + !PreviousWButtonTextColors.TryGetValue(key, out Color previousText) + || !ColorsEqual(previousText, textColor.colorValue) + ) + { + textChanged = true; + } + } + + if (buttonChanged || textChanged) + { + changedKeys ??= new HashSet(StringComparer.OrdinalIgnoreCase); + changedKeys.Add(key); } } + + // Removed keys may exist only in a text-color snapshot; inspect every map. + CollectRemovedKeys(PreviousWButtonButtonColors, currentKeys, ref changedKeys); + CollectRemovedKeys(PreviousWButtonTextColors, currentKeys, ref changedKeys); + + return changedKeys; } - private static void CaptureWEnumToggleButtonsColors(SerializedObject settingsObject) + internal static HashSet DetectWEnumToggleButtonsChanges( + SerializedObject settingsObject + ) { - PreviousWEnumSelectedBackgrounds.Clear(); - PreviousWEnumSelectedTexts.Clear(); - PreviousWEnumInactiveBackgrounds.Clear(); - PreviousWEnumInactiveTexts.Clear(); + HashSet changedKeys = null; SerializedProperty customColors = settingsObject.FindProperty( UnityHelpersSettings.SerializedPropertyNames.WEnumToggleButtonsCustomColors @@ -216,7 +230,7 @@ private static void CaptureWEnumToggleButtonsColors(SerializedObject settingsObj if (customColors == null) { - return; + return changedKeys; } SerializedProperty keys = customColors.FindPropertyRelative( @@ -228,9 +242,11 @@ private static void CaptureWEnumToggleButtonsColors(SerializedObject settingsObj if (keys == null || values == null) { - return; + return changedKeys; } + HashSet currentKeys = new(StringComparer.OrdinalIgnoreCase); + int count = Mathf.Min(keys.arraySize, values.arraySize); for (int index = 0; index < count; ++index) { @@ -248,6 +264,8 @@ private static void CaptureWEnumToggleButtonsColors(SerializedObject settingsObj continue; } + currentKeys.Add(key); + SerializedProperty selectedBg = valueProp.FindPropertyRelative( UnityHelpersSettings .SerializedPropertyNames @@ -265,31 +283,71 @@ private static void CaptureWEnumToggleButtonsColors(SerializedObject settingsObj UnityHelpersSettings.SerializedPropertyNames.WEnumToggleButtonsInactiveText ); + bool anyChanged = false; + if (selectedBg != null) { - PreviousWEnumSelectedBackgrounds[key] = selectedBg.colorValue; + if ( + !PreviousWEnumSelectedBackgrounds.TryGetValue(key, out Color previous) + || !ColorsEqual(previous, selectedBg.colorValue) + ) + { + anyChanged = true; + } } - if (selectedText != null) + if (!anyChanged && selectedText != null) { - PreviousWEnumSelectedTexts[key] = selectedText.colorValue; + if ( + !PreviousWEnumSelectedTexts.TryGetValue(key, out Color previous) + || !ColorsEqual(previous, selectedText.colorValue) + ) + { + anyChanged = true; + } } - if (inactiveBg != null) + if (!anyChanged && inactiveBg != null) { - PreviousWEnumInactiveBackgrounds[key] = inactiveBg.colorValue; + if ( + !PreviousWEnumInactiveBackgrounds.TryGetValue(key, out Color previous) + || !ColorsEqual(previous, inactiveBg.colorValue) + ) + { + anyChanged = true; + } } - if (inactiveText != null) + if (!anyChanged && inactiveText != null) { - PreviousWEnumInactiveTexts[key] = inactiveText.colorValue; + if ( + !PreviousWEnumInactiveTexts.TryGetValue(key, out Color previous) + || !ColorsEqual(previous, inactiveText.colorValue) + ) + { + anyChanged = true; + } + } + + if (anyChanged) + { + changedKeys ??= new HashSet(StringComparer.OrdinalIgnoreCase); + changedKeys.Add(key); } } + + CollectRemovedKeys(PreviousWEnumSelectedBackgrounds, currentKeys, ref changedKeys); + CollectRemovedKeys(PreviousWEnumSelectedTexts, currentKeys, ref changedKeys); + CollectRemovedKeys(PreviousWEnumInactiveBackgrounds, currentKeys, ref changedKeys); + CollectRemovedKeys(PreviousWEnumInactiveTexts, currentKeys, ref changedKeys); + + return changedKeys; } - private static HashSet DetectWButtonChanges(SerializedObject settingsObject) + private static void CaptureWButtonColors(SerializedObject settingsObject) { - HashSet changedKeys = null; + PreviousWButtonButtonColors.Clear(); + PreviousWButtonTextColors.Clear(); SerializedProperty customColors = settingsObject.FindProperty( UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors @@ -297,7 +355,7 @@ private static HashSet DetectWButtonChanges(SerializedObject settingsObj if (customColors == null) { - return changedKeys; + return; } SerializedProperty keys = customColors.FindPropertyRelative( @@ -309,11 +367,9 @@ private static HashSet DetectWButtonChanges(SerializedObject settingsObj if (keys == null || values == null) { - return changedKeys; + return; } - HashSet currentKeys = new(StringComparer.OrdinalIgnoreCase); - int count = Mathf.Min(keys.arraySize, values.arraySize); for (int index = 0; index < count; ++index) { @@ -331,8 +387,6 @@ private static HashSet DetectWButtonChanges(SerializedObject settingsObj continue; } - currentKeys.Add(key); - SerializedProperty buttonColor = valueProp.FindPropertyRelative( UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColorButton ); @@ -340,68 +394,24 @@ private static HashSet DetectWButtonChanges(SerializedObject settingsObj UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColorText ); - bool buttonChanged = false; - bool textChanged = false; - if (buttonColor != null) { - if ( - !PreviousWButtonButtonColors.TryGetValue(key, out Color previousButton) - || !ColorsEqual(previousButton, buttonColor.colorValue) - ) - { - buttonChanged = true; - } + PreviousWButtonButtonColors[key] = buttonColor.colorValue; } if (textColor != null) { - if ( - !PreviousWButtonTextColors.TryGetValue(key, out Color previousText) - || !ColorsEqual(previousText, textColor.colorValue) - ) - { - textChanged = true; - } - } - - if (buttonChanged || textChanged) - { - changedKeys ??= new HashSet(StringComparer.OrdinalIgnoreCase); - changedKeys.Add(key); - } - } - - // Removed keys may exist only in a text-color snapshot; inspect every map. - CollectRemovedKeys(PreviousWButtonButtonColors, currentKeys, ref changedKeys); - CollectRemovedKeys(PreviousWButtonTextColors, currentKeys, ref changedKeys); - - return changedKeys; - } - - private static void CollectRemovedKeys( - Dictionary previous, - HashSet currentKeys, - ref HashSet changedKeys - ) - { - foreach (string previousKey in previous.Keys) - { - if (currentKeys.Contains(previousKey)) - { - continue; + PreviousWButtonTextColors[key] = textColor.colorValue; } - - changedKeys ??= new HashSet(StringComparer.OrdinalIgnoreCase); - changedKeys.Add(previousKey); } } - private static HashSet DetectWEnumToggleButtonsChanges( - SerializedObject settingsObject - ) + private static void CaptureWEnumToggleButtonsColors(SerializedObject settingsObject) { - HashSet changedKeys = null; + PreviousWEnumSelectedBackgrounds.Clear(); + PreviousWEnumSelectedTexts.Clear(); + PreviousWEnumInactiveBackgrounds.Clear(); + PreviousWEnumInactiveTexts.Clear(); SerializedProperty customColors = settingsObject.FindProperty( UnityHelpersSettings.SerializedPropertyNames.WEnumToggleButtonsCustomColors @@ -409,7 +419,7 @@ SerializedObject settingsObject if (customColors == null) { - return changedKeys; + return; } SerializedProperty keys = customColors.FindPropertyRelative( @@ -421,11 +431,9 @@ SerializedObject settingsObject if (keys == null || values == null) { - return changedKeys; + return; } - HashSet currentKeys = new(StringComparer.OrdinalIgnoreCase); - int count = Mathf.Min(keys.arraySize, values.arraySize); for (int index = 0; index < count; ++index) { @@ -443,8 +451,6 @@ SerializedObject settingsObject continue; } - currentKeys.Add(key); - SerializedProperty selectedBg = valueProp.FindPropertyRelative( UnityHelpersSettings .SerializedPropertyNames @@ -462,65 +468,44 @@ SerializedObject settingsObject UnityHelpersSettings.SerializedPropertyNames.WEnumToggleButtonsInactiveText ); - bool anyChanged = false; - if (selectedBg != null) { - if ( - !PreviousWEnumSelectedBackgrounds.TryGetValue(key, out Color previous) - || !ColorsEqual(previous, selectedBg.colorValue) - ) - { - anyChanged = true; - } + PreviousWEnumSelectedBackgrounds[key] = selectedBg.colorValue; } - if (!anyChanged && selectedText != null) + if (selectedText != null) { - if ( - !PreviousWEnumSelectedTexts.TryGetValue(key, out Color previous) - || !ColorsEqual(previous, selectedText.colorValue) - ) - { - anyChanged = true; - } + PreviousWEnumSelectedTexts[key] = selectedText.colorValue; } - if (!anyChanged && inactiveBg != null) + if (inactiveBg != null) { - if ( - !PreviousWEnumInactiveBackgrounds.TryGetValue(key, out Color previous) - || !ColorsEqual(previous, inactiveBg.colorValue) - ) - { - anyChanged = true; - } + PreviousWEnumInactiveBackgrounds[key] = inactiveBg.colorValue; } - if (!anyChanged && inactiveText != null) + if (inactiveText != null) { - if ( - !PreviousWEnumInactiveTexts.TryGetValue(key, out Color previous) - || !ColorsEqual(previous, inactiveText.colorValue) - ) - { - anyChanged = true; - } + PreviousWEnumInactiveTexts[key] = inactiveText.colorValue; } + } + } - if (anyChanged) + private static void CollectRemovedKeys( + Dictionary previous, + HashSet currentKeys, + ref HashSet changedKeys + ) + { + foreach (string previousKey in previous.Keys) + { + if (currentKeys.Contains(previousKey)) { - changedKeys ??= new HashSet(StringComparer.OrdinalIgnoreCase); - changedKeys.Add(key); + continue; } - } - - CollectRemovedKeys(PreviousWEnumSelectedBackgrounds, currentKeys, ref changedKeys); - CollectRemovedKeys(PreviousWEnumSelectedTexts, currentKeys, ref changedKeys); - CollectRemovedKeys(PreviousWEnumInactiveBackgrounds, currentKeys, ref changedKeys); - CollectRemovedKeys(PreviousWEnumInactiveTexts, currentKeys, ref changedKeys); - return changedKeys; + changedKeys ??= new HashSet(StringComparer.OrdinalIgnoreCase); + changedKeys.Add(previousKey); + } } /// diff --git a/Editor/Settings/UnityHelpersSettings.cs b/Editor/Settings/UnityHelpersSettings.cs index d3822a8f3..8e1b7087b 100644 --- a/Editor/Settings/UnityHelpersSettings.cs +++ b/Editor/Settings/UnityHelpersSettings.cs @@ -155,6 +155,8 @@ public sealed class UnityHelpersSettings : ScriptableSingleton SettingsGroupFoldoutStates = new(); - private static SerializedObject _cachedSettingsSerializedObject; private static UnityHelpersBufferSettingsAsset _waitInstructionBufferSettingsAsset; private static readonly GUIContent StringInListPageSizeContent = EditorGUIUtility.TrTextContent( @@ -906,6 +908,31 @@ internal HashSet WButtonCustomColorSkipAutoSuggest [WGroupEnd(FailedTestsExporterFoldoutKey)] internal string _failedTestsOutputDirectory = DefaultFailedTestsOutputDirectory; + [FormerlySerializedAs("wgroupAutoIncludeMode")] + [SerializeField] + [Tooltip( + "Controls how WGroup automatically includes additional serialized members after a group declaration." + )] + [WGroup( + "WGroup Defaults", + displayName: "WGroup Defaults", + autoIncludeCount: 4, + collapsible: true + )] + internal WGroupAutoIncludeMode _wgroupAutoIncludeMode = WGroupAutoIncludeMode.Infinite; + + [FormerlySerializedAs("wgroupAutoIncludeRowCount")] + [SerializeField] + [Tooltip( + "Number of additional serialized members captured when the WGroup auto include mode is set to Finite." + )] + [WShowIf( + nameof(_wgroupAutoIncludeMode), + expectedValues: new object[] { WGroupAutoIncludeMode.Finite } + )] + [Range(MinWGroupAutoIncludeRowCount, MaxWGroupAutoIncludeRowCount)] + internal int _wgroupAutoIncludeRowCount = DefaultWGroupAutoIncludeRowCount; + [FormerlySerializedAs("waitInstructionBufferApplyOnLoad")] [SerializeField] [Tooltip( @@ -1232,31 +1259,6 @@ internal HashSet WButtonCustomColorSkipAutoSuggest private string[] _serializableTypeIgnorePatternCache = Array.Empty(); private int _serializableTypeIgnorePatternCacheVersion = int.MinValue; - [FormerlySerializedAs("wgroupAutoIncludeMode")] - [SerializeField] - [Tooltip( - "Controls how WGroup automatically includes additional serialized members after a group declaration." - )] - [WGroup( - "WGroup Defaults", - displayName: "WGroup Defaults", - autoIncludeCount: 4, - collapsible: true - )] - private WGroupAutoIncludeMode _wgroupAutoIncludeMode = WGroupAutoIncludeMode.Infinite; - - [FormerlySerializedAs("wgroupAutoIncludeRowCount")] - [SerializeField] - [Tooltip( - "Number of additional serialized members captured when the WGroup auto include mode is set to Finite." - )] - [WShowIf( - nameof(_wgroupAutoIncludeMode), - expectedValues: new object[] { WGroupAutoIncludeMode.Finite } - )] - [Range(MinWGroupAutoIncludeRowCount, MaxWGroupAutoIncludeRowCount)] - private int _wgroupAutoIncludeRowCount = DefaultWGroupAutoIncludeRowCount; - [FormerlySerializedAs("wgroupFoldoutsStartCollapsed")] [SerializeField] [Tooltip( @@ -1736,21 +1738,6 @@ internal static void SetWGroupFoldoutTweenEnabled(bool value) instance._wgroupFoldoutTweenEnabled = value; } - internal static void SetWGroupAutoIncludeConfigurationForTests( - WGroupAutoIncludeMode mode, - int rowCount - ) - { - UnityHelpersSettings settings = instance; - settings._wgroupAutoIncludeMode = mode; - settings._wgroupAutoIncludeRowCount = Mathf.Clamp( - rowCount, - MinWGroupAutoIncludeRowCount, - MaxWGroupAutoIncludeRowCount - ); - settings.SaveSettings(); - } - internal static bool HasWButtonPaletteColorKey(string colorKey) { return instance.ContainsColorKey(colorKey); @@ -1828,15 +1815,6 @@ UnityHelpersSettings settings return _cachedSettingsSerializedObject; } - /// - /// Clears the cached SerializedObject for testing purposes. - /// - internal static void ClearCachedSerializedObjectForTests() - { - _cachedSettingsSerializedObject?.Dispose(); - _cachedSettingsSerializedObject = null; - } - private static string ValidateFailedTestsOutputDirectory(string directory) { if (string.IsNullOrWhiteSpace(directory)) @@ -5502,67 +5480,10 @@ internal static class SerializedPropertyNames WEnumToggleButtonsCustomColor._hasInactiveTextColor ); internal const string FailedTestsOutputDirectory = nameof(_failedTestsOutputDirectory); - - /// - /// Gets the serialized property name value for the given constant name. - /// Exposed for testing to avoid reflection-based field access. - /// - internal static string GetPropertyNameValue(string constantName) - { - return constantName switch - { - nameof(SerializableTypeIgnorePatterns) => SerializableTypeIgnorePatterns, - nameof(SerializableTypePatternsInitialized) => - SerializableTypePatternsInitialized, - nameof(SerializableTypePattern) => SerializableTypePattern, - nameof(LegacyWButtonPriorityColors) => LegacyWButtonPriorityColors, - nameof(WButtonCustomColors) => WButtonCustomColors, - nameof(WGroupFoldoutsStartCollapsed) => WGroupFoldoutsStartCollapsed, - nameof(WGroupFoldoutTweenEnabled) => WGroupFoldoutTweenEnabled, - nameof(WGroupFoldoutSpeed) => WGroupFoldoutSpeed, - nameof(WEnumToggleButtonsCustomColors) => WEnumToggleButtonsCustomColors, - nameof(InlineEditorFoldoutBehavior) => InlineEditorFoldoutBehavior, - nameof(InlineEditorFoldoutTweenEnabled) => InlineEditorFoldoutTweenEnabled, - nameof(InlineEditorFoldoutSpeed) => InlineEditorFoldoutSpeed, - nameof(WButtonFoldoutTweenEnabled) => WButtonFoldoutTweenEnabled, - nameof(SerializableDictionaryFoldoutTweenEnabled) => - SerializableDictionaryFoldoutTweenEnabled, - nameof(SerializableSortedDictionaryFoldoutTweenEnabled) => - SerializableSortedDictionaryFoldoutTweenEnabled, - nameof(SerializableSetFoldoutTweenEnabled) => - SerializableSetFoldoutTweenEnabled, - nameof(SerializableSortedSetFoldoutTweenEnabled) => - SerializableSortedSetFoldoutTweenEnabled, - nameof(FoldoutTweenSettingsInitialized) => FoldoutTweenSettingsInitialized, - nameof(SerializableDictionaryFoldoutSpeed) => - SerializableDictionaryFoldoutSpeed, - nameof(SerializableSortedDictionaryFoldoutSpeed) => - SerializableSortedDictionaryFoldoutSpeed, - nameof(SerializableSetFoldoutSpeed) => SerializableSetFoldoutSpeed, - nameof(SerializableSortedSetFoldoutSpeed) => SerializableSortedSetFoldoutSpeed, - nameof(DetectAssetChangeLoopWindowSeconds) => - DetectAssetChangeLoopWindowSeconds, - nameof(DeferAssetPostprocessorCallbacks) => DeferAssetPostprocessorCallbacks, - nameof(WButtonPriority) => WButtonPriority, - nameof(WButtonCustomColorButton) => WButtonCustomColorButton, - nameof(WButtonCustomColorText) => WButtonCustomColorText, - nameof(WButtonCustomColorHasText) => WButtonCustomColorHasText, - nameof(WEnumToggleButtonsSelectedBackground) => - WEnumToggleButtonsSelectedBackground, - nameof(WEnumToggleButtonsSelectedText) => WEnumToggleButtonsSelectedText, - nameof(WEnumToggleButtonsInactiveBackground) => - WEnumToggleButtonsInactiveBackground, - nameof(WEnumToggleButtonsInactiveText) => WEnumToggleButtonsInactiveText, - nameof(WEnumToggleButtonsHasSelectedText) => WEnumToggleButtonsHasSelectedText, - nameof(WEnumToggleButtonsHasInactiveText) => WEnumToggleButtonsHasInactiveText, - nameof(FailedTestsOutputDirectory) => FailedTestsOutputDirectory, - _ => null, - }; - } } /// - /// Constants for custom color drawer layout calculations, exposed for testing. + /// Calculates label and field widths for the custom color drawers. /// internal static class CustomColorDrawerLayout { @@ -5654,11 +5575,7 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten float availableWidth = Mathf.Max(0f, position.width - spacing); float halfWidth = availableWidth * 0.5f; - float labelWidth = Mathf.Clamp( - halfWidth * CustomColorDrawerLabelWidthRatio, - CustomColorDrawerMinLabelWidth, - CustomColorDrawerMaxLabelWidth - ); + float labelWidth = CustomColorDrawerLayout.CalculateLabelWidth(halfWidth); float previousLabelWidth = EditorGUIUtility.labelWidth; EditorGUIUtility.labelWidth = labelWidth; @@ -5673,8 +5590,7 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten position.height ); - float minFieldWidth = CustomColorDrawerMinColorFieldWidth + labelWidth; - bool useLabels = minFieldWidth <= halfWidth; + bool useLabels = CustomColorDrawerLayout.ShouldShowLabels(halfWidth); EditorGUI.PropertyField( buttonRect, @@ -5745,11 +5661,7 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten float halfWidth = availableWidth * 0.5f; float lineHeight = EditorGUIUtility.singleLineHeight; - float labelWidth = Mathf.Clamp( - halfWidth * CustomColorDrawerLabelWidthRatio, - CustomColorDrawerMinLabelWidth, - CustomColorDrawerMaxLabelWidth - ); + float labelWidth = CustomColorDrawerLayout.CalculateLabelWidth(halfWidth); float previousLabelWidth = EditorGUIUtility.labelWidth; EditorGUIUtility.labelWidth = labelWidth; @@ -5781,8 +5693,7 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten lineHeight ); - float minFieldWidth = CustomColorDrawerMinColorFieldWidth + labelWidth; - bool useLabels = minFieldWidth <= halfWidth; + bool useLabels = CustomColorDrawerLayout.ShouldShowLabels(halfWidth); EditorGUI.PropertyField( selectedBackgroundRect, diff --git a/Editor/Sprites/AnimationCopierWindow.cs b/Editor/Sprites/AnimationCopierWindow.cs index 4a915785a..a4ea634f0 100644 --- a/Editor/Sprites/AnimationCopierWindow.cs +++ b/Editor/Sprites/AnimationCopierWindow.cs @@ -63,11 +63,12 @@ internal bool IncludeUnchangedInCopyAll internal int UnchangedCount => _unchangedAnimations.Count; internal int OrphansCount => _destinationOrphans.Count; - internal SerializedObject SerializedStateForTesting => _serializedObject; internal string _filterText = string.Empty; internal bool _filterUseRegex; internal bool _sortAscending = true; + internal SerializedObject _serializedObject; + [SerializeField] private string _animationSourcePathRelative = "Assets/Sprites"; @@ -80,8 +81,6 @@ internal bool IncludeUnchangedInCopyAll private bool _isAnalyzing; private bool _isCopying; private bool _isDeleting; - - private SerializedObject _serializedObject; private SerializedProperty _animationSourcesPathProperty; private SerializedProperty _animationDestinationPathProperty; diff --git a/Editor/Sprites/AnimationCreatorAPI.cs b/Editor/Sprites/AnimationCreatorAPI.cs index 9c41da745..34c0b2369 100644 --- a/Editor/Sprites/AnimationCreatorAPI.cs +++ b/Editor/Sprites/AnimationCreatorAPI.cs @@ -20,8 +20,6 @@ namespace WallstopStudios.UnityHelpers.Editor.Sprites /// public static class AnimationCreatorAPI { - internal static Action SaveAssetsAction = AssetDatabase.SaveAssets; - private static readonly char[] InvalidNameCharacters = { '/', @@ -247,7 +245,7 @@ out List validFrames createdAsset = true; if (saveAssets) { - SaveAssetsAction(); + AssetDatabase.SaveAssets(); } error = null; assetPath = finalPath; diff --git a/Editor/Sprites/AnimationCreatorWindow.cs b/Editor/Sprites/AnimationCreatorWindow.cs index c09e22221..54e66fbb1 100644 --- a/Editor/Sprites/AnimationCreatorWindow.cs +++ b/Editor/Sprites/AnimationCreatorWindow.cs @@ -179,8 +179,6 @@ public sealed class AnimationCreatorWindow : EditorWindow RegexOptions.Compiled | RegexOptions.CultureInvariant ); - internal SerializedObject SerializedStateForTesting => _serializedObject; - private Dictionary< string, Dictionary> @@ -294,7 +292,7 @@ private Dictionary< public bool strictNumericOrdering = false; internal readonly SpritePreviewCache _previewTextureCache = new(); - private SerializedObject _serializedObject; + internal SerializedObject _serializedObject; private SerializedProperty _animationDataProp; private SerializedProperty _animationSourcesProp; private SerializedProperty _spriteNameRegexProp; @@ -357,37 +355,6 @@ public static void ShowWindow() GetWindow("Animation Creator"); } - internal static float GetCurrentFpsForTests(AnimationData data, int frameIndex) - { - if (data.framerateMode == FramerateMode.Constant) - { - return 0 < data.framesPerSecond - ? data.framesPerSecond - : AnimationData.DefaultFramesPerSecond; - } - - float normalizedPosition = - 1 < data.frames.Count ? (float)frameIndex / (data.frames.Count - 1) : 0f; - - float fps = data.framesPerSecondCurve.Evaluate(normalizedPosition); - return 0 < fps ? fps : AnimationData.DefaultFramesPerSecond; - } - - internal static AnimationClip CreateAnimationClipForTests( - AnimationData data, - List validFrames - ) - { - return AnimationCreatorAPI.TryCreateClip( - data, - validFrames, - out AnimationClip clip, - out _ - ) - ? clip - : null; - } - internal static int CalculateScrubberFrame(float scrubberValue, int frameCount) { if (frameCount <= 0) @@ -405,6 +372,22 @@ internal static float CalculateCycleOffsetClamped(float inputOffset) return Mathf.Clamp01(inputOffset); } + internal static float GetCurrentFps(AnimationData data, int frameIndex) + { + if (data.framerateMode == FramerateMode.Constant) + { + return 0 < data.framesPerSecond + ? data.framesPerSecond + : AnimationData.DefaultFramesPerSecond; + } + + float normalizedPosition = + 1 < data.frames.Count ? (float)frameIndex / (data.frames.Count - 1) : 0f; + + float fps = data.framesPerSecondCurve.Evaluate(normalizedPosition); + return 0 < fps ? fps : AnimationData.DefaultFramesPerSecond; + } + private static string SanitizeName(string inputName) { inputName = inputName.Replace(" ", "_"); @@ -1862,22 +1845,6 @@ private Texture2D GetPreviewTexture(Sprite sprite) return preview; } - private float GetCurrentFps(AnimationData data, int frameIndex) - { - if (data.framerateMode == FramerateMode.Constant) - { - return 0 < data.framesPerSecond - ? data.framesPerSecond - : AnimationData.DefaultFramesPerSecond; - } - - float normalizedPosition = - 1 < data.frames.Count ? (float)frameIndex / (data.frames.Count - 1) : 0f; - - float fps = data.framesPerSecondCurve.Evaluate(normalizedPosition); - return 0 < fps ? fps : AnimationData.DefaultFramesPerSecond; - } - private void DrawActionButtons() { DrawCheckSpritesButton(); diff --git a/Editor/Sprites/SpriteCropper.cs b/Editor/Sprites/SpriteCropper.cs index d2eedf304..dc72c3956 100644 --- a/Editor/Sprites/SpriteCropper.cs +++ b/Editor/Sprites/SpriteCropper.cs @@ -61,8 +61,6 @@ public sealed class SpriteCropper : EditorWindow private const int ParallelScanRowThreshold = 512; - internal SerializedObject SerializedStateForTesting => _serializedObject; - [SerializeField] internal List _inputDirectories = new(); @@ -97,7 +95,7 @@ public sealed class SpriteCropper : EditorWindow internal bool _copyDefaultPlatformSettings = true; internal List _filesToProcess; - private SerializedObject _serializedObject; + internal SerializedObject _serializedObject; private SerializedProperty _inputDirectoriesProperty; private SerializedProperty _onlyNecessaryProperty; private SerializedProperty _leftPaddingProperty; diff --git a/Editor/Sprites/SpritePivotAdjuster.cs b/Editor/Sprites/SpritePivotAdjuster.cs index 17b28de4c..31cd66d25 100644 --- a/Editor/Sprites/SpritePivotAdjuster.cs +++ b/Editor/Sprites/SpritePivotAdjuster.cs @@ -46,8 +46,6 @@ public class SpritePivotAdjuster : EditorWindow internal static bool SuppressUserPrompts { get; set; } - internal SerializedObject SerializedStateForTesting => _serializedObject; - [SerializeField] internal List _directoryPaths = new(); @@ -60,10 +58,10 @@ public class SpritePivotAdjuster : EditorWindow [SerializeField] internal bool _forceReimport; + internal SerializedObject _serializedObject; + [SerializeField] private string _spriteNameRegex = ".*"; - - private SerializedObject _serializedObject; private SerializedProperty _directoryPathsProperty; private List _filesToProcess; private string _regexError; diff --git a/Editor/Sprites/SpriteSettingsApplierAPI.cs b/Editor/Sprites/SpriteSettingsApplierAPI.cs index d7ae3e4f6..e12fd54f0 100644 --- a/Editor/Sprites/SpriteSettingsApplierAPI.cs +++ b/Editor/Sprites/SpriteSettingsApplierAPI.cs @@ -21,18 +21,6 @@ namespace WallstopStudios.UnityHelpers.Editor.Sprites /// public static class SpriteSettingsApplierAPI { - internal static Func DeleteProfileAssetAction = AssetDatabase.DeleteAsset; - internal static Action CreateProfileAssetAction = - AssetDatabase.CreateAsset; - internal static Action BeforeAbsentRestoreMoveForTests; - internal static Action SaveProfileAssetsAction = AssetDatabase.SaveAssets; - internal static Func MoveProfileAssetAction = - AssetDatabase.MoveAsset; - internal static Action ImportProfileAssetAction = - AssetDatabase.ImportAsset; - internal static Func LoadProfileAssetAction = - AssetDatabase.LoadAssetAtPath; - /// Applies profiles to explicit asset paths and persists changed importers. /// /// Cancellation retains completed changes. Reimport and asset-database side effects cannot @@ -683,6 +671,48 @@ SpriteSettings spriteData return changed; } + internal static bool ProfilesMatchExpected( + SpriteSettingsProfileCollection committed, + int expectedCount, + string expectedJson + ) + { + return committed != null + && committed.profiles != null + && committed.profiles.Count == expectedCount + && string.Equals( + Serializer.JsonStringify(committed.profiles), + expectedJson, + StringComparison.Ordinal + ); + } + + internal static bool TryRestoreAbsentProfileBytes( + string fullPath, + byte[] bytes, + out Exception error + ) + { + return DurableFile.TryCreateAllBytes(fullPath, bytes, out error); + } + + internal static bool TryDeleteProfileAsset(string assetPath, out string error) + { + bool deleted = AssetDatabase.DeleteAsset(assetPath); + string fullPath = ProfileFullPath(assetPath); + if ( + !deleted + || File.Exists(fullPath) + || AssetDatabase.LoadMainAssetAtPath(assetPath) != null + ) + { + error = $"Could not remove partial profiles asset at {assetPath}."; + return false; + } + error = null; + return true; + } + private static bool TrySaveProfilesCore( string assetPath, IReadOnlyList profiles, @@ -771,14 +801,17 @@ out string error target.profiles = copies; target.name = existing != null ? existing.name : Path.GetFileNameWithoutExtension(path); - CreateProfileAssetAction(target, writePath); + AssetDatabase.CreateAsset(target, writePath); ownedGuid = AssetDatabase.AssetPathToGUID(writePath); if (string.IsNullOrEmpty(ownedGuid)) { throw new IOException($"Could not identify profiles asset at {writePath}."); } - SaveProfileAssetsAction(); - if (LoadProfileAssetAction(writePath) == null) + AssetDatabase.SaveAssets(); + if ( + AssetDatabase.LoadAssetAtPath(writePath) + == null + ) { throw new IOException($"Could not create profiles asset at {writePath}."); } @@ -787,7 +820,7 @@ out string error string moveError; try { - moveError = MoveProfileAssetAction(writePath, path); + moveError = AssetDatabase.MoveAsset(writePath, path); } catch (Exception moveException) { @@ -805,8 +838,9 @@ out string error ); } moveCommitted = true; - ImportProfileAssetAction(path, ImportAssetOptions.ForceSynchronousImport); - SpriteSettingsProfileCollection committed = LoadProfileAssetAction(path); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); + SpriteSettingsProfileCollection committed = + AssetDatabase.LoadAssetAtPath(path); if ( !ProfilesMatchExpected( committed, @@ -845,10 +879,10 @@ out Exception writeError } replacementSucceeded = true; } - ImportProfileAssetAction(path, ImportAssetOptions.ForceSynchronousImport); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); if ( !ProfilesMatchExpected( - LoadProfileAssetAction(path), + AssetDatabase.LoadAssetAtPath(path), expectedProfilesCount, expectedProfilesJson ) @@ -940,53 +974,6 @@ is string preservedPath } } - private static bool ProfilesMatchExpected( - SpriteSettingsProfileCollection committed, - int expectedCount, - string expectedJson - ) - { - return committed != null - && committed.profiles != null - && committed.profiles.Count == expectedCount - && string.Equals( - Serializer.JsonStringify(committed.profiles), - expectedJson, - StringComparison.Ordinal - ); - } - - private static bool TryRestoreAbsentProfileBytes( - string fullPath, - byte[] bytes, - out Exception error - ) - { - return DurableFile.TryCreateAllBytes( - fullPath, - bytes, - out error, - BeforeAbsentRestoreMoveForTests - ); - } - - private static bool TryDeleteProfileAsset(string assetPath, out string error) - { - bool deleted = DeleteProfileAssetAction(assetPath); - string fullPath = ProfileFullPath(assetPath); - if ( - !deleted - || File.Exists(fullPath) - || AssetDatabase.LoadMainAssetAtPath(assetPath) != null - ) - { - error = $"Could not remove partial profiles asset at {assetPath}."; - return false; - } - error = null; - return true; - } - private static string ProfileFullPath(string assetPath) { return Path.Combine(Path.GetDirectoryName(Application.dataPath), assetPath); diff --git a/Editor/Sprites/SpriteSettingsApplierWindow.cs b/Editor/Sprites/SpriteSettingsApplierWindow.cs index fabfd6b48..2dc92bffd 100644 --- a/Editor/Sprites/SpriteSettingsApplierWindow.cs +++ b/Editor/Sprites/SpriteSettingsApplierWindow.cs @@ -41,8 +41,6 @@ namespace WallstopStudios.UnityHelpers.Editor.Sprites /// public sealed class SpriteSettingsApplierWindow : EditorWindow { - internal SerializedObject SerializedStateForTesting => _serializedObject; - private List<(string fullFilePath, string relativePath)> GetTargetSpritePaths() { List<(string fullFilePath, string relativePath)> filePaths = _targetSpriteBuffer; @@ -156,7 +154,7 @@ out List folderAssetPaths public List spriteSettings = new() { new SpriteSettings() }; public List directories = new(); - private SerializedObject _serializedObject; + internal SerializedObject _serializedObject; private SerializedProperty _spritesProp; private SerializedProperty _spriteFileExtensionsProp; private SerializedProperty _spriteSettingsProp; diff --git a/Editor/Sprites/SpriteSheetAnimationAPI.cs b/Editor/Sprites/SpriteSheetAnimationAPI.cs index 4fc543ed3..28e84ec25 100644 --- a/Editor/Sprites/SpriteSheetAnimationAPI.cs +++ b/Editor/Sprites/SpriteSheetAnimationAPI.cs @@ -17,8 +17,6 @@ namespace WallstopStudios.UnityHelpers.Editor.Sprites /// public static class SpriteSheetAnimationAPI { - internal static Action SaveAssetsAction = AssetDatabase.SaveAssets; - private static readonly char[] InvalidNameCharacters = { '/', @@ -301,7 +299,7 @@ public static bool TryCreate( createdAsset = true; if (saveAssets) { - SaveAssetsAction(); + AssetDatabase.SaveAssets(); } assetPath = plannedAssetPath; error = null; diff --git a/Editor/Sprites/SpriteSheetExtractor.cs b/Editor/Sprites/SpriteSheetExtractor.cs index 60404f61b..d1f857e46 100644 --- a/Editor/Sprites/SpriteSheetExtractor.cs +++ b/Editor/Sprites/SpriteSheetExtractor.cs @@ -322,8 +322,6 @@ out List regionHeights /// private static readonly Color SheetPivotColor = new Color(1f, 0.84f, 0f, 0.8f); - internal SerializedObject SerializedStateForTesting => _serializedObject; - [SerializeField] internal List _inputDirectories = new(); @@ -429,10 +427,10 @@ out List regionHeights internal List _discoveredSheets; + internal SerializedObject _serializedObject; + // Intentionally not serialized - users must re-acknowledge danger each session private bool _ackDanger; - - private SerializedObject _serializedObject; private SerializedProperty _inputDirectoriesProperty; private SerializedProperty _spriteNameRegexProperty; private SerializedProperty _outputDirectoryProperty; diff --git a/Editor/Sprites/TextureSettingsApplierWindow.cs b/Editor/Sprites/TextureSettingsApplierWindow.cs index fbb5cbd6b..133075ea5 100644 --- a/Editor/Sprites/TextureSettingsApplierWindow.cs +++ b/Editor/Sprites/TextureSettingsApplierWindow.cs @@ -19,8 +19,6 @@ namespace WallstopStudios.UnityHelpers.Editor.Sprites public sealed class TextureSettingsApplierWindow : EditorWindow { - internal SerializedObject SerializedStateForTesting => _so; - public bool applyReadOnly; public bool isReadOnly; public bool applyMipMaps; @@ -47,10 +45,10 @@ public sealed class TextureSettingsApplierWindow : EditorWindow public List platformOverrides = new(); public bool requireChangesBeforeApply = true; + + internal SerializedObject _so; private int _addPlatformIndex; private readonly Dictionary _replaceSelectionByIndex = new(); - - private SerializedObject _so; private SerializedProperty _texturesProp; private SerializedProperty _directoriesProp; private SerializedProperty _extensionsProp; diff --git a/Editor/Tags/AttributeMetadataCacheGenerator.cs b/Editor/Tags/AttributeMetadataCacheGenerator.cs index e9cc48655..9c492576f 100644 --- a/Editor/Tags/AttributeMetadataCacheGenerator.cs +++ b/Editor/Tags/AttributeMetadataCacheGenerator.cs @@ -29,15 +29,18 @@ static AttributeMetadataCacheGenerator() internal static void GenerateCache() { - // Automatic cache generation can open modal failure dialogs during tests; require explicit test opt-in. - if ( - EditorUi.Suppress - && !ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression - ) + // Automatic cache generation honors editor UI suppression. + if (EditorUi.Suppress) { return; } + GenerateCacheContents(); + } + + /// Builds and persists the cache after automatic generation policy permits it. + internal static void GenerateCacheContents() + { try { List attributeComponentTypes = FindAttributeComponentTypes(); @@ -164,13 +167,6 @@ Type annotated in TypeCache.GetTypesWithAttribute() /// Reports a that cannot do what its author meant, /// or null when the annotation is sound. /// - /// - /// The attributes arrive as arguments rather than being read off so a - /// test can drive every branch without annotating a deliberately wrong type -- which - /// would then find on every editor load, and this method would then - /// complain about forever. It is the technique RuntimeMismatchSingleton already uses - /// for the auto-loader's own mismatch rules. - /// /// The annotated type. /// Its , or null. /// Its , or null. diff --git a/Editor/Tools/ImageBlurAPI.cs b/Editor/Tools/ImageBlurAPI.cs index 0302beb14..638c32b7b 100644 --- a/Editor/Tools/ImageBlurAPI.cs +++ b/Editor/Tools/ImageBlurAPI.cs @@ -318,7 +318,7 @@ out string error try { - Texture2D created = ImageBlurTool.CreateBlurredTexture(source, radius, null); + Texture2D created = ImageBlurTool.CreateBlurredTexture(source, radius); if (created != null) { blurred = created; diff --git a/Editor/Tools/ImageBlurTool.cs b/Editor/Tools/ImageBlurTool.cs index f920f880c..1f2009bc4 100644 --- a/Editor/Tools/ImageBlurTool.cs +++ b/Editor/Tools/ImageBlurTool.cs @@ -21,9 +21,8 @@ public sealed class ImageBlurTool : EditorWindow private const long ParallelBlurPixelThreshold = 256L; - internal SerializedObject SerializedStateForTesting => _serializedObject; - public List imageSources = new(); + internal SerializedObject _serializedObject; private readonly List _orderedTextures = new(); private readonly List _manualTextures = new(); @@ -32,7 +31,6 @@ public sealed class ImageBlurTool : EditorWindow private Vector2 _scrollPosition; private GUIStyle _impactButtonStyle; - private SerializedObject _serializedObject; private SerializedProperty _imageSourcesProperty; private readonly List _lastSeenImageSources = new(); @@ -63,35 +61,12 @@ internal static void TrySyncDirectory(string directory, List output) } } - internal static Texture2D BlurredForTests(Texture2D original, int radius) - { - return CreateBlurredTexture(original, radius, null); - } - - internal static Texture2D BlurredForTests( - Texture2D original, - int radius, - bool runInParallel - ) - { - return CreateBlurredTexture(original, radius, runInParallel); - } - - internal static float[] KernelForTests(int radius) - { - return GenerateGaussianKernel(radius); - } - internal static bool ShouldBlurInParallel(int width, int height, int partitionCount) { return ParallelBlurPixelThreshold <= (long)width * height && 1 < partitionCount; } - internal static Texture2D CreateBlurredTexture( - Texture2D original, - int radius, - bool? parallelOverride - ) + internal static Texture2D CreateBlurredTexture(Texture2D original, int radius) { Texture2D blurred = new(original.width, original.height, original.format, false) { @@ -141,12 +116,8 @@ out Color[] tempStraight tempStraight, blurredPixels ); - bool horizontalParallel = parallelOverride.HasValue - ? parallelOverride.Value - : ShouldBlurInParallel(width, height, height); - bool verticalParallel = parallelOverride.HasValue - ? parallelOverride.Value - : ShouldBlurInParallel(width, height, width); + bool horizontalParallel = ShouldBlurInParallel(width, height, height); + bool verticalParallel = ShouldBlurInParallel(width, height, width); job.ExecuteHorizontalPass(horizontalParallel); job.ExecuteVerticalPass(verticalParallel); @@ -161,7 +132,7 @@ out Color[] tempStraight } } - private static float[] GenerateGaussianKernel(int radius) + internal static float[] GenerateGaussianKernel(int radius) { int size = radius * 2 + 1; float[] kernel = new float[size]; @@ -502,7 +473,7 @@ out List combined ApplyBlurToTextures(toProcess, _blurRadius, EditorUi.Info); } - private sealed class BlurJob + internal sealed class BlurJob { private readonly int _width; private readonly int _height; diff --git a/Editor/Tools/ManualRecompile.cs b/Editor/Tools/ManualRecompile.cs index d63b7f0cc..5592db02a 100644 --- a/Editor/Tools/ManualRecompile.cs +++ b/Editor/Tools/ManualRecompile.cs @@ -19,95 +19,33 @@ public static class ManualRecompile "Tools/Wallstop Studios/Unity Helpers/Request Script Compilation"; private const string LogPrefix = "[Unity Helpers]"; - internal static Func IsCompilationPendingEvaluator - { - get => isCompilationPendingEvaluator; - set - { - if (value != null) - { - isCompilationPendingEvaluator = value; - return; - } - - isCompilationPendingEvaluator = () => EditorApplication.isCompiling; - } - } - - internal static bool SkipCompilationRequestForTests { get; set; } - - internal static Action AssetsRefreshedForTests { get; set; } - - internal static Action CompilationRequestedForTests { get; set; } - - internal static Func isCompilationPendingEvaluator = () => - EditorApplication.isCompiling; - private static readonly ImportAssetOptions RefreshOptions = ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport; [MenuItem(MenuItemPath)] public static void RequestFromMenu() { - Request(); + Request(EditorApplication.isCompiling); } [Shortcut(ShortcutId, KeyCode.R, ShortcutModifiers.Alt | ShortcutModifiers.Action)] public static void RequestFromShortcut() { - Request(); + Request(EditorApplication.isCompiling); } - private static void Request() + internal static void Request(bool compilationPending) { - if (IsCompilationPending()) + if (compilationPending) { Debug.Log( $"{LogPrefix} Script compilation already in progress; manual request skipped." ); return; } - - bool skipCompilation = SkipCompilationRequestForTests; - - try - { - AssetDatabase.Refresh(RefreshOptions); - AssetsRefreshedForTests?.Invoke(); - - if (skipCompilation) - { - Debug.Log( - $"{LogPrefix} Asset database refreshed; compilation request skipped (tests)." - ); - return; - } - - CompilationRequestedForTests?.Invoke(); - CompilationPipeline.RequestScriptCompilation(); - Debug.Log($"{LogPrefix} Refreshed assets and requested script compilation."); - } - finally - { - // Reset the skip flag even if callbacks throw so later compile requests retain normal behavior. - if (skipCompilation) - { - SkipCompilationRequestForTests = false; - } - } - } - - private static bool IsCompilationPending() - { - if (isCompilationPendingEvaluator == null) - { - Debug.LogWarning( - $"{LogPrefix} Compilation pending evaluator is null; resetting to default." - ); - isCompilationPendingEvaluator = () => EditorApplication.isCompiling; - } - - return isCompilationPendingEvaluator(); + AssetDatabase.Refresh(RefreshOptions); + CompilationPipeline.RequestScriptCompilation(); + Debug.Log($"{LogPrefix} Refreshed assets and requested script compilation."); } } } diff --git a/Editor/Tools/ProtoSchemaExporterWindow.cs b/Editor/Tools/ProtoSchemaExporterWindow.cs index 40db470d7..9a0a37a5c 100644 --- a/Editor/Tools/ProtoSchemaExporterWindow.cs +++ b/Editor/Tools/ProtoSchemaExporterWindow.cs @@ -36,14 +36,7 @@ public sealed class ProtoSchemaExporterWindow : EditorWindow private const float MinimumContractListHeight = 120f; private const float MaximumDiagnosticsHeight = 120f; - internal static IReadOnlyList SelectableLayoutsForTest => SelectableLayouts; - - internal static IReadOnlyList LayoutLabelsForTest => LayoutLabels; - - // A replaceable dialog callback lets tests exercise the real export without opening an OS dialog. - internal static bool SuppressUserPrompts; - - private static readonly ExportLayout[] SelectableLayouts = new ExportLayout[] + internal static readonly ExportLayout[] SelectableLayouts = new ExportLayout[] { ExportLayout.SingleFile, ExportLayout.OneFilePerAssembly, @@ -51,6 +44,14 @@ public sealed class ProtoSchemaExporterWindow : EditorWindow ExportLayout.OneFilePerContract, }; + internal static readonly string[] LayoutLabels = new string[] + { + "Single File", + "Per Assembly", + "Per Namespace", + "Per Type", + }; + // Fixed for the process, and consulted once per character of every group key. private static readonly HashSet InvalidFileNameCharacters = new HashSet( Path.GetInvalidFileNameChars() @@ -67,68 +68,31 @@ public sealed class ProtoSchemaExporterWindow : EditorWindow '*', }; - private static readonly string[] LayoutLabels = new string[] - { - "Single File", - "Per Assembly", - "Per Namespace", - "Per Type", - }; - - internal string LastStatusForTest => _lastStatus; - - internal bool LastStatusIsFailureForTest => _lastStatusIsFailure; - - internal IReadOnlyList PersistedExclusionsForTest => _excludedContractKeys; - - internal IReadOnlyList LastDiagnosticsForTest => _lastDiagnostics; - - internal IReadOnlyList SelectedContractsForTest => SelectedContracts(); - - internal IReadOnlyList VisibleContractsForTest => - _contracts.Where(IsVisible).ToList(); - - internal ExportLayout ExportLayoutForTest - { - get => _exportLayout; - set => _exportLayout = value; - } - - internal string PackageNameForTest - { - get => _packageName; - set => _packageName = value; - } - - internal string SearchFilterForTest - { - get => _searchFilter; - set => _searchFilter = value; - } - - internal bool HasUsablePackageNameForTest => HasUsablePackageName(); - // Store deselections so newly discovered contracts are selected after recompilation. [SerializeField] - private List _excludedContractKeys = new List(); + internal List _excludedContractKeys = new List(); [SerializeField] - private List _collapsedAssemblyNames = new List(); + internal string _packageName = string.Empty; [SerializeField] - private string _outputPath = DefaultOutputPath; + internal string _searchFilter = string.Empty; [SerializeField] - private string _outputDirectory = DefaultOutputDirectory; + internal ExportLayout _exportLayout = ExportLayout.SingleFile; + internal readonly List _contracts = new List(); + internal readonly List _lastDiagnostics = new List(); + internal bool _lastStatusIsFailure; + internal string _lastStatus; [SerializeField] - private string _packageName = string.Empty; + private List _collapsedAssemblyNames = new List(); [SerializeField] - private string _searchFilter = string.Empty; + private string _outputPath = DefaultOutputPath; [SerializeField] - private ExportLayout _exportLayout = ExportLayout.SingleFile; + private string _outputDirectory = DefaultOutputDirectory; private readonly HashSet _excludedKeys = new HashSet( StringComparer.Ordinal @@ -136,20 +100,16 @@ internal string SearchFilterForTest private readonly HashSet _collapsedAssemblies = new HashSet( StringComparer.Ordinal ); - private readonly List _contracts = new List(); private readonly List _assemblyNames = new List(); private readonly Dictionary _surrogates = new Dictionary(); - private readonly List _lastDiagnostics = new List(); private string _surrogateDiscoveryError; private HelpBox _summary; private ScrollView _contractList; private HelpBox _packageError; - private bool _lastStatusIsFailure; private TextField _outputField; private Button _exportButton; private HelpBox _statusBox; private ScrollView _diagnosticsContainer; - private string _lastStatus; /// /// Opens the exporter window and re-scans the project for contracts. @@ -163,14 +123,35 @@ public static void ShowWindow() window.RefreshFromUserInterface(); } - internal static string ContractKeyForTest(Type contract) + internal static string AssemblyNameOf(Type contract) { - return ContractKey(contract); + return contract.Assembly.GetName().Name; + } + + // Keep raw nested-type separators so Outer+Inner and Outer.Inner remain distinct keys. + internal static string ContractKey(Type contract) + { + return $"{AssemblyNameOf(contract)}::{contract.FullName ?? contract.Name}"; } - internal static string UniqueFileNameForTest(string groupKey, HashSet usedFileNames) + // Distinct group keys can sanitize to the same filename; reserve unique names to prevent overwrite. + internal static string UniqueFileName(string groupKey, HashSet usedFileNames) { - return UniqueFileName(groupKey, usedFileNames); + string baseName = PortableFileName(groupKey); + if (string.IsNullOrEmpty(baseName)) + { + baseName = GlobalNamespaceGroup; + } + + string candidate = baseName + ".proto"; + int suffix = 2; + while (!usedFileNames.Add(candidate)) + { + candidate = $"{baseName}-{suffix}.proto"; + ++suffix; + } + + return candidate; } private static T PinToNaturalHeight(T element) @@ -185,11 +166,6 @@ private static bool IsAsciiLetter(char character) return character is >= 'a' and <= 'z' || character is >= 'A' and <= 'Z'; } - private static string AssemblyNameOf(Type contract) - { - return contract.Assembly.GetName().Name; - } - private static string NamespaceGroupOf(Type contract) { return string.IsNullOrEmpty(contract.Namespace) @@ -197,12 +173,6 @@ private static string NamespaceGroupOf(Type contract) : contract.Namespace; } - // Keep raw nested-type separators so Outer+Inner and Outer.Inner remain distinct keys. - private static string ContractKey(Type contract) - { - return $"{AssemblyNameOf(contract)}::{contract.FullName ?? contract.Name}"; - } - private static string ContractDisplayName(Type contract) { return (contract.FullName ?? contract.Name).Replace('+', '.'); @@ -221,26 +191,6 @@ private static string ShortDisplayName(Type contract) return fullName.Substring(namespaceName.Length + 1); } - // Distinct group keys can sanitize to the same filename; reserve unique names to prevent overwrite. - private static string UniqueFileName(string groupKey, HashSet usedFileNames) - { - string baseName = PortableFileName(groupKey); - if (string.IsNullOrEmpty(baseName)) - { - baseName = GlobalNamespaceGroup; - } - - string candidate = baseName + ".proto"; - int suffix = 2; - while (!usedFileNames.Add(candidate)) - { - candidate = $"{baseName}-{suffix}.proto"; - ++suffix; - } - - return candidate; - } - private static string PortableFileName(string value) { StringBuilder builder = new StringBuilder(value.Length); @@ -364,25 +314,6 @@ out _surrogateDiscoveryError } } - internal void SetSelectedAssembliesForTest(IEnumerable assemblyNames) - { - HashSet selectedAssemblies = new HashSet( - assemblyNames ?? Array.Empty(), - StringComparer.Ordinal - ); - SetSelection(_contracts, false); - SetSelection( - _contracts.Where(contract => selectedAssemblies.Contains(AssemblyNameOf(contract))), - true - ); - } - - internal void SetSelectedContractsForTest(IEnumerable contracts) - { - SetSelection(_contracts, false); - SetSelection(contracts ?? Array.Empty(), true); - } - internal void CaptureSelectionState() { _excludedContractKeys = _excludedKeys.ToList(); @@ -444,6 +375,82 @@ internal bool ExportSchemasToDirectory(string outputDirectory) ); } + // Malformed package names produce invalid proto3, so refuse export before writing. + internal bool HasUsablePackageName() + { + string package = PackageClause(); + if (package == null) + { + return true; + } + + foreach (string segment in package.Split('.')) + { + if (segment.Length == 0) + { + return false; + } + + if (segment[0] != '_' && !IsAsciiLetter(segment[0])) + { + return false; + } + + int segmentLength = segment.Length; + for (int index = 1; index < segmentLength; ++index) + { + char character = segment[index]; + if ( + character != '_' + && !IsAsciiLetter(character) + && !(character is >= '0' and <= '9') + ) + { + return false; + } + } + } + + return true; + } + + internal List SelectedContracts() + { + return _contracts.Where(IsSelected).ToList(); + } + + // Persist immediately; bulk selection records once to avoid quadratic work. + internal void SetSelection(Type contract, bool selected) + { + Exclude(contract, selected); + CaptureSelectionState(); + } + + internal void SetSelection(IEnumerable contracts, bool selected) + { + foreach (Type contract in contracts) + { + Exclude(contract, selected); + } + + CaptureSelectionState(); + } + + internal bool IsVisible(Type contract) + { + if (string.IsNullOrWhiteSpace(_searchFilter)) + { + return true; + } + + string filter = _searchFilter.Trim(); + return 0 + <= ContractDisplayName(contract) + .IndexOf(filter, StringComparison.OrdinalIgnoreCase) + || 0 + <= AssemblyNameOf(contract).IndexOf(filter, StringComparison.OrdinalIgnoreCase); + } + private bool ApplyExportResult(ProtoSchemaExporter.ExportResult result) { _lastDiagnostics.Clear(); @@ -935,7 +942,7 @@ private void RunExport() outputPath = Path.Combine(projectRoot, outputPath); } - if (!SuppressUserPrompts && !ConfirmOverwrite(outputPath, singleFile)) + if (!ConfirmOverwrite(outputPath, singleFile)) { return; } @@ -1013,50 +1020,6 @@ private string PackageClause() return string.IsNullOrWhiteSpace(_packageName) ? null : _packageName.Trim(); } - // Malformed package names produce invalid proto3, so refuse export before writing. - private bool HasUsablePackageName() - { - string package = PackageClause(); - if (package == null) - { - return true; - } - - foreach (string segment in package.Split('.')) - { - if (segment.Length == 0) - { - return false; - } - - if (segment[0] != '_' && !IsAsciiLetter(segment[0])) - { - return false; - } - - int segmentLength = segment.Length; - for (int index = 1; index < segmentLength; ++index) - { - char character = segment[index]; - if ( - character != '_' - && !IsAsciiLetter(character) - && !(character is >= '0' and <= '9') - ) - { - return false; - } - } - } - - return true; - } - - private List SelectedContracts() - { - return _contracts.Where(IsSelected).ToList(); - } - private int SelectedContractCount() { return _contracts.Count(IsSelected); @@ -1067,23 +1030,6 @@ private bool IsSelected(Type contract) return !_excludedKeys.Contains(ContractKey(contract)); } - // Persist immediately; bulk selection records once to avoid quadratic work. - private void SetSelection(Type contract, bool selected) - { - Exclude(contract, selected); - CaptureSelectionState(); - } - - private void SetSelection(IEnumerable contracts, bool selected) - { - foreach (Type contract in contracts) - { - Exclude(contract, selected); - } - - CaptureSelectionState(); - } - private void Exclude(Type contract, bool selected) { if (selected) @@ -1101,21 +1047,6 @@ private void SetSelectionForVisibleContracts(bool selected) SetSelection(_contracts.Where(IsVisible), selected); } - private bool IsVisible(Type contract) - { - if (string.IsNullOrWhiteSpace(_searchFilter)) - { - return true; - } - - string filter = _searchFilter.Trim(); - return 0 - <= ContractDisplayName(contract) - .IndexOf(filter, StringComparison.OrdinalIgnoreCase) - || 0 - <= AssemblyNameOf(contract).IndexOf(filter, StringComparison.OrdinalIgnoreCase); - } - private int CountSelectedAssemblies() { return _contracts diff --git a/Editor/Tools/TestRunReporter/TestRunReporter.cs b/Editor/Tools/TestRunReporter/TestRunReporter.cs index a0c94b1f4..c587c2fed 100644 --- a/Editor/Tools/TestRunReporter/TestRunReporter.cs +++ b/Editor/Tools/TestRunReporter/TestRunReporter.cs @@ -140,9 +140,12 @@ internal static bool TryRegisterForRunInFlight() return TryEnsureRegistered(); } - internal static void ClearRunSessionForTests() + internal static void ClearActiveOwner() { - ClearActiveOwner(); + _owner = string.Empty; + _runStarted = false; + SessionState.EraseString(OwnerSessionKey); + SessionState.EraseBool(RunStartedSessionKey); } // Re-register synchronously after reload so early RunFinished events are captured even when delayCall never runs. @@ -240,14 +243,6 @@ private static void SetActiveOwner(string owner) SessionState.SetBool(RunStartedSessionKey, value: false); } - private static void ClearActiveOwner() - { - _owner = string.Empty; - _runStarted = false; - SessionState.EraseString(OwnerSessionKey); - SessionState.EraseBool(RunStartedSessionKey); - } - private static TestRunResultNode BuildNode(ITestResultAdaptor result, int depth) { if (result == null || TestRunSummaryFormatter.MaximumTreeDepth <= depth) diff --git a/Editor/Tools/TestRunReporter/TestRunSummaryFile.cs b/Editor/Tools/TestRunReporter/TestRunSummaryFile.cs index 4f5859f6e..859a98656 100644 --- a/Editor/Tools/TestRunReporter/TestRunSummaryFile.cs +++ b/Editor/Tools/TestRunReporter/TestRunSummaryFile.cs @@ -33,8 +33,6 @@ internal static class TestRunSummaryFile private const string ClaimSuffix = ".claim"; - internal static Action AfterBeginClaimForTests { get; set; } - /// /// Resolves the summary path for a single test mode. /// @@ -135,7 +133,6 @@ internal static bool TryBeginRun( } using FileStream claim = OpenBeginClaim(summaryPath, competingSummaryPath); - AfterBeginClaimForTests?.Invoke(); if ( IsMarkedRunning(summaryPath) || ( @@ -322,10 +319,17 @@ internal static bool TryDiscardRun(string summaryPath, string owner) } } - private static FileStream OpenClaim(string summaryPath) + internal static FileStream OpenBeginClaim(string summaryPath, string competingSummaryPath) { + if (string.IsNullOrWhiteSpace(competingSummaryPath)) + { + return OpenClaim(summaryPath); + } + + string directory = Path.GetDirectoryName(summaryPath) ?? string.Empty; + string claimPath = Path.Combine(directory, "unity-helpers-test-run.claim"); return new FileStream( - summaryPath + ClaimSuffix, + claimPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read, @@ -334,17 +338,10 @@ private static FileStream OpenClaim(string summaryPath) ); } - private static FileStream OpenBeginClaim(string summaryPath, string competingSummaryPath) + private static FileStream OpenClaim(string summaryPath) { - if (string.IsNullOrWhiteSpace(competingSummaryPath)) - { - return OpenClaim(summaryPath); - } - - string directory = Path.GetDirectoryName(summaryPath) ?? string.Empty; - string claimPath = Path.Combine(directory, "unity-helpers-test-run.claim"); return new FileStream( - claimPath, + summaryPath + ClaimSuffix, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read, diff --git a/Editor/Tools/UnityMethodAnalyzer/UnityMethodAnalyzerWindow.cs b/Editor/Tools/UnityMethodAnalyzer/UnityMethodAnalyzerWindow.cs index d6e32c22b..86b18546f 100644 --- a/Editor/Tools/UnityMethodAnalyzer/UnityMethodAnalyzerWindow.cs +++ b/Editor/Tools/UnityMethodAnalyzer/UnityMethodAnalyzerWindow.cs @@ -52,9 +52,7 @@ public sealed class UnityMethodAnalyzerWindow : EditorWindow private static bool _isUpdateSubscribed; /// - /// Internal reference to the current analysis task for test synchronization. - /// Tests can await this task to know when the async analysis work is complete, - /// then call FlushMainThreadQueue() to process the completion callback. + /// Current analysis operation whose continuation dispatches completion to the editor thread. /// internal Task _analysisTask; @@ -79,12 +77,6 @@ public sealed class UnityMethodAnalyzerWindow : EditorWindow "Refresh compiler diagnostics, or recompile scripts to capture them."; internal CancellationTokenSource _cancellationTokenSource; - /// - /// Internal TaskCompletionSource for tests to await analysis completion. - /// Set before StartAnalysis() to enable awaiting completion. - /// -#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value - internal TaskCompletionSource _analysisCompletionSource; private IssueSeverity? _severityFilter; private IssueCategory? _categoryFilter; private string _searchFilter = string.Empty; @@ -159,8 +151,6 @@ internal static void FlushMainThreadQueue() } } -#pragma warning restore CS0649 // Field is never assigned to, and will always have its default value - private static string GetProjectRoot() { return Path.GetDirectoryName(Application.dataPath) ?? Application.dataPath; @@ -335,7 +325,6 @@ private static string ConvertToAssetPath(string fullPath) /// /// Initializes the window's analyzer and tree view. Called from OnEnable(). - /// Also accessible for testing purposes. /// internal void Initialize() { @@ -453,10 +442,9 @@ after the analysis task completion callback has already run ResetAnalysisState() CancellationToken token = _cancellationTokenSource.Token; - Task analysisTask = _analyzer.AnalyzeAsync(rootPath, directories, progress, token); - _analysisTask = analysisTask; + _analysisTask = _analyzer.AnalyzeAsync(rootPath, directories, progress, token); - analysisTask.ContinueWith( + _analysisTask.ContinueWith( task => { EnqueueOnMainThread(() => HandleAnalysisCompletion(task)); @@ -1309,14 +1297,12 @@ private void HandleAnalysisCompletion(Task task) } /// - /// Finalizes the analysis by resetting state and signaling completion. + /// Finalizes the analysis by resetting its operation state. /// Called after analysis completes, fails, or is cancelled. /// private void FinalizeAnalysis() { ResetAnalysisState(); - - _analysisCompletionSource?.TrySetResult(true); } private void UpdateTreeViewGrouping() diff --git a/Editor/Utils/AssetDatabaseBatchScope.cs b/Editor/Utils/AssetDatabaseBatchScope.cs index a65c342de..c7ae03608 100644 --- a/Editor/Utils/AssetDatabaseBatchScope.cs +++ b/Editor/Utils/AssetDatabaseBatchScope.cs @@ -303,8 +303,8 @@ internal static int ActualUnityBatchDepth } } - private static readonly object Lock = new(); - private static int _batchDepth; + internal static readonly object Lock = new(); + internal static int _batchDepth; /// /// Tracks the number of actual Unity AssetDatabase API calls we've made. @@ -630,27 +630,6 @@ public static AssetDatabasePauseScope PauseBatch() return new AssetDatabasePauseScope(wasBatching); } - /// - /// Increments the batch depth counter and returns whether this is the outermost scope. - /// This method ONLY increments the counter - it does NOT call Unity's AssetDatabase APIs. - /// - /// - /// Warning: Using this method directly creates a mismatch between the - /// tracked counter and Unity's actual state. Prefer using instead, - /// which properly manages both the counter and Unity's state. - /// This method exists primarily for testing the counter logic in isolation. - /// - /// true if this is the outermost (first) scope; otherwise, false. - internal static bool IncrementBatchDepth() - { - lock (Lock) - { - int previousDepth = _batchDepth; - ++_batchDepth; - return previousDepth == 0; - } - } - /// /// Increments the batch depth counter and tracks that Unity APIs will be called. /// This is used internally by when it will @@ -672,28 +651,6 @@ internal static bool IncrementBatchDepthWithUnityCall() } } - /// - /// Decrements the batch depth counter and returns whether this was the outermost scope. - /// This method ONLY decrements the counter - use - /// when Unity cleanup will be performed. - /// - /// true if this was the outermost scope (depth is now 0); otherwise, false. - internal static bool DecrementBatchDepth() - { - lock (Lock) - { - _batchDepth--; - - if (_batchDepth < 0) - { - _batchDepth = 0; - return false; - } - - return _batchDepth == 0; - } - } - /// /// Decrements both the batch depth counter and the Unity API call tracker. /// Returns whether this was the outermost scope (and thus Unity cleanup should be performed). diff --git a/Editor/Utils/EditorUi.cs b/Editor/Utils/EditorUi.cs index d359079ec..8980031d6 100644 --- a/Editor/Utils/EditorUi.cs +++ b/Editor/Utils/EditorUi.cs @@ -17,9 +17,6 @@ public static bool Suppress set => _suppressManual = value; } - internal static Func ProgressForTesting; - internal static Action ProgressClearedForTesting; - private static bool _suppressManual; private static bool _suppressAuto; @@ -66,7 +63,6 @@ public static void Info(string title, string message) public static void ShowProgress(string title, string info, float progress) { - ProgressForTesting?.Invoke(); if (Suppress) { return; @@ -76,10 +72,6 @@ public static void ShowProgress(string title, string info, float progress) public static bool CancelableProgress(string title, string info, float progress) { - if (ProgressForTesting != null) - { - return ProgressForTesting(); - } if (Suppress) { return false; @@ -90,7 +82,6 @@ public static bool CancelableProgress(string title, string info, float progress) public static void ClearProgress() { EditorUtility.ClearProgressBar(); - ProgressClearedForTesting?.Invoke(); } public static string OpenFilePanel(string title, string directory, string extension) diff --git a/Editor/Utils/ExclusiveFilePublisher.cs b/Editor/Utils/ExclusiveFilePublisher.cs index 78c011304..56b5391c8 100644 --- a/Editor/Utils/ExclusiveFilePublisher.cs +++ b/Editor/Utils/ExclusiveFilePublisher.cs @@ -13,8 +13,6 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils /// internal static class ExclusiveFilePublisher { - internal static Action DeleteStagedFile = File.Delete; - /// /// Returns false when another entry occupies the destination. Publish failures throw; /// cleanup failures return a warning after the output is already published. @@ -40,22 +38,31 @@ out bool removeStagedFile if (removeStagedFile) { - try - { - DeleteStagedFile(stagedPath); - } - catch (Exception cleanupError) - { - warning = new IOException( - $"Published '{destinationPath}' but could not remove staged file '{stagedPath}'.", - cleanupError - ); - } + warning = RemoveStagedFileAfterPublication(stagedPath, destinationPath); } cleanupWarning = warning; return true; } + + internal static Exception RemoveStagedFileAfterPublication( + string stagedPath, + string destinationPath + ) + { + try + { + File.Delete(stagedPath); + return null; + } + catch (Exception cleanupError) + { + return new IOException( + $"Published '{destinationPath}' but could not remove staged file '{stagedPath}'.", + cleanupError + ); + } + } } #endif } diff --git a/Editor/Utils/GroupGUIWidthUtility.cs b/Editor/Utils/GroupGUIWidthUtility.cs index 4c8c6af97..216f4820c 100644 --- a/Editor/Utils/GroupGUIWidthUtility.cs +++ b/Editor/Utils/GroupGUIWidthUtility.cs @@ -144,12 +144,12 @@ internal static class GroupGUIWidthUtility internal static WGroupThemeState? CurrentThemeState => _currentThemeState; - private static float _totalPadding; - private static float _totalLeftPadding; - private static float _totalRightPadding; - private static int _scopeDepth; - private static bool _isInsideWGroupPropertyDraw; - private static WGroupThemeState? _currentThemeState; + internal static float _totalPadding; + internal static float _totalLeftPadding; + internal static float _totalRightPadding; + internal static int _scopeDepth; + internal static bool _isInsideWGroupPropertyDraw; + internal static WGroupThemeState? _currentThemeState; /// /// Cached foldout arrow texture for drawing themed foldouts. @@ -161,17 +161,6 @@ internal static class GroupGUIWidthUtility /// private static Texture2D _foldoutArrowDown; - [System.Diagnostics.Conditional("UNITY_EDITOR")] - internal static void ResetForTests() - { - _totalPadding = 0f; - _totalLeftPadding = 0f; - _totalRightPadding = 0f; - _scopeDepth = 0; - _isInsideWGroupPropertyDraw = false; - _currentThemeState = null; - } - internal static IDisposable PushWGroupPropertyContext() { return new WGroupPropertyContextScope(); diff --git a/Editor/Utils/ProjectBrowserVisibilityUtility.cs b/Editor/Utils/ProjectBrowserVisibilityUtility.cs index ba1b71478..98c85acfc 100644 --- a/Editor/Utils/ProjectBrowserVisibilityUtility.cs +++ b/Editor/Utils/ProjectBrowserVisibilityUtility.cs @@ -20,6 +20,10 @@ internal static class ProjectBrowserVisibilityUtility private const string HostViewTypeName = "UnityEditor.HostView"; private const double PollIntervalSeconds = 0.25d; + internal static bool _cachedVisibility; + + internal static double _nextPollTime; + private static readonly Type ProjectBrowserType = Type.GetType(ProjectBrowserTypeName); private static readonly FieldInfo EditorWindowParentField = typeof(EditorWindow).GetField( "m_Parent", @@ -33,10 +37,6 @@ internal static class ProjectBrowserVisibilityUtility BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic ); - private static bool? _visibilityOverride; - private static bool _cachedVisibility; - private static double _nextPollTime; - static ProjectBrowserVisibilityUtility() { _cachedVisibility = EvaluateProjectBrowserVisibility(); @@ -44,37 +44,13 @@ static ProjectBrowserVisibilityUtility() EditorApplication.update += HandleEditorApplicationUpdate; } - internal static void SetProjectBrowserVisibilityForTesting(bool? visible) - { - _visibilityOverride = visible; - if (visible.HasValue) - { - UpdateCachedVisibility(visible.Value); - } - else - { - ForceVisibilityPoll(); - } - } - internal static bool IsProjectBrowserVisible() { - if (_visibilityOverride.HasValue) - { - return _visibilityOverride.Value; - } - return _cachedVisibility; } private static void HandleEditorApplicationUpdate() { - if (_visibilityOverride.HasValue) - { - UpdateCachedVisibility(_visibilityOverride.Value); - return; - } - double time = EditorApplication.timeSinceStartup; if (time < _nextPollTime) { @@ -86,11 +62,6 @@ private static void HandleEditorApplicationUpdate() UpdateCachedVisibility(visibility); } - private static void ForceVisibilityPoll() - { - _nextPollTime = 0d; - } - private static void UpdateCachedVisibility(bool newValue) { if (_cachedVisibility == newValue) diff --git a/Editor/Utils/ScriptableObjectSingletonCreator.cs b/Editor/Utils/ScriptableObjectSingletonCreator.cs index 9c6c8630d..c2940124d 100644 --- a/Editor/Utils/ScriptableObjectSingletonCreator.cs +++ b/Editor/Utils/ScriptableObjectSingletonCreator.cs @@ -1,4 +1,4 @@ -// MIT License - Copyright (c) 2025 wallstop +// MIT License - Copyright (c) 2025-2026 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.Utils @@ -18,6 +18,11 @@ namespace WallstopStudios.UnityHelpers.Editor.Utils using Debug = UnityEngine.Debug; using Object = UnityEngine.Object; + /// Creates and relocates singleton assets during editor initialization. + /// + /// Unexpected processing failures release the reentrancy guard after leaving the asset batch scope. + /// Creation and synchronous import run with batching paused before the created asset is verified. + /// [InitializeOnLoad] public static class ScriptableObjectSingletonCreator { @@ -28,28 +33,15 @@ public static class ScriptableObjectSingletonCreator internal static bool VerboseLogging { get; set; } - internal static bool IncludeTestAssemblies { get; set; } - internal static bool DisableAutomaticRetries { get; set; } - internal static Func AssetImportWorkerProcessCheck { get; set; } - - internal static Func TypeFilter { get; set; } - - internal static bool IgnoreExclusionAttribute { get; set; } - - internal static bool AllowAssetCreationDuringSuppression { get; set; } - - // Tests can explicitly bypass compilation-state signals that lag asset operations. - internal static bool IgnoreCompilationState { get; set; } - - private static bool _isEnsuring; + internal static bool _isEnsuring; + internal static int _retryAttempts; + internal static int _consecutiveZeroProgressRetries; + internal static bool? _assetImportWorkerEnvCachedValue; + internal static Func _defaultAssetImportWorkerDetector; + internal static bool _mainThreadConfirmed; + internal static bool _mainThreadConfirmationPending; + internal static int _capturedMainThreadId; private static bool _ensureScheduled; - private static int _retryAttempts; - private static int _consecutiveZeroProgressRetries; - private static bool? _assetImportWorkerEnvCachedValue; - private static Func _defaultAssetImportWorkerDetector; - private static bool _mainThreadConfirmed; - private static bool _mainThreadConfirmationPending; - private static int _capturedMainThreadId; static ScriptableObjectSingletonCreator() { @@ -61,11 +53,11 @@ internal static void EnsureSingletonAssets() { CancelScheduledEnsureInvocation(); - // Automatic asset creation can open modal dialogs during tests; fixtures must explicitly opt in. - if (EditorUi.Suppress && !AllowAssetCreationDuringSuppression) + // Suppressed editor sessions do not create assets automatically. + if (EditorUi.Suppress) { LogVerbose( - "ScriptableObjectSingletonCreator: Skipping ensure because EditorUi.Suppress is true (test mode)." + "ScriptableObjectSingletonCreator: Skipping ensure because EditorUi.Suppress is true." ); return; } @@ -92,11 +84,8 @@ internal static void EnsureSingletonAssets() return; } - // Wait until import and compilation finish before creating assets; tests may explicitly bypass this guard. - if ( - !IgnoreCompilationState - && (EditorApplication.isCompiling || EditorApplication.isUpdating) - ) + // Wait until import and compilation finish before creating assets. + if (EditorApplication.isCompiling || EditorApplication.isUpdating) { LogVerbose( "ScriptableObjectSingletonCreator: Deferring ensure during compilation/updating." @@ -105,6 +94,41 @@ internal static void EnsureSingletonAssets() return; } + EnsureSingletonAssets(DiscoverSingletonTypes()); + } + + internal static List DiscoverSingletonTypes() + { + List candidates = new(); + foreach ( + Type candidate in ReflectionHelpers.GetTypesDerivedFrom( + typeof(UnityHelpers.Utils.ScriptableObjectSingleton<>), + includeAbstract: false + ) + ) + { + if ( + !candidate.IsGenericType + && !TestAssemblyHelper.IsTestType(candidate) + && !ReflectionHelpers.TryGetAttributeSafe( + candidate, + out _, + inherit: false + ) + ) + { + candidates.Add(candidate); + } + } + return candidates; + } + + internal static void EnsureSingletonAssets(IReadOnlyList allCandidates) + { + if (_isEnsuring || allCandidates == null) + { + return; + } _isEnsuring = true; bool anyChanges = false; bool retryRequested = false; @@ -112,9 +136,9 @@ internal static void EnsureSingletonAssets() int singletonsSucceeded = 0; List emptyFolderCandidates = null; - using (AssetDatabaseBatchHelper.BeginBatch(refreshOnDispose: false)) + try { - try + using (AssetDatabaseBatchHelper.BeginBatch(refreshOnDispose: false)) { int staleCount = ScriptableObjectSingletonMetadataUtility.CleanupStaleEntries(); if (0 < staleCount) @@ -125,32 +149,6 @@ internal static void EnsureSingletonAssets() anyChanges = true; } - List allCandidates = new(); - foreach ( - Type t in ReflectionHelpers.GetTypesDerivedFrom( - typeof(UnityHelpers.Utils.ScriptableObjectSingleton<>), - includeAbstract: false - ) - ) - { - if ( - !t.IsGenericType - && (IncludeTestAssemblies || !TestAssemblyHelper.IsTestType(t)) - && (TypeFilter == null || TypeFilter(t)) - && ( - IgnoreExclusionAttribute - || !ReflectionHelpers.TryGetAttributeSafe( - t, - out _, - inherit: false - ) - ) - ) - { - allCandidates.Add(t); - } - } - Dictionary> byName = new(StringComparer.OrdinalIgnoreCase); foreach (Type t in allCandidates) { @@ -293,12 +291,15 @@ ref anyChanges { // Register the parent outside batching before CreateAsset requires it. AssetDatabaseBatchHelper.EnsureAssetParentFolder(targetAssetPath); - AssetDatabase.CreateAsset(instance, targetAssetPath); - // Import synchronously so the following LoadAssetAtPath can see the new asset. - AssetDatabase.ImportAsset( - targetAssetPath, - ImportAssetOptions.ForceSynchronousImport - ); + // Imports must finish before the following lookup; active batching defers them. + using (AssetDatabaseBatchHelper.PauseBatch()) + { + AssetDatabase.CreateAsset(instance, targetAssetPath); + AssetDatabase.ImportAsset( + targetAssetPath, + ImportAssetOptions.ForceSynchronousImport + ); + } } catch (Exception ex) { @@ -359,10 +360,11 @@ out emptyFolderCandidates anyChanges = true; } } - finally { } } - - _isEnsuring = false; + finally + { + _isEnsuring = false; + } bool foldersDeleted = false; if (emptyFolderCandidates is { Count: > 0 }) @@ -392,7 +394,7 @@ out emptyFolderCandidates bool madeProgress = 0 < singletonsSucceeded || anyChanges; - if (retryRequested && !DisableAutomaticRetries) + if (retryRequested) { ScheduleEnsureSingletonAssets(madeProgress); } @@ -447,41 +449,42 @@ internal static bool IsNumberedDuplicate(string actualName, string desiredName) return int.TryParse(suffix, out int number) && 0 < number; } - [Conditional("UNITY_INCLUDE_TESTS")] - internal static void ResetAssetImportWorkerDetectionStateForTests() + internal static void CancelScheduledEnsureInvocation() { - _assetImportWorkerEnvCachedValue = null; - _defaultAssetImportWorkerDetector = null; - _mainThreadConfirmed = false; - _mainThreadConfirmationPending = false; - _capturedMainThreadId = 0; - } + if (!_ensureScheduled) + { + return; + } - [Conditional("UNITY_INCLUDE_TESTS")] - internal static void ResetRetryStateForTests() - { - _retryAttempts = 0; - _consecutiveZeroProgressRetries = 0; - CancelScheduledEnsureInvocation(); - } + EditorApplication.delayCall -= RunScheduledEnsure; + _ensureScheduled = false; - [Conditional("UNITY_INCLUDE_TESTS")] - internal static void ResetInitialEnsureStateForTests() - { - UnityHelpers.Utils.ScriptableObjectSingletonInitState.InitialEnsureCompleted = false; + if (0 < _retryAttempts) + { + _retryAttempts--; + } } - /// - /// Resets state for testing. Cleanup of AssetDatabase batch state is now handled - /// by the unified . - /// - internal static void ResetAssetEditingScopeDepthForTesting() + internal static bool IsRunningInsideAssetImportWorkerProcess() { - _isEnsuring = false; + if (IsAssetImportWorkerProcessViaEnvironment()) + { + _mainThreadConfirmationPending = false; + return true; + } - CancelScheduledEnsureInvocation(); - _retryAttempts = 0; - _consecutiveZeroProgressRetries = 0; + if (!TryConfirmEditorMainThread()) + { + LogVerbose( + "ScriptableObjectSingletonCreator: Main thread not yet confirmed; deferring singleton ensure." + ); + _mainThreadConfirmationPending = true; + return true; + } + + _mainThreadConfirmationPending = false; + _defaultAssetImportWorkerDetector ??= AssetDatabase.IsAssetImportWorkerProcess; + return InvokeDetector(_defaultAssetImportWorkerDetector, assumeWorkerOnFailure: false); } private static void MarkInitialEnsureCompleted() @@ -537,24 +540,8 @@ private static void RunScheduledEnsure() EnsureSingletonAssets(); } - private static void CancelScheduledEnsureInvocation() - { - if (!_ensureScheduled) - { - return; - } - - EditorApplication.delayCall -= RunScheduledEnsure; - _ensureScheduled = false; - - if (0 < _retryAttempts) - { - _retryAttempts--; - } - } - private static int CleanupDuplicateSingletonAssets( - List candidateTypes, + IReadOnlyList candidateTypes, out List emptyFolderCandidates ) { @@ -1347,6 +1334,7 @@ private static bool TryRemoveStaleAssetArtifacts(string assetsRelativePath) AssetDatabase.ImportAsset( NormalizePath(assetsRelativePath), ImportAssetOptions.ForceUpdate + | ImportAssetOptions.ForceSynchronousImport ); } catch (Exception ex) @@ -1660,35 +1648,6 @@ private static string ResolveExistingFolderPath(string intended) return current; } - private static bool IsRunningInsideAssetImportWorkerProcess() - { - Func detectorOverride = AssetImportWorkerProcessCheck; - if (detectorOverride != null) - { - _mainThreadConfirmationPending = false; - return InvokeDetector(detectorOverride, assumeWorkerOnFailure: false); - } - - if (IsAssetImportWorkerProcessViaEnvironment()) - { - _mainThreadConfirmationPending = false; - return true; - } - - if (!TryConfirmEditorMainThread()) - { - LogVerbose( - "ScriptableObjectSingletonCreator: Main thread not yet confirmed; deferring singleton ensure." - ); - _mainThreadConfirmationPending = true; - return true; - } - - _mainThreadConfirmationPending = false; - _defaultAssetImportWorkerDetector ??= AssetDatabase.IsAssetImportWorkerProcess; - return InvokeDetector(_defaultAssetImportWorkerDetector, assumeWorkerOnFailure: false); - } - private static bool InvokeDetector(Func detector, bool assumeWorkerOnFailure) { try diff --git a/Editor/Utils/ScriptableObjectSingletonMetadataUtility.cs b/Editor/Utils/ScriptableObjectSingletonMetadataUtility.cs index 11b1b6b8f..d5176f9bf 100644 --- a/Editor/Utils/ScriptableObjectSingletonMetadataUtility.cs +++ b/Editor/Utils/ScriptableObjectSingletonMetadataUtility.cs @@ -32,26 +32,26 @@ internal static ScriptableObjectSingletonMetadata LoadOrCreateMetadataAsset() ); if (legacyMetadata != null) { - // Automatic migrations can open modal dialogs during tests; require explicit opt-in. - if ( - EditorUi.Suppress - && !ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression - ) + // Automatic migrations honor editor UI suppression. + if (EditorUi.Suppress) { return legacyMetadata; } return MigrateLegacyMetadata(legacyMetadata); } - // Automatic asset creation can open modal failure dialogs during tests; require explicit opt-in. - if ( - EditorUi.Suppress - && !ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression - ) + // Automatic asset creation honors editor UI suppression. + if (EditorUi.Suppress) { return null; } + return CreateMetadataAsset(); + } + + /// Creates missing metadata after the caller resolves existing assets and creation policy. + internal static ScriptableObjectSingletonMetadata CreateMetadataAsset() + { if (!EnsureResourcesFolder()) { Debug.LogWarning( @@ -94,11 +94,8 @@ internal static bool UpdateEntry( string assetGuid ) { - // During tests, update existing metadata only unless asset creation is explicitly allowed. - if ( - EditorUi.Suppress - && !ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression - ) + // Suppressed editor sessions may update existing metadata without creating assets. + if (EditorUi.Suppress) { ScriptableObjectSingletonMetadata existing = AssetDatabase.LoadAssetAtPath( @@ -212,19 +209,6 @@ out List staleEntries return staleEntries.Count; } - /// - /// Resets legacy state for testing. AssetDatabase batch cleanup is now handled - /// by the unified . - /// - /// - /// This method is kept for backward compatibility with test cleanup code. - /// The actual AssetDatabase state cleanup is handled by AssetDatabaseBatchHelper.ResetBatchDepth(). - /// - internal static void ResetAssetEditingDepthForTesting() - { - // CommonTestBase owns batch cleanup; this compatibility entry point remains inert. - } - /// /// Re-scans all assemblies for ScriptableObjectSingleton types and updates their metadata entries. /// This removes stale entries and adds/updates metadata for all existing singleton assets. diff --git a/Editor/Utils/WButton/WButtonGUI.cs b/Editor/Utils/WButton/WButtonGUI.cs index 426a69b4d..9e3105f55 100644 --- a/Editor/Utils/WButton/WButtonGUI.cs +++ b/Editor/Utils/WButton/WButtonGUI.cs @@ -115,8 +115,38 @@ internal static class WButtonGUI private const float ClearHistoryMinWidth = 96f; private const float ClearHistorySpacing = 6f; - private static readonly Dictionary GroupCounts = new(); - private static readonly Dictionary GroupNames = new(); + internal static readonly Dictionary GroupCounts = new(); + internal static readonly Dictionary GroupNames = new(); + + /// + /// Warnings about groups with conflicting draw orders. Populated during grouping. + /// + internal static readonly Dictionary< + string, + DrawOrderConflictInfo + > ConflictingDrawOrderWarnings = new(); + + /// + /// Warnings about groups with conflicting group priorities. Populated during grouping. + /// + internal static readonly Dictionary< + string, + GroupPriorityConflictInfo + > ConflictingGroupPriorityWarnings = new(); + + /// + /// Warnings about groups with conflicting group placements. Populated during grouping. + /// + internal static readonly Dictionary< + string, + GroupPlacementConflictInfo + > ConflictingGroupPlacementWarnings = new(); + + internal static readonly Dictionary ConflictWarningTextCache = new(); + + internal static readonly Dictionary GroupPriorityWarningTextCache = new(); + + internal static readonly Dictionary GroupPlacementWarningTextCache = new(); private static readonly Cache FoldoutAnimations = CacheBuilder< WButtonGroupKey, @@ -128,6 +158,7 @@ internal static class WButtonGUI .OnEviction(static (_, anim, _) => Unsubscribe(anim)) .TransferOwnershipOnRemoval() .Build(); + private static readonly Dictionary GroupHeaderCache = new(); private static readonly Dictionary<(string, int), string> GroupHeaderTextCache = new(); private static readonly GUIContent ClearHistoryContent = new("Clear History"); @@ -150,34 +181,6 @@ private static readonly Dictionary< private static readonly Dictionary ContextCache = new(); - /// - /// Warnings about groups with conflicting draw orders. Populated during grouping. - /// - private static readonly Dictionary< - string, - DrawOrderConflictInfo - > ConflictingDrawOrderWarnings = new(); - - /// - /// Warnings about groups with conflicting group priorities. Populated during grouping. - /// - private static readonly Dictionary< - string, - GroupPriorityConflictInfo - > ConflictingGroupPriorityWarnings = new(); - - /// - /// Warnings about groups with conflicting group placements. Populated during grouping. - /// - private static readonly Dictionary< - string, - GroupPlacementConflictInfo - > ConflictingGroupPlacementWarnings = new(); - - private static readonly Dictionary ConflictWarningTextCache = new(); - private static readonly Dictionary GroupPriorityWarningTextCache = new(); - private static readonly Dictionary GroupPlacementWarningTextCache = new(); - internal static bool DrawButtons( Editor editor, WButtonPlacement placement, @@ -288,194 +291,11 @@ out List contexts } } - internal static Dictionary GetGroupCountsForTesting() - { - return GroupCounts; - } - - internal static Dictionary GetGroupNamesForTesting() - { - return GroupNames; - } - - /// - /// For testing: sets group counts with simple int keys (legacy compatibility). - /// Creates group keys with the given draw order and empty group name. - /// - internal static void SetGroupCountsForTesting(Dictionary counts) - { - GroupCounts.Clear(); - foreach (KeyValuePair entry in counts) - { - WButtonGroupKey key = new( - WButtonAttribute.NoGroupPriority, - entry.Key, - null, - 0, - WButtonGroupPlacement.UseGlobalSetting - ); - GroupCounts[key] = entry.Value; - } - } - - /// - /// For testing: sets group names with simple int keys (legacy compatibility). - /// Creates group keys with the given draw order and empty group name. - /// - internal static void SetGroupNamesForTesting(Dictionary names) - { - GroupNames.Clear(); - foreach (KeyValuePair entry in names) - { - WButtonGroupKey key = new( - WButtonAttribute.NoGroupPriority, - entry.Key, - null, - 0, - WButtonGroupPlacement.UseGlobalSetting - ); - GroupNames[key] = entry.Value; - } - } - - /// - /// For testing: clears all group counts and names. - /// - internal static void ClearGroupDataForTesting() - { - GroupCounts.Clear(); - GroupNames.Clear(); - } - internal static void ClearContextCache() { ContextCache.Clear(); } - /// - /// Gets the current conflicting draw order warnings. Used for testing and UI display. - /// - internal static IReadOnlyDictionary< - string, - DrawOrderConflictInfo - > GetConflictingDrawOrderWarnings() - { - return ConflictingDrawOrderWarnings; - } - - /// - /// Gets the current conflicting group priority warnings. Used for testing and UI display. - /// - internal static IReadOnlyDictionary< - string, - GroupPriorityConflictInfo - > GetConflictingGroupPriorityWarnings() - { - return ConflictingGroupPriorityWarnings; - } - - /// - /// Gets the current conflicting group placement warnings. Used for testing and UI display. - /// - internal static IReadOnlyDictionary< - string, - GroupPlacementConflictInfo - > GetConflictingGroupPlacementWarnings() - { - return ConflictingGroupPlacementWarnings; - } - - /// - /// Clears conflicting draw order warnings. Used for testing. - /// - internal static void ClearConflictingDrawOrderWarningsForTesting() - { - ConflictingDrawOrderWarnings.Clear(); - } - - /// - /// Clears conflicting group priority warnings. Used for testing. - /// - internal static void ClearConflictingGroupPriorityWarningsForTesting() - { - ConflictingGroupPriorityWarnings.Clear(); - } - - /// - /// Clears conflicting group placement warnings. Used for testing. - /// - internal static void ClearConflictingGroupPlacementWarningsForTesting() - { - ConflictingGroupPlacementWarnings.Clear(); - } - - /// - /// Clears the conflict warning content cache. Used for testing. - /// - internal static void ClearConflictWarningContentCacheForTesting() - { - ConflictWarningTextCache.Clear(); - GroupPriorityWarningTextCache.Clear(); - GroupPlacementWarningTextCache.Clear(); - } - - /// - /// Gets cached group placement warning text by group name. Used for testing. - /// - internal static bool TryGetGroupPlacementWarningTextForTesting( - string groupName, - out string warningText - ) - { - if (string.IsNullOrEmpty(groupName)) - { - warningText = null; - return false; - } - - return GroupPlacementWarningTextCache.TryGetValue( - "placement_" + groupName, - out warningText - ); - } - - /// - /// Gets cached group priority warning text by group name. Used for testing. - /// - internal static bool TryGetGroupPriorityWarningTextForTesting( - string groupName, - out string warningText - ) - { - if (string.IsNullOrEmpty(groupName)) - { - warningText = null; - return false; - } - - return GroupPriorityWarningTextCache.TryGetValue( - "priority_" + groupName, - out warningText - ); - } - - /// - /// Gets cached draw order warning text by group name. Used for testing. - /// - internal static bool TryGetDrawOrderWarningTextForTesting( - string groupName, - out string warningText - ) - { - if (string.IsNullOrEmpty(groupName)) - { - warningText = null; - return false; - } - - return ConflictWarningTextCache.TryGetValue(groupName, out warningText); - } - internal static GUIContent BuildGroupHeader(WButtonGroupKey groupKey) { WButtonGroupPlacement groupPlacement = groupKey._groupPlacement; @@ -534,21 +354,6 @@ internal static GUIContent BuildGroupHeader(WButtonGroupKey groupKey) return cachedWithOrder; } - /// - /// Legacy overload for testing compatibility. - /// - internal static GUIContent BuildGroupHeader(int drawOrder) - { - WButtonGroupKey key = new( - WButtonAttribute.NoGroupPriority, - drawOrder, - null, - 0, - WButtonGroupPlacement.UseGlobalSetting - ); - return BuildGroupHeader(key); - } - internal static void GetInvocationStatus( WButtonMethodState[] states, out int runningCount, diff --git a/Editor/Utils/WButton/WButtonStyles.cs b/Editor/Utils/WButton/WButtonStyles.cs index f7806880c..2fd0be875 100644 --- a/Editor/Utils/WButton/WButtonStyles.cs +++ b/Editor/Utils/WButton/WButtonStyles.cs @@ -29,7 +29,7 @@ internal static class WButtonStyles /// The bound is far above the handful of palette entries a project authors, so an eviction /// only ever discards a colour the user dragged through. /// - private const int MaxColoredButtonStyles = 64; + internal const int MaxColoredButtonStyles = 64; internal static GUIStyle GroupStyle { @@ -133,19 +133,7 @@ private static GUIStyle MiniButtonStyle } } - private static GUIStyle _groupStyle; - private static GUIStyle _headerStyle; - private static GUIStyle _baseButtonStyle; - private static GUIStyle _baseMiniButtonStyle; - private static GUIStyle _arrayHeaderStyle; - private static GUIStyle _foldoutContainerExpanded; - private static GUIStyle _foldoutContainerCollapsed; - private static GUIStyle _foldoutHeaderStyle; - private static GUIContent _topHeaderContent; - private static GUIContent _bottomHeaderContent; - private static readonly EditorCacheHelper.ColorComparer ColorEquality = new(); - - private static readonly Cache ColoredButtonStyles = + internal static readonly Cache ColoredButtonStyles = CacheBuilder .NewBuilder() .MaximumSize(MaxColoredButtonStyles) @@ -154,7 +142,8 @@ private static GUIStyle MiniButtonStyle .OnEviction(static (_, evicted, _) => evicted.Destroy()) .TransferOwnershipOnRemoval() .Build(); - private static readonly Cache ColoredMiniButtonStyles = + + internal static readonly Cache ColoredMiniButtonStyles = CacheBuilder .NewBuilder() .MaximumSize(MaxColoredButtonStyles) @@ -164,6 +153,18 @@ private static GUIStyle MiniButtonStyle .TransferOwnershipOnRemoval() .Build(); + private static GUIStyle _groupStyle; + private static GUIStyle _headerStyle; + private static GUIStyle _baseButtonStyle; + private static GUIStyle _baseMiniButtonStyle; + private static GUIStyle _arrayHeaderStyle; + private static GUIStyle _foldoutContainerExpanded; + private static GUIStyle _foldoutContainerCollapsed; + private static GUIStyle _foldoutHeaderStyle; + private static GUIContent _topHeaderContent; + private static GUIContent _bottomHeaderContent; + private static readonly EditorCacheHelper.ColorComparer ColorEquality = new(); + internal static GUIStyle GetFoldoutContainerStyle(bool expanded) { _foldoutContainerExpanded ??= CreateFoldoutContainerStyle(expanded: true); @@ -289,7 +290,7 @@ private static GUIStyle CreateFoldoutContainerStyle(bool expanded) return style; } - private readonly struct ButtonStyleKey : System.IEquatable + internal readonly struct ButtonStyleKey : System.IEquatable { private Color ButtonColor { get; } @@ -334,7 +335,7 @@ public int GetHashCode(ButtonStyleKey obj) } } - private sealed class ColoredButtonStyle + internal sealed class ColoredButtonStyle { internal readonly GUIStyle style; @@ -370,33 +371,6 @@ internal void Destroy() DestroyTexture(_active); } } - - internal static class TestHooks - { - /// - /// Gets the number of colored button styles currently retained, for testing. - /// - internal static int ColoredButtonStyleCount => ColoredButtonStyles.Count; - - /// - /// Gets the number of colored mini button styles currently retained, for testing. - /// - internal static int ColoredMiniButtonStyleCount => ColoredMiniButtonStyles.Count; - - /// - /// Gets the bound both colored style caches evict at. - /// - internal static int MaxColoredButtonStyleCount => MaxColoredButtonStyles; - - /// - /// Drops every cached colored style, destroying the textures each owns. - /// - internal static void ClearColoredStyleCaches() - { - ColoredButtonStyles.Clear(); - ColoredMiniButtonStyles.Clear(); - } - } } #endif } diff --git a/Editor/Utils/WGroup/WGroupAnimationState.cs b/Editor/Utils/WGroup/WGroupAnimationState.cs index f47bc3e85..889a6a446 100644 --- a/Editor/Utils/WGroup/WGroupAnimationState.cs +++ b/Editor/Utils/WGroup/WGroupAnimationState.cs @@ -26,19 +26,12 @@ internal static class WGroupAnimationState /// needs the bound's worth of distinct keys touched inside one tween to reach, because the /// least recently used entry is by definition not the foldout being drawn. /// - private const int MaxFoldoutAnimations = 256; + internal const int MaxFoldoutAnimations = 256; - /// - /// The number of foldout animations currently retained, for testing. - /// - internal static int CachedAnimationCount => FoldoutAnimations.Count; - - /// - /// The bound this cache evicts at, for testing. - /// - internal static int MaxCachedAnimations => MaxFoldoutAnimations; - - private static readonly Cache FoldoutAnimations = CacheBuilder + internal static readonly Cache FoldoutAnimations = CacheBuilder< + int, + AnimBool + > .NewBuilder() .MaximumSize(MaxFoldoutAnimations) .InitialCapacity(16) @@ -100,15 +93,6 @@ internal static float GetFadeProgress( return anim.faded; } - /// - /// Clears all cached animation states. - /// Useful for testing and when settings change. - /// - internal static void ClearCache() - { - FoldoutAnimations.Clear(); - } - private static void Unsubscribe(AnimBool anim) { if (anim != null) diff --git a/Editor/Validation/Continuous/ValidationAutoRun.cs b/Editor/Validation/Continuous/ValidationAutoRun.cs index b7f320d18..bc25c96c8 100644 --- a/Editor/Validation/Continuous/ValidationAutoRun.cs +++ b/Editor/Validation/Continuous/ValidationAutoRun.cs @@ -74,17 +74,18 @@ public static bool Enabled internal static bool IsActive => _enabled && ValidationPreferences.Enabled; - private static readonly Action DrainAction = Drain; - private static readonly EditorApplication.CallbackFunction RetryAction = Retry; - private static readonly HashSet Pending = new HashSet( + internal static readonly HashSet Pending = new HashSet( StringComparer.Ordinal ); - private static readonly Dictionary TriggerSources = new Dictionary< + internal static readonly Dictionary TriggerSources = new Dictionary< string, int >(StringComparer.Ordinal); + private static readonly Action DrainAction = Drain; + private static readonly EditorApplication.CallbackFunction RetryAction = Retry; + private static bool _enabled = EditorPrefs.GetBool(EnabledPreferenceKey, false); private static bool _pruneDeleted; @@ -142,15 +143,39 @@ internal static void Queue( AssetPostprocessorDeferral.Schedule(DrainAction); } - internal static void CompleteRunForTesting(ValidationRun run) + internal static void CompleteRun(ValidationRun run) { - CompleteRun(run); - } + if (!ValidationPreferences.Enabled) + return; + if (ValidationResults.TryMergeScopedRun(run)) + { + return; + } - internal static void ClearPendingForTesting() - { - Pending.Clear(); - TriggerSources.Clear(); + if (_enabled && run != null) + { + IReadOnlyList targets = run.Targets; + for (int index = 0; index < targets.Count; ++index) + { + Pending.Add(targets[index].AssetGuid); + } + } + + string reason = + run == null ? "no run result" + : run.IsCancelled ? "the run was cancelled" + : !run.IsComplete ? "the run was incomplete" + : run.Failures.Count == 0 ? "the run returned an invalid result" + : run.Failures.Count + " rule or load failure(s)"; + string queueStatus = _enabled + ? " The affected assets remain queued for the next import." + : " Automatic validation is disabled, so the affected assets were not requeued."; + UnityEngine.Debug.LogWarning( + "[Asset Validation] Incremental validation retained previous results because " + + reason + + "." + + queueStatus + ); } private static void Retry() @@ -249,41 +274,6 @@ private static void ScheduleRetry() EditorApplication.delayCall += RetryAction; } - private static void CompleteRun(ValidationRun run) - { - if (!ValidationPreferences.Enabled) - return; - if (ValidationResults.TryMergeScopedRun(run)) - { - return; - } - - if (_enabled && run != null) - { - IReadOnlyList targets = run.Targets; - for (int index = 0; index < targets.Count; ++index) - { - Pending.Add(targets[index].AssetGuid); - } - } - - string reason = - run == null ? "no run result" - : run.IsCancelled ? "the run was cancelled" - : !run.IsComplete ? "the run was incomplete" - : run.Failures.Count == 0 ? "the run returned an invalid result" - : run.Failures.Count + " rule or load failure(s)"; - string queueStatus = _enabled - ? " The affected assets remain queued for the next import." - : " Automatic validation is disabled, so the affected assets were not requeued."; - UnityEngine.Debug.LogWarning( - "[Asset Validation] Incremental validation retained previous results because " - + reason - + "." - + queueStatus - ); - } - private static void Prune() { List gone = null; diff --git a/Editor/Validation/Continuous/ValidationRun.cs b/Editor/Validation/Continuous/ValidationRun.cs index ceb860ec7..cbc0648b3 100644 --- a/Editor/Validation/Continuous/ValidationRun.cs +++ b/Editor/Validation/Continuous/ValidationRun.cs @@ -16,9 +16,8 @@ namespace WallstopStudios.UnityHelpers.Editor.Validation.Continuous /// /// /// The run does not schedule itself. A caller drives with the time it is - /// willing to give up -- from EditorApplication.update, from a progress bar, or from a - /// test loop -- which is what makes the engine testable without an editor tick and what keeps a - /// project-wide scan from stalling the editor the way an unbounded one did. + /// willing to give up, from EditorApplication.update or a progress bar. Bounded slices + /// keep a project-wide scan from stalling the editor the way an unbounded one did. /// /// /// Work is ordered asset-major: for each asset, every rule that claims it runs before the next @@ -89,13 +88,14 @@ IReadOnlyList targets : this(rules, targets, LoadMainAsset) { } /// - /// Initializes a run that loads assets through . + /// Initializes a run with a resolver for the objects represented by its targets. /// /// The rules to apply. null entries are ignored. /// The assets to consider. Invalid entries are ignored. /// - /// How to load an asset a rule claimed. A test supplies its own so the engine can be driven - /// without an asset database; null falls back to the asset database. + /// Resolves a claimed target to its current object. Automatic object-change validation uses + /// this to prefer a live prefab root over its saved asset. A null resolver falls back + /// to Unity's asset database. Each claimed target is resolved at most once per run. /// public ValidationRun( IReadOnlyList rules, @@ -108,6 +108,11 @@ Func loader _loader = loader ?? LoadMainAsset; } + internal static Object LoadMainAsset(ValidationTarget target) + { + return AssetDatabase.LoadMainAssetAtPath(target.AssetPath); + } + private static string RuleIdOf(IValidationRule rule) { // Reserve an empty rule ID for loader failures. @@ -122,11 +127,6 @@ private static string RuleIdOf(IValidationRule rule) } } - private static Object LoadMainAsset(ValidationTarget target) - { - return AssetDatabase.LoadMainAssetAtPath(target.AssetPath); - } - private static IValidationRule[] Compact(IReadOnlyList rules) { if (rules == null) @@ -202,7 +202,7 @@ public void Cancel() _cancelled = true; } - private void ProcessOneTarget(ValidationTarget target) + internal void ProcessOneTarget(ValidationTarget target) { bool loaded = false; Object asset = null; @@ -230,29 +230,34 @@ private void ProcessOneTarget(ValidationTarget target) } } - private bool Claims(IValidationRule rule, ValidationTarget target) + internal Object Load(ValidationTarget target) { try { - return rule.AppliesTo(in target); + return _loader(target); } catch (Exception thrown) { - _failures.Add(new ValidationRuleFailure(RuleIdOf(rule), target.AssetPath, thrown)); - return false; + RecordLoadFailure(target, thrown); + return null; } } - private Object Load(ValidationTarget target) + internal void RecordLoadFailure(ValidationTarget target, Exception thrown) + { + _failures.Add(new ValidationRuleFailure(null, target.AssetPath, thrown)); + } + + private bool Claims(IValidationRule rule, ValidationTarget target) { try { - return _loader(target); + return rule.AppliesTo(in target); } catch (Exception thrown) { - _failures.Add(new ValidationRuleFailure(null, target.AssetPath, thrown)); - return null; + _failures.Add(new ValidationRuleFailure(RuleIdOf(rule), target.AssetPath, thrown)); + return false; } } diff --git a/Editor/Validation/Continuous/ValidationWindow.cs b/Editor/Validation/Continuous/ValidationWindow.cs index 4d0f2e089..cac75435d 100644 --- a/Editor/Validation/Continuous/ValidationWindow.cs +++ b/Editor/Validation/Continuous/ValidationWindow.cs @@ -38,7 +38,8 @@ public sealed partial class ValidationWindow : EditorWindow /// private const string DefaultSuppressionsPath = "ValidationSuppressions.txt"; - internal string StatusForTesting => _status; + /// What the progress label reads when no run is active. + internal string _status = string.Empty; private readonly List _visible = new List(); @@ -57,9 +58,6 @@ public sealed partial class ValidationWindow : EditorWindow /// Whether the run the scheduler is driving is the one this window started. private bool _owned; - /// What the progress label reads when no run is active. - private string _status = string.Empty; - private Label _summary; private Label _progress; private ListView _list; @@ -116,9 +114,39 @@ private static string ReadOrEmpty(string path) } } - internal void CompleteForTesting(ValidationRun run) + internal void Complete(ValidationRun run) { - Complete(run); + _lastCompletedRun = run; + bool committed = ValidationResults.TryRecordRun(run); + if (run.IsCancelled) + { + _status = "Cancelled. Previous results retained."; + } + else if (!committed) + { + _status = "Validation failed. Previous results retained."; + Debug.LogWarning("[Asset Validation] " + _status); + } + else + { + _status = string.Empty; + } + + for (int index = 0; index < run.Failures.Count; ++index) + { + Debug.LogError("[Asset Validation] " + run.Failures[index]); + } + + _owned = false; + if (_run != null) + { + _run.text = "Validate Project"; + } + if (_progress != null) + { + _progress.text = _status; + } + Refresh(); } private void OnEnable() @@ -280,41 +308,6 @@ private bool Refuse(List coverage) return true; } - private void Complete(ValidationRun run) - { - _lastCompletedRun = run; - bool committed = ValidationResults.TryRecordRun(run); - if (run.IsCancelled) - { - _status = "Cancelled. Previous results retained."; - } - else if (!committed) - { - _status = "Validation failed. Previous results retained."; - Debug.LogWarning("[Asset Validation] " + _status); - } - else - { - _status = string.Empty; - } - - for (int index = 0; index < run.Failures.Count; ++index) - { - Debug.LogError("[Asset Validation] " + run.Failures[index]); - } - - _owned = false; - if (_run != null) - { - _run.text = "Validate Project"; - } - if (_progress != null) - { - _progress.text = _status; - } - Refresh(); - } - /// /// Shows the active run's progress, and the last status once nothing is running. /// diff --git a/Editor/Validation/StaleSerializedKeyRepair.cs b/Editor/Validation/StaleSerializedKeyRepair.cs index 865b9207d..6b0abce96 100644 --- a/Editor/Validation/StaleSerializedKeyRepair.cs +++ b/Editor/Validation/StaleSerializedKeyRepair.cs @@ -62,56 +62,6 @@ Dictionary outcomes /// What happened. public static StaleSerializedKeyRepairOutcome RepairAsset(string assetPath) { - return RepairAsset(assetPath, null); - } - - /// - /// Rewrites one asset, counting what it holds through . - /// - /// The asset to rewrite. - /// - /// Answers how many non-null objects the asset holds, or null for the asset database. - /// - /// What happened. - /// - /// The production entry point supplies no counter and so takes the asset database's answer. - /// The seam exists because nothing a test can author makes ForceReserializeAssets - /// lose content -- a VolumeProfile does, and five modelled HideFlags shapes do - /// not -- so the undo this type exists for would otherwise never execute. - /// - internal static StaleSerializedKeyRepairOutcome RepairAsset( - string assetPath, - Func objectCounter - ) - { - return RepairAsset(assetPath, objectCounter, null); - } - - /// - /// Rewrites one asset through , counting what it holds - /// through . - /// - /// The asset to rewrite. - /// - /// Answers how many non-null objects the asset holds, or null for the asset database. - /// - /// - /// Rewrites the asset, or null for ForceReserializeAssets. - /// - /// What happened. - /// - /// The second seam exists for the same reason the first one does: nothing a test can author - /// makes ForceReserializeAssets throw, so the branch that undoes a failed rewrite - /// would otherwise never execute and its outcome would be unverified forever. - /// - internal static StaleSerializedKeyRepairOutcome RepairAsset( - string assetPath, - Func objectCounter, - Action rewriteAsset - ) - { - Func countObjects = objectCounter ?? LoadedObjectCount; - Action rewrite = rewriteAsset ?? ForceReserialize; if (string.IsNullOrEmpty(assetPath)) { return StaleSerializedKeyRepairOutcome.RefusedUnreadable; @@ -129,7 +79,7 @@ Action rewriteAsset return StaleSerializedKeyRepairOutcome.RefusedUnreadable; } - int before = countObjects(assetPath); + int before = LoadedObjectCount(assetPath); if (before <= 0) { return StaleSerializedKeyRepairOutcome.RefusedUnreadable; @@ -137,19 +87,26 @@ Action rewriteAsset try { - rewrite(assetPath); + ForceReserialize(assetPath); } catch (Exception exception) { - // Report restoration only after its outcome is known. - Debug.LogError($"[Unity Helpers] Rewriting {assetPath} threw: {exception}."); - return Restore(assetPath, filePath, original, ReadCurrentBytes(filePath)) - ? StaleSerializedKeyRepairOutcome.RefusedRewriteThrew - : StaleSerializedKeyRepairOutcome.RefusedUndoFailed; + return RecoverRewriteFailure(assetPath, filePath, original, exception); } + return CompleteRewrite(assetPath, filePath, original, before); + } + + /// Checks the rewritten asset against its original object count and bytes. + internal static StaleSerializedKeyRepairOutcome CompleteRewrite( + string assetPath, + string filePath, + byte[] original, + int before + ) + { byte[] rewritten = ReadCurrentBytes(filePath); - int after = countObjects(assetPath); + int after = LoadedObjectCount(assetPath); if (after < before) { return Restore(assetPath, filePath, original, rewritten) @@ -162,6 +119,62 @@ Action rewriteAsset : StaleSerializedKeyRepairOutcome.Repaired; } + /// Reports a failed rewrite and restores its original bytes before re-importing. + internal static StaleSerializedKeyRepairOutcome RecoverRewriteFailure( + string assetPath, + string filePath, + byte[] original, + Exception exception + ) + { + // Report restoration only after its outcome is known. + Debug.LogError($"[Unity Helpers] Rewriting {assetPath} threw: {exception}."); + return Restore(assetPath, filePath, original, ReadCurrentBytes(filePath)) + ? StaleSerializedKeyRepairOutcome.RefusedRewriteThrew + : StaleSerializedKeyRepairOutcome.RefusedUndoFailed; + } + + /// Puts the original bytes back and makes the editor re-read them. + /// The asset path, for the re-import. + /// The resolved path, for the write. + /// The bytes captured before the rewrite. + /// false when the undo did not complete. + /// + /// A failure here is the worst outcome this type has: the rewrite already happened, so + /// swallowing it would leave a damaged asset while the caller reported a refusal. It is + /// reported as an error naming the file, because the recovery is a human restoring it. + /// + internal static bool Restore( + string assetPath, + string filePath, + byte[] original, + byte[] rewritten + ) + { + if ( + !DurableFile.TryCompareThenReplaceBytes( + filePath, + rewritten, + original, + out Exception writeError + ) + ) + { + Debug.LogError( + $"[Unity Helpers] Could not undo the rewrite of {assetPath}: {writeError}. " + + $"Inspect {filePath} and restore the original bytes from source control " + + "before saving the project." + ); + return false; + } + + AssetDatabase.ImportAsset( + assetPath, + ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport + ); + return true; + } + private static void ForceReserialize(string assetPath) { // Prefab rewrites require metadata serialization; assets-only silently leaves them unchanged. @@ -229,47 +242,6 @@ private static byte[] ReadCurrentBytes(string filePath) return null; } } - - /// Puts the original bytes back and makes the editor re-read them. - /// The asset path, for the re-import. - /// The resolved path, for the write. - /// The bytes captured before the rewrite. - /// false when the undo did not complete. - /// - /// A failure here is the worst outcome this type has: the rewrite already happened, so - /// swallowing it would leave a damaged asset while the caller reported a refusal. It is - /// reported as an error naming the file, because the recovery is a human restoring it. - /// - private static bool Restore( - string assetPath, - string filePath, - byte[] original, - byte[] rewritten - ) - { - if ( - !DurableFile.TryCompareThenReplaceBytes( - filePath, - rewritten, - original, - out Exception writeError - ) - ) - { - Debug.LogError( - $"[Unity Helpers] Could not undo the rewrite of {assetPath}: {writeError}. " - + $"Inspect {filePath} and restore the original bytes from source control " - + "before saving the project." - ); - return false; - } - - AssetDatabase.ImportAsset( - assetPath, - ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport - ); - return true; - } } #endif } diff --git a/Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/DisposalOwnershipCompilerTests.cs b/Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/DisposalOwnershipCompilerTests.cs new file mode 100644 index 000000000..5ccc8eb3e --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/DisposalOwnershipCompilerTests.cs @@ -0,0 +1,176 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Analyzers.Tests +{ + using System; + using System.Collections.Generic; + using System.Collections.Immutable; + using System.IO; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp; + using Microsoft.CodeAnalysis.Diagnostics; + using NUnit.Framework; + + [TestFixture] + public sealed class DisposalOwnershipCompilerTests + { + private const string BoxingDiagnosticId = "WUH022"; + private const string ConsumerSourcePath = "Consumer.cs"; + + private static IEnumerable OwnershipCases() + { + string[] bodies = + { + "SemaphoreLease copy = lease; lease.Dispose(); copy.Dispose();", + "DisposeCopy(lease); lease.Dispose();", + "Action release = () => lease.Dispose(); release(); lease.Dispose();", + "Saved = lease; lease.Dispose(); Saved.Dispose();", + "lease.Dispose(); lease.Dispose();", + "using (lease) { lease.Dispose(); }", + }; + string[] names = + { + "Assignment", + "ByValue", + "Capture", + "HeapRetention", + "RepeatedDisposal", + "UsingAndManualDisposal", + }; + for (int index = 0; index < bodies.Length; ++index) + { + yield return new TestCaseData(bodies[index], false).SetName( + "DisposalOwnership.Compiler." + names[index] + ".Threaded" + ); + yield return new TestCaseData(bodies[index], true).SetName( + "DisposalOwnership.Compiler." + names[index] + ".SingleThreaded" + ); + } + } + + private static CSharpCompilation CompileConsumer(string body, bool singleThreaded) + { + DirectoryInfo directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while ( + directory != null + && !File.Exists( + Path.Combine(directory.FullName, "Runtime", "Utils", "DisposalLease.cs") + ) + ) + { + directory = directory.Parent; + } + Assert.That(directory != null, Is.True, "Actual shipped lease sources were not found."); + CSharpParseOptions parseOptions = new CSharpParseOptions(LanguageVersion.CSharp9); + if (singleThreaded) + { + parseOptions = parseOptions.WithPreprocessorSymbols("SINGLE_THREADED"); + } + string disposalPath = Path.Combine( + directory.FullName, + "Runtime", + "Utils", + "DisposalLease.cs" + ); + string semaphorePath = Path.Combine( + directory.FullName, + "Runtime", + "Core", + "Threading", + "SemaphoreLease.cs" + ); + SyntaxTree[] trees = + { + CSharpSyntaxTree.ParseText( + File.ReadAllText(disposalPath), + parseOptions, + disposalPath + ), + CSharpSyntaxTree.ParseText( + File.ReadAllText(semaphorePath), + parseOptions, + semaphorePath + ), + CSharpSyntaxTree.ParseText( + "using System; using System.Threading; using WallstopStudios.UnityHelpers.Core.Threading; public static class Consumer { public static SemaphoreLease Saved; public static void Run(SemaphoreSlim semaphore) { SemaphoreLease lease = semaphore.Acquire(); " + + body + + " } private static void DisposeCopy(SemaphoreLease lease) { lease.Dispose(); } }", + parseOptions, + ConsumerSourcePath + ), + }; + string platformAssemblies = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"); + Assert.That(string.IsNullOrEmpty(platformAssemblies), Is.False); + List references = new List(); + foreach (string path in platformAssemblies.Split(Path.PathSeparator)) + { + references.Add(MetadataReference.CreateFromFile(path)); + } + CSharpCompilation compilation = CSharpCompilation.Create( + nameof(DisposalOwnershipCompilerTests), + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + foreach (Diagnostic diagnostic in compilation.GetDiagnostics()) + { + Assert.That( + diagnostic.Severity, + Is.Not.EqualTo(DiagnosticSeverity.Error), + diagnostic.ToString() + ); + } + return compilation; + } + + private static ImmutableArray Analyze(CSharpCompilation compilation) + { + return compilation + .WithAnalyzers( + ImmutableArray.Create( + new DisposableStructAssignmentAnalyzer(), + new DisposableStructBoxingAnalyzer() + ) + ) + .GetAnalyzerDiagnosticsAsync() + .GetAwaiter() + .GetResult(); + } + + [TestCaseSource(nameof(OwnershipCases))] + public void ActualPublicLeaseOwnershipCopiesCompileWithoutOwnershipDiagnostics( + string body, + bool singleThreaded + ) + { + Assert.That(Analyze(CompileConsumer(body, singleThreaded)), Is.Empty); + } + + [TestCase(false)] + [TestCase(true)] + public void ActualPublicLeaseBoxingWarningCanBeSuppressed(bool singleThreaded) + { + const string boxingBody = "IDisposable copy = lease; lease.Dispose(); copy.Dispose();"; + ImmutableArray diagnostics = Analyze( + CompileConsumer(boxingBody, singleThreaded) + ); + Assert.That(diagnostics.Length, Is.EqualTo(1)); + Assert.That(diagnostics[0].Id, Is.EqualTo(BoxingDiagnosticId)); + Assert.That(diagnostics[0].Severity, Is.EqualTo(DiagnosticSeverity.Warning)); + Assert.That( + diagnostics[0].Location.SourceTree.FilePath, + Is.EqualTo(ConsumerSourcePath) + ); + Assert.That( + Analyze( + CompileConsumer( + "\n#pragma warning disable WUH022\n" + boxingBody, + singleThreaded + ) + ), + Is.Empty + ); + } + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/DisposalOwnershipCompilerTests.cs.meta b/Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/DisposalOwnershipCompilerTests.cs.meta new file mode 100644 index 000000000..c3988f65a --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/DisposalOwnershipCompilerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f4a581158a056a736bbbeb7cce9b669 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.EditorTestCheck/WallstopStudios.UnityHelpers.EditorTestCheck.csproj b/Generator~/WallstopStudios.UnityHelpers.EditorTestCheck/WallstopStudios.UnityHelpers.EditorTestCheck.csproj index 2c2f167fd..9a3a07c2b 100644 --- a/Generator~/WallstopStudios.UnityHelpers.EditorTestCheck/WallstopStudios.UnityHelpers.EditorTestCheck.csproj +++ b/Generator~/WallstopStudios.UnityHelpers.EditorTestCheck/WallstopStudios.UnityHelpers.EditorTestCheck.csproj @@ -68,7 +68,7 @@ ================================ WHAT IT COMPILES ================================ - 764 of the 807 files under `Tests/Editor/`, plus the four trees they bind: + 811 of the 856 files under `Tests/Editor/`, plus the four trees they bind: * `Runtime/**`, `Styles/**` and `Editor/**`, with TypeCheck's and EditorCheck's exclusions, because every editmode asmdef references `WallstopStudios.UnityHelpers`, all but three @@ -126,7 +126,7 @@ ============================ WHAT IT DOES NOT COVER ============================ - 42 of the 820 files under `Tests/Editor/` are excluded, in FIVE groups, every one of them + 45 of the 856 files under `Tests/Editor/` are excluded, in FIVE groups, every one of them removed ONE PER LINE WITH ITS REASON below rather than through a semicolon-packed `Exclude`, so each reason travels with the exclusion it explains. Not one of them is excluded because the fixture is wrong: @@ -383,7 +383,7 @@ @@ -394,11 +394,16 @@ + + + + + diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/DeclaredRootTests.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/DeclaredRootTests.cs index 12a4d858a..fdcc26af9 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/DeclaredRootTests.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/DeclaredRootTests.cs @@ -107,7 +107,10 @@ public void AValueHeldAsTheInterfaceComesBackAsItsSubtype() Assert.IsTrue(WProtoFacade.TryDeserialize(bytes, out IIncludeThing restored)); IncludeGamma gamma = restored as IncludeGamma; - Assert.IsNotNull(gamma, "the subtype must survive a round trip through the interface"); + Assert.IsTrue( + gamma != null, + "the subtype must survive a round trip through the interface" + ); Assert.AreEqual(4, gamma.Id); Assert.AreEqual("z", gamma.Label); Assert.AreEqual(1.5, gamma.BetaOnly); @@ -126,7 +129,7 @@ public void TheDeclaredRootReadsWhatProtobufNetWroteThroughIt() WProtoFacade.TryDeserialize(stream.ToArray(), out IIncludeThing restored) ); IncludeGamma gamma = restored as IncludeGamma; - Assert.IsNotNull(gamma); + Assert.IsTrue(gamma != null); Assert.AreEqual(9, gamma.Id); Assert.AreEqual(4.5, gamma.BetaOnly); } @@ -137,7 +140,7 @@ public void AnImplementationOutsideTheRootsChainIsNotServed() IIncludeThing foreign = new ForeignThing { Value = 3 }; Assert.IsFalse(WProtoFacade.TrySerialize(foreign, out byte[] bytes)); - Assert.IsNull(bytes); + Assert.IsTrue(bytes == null); byte[] buffer = new byte[8]; byte[] original = buffer; @@ -236,7 +239,7 @@ public void AClaimBeatsADeclarationWhicheverRunsFirst() } finally { - WProtoDeclaredRootProvider.Unregister(); + WProtoDeclaredRootProvider.Formatters.Value = null; } } @@ -255,7 +258,7 @@ public void ARootWithNoFormatterIsDeclinedRatherThanCalled() } finally { - WProtoDeclaredRootProvider.Unregister(); + WProtoDeclaredRootProvider.Formatters.Value = null; } } @@ -281,7 +284,7 @@ out IWProtoFormatter formatter } finally { - WProtoDeclaredRootProvider.Unregister(); + WProtoDeclaredRootProvider.Formatters.Value = null; } } @@ -295,7 +298,10 @@ out IWProtoFormatter registered ); IWProtoPolymorphicFormatter adapter = registered as IWProtoPolymorphicFormatter; - Assert.IsNotNull(adapter, "the facade asks this question of every non-exact match"); + Assert.IsTrue( + adapter != null, + "the facade asks this question of every non-exact match" + ); Assert.IsTrue(adapter.CanWrite(typeof(IncludeBase))); Assert.IsTrue(adapter.CanWrite(typeof(IncludeGamma))); Assert.IsFalse(adapter.CanWrite(typeof(ForeignThing))); @@ -329,7 +335,7 @@ public void ADeclaredTypeAValueCanBeIsNotServed() WProtoFacade.TrySerialize(new ConcreteDeclared(), out byte[] bytes), "a value that IS the declared type narrows to nothing and would write zero bytes" ); - Assert.IsNull(bytes); + Assert.IsTrue(bytes == null); Assert.IsTrue( WProtoFacade.TrySerialize( @@ -341,7 +347,7 @@ out byte[] derived } finally { - WProtoDeclaredRootProvider.Unregister(); + WProtoDeclaredRootProvider.Formatters.Value = null; WProtoFormatterProvider.Register(null); } } @@ -368,13 +374,13 @@ out IWProtoFormatter registered ); IWProtoPolymorphicFormatter adapter = registered as IWProtoPolymorphicFormatter; - Assert.IsNotNull(adapter); + Assert.IsTrue(adapter != null); Assert.IsTrue(adapter.CanWrite(typeof(UnformattedRoot))); Assert.IsFalse(adapter.CanWrite(typeof(OtherUnformatted))); } finally { - WProtoDeclaredRootProvider.Unregister(); + WProtoDeclaredRootProvider.Formatters.Value = null; WProtoFormatterProvider.Register(null); } } diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/DiagnosticTests.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/DiagnosticTests.cs index 856aab538..9efd604b8 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/DiagnosticTests.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/DiagnosticTests.cs @@ -1724,6 +1724,35 @@ public void AnUndeclaredCrossAssemblySubclassOptsOutWithNotSerialized() ); } + /// An opted-out intermediate type stops inherited cross-assembly diagnostics. + [TestCase(false, TestName = "OptOutBoundary.ConsumerIntermediate")] + [TestCase(true, TestName = "OptOutBoundary.ReferencedIntermediate")] + public void AnUnannotatedLeafBelowAnOptOutHasNoInheritedContract(bool upstreamIntermediate) + { + string upstreamBody = + @"namespace Upstream { + [WProtoContract] public partial class Base { [WProtoMember(1)] public int A; }"; + if (upstreamIntermediate) + { + upstreamBody += @"[WProtoNotSerialized] public class Middle : Base { }"; + } + MetadataReference upstream = CompileReference("UpstreamAssembly", upstreamBody + "}"); + string consumerBody = upstreamIntermediate + ? @"public sealed class Leaf : Upstream.Middle { }" + : @"[WProtoNotSerialized] public class Middle : Upstream.Base { } + public sealed class Leaf : Middle { }"; + ImmutableArray diagnostics = Run(consumerBody, upstream); + foreach (Diagnostic diagnostic in diagnostics) + { + Assert.AreNotEqual("WPROTO044", diagnostic.Id, diagnostic.GetMessage()); + Assert.AreNotEqual( + DiagnosticSeverity.Error, + diagnostic.Severity, + diagnostic.GetMessage() + ); + } + } + /// /// A GENERIC contract base is deliberately exempt, and this test is the record of it. /// diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/MapDifferentialTests.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/MapDifferentialTests.cs index 237f0c1e0..febca5bf9 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/MapDifferentialTests.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/MapDifferentialTests.cs @@ -22,6 +22,148 @@ namespace WallstopStudios.UnityHelpers.Proto.Generator.Tests [TestFixture] public sealed class MapDifferentialTests { +#if !PROTOBUF_NET_ORACLE_V2 + [Test] + public void MapOracleIsPinnedToVersionThreeTwoFiftySix() + { + System.Diagnostics.FileVersionInfo version = + System.Diagnostics.FileVersionInfo.GetVersionInfo( + typeof(ProtoBuf.Serializer).Assembly.Location + ); + Assert.AreEqual(3, version.FileMajorPart); + Assert.AreEqual(2, version.FileMinorPart); + Assert.AreEqual(56, version.FileBuildPart); + } +#endif + + [TestCase(0, 0d, "0A00")] + [TestCase(0, 1d, "0A0911000000000000F03F")] + [TestCase(1, 0d, "0A020801")] + [TestCase(1, 1d, "0A0B080111000000000000F03F")] + public void IntegerDoubleMapMatchesPinnedV3Bytes(int key, double value, string expected) + { + ZeroKeyMapContract contract = new ZeroKeyMapContract + { + Signed32 = new Dictionary { { key, value } }, + }; + Assert.AreEqual(expected, OracleHex(contract), "Pinned integer-key oracle bytes"); + Assert.AreEqual(expected, Encode(contract)); + using MemoryStream stream = new MemoryStream(Parse(expected)); + ZeroKeyMapContract oracleRead = ProtoBuf.Serializer.Deserialize( + stream + ); + Assert.AreEqual(value, oracleRead.Signed32.ValueFor(key)); + } + + [TestCase((ZeroMapKeyKind)0, 0d, "32020800")] + [TestCase((ZeroMapKeyKind)0, 1d, "320B080011000000000000F03F")] + [TestCase(ZeroMapKeyKind.Other, 0d, "32020807")] + [TestCase(ZeroMapKeyKind.Other, 1d, "320B080711000000000000F03F")] + public void EnumDoubleMapMatchesPinnedV3Bytes( + ZeroMapKeyKind key, + double value, + string expected + ) + { + ZeroKeyMapContract contract = new ZeroKeyMapContract + { + Enumeration = new Dictionary { { key, value } }, + }; +#if PROTOBUF_NET_ORACLE_V2 + string v2Expected = + "320B080" + + (key == (ZeroMapKeyKind)0 ? "0" : "7") + + "11" + + (value == 0d ? "0000000000000000" : "000000000000F03F"); + Assert.AreEqual( + v2Expected, + OracleHex(contract), + "v2 retains enum keys and default fixed-width values" + ); +#else + Assert.AreEqual(expected, OracleHex(contract), "Pinned protobuf-net 3.2.56 bytes"); +#endif + Assert.AreEqual(expected, Encode(contract)); + using MemoryStream stream = new MemoryStream(Parse(expected)); + ZeroKeyMapContract oracleRead = ProtoBuf.Serializer.Deserialize( + stream + ); + Assert.AreEqual(value, oracleRead.Enumeration.ValueFor(key)); + } + + [TestCase("320911000000000000F03F", 1d)] + [TestCase("320B080011000000000000F03F", 1d)] + [TestCase("3200", 0d)] + [TestCase("32020800", 0d)] + public void KeylessAndExplicitZeroEnumEntriesCrossRead(string hex, double expected) + { + WProtoReader reader = new WProtoReader(Parse(hex)); + Assert.IsTrue( + WProtoFormatterProvider + .Get() + .TryRead(ref reader, out ZeroKeyMapContract read) + ); + using MemoryStream stream = new MemoryStream(Parse(hex)); + ZeroKeyMapContract oracle = ProtoBuf.Serializer.Deserialize(stream); + CollectionAssert.AreEquivalent(oracle.Enumeration, read.Enumeration); + Assert.AreEqual(expected, read.Enumeration.ValueFor((ZeroMapKeyKind)0)); + } + + [TestCase("0A0911000000000000F03F")] + [TestCase("0A0B080011000000000000F03F")] + [TestCase("0A00")] + [TestCase("0A020800")] + public void KeylessAndExplicitZeroMapEntriesCrossRead(string hex) + { + WProtoReader reader = new WProtoReader(Parse(hex)); + Assert.IsTrue( + WProtoFormatterProvider + .Get() + .TryRead(ref reader, out ZeroKeyMapContract read) + ); + using MemoryStream stream = new MemoryStream(Parse(hex)); + ZeroKeyMapContract oracle = ProtoBuf.Serializer.Deserialize(stream); + CollectionAssert.AreEquivalent(oracle.Signed32, read.Signed32); + Assert.AreEqual(hex.Contains("11") ? 1d : 0d, read.Signed32.ValueFor(0)); + } + +#if !PROTOBUF_NET_ORACLE_V2 + [Test] + public void DefaultScalarMapKeysMatchV3AcrossShapes() + { + ZeroKeyMapContract[] contracts = + { + new ZeroKeyMapContract { Signed32 = new Dictionary { { 0, 1 } } }, + new ZeroKeyMapContract { Signed64 = new Dictionary { { 0, 1 } } }, + new ZeroKeyMapContract { Unsigned32 = new Dictionary { { 0, 1 } } }, + new ZeroKeyMapContract { Unsigned64 = new Dictionary { { 0, 1 } } }, + new ZeroKeyMapContract { Boolean = new Dictionary { { false, 1 } } }, + new ZeroKeyMapContract + { + Enumeration = new Dictionary + { + { (ZeroMapKeyKind)0, 1 }, + }, + }, + new ZeroKeyMapContract { Single = new Dictionary { { 0, 1 } } }, + new ZeroKeyMapContract { Double = new Dictionary { { 0, 1 } } }, + new ZeroKeyMapContract + { + Text = new Dictionary { { string.Empty, 1 } }, + }, + }; + Assert.Multiple(() => + { + foreach (ZeroKeyMapContract contract in contracts) + { + string expected = OracleHex(contract); + TestContext.WriteLine(expected); + Assert.AreEqual(expected, Encode(contract)); + } + }); + } +#endif + [Test] public void AnEntryIsAMessageWithTheKeyAtOneAndTheValueAtTwo() { @@ -214,7 +356,9 @@ public void AnEmptyMapDoesNotSurviveARoundTrip() string.Empty, Encode(Bare(c => c.ByName = new Dictionary())) ); - Assert.IsNull(RoundTrip(Bare(c => c.ByName = new Dictionary())).ByName); + Assert.IsTrue( + RoundTrip(Bare(c => c.ByName = new Dictionary())).ByName == null + ); } [Test] @@ -447,6 +591,32 @@ public void AHookedMapValueRunsItsSerializationHookOnce() Assert.AreEqual(0, writer.Depth, "a map entry left the nesting depth unbalanced"); } +#if !PROTOBUF_NET_ORACLE_V2 + [Test] + public void ExoticNumericKeysMatchActualV3OracleForZeroAndNonzero() + { + ExoticKeyContract[] contracts = + { + new ExoticKeyContract { ByFloat = new Dictionary { { 0f, 2 } } }, + new ExoticKeyContract { ByFloat = new Dictionary { { 1.5f, 2 } } }, + new ExoticKeyContract { ByDouble = new Dictionary { { 0d, 2 } } }, + new ExoticKeyContract { ByDouble = new Dictionary { { 1.5d, 2 } } }, + new ExoticKeyContract + { + ByEnum = new Dictionary { { MapKeyKind.None, 2 } }, + }, + new ExoticKeyContract + { + ByEnum = new Dictionary { { MapKeyKind.Other, 2 } }, + }, + }; + foreach (ExoticKeyContract contract in contracts) + { + Assert.AreEqual(OracleHex(contract), Encode(contract)); + } + } +#endif + [Test] public void KeysTheSpecForbidsMatchTheOracleRatherThanBeingRefused() { @@ -461,7 +631,7 @@ public void KeysTheSpecForbidsMatchTheOracleRatherThanBeingRefused() ) ); Assert.AreEqual( - "0A070D000000001002", + "0A021002", Encode(new ExoticKeyContract { ByFloat = new Dictionary { { 0f, 2 } } }) ); Assert.AreEqual( @@ -471,7 +641,7 @@ public void KeysTheSpecForbidsMatchTheOracleRatherThanBeingRefused() ) ); Assert.AreEqual( - "120B0900000000000000001002", + "12021002", Encode( new ExoticKeyContract { ByDouble = new Dictionary { { 0d, 2 } } } ) diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/PackingOptOutTests.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/PackingOptOutTests.cs new file mode 100644 index 000000000..a961e6ac3 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/PackingOptOutTests.cs @@ -0,0 +1,291 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Proto.Generator.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using NUnit.Framework; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// Pins repeated write packing policy and read compatibility. + [TestFixture] + public sealed class PackingOptOutTests + { + private static void AssertEncoded(T value, string hex) + { + AssertOracle(value); + byte[] bytes = Encode(value); + CollectionAssert.AreEqual(Parse(hex), bytes); + } + + private static void AssertOracle(T value) + { + using (MemoryStream stream = new MemoryStream()) + { + ProtoBuf.Serializer.Serialize(stream, value); + CollectionAssert.AreEqual( + stream.ToArray(), + Encode(value), + "Live protobuf-net oracle bytes" + ); + stream.Position = 0; + Assert.IsTrue(ProtoBuf.Serializer.Deserialize(stream) != null); + } + } + + private static void AssertShortBuffer(T value) + { + IWProtoFormatter formatter = WProtoFormatterProvider.Get(); + WProtoWriter writer = new WProtoWriter(new byte[formatter.Measure(value) - 1]); + Assert.IsFalse(formatter.Write(ref writer, value)); + } + + private static byte[] Encode(T value) + { + IWProtoFormatter formatter = WProtoFormatterProvider.Get(); + int measured = formatter.Measure(value); + byte[] bytes = new byte[measured]; + WProtoWriter writer = new WProtoWriter(bytes); + Assert.IsTrue(formatter.Write(ref writer, value)); + Assert.AreEqual(measured, writer.Position); + return bytes; + } + + private static T Decode(byte[] bytes) + { + WProtoReader reader = new WProtoReader(bytes); + Assert.IsTrue(WProtoFormatterProvider.Get().TryRead(ref reader, out T value)); + Assert.IsTrue(reader.End); + Assert.IsFalse(reader.Malformed); + return value; + } + + private static byte[] Parse(string hex) + { + byte[] bytes = new byte[hex.Length / 2]; + for (int index = 0; index < bytes.Length; ++index) + { + bytes[index] = Convert.ToByte(hex.Substring(index * 2, 2), 16); + } + return bytes; + } + + [TestCase(0, "08030801080408000802", TestName = "Unpacked.IntArray")] + [TestCase(1, "10001002", TestName = "Unpacked.IntList")] + [TestCase(2, "180018AC02", TestName = "Unpacked.EnumArray")] + [TestCase(3, "200020AC02", TestName = "Unpacked.EnumList")] + [TestCase(4, "29000000000000000029000000000000F03F", TestName = "Unpacked.DoubleArray")] + [TestCase(5, "30003001", TestName = "Unpacked.BoolArray")] + [TestCase(6, "3A003A0161", TestName = "Unpacked.StringArray")] + [TestCase(7, "42020001", TestName = "Packed.ExplicitTrue")] + [TestCase(8, "48004861", TestName = "Unpacked.CharArray")] + public void ExplicitWritePolicyProducesExpectedBytes(int shape, string hex) + { + UnpackedRepeatedContract value = new UnpackedRepeatedContract(); + switch (shape) + { + case 0: + value.Ints = new[] { 3, 1, 4, 0, 2 }; + break; + case 1: + value.IntList = new List { 0, 2 }; + break; + case 2: + value.Modes = new[] { Mode.None, Mode.Careful }; + break; + case 3: + value.ModeList = new List { Mode.None, Mode.Careful }; + break; + case 4: + value.Doubles = new[] { 0d, 1d }; + break; + case 5: + value.Flags = new[] { false, true }; + break; + case 6: + value.Texts = new[] { string.Empty, "a" }; + break; + case 7: + value.PackedInts = new[] { 0, 1 }; + break; + case 8: + value.Chars = new[] { '\0', 'a' }; + break; + } + AssertEncoded(value, hex); + } + + [TestCase(false, TestName = "Unpacked.NullCollections")] + [TestCase(true, TestName = "Unpacked.EmptyCollections")] + public void NullAndEmptyCollectionsWriteNothing(bool empty) + { + UnpackedRepeatedContract value = new UnpackedRepeatedContract(); + UnpackedRepeatedBox generic = new UnpackedRepeatedBox(); + if (empty) + { + value.Ints = Array.Empty(); + value.IntList = new List(); + value.Modes = Array.Empty(); + value.ModeList = new List(); + generic.Array = Array.Empty(); + generic.List = new List(); + } + AssertEncoded(value, string.Empty); + AssertEncoded(generic, string.Empty); + Assert.IsTrue(Decode(Array.Empty()).Ints == null); + Assert.IsTrue(Decode>(Array.Empty()).Array == null); + } + + [Test] + public void AnEmptyDefaultPackedRunRetainsExistingOmission() + { + UnpackedRepeatedBox value = new UnpackedRepeatedBox + { + DefaultPacked = Array.Empty(), + }; + CollectionAssert.AreEqual(Array.Empty(), Encode(value)); + using (MemoryStream stream = new MemoryStream()) + { + ProtoBuf.Serializer.Serialize(stream, value); + CollectionAssert.AreEqual( + Parse("1A00"), + stream.ToArray(), + "The explicit-packed oracle preserves a present empty run." + ); + } + } + + [Test] + public void GenericIntegerArrayAndListUseUnpackedWhileDefaultStillPacks() + { + UnpackedRepeatedBox value = new UnpackedRepeatedBox + { + Array = new[] { 0, -1, int.MaxValue }, + List = new List { 0, 2 }, + DefaultPacked = new[] { 0, 2 }, + }; + byte[] bytes = Encode(value); + AssertEncoded(value, "080008FFFFFFFFFFFFFFFFFF0108FFFFFFFF07100010021A020002"); + UnpackedRepeatedBox restored = Decode>(bytes); + CollectionAssert.AreEqual(value.Array, restored.Array); + CollectionAssert.AreEqual(value.List, restored.List); + CollectionAssert.AreEqual(value.DefaultPacked, restored.DefaultPacked); + } + + [Test] + public void GenericEnumArrayAndListUseUnpacked() + { + UnpackedRepeatedBox value = new UnpackedRepeatedBox + { + Array = new[] { Mode.None, Mode.Careful }, + List = new List { Mode.None, Mode.Fast }, + }; + AssertEncoded(value, "080008AC0210001001"); + UnpackedRepeatedBox restored = Decode>(Encode(value)); + CollectionAssert.AreEqual(value.Array, restored.Array); + CollectionAssert.AreEqual(value.List, restored.List); + } + + [Test] + public void GenericStringArrayAndListRemainUnpacked() + { + UnpackedRepeatedValues value = new UnpackedRepeatedValues + { + Array = new[] { string.Empty, "a" }, + List = new List { "b" }, + }; + AssertEncoded(value, "0A000A0161120162"); + UnpackedRepeatedValues restored = Decode>( + Encode(value) + ); + CollectionAssert.AreEqual(value.Array, restored.Array); + CollectionAssert.AreEqual(value.List, restored.List); + } + + [TestCase("08000801", TestName = "Read.Unpacked")] + [TestCase("0A020001", TestName = "Read.Packed")] + [TestCase("08000A0101", TestName = "Read.Mixed")] + public void UnpackedWritePolicyReadsEitherWireForm(string hex) + { + byte[] payload = Parse(hex); + CollectionAssert.AreEqual( + new[] { 0, 1 }, + Decode(payload).Ints + ); + CollectionAssert.AreEqual( + new[] { 0, 1 }, + Decode>(payload).Array + ); + } + + [Test] + public void UnpackedEnumWritePolicyReadsMixedRuns() + { + UnpackedRepeatedContract value = Decode( + Parse("18001A02AC02") + ); + CollectionAssert.AreEqual(new[] { Mode.None, Mode.Careful }, value.Modes); + UnpackedRepeatedBox generic = Decode>( + Parse("08000A02AC02") + ); + CollectionAssert.AreEqual(new[] { Mode.None, Mode.Careful }, generic.Array); + } + + [TestCase(false, TestName = "ShortBuffer.Concrete")] + [TestCase(true, TestName = "ShortBuffer.Generic")] + public void UnpackedWriteReportsAShortBuffer(bool generic) + { + if (generic) + { + AssertShortBuffer( + new UnpackedRepeatedBox { Array = new[] { 0, 1, int.MaxValue } } + ); + } + else + { + AssertShortBuffer( + new UnpackedRepeatedContract { Ints = new[] { 0, 1, int.MaxValue } } + ); + } + } + + [TestCase("08", TestName = "Malformed.Unpacked")] + [TestCase("0A0201", TestName = "Malformed.Packed")] + public void UnpackedWritePolicyDoesNotRelaxReadValidation(string hex) + { + byte[] bytes = Parse(hex); + WProtoReader concrete = new WProtoReader(bytes); + Assert.IsFalse( + WProtoFormatterProvider.Get().TryRead(ref concrete, out _) + ); + WProtoReader generic = new WProtoReader(bytes); + Assert.IsFalse( + WProtoFormatterProvider.Get>().TryRead(ref generic, out _) + ); + } + + [Test] + public void ALargeUnpackedRunMeasuresExactlyAndRoundTrips() + { + int[] values = new int[10000]; + for (int index = 0; index < values.Length; ++index) + { + values[index] = index % 3 == 0 ? 0 : index; + } + UnpackedRepeatedContract concrete = new UnpackedRepeatedContract { Ints = values }; + UnpackedRepeatedBox generic = new UnpackedRepeatedBox { Array = values }; + CollectionAssert.AreEqual( + values, + Decode(Encode(concrete)).Ints + ); + CollectionAssert.AreEqual( + values, + Decode>(Encode(generic)).Array + ); + AssertOracle(concrete); + AssertOracle(generic); + } + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/PackingOptOutTests.cs.meta b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/PackingOptOutTests.cs.meta new file mode 100644 index 000000000..ded5d391a --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/PackingOptOutTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0f241b67cdf2681389651366d56d5000 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/RegistrarInstrumentationTests.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/RegistrarInstrumentationTests.cs index a5f4f6735..75e5d5d69 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/RegistrarInstrumentationTests.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/RegistrarInstrumentationTests.cs @@ -6,23 +6,17 @@ namespace WallstopStudios.UnityHelpers.Proto.Generator.Tests using System; using System.Collections.Generic; using System.Collections.Immutable; - using System.IO; using System.Linq; - using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; - using Microsoft.CodeAnalysis.Emit; using NUnit.Framework; /// - /// Pins the generated registrar's compile-time name and test-only first-invocation latch. + /// Pins generated registration without test-only instrumentation. /// [TestFixture] public sealed class RegistrarInstrumentationTests { - private const string RegistrarName = - "WallstopStudios.UnityHelpers.Generated.WProtoGeneratedRegistrar"; - private const string ContractSource = "using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; " + "namespace Consumer { " @@ -91,78 +85,20 @@ private static void AssertGeneratedCompilationSucceeded(Compilation generated) ); } - private static Assembly EmitAndLoad(Compilation generated) + [TestCase(false)] + [TestCase(true)] + public void RegistrarOmitsTestOnlyTimingSurface(bool includeTests) { - using MemoryStream assemblyBytes = new MemoryStream(); - EmitResult emitted = generated.Emit(assemblyBytes); - Assert.IsTrue( - emitted.Success, - string.Join( - Environment.NewLine, - emitted.Diagnostics.Select(diagnostic => diagnostic.ToString()) - ) - ); - return Assembly.Load(assemblyBytes.ToArray()); - } - - [Test] - public void ProductionRegistrarOmitsTestOnlyTimingSurface() - { - Compilation generated = Generate(includeTests: false, out string registrarSource); - - StringAssert.Contains("#if UNITY_INCLUDE_TESTS", registrarSource); + Compilation generated = Generate(includeTests, out string registrarSource); AssertGeneratedCompilationSucceeded(generated); - - // REFLECTION REQUIRED: the registrar exists only in the synthetic assembly emitted by this test. - Assembly assembly = EmitAndLoad(generated); - Type registrar = assembly.GetType(RegistrarName, throwOnError: true); - const BindingFlags StaticInternal = BindingFlags.Static | BindingFlags.NonPublic; - Assert.IsTrue( - registrar.GetProperty("FirstRegistrationElapsedTimestampTicks", StaticInternal) - == null - ); - Assert.IsTrue( - registrar.GetProperty("HasRecordedFirstRegistration", StaticInternal) == null - ); - } - - [Test] - public void TestRegistrarRecordsOnlyItsFirstInvocation() - { - Compilation generated = Generate(includeTests: true, out string registrarSource); - StringAssert.Contains( "internal static class WProtoGeneratedRegistrar", registrarSource ); - StringAssert.Contains("FirstRegistrationElapsedTimestampTicks", registrarSource); - StringAssert.Contains("HasRecordedFirstRegistration", registrarSource); - - // REFLECTION REQUIRED: the registrar exists only in the synthetic assembly emitted by this test. - Assembly assembly = EmitAndLoad(generated); - Type registrar = assembly.GetType(RegistrarName, throwOnError: true); - const BindingFlags StaticInternal = BindingFlags.Static | BindingFlags.NonPublic; - MethodInfo register = registrar.GetMethod("Register", StaticInternal); - PropertyInfo hasRecorded = registrar.GetProperty( - "HasRecordedFirstRegistration", - StaticInternal - ); - PropertyInfo elapsedTicks = registrar.GetProperty( - "FirstRegistrationElapsedTimestampTicks", - StaticInternal - ); - - Assert.IsTrue((bool)hasRecorded.GetValue(null)); - long firstElapsed = (long)elapsedTicks.GetValue(null); - Assert.GreaterOrEqual(firstElapsed, 0); - - register.Invoke(null, null); - long afterSecondInvocation = (long)elapsedTicks.GetValue(null); - register.Invoke(null, null); - long afterThirdInvocation = (long)elapsedTicks.GetValue(null); - - Assert.AreEqual(firstElapsed, afterSecondInvocation); - Assert.AreEqual(firstElapsed, afterThirdInvocation); + StringAssert.DoesNotContain("UNITY_INCLUDE_TESTS", registrarSource); + StringAssert.DoesNotContain("FirstRegistrationElapsedTimestampTicks", registrarSource); + StringAssert.DoesNotContain("HasRecordedFirstRegistration", registrarSource); + StringAssert.DoesNotContain("Stopwatch", registrarSource); } } } diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedBox.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedBox.cs new file mode 100644 index 000000000..e2a849211 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedBox.cs @@ -0,0 +1,31 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Proto.Generator.Tests +{ + using System.Collections.Generic; + using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// A generic repeated contract with unpacked and default write policies. + /// The element type. + [ProtoContract] + [WProtoContract] + public sealed partial class UnpackedRepeatedBox + { + /// The Array run. + [ProtoMember(1, IsPacked = false)] + [WProtoMember(1, IsPacked = false)] + public T[] Array; + + /// The List run. + [ProtoMember(2, IsPacked = false)] + [WProtoMember(2, IsPacked = false)] + public List List; + + /// The DefaultPacked run. + [ProtoMember(3, IsPacked = true)] + [WProtoMember(3)] + public T[] DefaultPacked; + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedBox.cs.meta b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedBox.cs.meta new file mode 100644 index 000000000..7ea6392f1 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedBox.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc5a30c6595ba667242a564bede678d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedContract.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedContract.cs new file mode 100644 index 000000000..8a1e7acec --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedContract.cs @@ -0,0 +1,60 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Proto.Generator.Tests +{ + using System.Collections.Generic; + using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// Repeated members with explicit write packing policies. + [ProtoContract] + [WProtoContract] + public sealed partial class UnpackedRepeatedContract + { + /// The Ints run. + [ProtoMember(1, IsPacked = false)] + [WProtoMember(1, IsPacked = false)] + public int[] Ints; + + /// The IntList run. + [ProtoMember(2, IsPacked = false)] + [WProtoMember(2, IsPacked = false)] + public List IntList; + + /// The Modes run. + [ProtoMember(3, IsPacked = false)] + [WProtoMember(3, IsPacked = false)] + public Mode[] Modes; + + /// The ModeList run. + [ProtoMember(4, IsPacked = false)] + [WProtoMember(4, IsPacked = false)] + public List ModeList; + + /// The Doubles run. + [ProtoMember(5, IsPacked = false)] + [WProtoMember(5, IsPacked = false)] + public double[] Doubles; + + /// The Flags run. + [ProtoMember(6, IsPacked = false)] + [WProtoMember(6, IsPacked = false)] + public bool[] Flags; + + /// The Texts run. + [ProtoMember(7, IsPacked = false)] + [WProtoMember(7, IsPacked = false)] + public string[] Texts; + + /// The PackedInts run. + [ProtoMember(8, IsPacked = true)] + [WProtoMember(8, IsPacked = true)] + public int[] PackedInts; + + /// The Chars run. + [ProtoMember(9, IsPacked = false)] + [WProtoMember(9, IsPacked = false)] + public char[] Chars; + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedContract.cs.meta b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedContract.cs.meta new file mode 100644 index 000000000..9c264ae86 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedContract.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9bce126c1543fe8d8e16956cbf7cc662 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedValues.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedValues.cs new file mode 100644 index 000000000..a5ca4df69 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedValues.cs @@ -0,0 +1,26 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Proto.Generator.Tests +{ + using System.Collections.Generic; + using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// A generic contract whose repeated members explicitly use unpacked writes. + /// The element type. + [ProtoContract] + [WProtoContract] + public sealed partial class UnpackedRepeatedValues + { + /// The Array run. + [ProtoMember(1, IsPacked = false)] + [WProtoMember(1, IsPacked = false)] + public T[] Array; + + /// The List run. + [ProtoMember(2, IsPacked = false)] + [WProtoMember(2, IsPacked = false)] + public List List; + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedValues.cs.meta b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedValues.cs.meta new file mode 100644 index 000000000..c861fca2c --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/UnpackedRepeatedValues.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3ed2fd10fa2291bf0afbf719c12c3f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroKeyMapContract.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroKeyMapContract.cs new file mode 100644 index 000000000..da9bd1162 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroKeyMapContract.cs @@ -0,0 +1,60 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Proto.Generator.Tests +{ + using System.Collections.Generic; + using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// Pins default map keys across the supported scalar shapes. + [ProtoContract] + [WProtoContract] + public sealed partial class ZeroKeyMapContract + { + /// A int key map. + [ProtoMember(1)] + [WProtoMember(1)] + public Dictionary Signed32; + + /// A long key map. + [ProtoMember(2)] + [WProtoMember(2)] + public Dictionary Signed64; + + /// A uint key map. + [ProtoMember(3)] + [WProtoMember(3)] + public Dictionary Unsigned32; + + /// A ulong key map. + [ProtoMember(4)] + [WProtoMember(4)] + public Dictionary Unsigned64; + + /// A bool key map. + [ProtoMember(5)] + [WProtoMember(5)] + public Dictionary Boolean; + + /// A ZeroMapKeyKind key map. + [ProtoMember(6)] + [WProtoMember(6)] + public Dictionary Enumeration; + + /// A float key map. + [ProtoMember(7)] + [WProtoMember(7)] + public Dictionary Single; + + /// A double key map. + [ProtoMember(8)] + [WProtoMember(8)] + public Dictionary Double; + + /// A string key map. + [ProtoMember(9)] + [WProtoMember(9)] + public Dictionary Text; + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroKeyMapContract.cs.meta b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroKeyMapContract.cs.meta new file mode 100644 index 000000000..474ec9f92 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroKeyMapContract.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 35a01c01c2ed6907e7f282691be45fa7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroMapKeyKind.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroMapKeyKind.cs new file mode 100644 index 000000000..c0877a9b8 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroMapKeyKind.cs @@ -0,0 +1,18 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Proto.Generator.Tests +{ + using System; + + /// A byte-backed map key matching the consumer's button kind. + public enum ZeroMapKeyKind : byte + { + /// No key. + [Obsolete("Use a defined key unless verifying the protobuf default.")] + None = 0, + + /// A nondefault key. + Other = 7, + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroMapKeyKind.cs.meta b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroMapKeyKind.cs.meta new file mode 100644 index 000000000..d7ac0fcc2 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/ZeroMapKeyKind.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 65bf79d7af4129963c03bd131c3fbafa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/MapMember.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/MapMember.cs index 6603abe48..82b12187b 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/MapMember.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/MapMember.cs @@ -20,7 +20,8 @@ namespace WallstopStudios.UnityHelpers.Proto.Generator /// scalar rules rather than always carrying both halves. {"a": 0} encodes as /// 0A 03 0A 01 61 -- tag, length 3, key only -- because the value equals its default and /// is omitted exactly as a member would be. An empty-string key is still written (0A 00), - /// only null is absent, and a null value is dropped the same way. An empty dictionary writes + /// only null is absent. Enum keys retain zero while numeric keys omit it. A null value is dropped + /// the same way. An empty dictionary writes /// nothing at all, so it reads back as whatever the constructor left behind. /// /// @@ -88,6 +89,7 @@ internal sealed class MapMember : Member private readonly bool _overwrite; private readonly bool _mapIsValueType; private readonly bool _keyIsString; + private readonly bool _keyIsEnum; private readonly bool _valueIsString; private MapMember( @@ -104,6 +106,7 @@ private MapMember( bool overwrite, bool mapIsValueType, bool keyIsString, + bool keyIsEnum, bool valueIsString ) : base(name, tag) @@ -119,6 +122,7 @@ bool valueIsString _overwrite = overwrite; _mapIsValueType = mapIsValueType; _keyIsString = keyIsString; + _keyIsEnum = keyIsEnum; _valueIsString = valueIsString; } @@ -241,6 +245,7 @@ NestedCollections nested overwriteList, named.IsValueType, keyIsString, + keyType.TypeKind == TypeKind.Enum, valueType.SpecialType == SpecialType.System_String ); } @@ -357,30 +362,6 @@ private static void CloseAll(Writer writer, int count) } } - private static string MapPresence(Shape shape, string access, bool isKey) - { - /* - * The shipped v3 oracle writes fixed-width zero keys but omits zero values; both forms remain - * readable across majors. - */ - return - isKey - && ( - string.Equals( - shape.WireType, - Proto + ".WProtoWireType.Fixed32", - System.StringComparison.Ordinal - ) - || string.Equals( - shape.WireType, - Proto + ".WProtoWireType.Fixed64", - System.StringComparison.Ordinal - ) - ) - ? "true" - : Shape.Fill(shape.PresenceTest, access); - } - /// internal override void EmitMeasure(Writer writer) { @@ -654,6 +635,12 @@ internal override void EmitReadEpilogue(Writer writer, string qualifiedContract) writer.Blank(); } + private string MapPresence(Shape shape, string access, bool isKey) + { + // protobuf-net 3.2.56 retains default enum keys but omits default numeric keys. + return isKey && _keyIsEnum ? "true" : Shape.Fill(shape.PresenceTest, access); + } + private void EmitHalfWrite(Writer writer, Shape shape, string access, int tag, bool isKey) { writer.Line("if (" + MapPresence(shape, access, isKey) + ")" + Writer.Open); diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/Member.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/Member.cs index 752952d1a..062ad8891 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/Member.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/Member.cs @@ -191,6 +191,7 @@ internal static Member Create( ITypeSymbol type, bool isRequired, bool overwriteList, + bool isPacked, bool zigZag, SurrogateMap surrogates, NestedCollections nested, @@ -247,6 +248,7 @@ out bool ambiguous tag, type, overwriteList, + isPacked, surrogates, nested ); diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/NestedCollections.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/NestedCollections.cs index 92a30c29f..e8149116b 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/NestedCollections.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/NestedCollections.cs @@ -440,6 +440,7 @@ internal Shape TryShape(ITypeSymbol type, string qualified, string memberName) false, // Wrappers have no constructor seed, so reads replace rather than append. true, + true, false, _surrogates, this, diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/RepeatedMember.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/RepeatedMember.cs index d8782bbd3..cca3b9645 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/RepeatedMember.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/RepeatedMember.cs @@ -102,7 +102,8 @@ internal sealed class RepeatedMember : Member /// 1000. /// /// - /// This is a deliberate divergence from protobuf-net's OUTPUT at CompatibilityLevel 200, + /// The member can opt out through IsPacked = false, retaining one key per element. + /// The default is a deliberate divergence from protobuf-net's OUTPUT at CompatibilityLevel 200, /// which writes unpacked, and it is safe because wire compatibility is about what the other /// side can READ. Measured against protobuf-net 3.2.56: a packed run decodes into a field it /// declares unpacked, exactly, for every packable element type -- int, long, ulong, bool, @@ -114,7 +115,7 @@ internal sealed class RepeatedMember : Member /// closure, so its run is decided at runtime by WProtoGeneric<T> instead. /// /// - private bool WritesPacked => !_elementIsGeneric && _shape.Packable; + private bool WritesPacked => _isPacked && !_elementIsGeneric && _shape.Packable; /// Whether the member is walked by index rather than with foreach. private bool IsArray => _form.WalksByIndex; @@ -142,6 +143,7 @@ internal sealed class RepeatedMember : Member private readonly string _collectionDisplay; private readonly bool _collectionIsValueType; private readonly bool _overwrite; + private readonly bool _isPacked; private readonly bool _elementIsReference; private readonly bool _elementIsGeneric; @@ -157,7 +159,8 @@ private RepeatedMember( string collectionDisplay, bool collectionIsValueType, bool overwrite, - bool elementIsGeneric + bool elementIsGeneric, + bool isPacked ) : base(name, tag) { @@ -170,6 +173,7 @@ bool elementIsGeneric _collectionDisplay = collectionDisplay; _collectionIsValueType = collectionIsValueType; _overwrite = overwrite; + _isPacked = isPacked; _elementIsGeneric = elementIsGeneric; _elementIsReference = !elementIsGeneric && shape.IsReference; } @@ -184,6 +188,7 @@ internal static RepeatedMember TryCreate( int tag, ITypeSymbol type, bool overwriteList, + bool isPacked, SurrogateMap surrogates, NestedCollections nested ) @@ -231,7 +236,8 @@ NestedCollections nested TypeNaming.Display(type), type.IsValueType, overwriteList, - elementIsGeneric + elementIsGeneric, + isPacked ); } @@ -291,7 +297,8 @@ NestedCollections nested false, // Wrapper arrays have no constructor seed, so reads replace rather than append. true, - elementIsGeneric + elementIsGeneric, + true ); } @@ -495,7 +502,7 @@ internal override void EmitMeasure(Writer writer) return; } - if (_elementIsGeneric) + if (_elementIsGeneric && _isPacked) { EmitGenericMeasure(writer); return; @@ -504,11 +511,15 @@ internal override void EmitMeasure(Writer writer) int open = OpenLoop(writer); writer.Line( "size += " - + Proto - + ".WProtoSizes.TagSize(" - + Tag - + ") + " - + Shape.Fill(_shape.SizeExpression, ElementLocal) + + ( + _elementIsGeneric + ? Generic + ".MeasureElement(" + Tag + ", " + ElementLocal + ")" + : Proto + + ".WProtoSizes.TagSize(" + + Tag + + ") + " + + Shape.Fill(_shape.SizeExpression, ElementLocal) + ) + ";" ); CloseAll(writer, open); @@ -524,7 +535,7 @@ internal override void EmitWrite(Writer writer) return; } - if (_elementIsGeneric) + if (_elementIsGeneric && _isPacked) { EmitGenericWrite(writer); return; diff --git a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/WProtoGenerator.cs b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/WProtoGenerator.cs index 5117fb3e2..a30dc6c0e 100644 --- a/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/WProtoGenerator.cs +++ b/Generator~/WallstopStudios.UnityHelpers.Proto.Generator/WProtoGenerator.cs @@ -527,7 +527,7 @@ private static bool IsContract(INamedTypeSymbol symbol) } /// - /// The nearest ancestor that carries [WProtoContract], or null. + /// The nearest declared contract before an opt-out boundary, or null. /// /// The type to walk up from. /// The declared contract this type inherits its serialization from. @@ -535,6 +535,11 @@ private static INamedTypeSymbol DeclaredContractAncestor(INamedTypeSymbol symbol { for (INamedTypeSymbol current = symbol; current != null; current = current.BaseType) { + if (HasAttribute(current, NotSerializedAttribute)) + { + return null; + } + if (HasAttribute(current, ContractAttribute)) { return current; @@ -2767,13 +2772,6 @@ bool disableModuleInitializer writer.Line("internal static class WProtoGeneratedRegistrar" + Writer.Open); writer.Indent(); - writer.Line("#if UNITY_INCLUDE_TESTS"); - writer.Line( - "internal static long FirstRegistrationElapsedTimestampTicks { get; private set; }" - ); - writer.Line("internal static bool HasRecordedFirstRegistration { get; private set; }"); - writer.Line("#endif"); - // BeforeSceneLoad follows built-in SubsystemRegistration so consumer formatter replacements win. writer.Line("#if UNITY_5_3_OR_NEWER"); writer.Line("#if UNITY_EDITOR"); @@ -2790,11 +2788,6 @@ bool disableModuleInitializer writer.Line("#endif"); writer.Line("internal static void Register()" + Writer.Open); writer.Indent(); - writer.Line("#if UNITY_INCLUDE_TESTS"); - writer.Line( - "long registrationStarted = global::System.Diagnostics.Stopwatch.GetTimestamp();" - ); - writer.Line("#endif"); writer.Line(Proto + ".WProtoScalarFormatters.RegisterAll();"); foreach (INamedTypeSymbol enumClosure in enumClosures) { @@ -2856,17 +2849,6 @@ bool disableModuleInitializer writer.Line(Proto + ".WProtoDeclaredRootProvider.Register" + declaredRoot + "();"); } - writer.Line("#if UNITY_INCLUDE_TESTS"); - writer.Line("if (!HasRecordedFirstRegistration)" + Writer.Open); - writer.Indent(); - writer.Line( - "FirstRegistrationElapsedTimestampTicks = global::System.Diagnostics.Stopwatch.GetTimestamp() - registrationStarted;" - ); - writer.Line("HasRecordedFirstRegistration = true;"); - writer.Outdent(); - writer.Line("}"); - writer.Line("#endif"); - writer.Outdent(); writer.Line("}"); writer.Outdent(); @@ -3357,6 +3339,7 @@ NestedCollections nested type, NamedFlag(attribute, "IsRequired"), NamedFlag(attribute, "OverwriteList"), + NamedFlag(attribute, "IsPacked", true), zigZag, surrogates, nested, @@ -3592,7 +3575,11 @@ private static bool AsksForZigZag(AttributeData attribute) return false; } - private static bool NamedFlag(AttributeData attribute, string name) + private static bool NamedFlag( + AttributeData attribute, + string name, + bool defaultValue = false + ) { foreach (KeyValuePair argument in attribute.NamedArguments) { @@ -3605,7 +3592,7 @@ private static bool NamedFlag(AttributeData attribute, string name) } } - return false; + return defaultValue; } private static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol contract) diff --git a/Generator~/WallstopStudios.UnityHelpers.TestCheck/WallstopStudios.UnityHelpers.TestCheck.csproj b/Generator~/WallstopStudios.UnityHelpers.TestCheck/WallstopStudios.UnityHelpers.TestCheck.csproj index 737f20886..866694f13 100644 --- a/Generator~/WallstopStudios.UnityHelpers.TestCheck/WallstopStudios.UnityHelpers.TestCheck.csproj +++ b/Generator~/WallstopStudios.UnityHelpers.TestCheck/WallstopStudios.UnityHelpers.TestCheck.csproj @@ -37,9 +37,9 @@ $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../..')) true false - + $(DefineConstants);UNITY_INCLUDE_TESTS;UNITY_5_3_OR_NEWER $(NoWarn);CS0414;CS0649;CS0219;CS0618;CS0612;CS1701 diff --git a/Runtime/Analyzers/WallstopStudios.UnityHelpers.Proto.Generator.dll b/Runtime/Analyzers/WallstopStudios.UnityHelpers.Proto.Generator.dll index 920f13ff8..82371dc83 100644 Binary files a/Runtime/Analyzers/WallstopStudios.UnityHelpers.Proto.Generator.dll and b/Runtime/Analyzers/WallstopStudios.UnityHelpers.Proto.Generator.dll differ diff --git a/Runtime/Core/DataStructure/Adapters/SerializableNullable.cs b/Runtime/Core/DataStructure/Adapters/SerializableNullable.cs index 4568fd3a7..83ddfe550 100644 --- a/Runtime/Core/DataStructure/Adapters/SerializableNullable.cs +++ b/Runtime/Core/DataStructure/Adapters/SerializableNullable.cs @@ -76,13 +76,13 @@ public T Value [SerializeField] [ProtoMember(1)] [WProtoMember(1)] - private bool _hasValue; + internal bool _hasValue; [SerializeField] [ProtoMember(2, IsRequired = false)] [WProtoMember(2, IsRequired = false)] [WShowIf(nameof(_hasValue))] - private T _value; + internal T _value; /// /// Initializes a new instance of the struct. @@ -310,12 +310,6 @@ public bool Equals(T other) return comparer.Equals(_value, other); } - internal void ForceStateForTesting(bool hasValue, T rawValue) - { - _hasValue = hasValue; - _value = rawValue; - } - bool IUnderlyingValueProvider.TryGetUnderlyingValue(out object value) { if (!_hasValue) diff --git a/Runtime/Core/DataStructure/Adapters/SerializableType.cs b/Runtime/Core/DataStructure/Adapters/SerializableType.cs index 92dbbb5e5..30d71da58 100644 --- a/Runtime/Core/DataStructure/Adapters/SerializableType.cs +++ b/Runtime/Core/DataStructure/Adapters/SerializableType.cs @@ -419,19 +419,9 @@ public static int MaxCachedFilterResults } } } + internal static Dictionary _descriptorByName; - internal static int CachedFilterResultCountForTesting => FilterCache.Count; - internal static bool IsDescriptorCacheInitializedForTesting => - Volatile.Read(ref _descriptorByName) != null; - - private static readonly object SyncRoot = new(); - private static SerializableTypeDescriptor[] _descriptors; - private static Dictionary _descriptorByName; - private static string[] _assemblyQualifiedNames; - private static string[] _displayNames; - private static string[] _tooltips; - - private static readonly Cache FilterCache = + internal static readonly Cache FilterCache = CacheBuilder .NewBuilder() .MaximumSize(DefaultMaxCachedFilterResults) @@ -439,6 +429,12 @@ public static int MaxCachedFilterResults .KeyComparer(StringComparer.OrdinalIgnoreCase) .Build(); + private static readonly object SyncRoot = new(); + private static SerializableTypeDescriptor[] _descriptors; + private static string[] _assemblyQualifiedNames; + private static string[] _displayNames; + private static string[] _tooltips; + private static int _maxCachedFilterResults = DefaultMaxCachedFilterResults; private static readonly object FilterCacheResizeLock = new(); diff --git a/Runtime/Core/DataStructure/Cache.cs b/Runtime/Core/DataStructure/Cache.cs index 73a51dcdd..7f3bfb945 100644 --- a/Runtime/Core/DataStructure/Cache.cs +++ b/Runtime/Core/DataStructure/Cache.cs @@ -9,6 +9,7 @@ namespace WallstopStudios.UnityHelpers.Core.DataStructure using System.Runtime.InteropServices; using System.Threading; using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Helper; using WallstopStudios.UnityHelpers.Core.Random; using WallstopStudios.UnityHelpers.Utils; #if !SINGLE_THREADED @@ -43,6 +44,7 @@ namespace WallstopStudios.UnityHelpers.Core.DataStructure /// /// The type of keys in the cache. /// The type of values in the cache. + /// Finite clock offsets and jitter remain significant beside large lifetimes; expiration includes the exact lifetime boundary. Disabled expiration has no deadline. public sealed class Cache : IDisposable { private const int InvalidIndex = -1; @@ -51,13 +53,13 @@ public sealed class Cache : IDisposable private const float ThrashWindowSeconds = 1f; private IRandom Random => _random ??= PRNG.Instance; + // Delay PRNG initialization to avoid static-initializer deadlocks while Unity opens a scene. + internal IRandom _random; + private readonly CacheOptions _options; private readonly Func _timeProvider; private readonly Queue _pendingEvictions; - - // Delay PRNG initialization to avoid static-initializer deadlocks while Unity opens a scene. - private IRandom _random; private ReaderWriterLockSlim _lock; #if SINGLE_THREADED @@ -112,12 +114,10 @@ public IEnumerable Keys } } } + internal int _probationCount; + internal int _protectedCount; + internal int _protectedCapacity; - internal int ProtectedCapacityForTesting => _protectedCapacity; - - internal int ProtectedCountForTesting => _protectedCount; - - internal int ProbationCountForTesting => _probationCount; private CacheEntry[] _entries; private int _count; private int _capacity; @@ -133,9 +133,6 @@ public IEnumerable Keys private int _probationTail; private int _protectedHead; private int _protectedTail; - private int _probationCount; - private int _protectedCount; - private int _protectedCapacity; private int _freeListHead; @@ -226,6 +223,29 @@ public Cache(CacheOptions options) InitializeLinkedLists(); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsLifetimeExpired( + float startedAt, + double duration, + double remainder, + float currentTime + ) + { + if (!float.IsFinite(startedAt) || !float.IsFinite(currentTime)) + { + float roundedDuration = (float)(duration + remainder); + float deadline = (float)((double)startedAt + roundedDuration); + return deadline <= currentTime; + } + return CompensatedTime.HasElapsed( + startedAt, + currentTime, + duration, + remainder, + inclusive: true + ); + } + private static float DefaultTimeProvider() { return (float)Stopwatch.Elapsed.TotalSeconds; @@ -387,6 +407,7 @@ public void Set(TKey key, TValue value) /// /// Adds or updates an entry in the cache with a custom TTL. /// + /// A failing custom lifetime function preserves existing values, expiration schedules, and cache storage. /// The key of the entry. /// The value to cache. /// Custom TTL in seconds. If null, uses default expiration. @@ -823,6 +844,13 @@ private bool TryGetUnlocked(TKey key, out TValue value, bool recordStatistics = private void SetUnlocked(TKey key, TValue value, float? ttlSeconds = null) { float currentTime = _timeProvider(); + double expirationDuration = ComputeExpirationDuration( + key, + value, + ttlSeconds, + out double expirationRemainder, + out bool hasExpiration + ); if (_keyToIndex.TryGetValue(key, out int existingIndex)) { @@ -830,14 +858,11 @@ private void SetUnlocked(TKey key, TValue value, float? ttlSeconds = null) long oldWeight = _options.Weigher != null ? _entries[existingIndex].Weight : 0; _entries[existingIndex].Value = value; - _entries[existingIndex].WriteTime = currentTime; + _entries[existingIndex].ExpirationStartedAt = currentTime; _entries[existingIndex].AccessTime = currentTime; - _entries[existingIndex].ExpirationTime = ComputeExpirationTime( - key, - value, - currentTime, - ttlSeconds - ); + _entries[existingIndex].ExpirationDuration = expirationDuration; + _entries[existingIndex].ExpirationRemainder = expirationRemainder; + _entries[existingIndex].HasExpiration = hasExpiration; if (_options.Weigher != null) { @@ -887,9 +912,11 @@ private void SetUnlocked(TKey key, TValue value, float? ttlSeconds = null) { Key = key, Value = value, - WriteTime = currentTime, + ExpirationStartedAt = currentTime, AccessTime = currentTime, - ExpirationTime = ComputeExpirationTime(key, value, currentTime, ttlSeconds), + ExpirationDuration = expirationDuration, + ExpirationRemainder = expirationRemainder, + HasExpiration = hasExpiration, Frequency = 1, PrevIndex = InvalidIndex, NextIndex = InvalidIndex, @@ -938,14 +965,15 @@ private void ClearEntriesUnlocked() InitializeLinkedLists(); } - private float ComputeExpirationTime( + private double ComputeExpirationDuration( TKey key, TValue value, - float currentTime, - float? explicitTtl + float? explicitTtl, + out double remainder, + out bool hasExpiration ) { - float ttl; + double ttl; if (explicitTtl.HasValue && 0f < explicitTtl.Value) { @@ -961,32 +989,51 @@ private float ComputeExpirationTime( } else { - return float.MaxValue; + remainder = 0d; + hasExpiration = false; + return 0d; } - if (_options.UseJitter && 0f < ttl) + float jitter = 0f; + if (_options.UseJitter && 0d < ttl) { float maxJitter = - 0f < _options.JitterMaxSeconds ? _options.JitterMaxSeconds : ttl * 0.1f; - ttl += Random.NextFloat(0f, maxJitter); + 0f < _options.JitterMaxSeconds ? _options.JitterMaxSeconds : (float)ttl * 0.1f; + jitter = Random.NextFloat(0f, maxJitter); } - return currentTime + ttl; + CompensatedTime.Sum(ttl, jitter, out double duration, out double durationRemainder); + remainder = durationRemainder; + hasExpiration = true; + return duration; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool IsExpired(int index, float currentTime) { - if (_entries[index].ExpirationTime <= currentTime) + if ( + _entries[index].HasExpiration + && IsLifetimeExpired( + _entries[index].ExpirationStartedAt, + _entries[index].ExpirationDuration, + _entries[index].ExpirationRemainder, + currentTime + ) + ) { return true; } if (0f < _options.ExpireAfterAccessSeconds) { - float slidingExpiration = - _entries[index].AccessTime + _options.ExpireAfterAccessSeconds; - if (slidingExpiration <= currentTime) + if ( + IsLifetimeExpired( + _entries[index].AccessTime, + _options.ExpireAfterAccessSeconds, + 0d, + currentTime + ) + ) { return true; } @@ -1001,7 +1048,10 @@ private void OnAccess(int index, float currentTime) if (0f < _options.ExpireAfterAccessSeconds) { - _entries[index].ExpirationTime = currentTime + _options.ExpireAfterAccessSeconds; + _entries[index].ExpirationStartedAt = currentTime; + _entries[index].ExpirationDuration = _options.ExpireAfterAccessSeconds; + _entries[index].ExpirationRemainder = 0d; + _entries[index].HasExpiration = true; } switch (_options.Policy) @@ -1773,14 +1823,16 @@ private struct CacheEntry { public TKey Key; public TValue Value; - public float WriteTime; + public float ExpirationStartedAt; public float AccessTime; - public float ExpirationTime; + public double ExpirationDuration; + public double ExpirationRemainder; public int Frequency; public int PrevIndex; public int NextIndex; public byte SegmentIndex; public bool IsAlive; + public bool HasExpiration; public long Weight; } diff --git a/Runtime/Core/DataStructure/StringWrapper.cs b/Runtime/Core/DataStructure/StringWrapper.cs index c51bfdd74..7ff3c8575 100644 --- a/Runtime/Core/DataStructure/StringWrapper.cs +++ b/Runtime/Core/DataStructure/StringWrapper.cs @@ -75,7 +75,7 @@ public static int MaxCachedWrappers /// public static int CachedCount => Cache.Count; - private static readonly Cache Cache = CacheBuilder< + internal static readonly Cache Cache = CacheBuilder< string, StringWrapper > @@ -142,11 +142,6 @@ public static int Clear() return count; } - internal static bool IsCachedForTesting(string value) - { - return value != null && Cache.ContainsKey(value); - } - public bool Equals(StringWrapper other) { if (ReferenceEquals(this, other)) diff --git a/Runtime/Core/DataStructure/TimedCache.cs b/Runtime/Core/DataStructure/TimedCache.cs index 79dddf285..b5e888a87 100644 --- a/Runtime/Core/DataStructure/TimedCache.cs +++ b/Runtime/Core/DataStructure/TimedCache.cs @@ -4,6 +4,7 @@ namespace WallstopStudios.UnityHelpers.Core.DataStructure { using System; + using Helper; using Random; using UnityEngine; @@ -20,6 +21,8 @@ namespace WallstopStudios.UnityHelpers.Core.DataStructure /// /// Use for expensive computations that can be reused for a short period (e.g., path costs, counts, queries). /// Optionally introduces a one-time jitter to spread refreshes across frames when many caches exist. + /// Failed factories or time providers preserve the cached value, timer, and initial jitter. + /// Finite clock offsets and jitter remain significant beside very large lifetimes. /// public sealed class TimedCache { @@ -36,15 +39,27 @@ public T Value } else { - float expiration = - _cacheTtl + (_shouldUseJitter && !_usedJitter ? _jitterAmount : 0f); - if (_lastRead.Value + expiration < CurrentTime) + float jitter = _shouldUseJitter && !_usedJitter ? _jitterAmount : 0f; + CompensatedTime.Sum( + _cacheTtl, + jitter, + out double expiration, + out double remainder + ); + float currentTime = CurrentTime; + bool expired = + float.IsFinite(_lastRead.Value) && float.IsFinite(currentTime) + ? CompensatedTime.HasElapsed( + _lastRead.Value, + currentTime, + expiration, + remainder, + inclusive: false + ) + : expiration < (double)currentTime - _lastRead.Value; + if (expired) { - if (_shouldUseJitter) - { - _usedJitter = true; - } - ResetInternal(consumeJitter: false); + ResetInternal(consumeJitter: true); } } @@ -108,8 +123,10 @@ public void Reset() private void ResetInternal(bool consumeJitter) { - _value = _valueProducer(); - _lastRead = CurrentTime; + T value = _valueProducer(); + float refreshedAt = CurrentTime; + _value = value; + _lastRead = refreshedAt; if (consumeJitter && _shouldUseJitter) { _usedJitter = true; diff --git a/Runtime/Core/Extension/Partials/UI.cs b/Runtime/Core/Extension/Partials/UI.cs index ea3ef6d47..24ed06658 100644 --- a/Runtime/Core/Extension/Partials/UI.cs +++ b/Runtime/Core/Extension/Partials/UI.cs @@ -11,17 +11,6 @@ namespace WallstopStudios.UnityHelpers.Core.Extension public static partial class UnityExtensions { -#if UNITY_EDITOR || UNITY_INCLUDE_TESTS - internal static Func ScreenDpiProvider - { - get => _screenDpiProvider ?? DefaultScreenDpiProvider; - set => _screenDpiProvider = value; - } - - private static readonly Func DefaultScreenDpiProvider = () => Screen.dpi; - private static Func _screenDpiProvider; -#endif - /// /// Sets all color states of a UI Slider to the same color. /// @@ -161,19 +150,12 @@ public static bool TryApplyPixelDragThresholdForCurrentDpi( eventSystem.pixelDragThreshold = CalculatePixelDragThreshold( baseThreshold, - GetScreenDpi(), + Screen.dpi, referenceDpi ); return true; } -#if UNITY_EDITOR || UNITY_INCLUDE_TESTS - internal static void ResetScreenDpiProvider() - { - _screenDpiProvider = null; - } -#endif - /// Tries to resolve a pointer position on the target rectangle's plane. /// The pointer event to resolve. /// The target rectangle. @@ -354,15 +336,6 @@ private static Vector2 ClampToScreen(Vector2 point) return new Vector2(x, y); } - private static float GetScreenDpi() - { -#if UNITY_EDITOR || UNITY_INCLUDE_TESTS - return ScreenDpiProvider(); -#else - return Screen.dpi; -#endif - } - private static bool IsFinite(Vector2 point) { return float.IsFinite(point.x) && float.IsFinite(point.y); diff --git a/Runtime/Core/Helper/CompensatedTime.cs b/Runtime/Core/Helper/CompensatedTime.cs new file mode 100644 index 000000000..ea956f07c --- /dev/null +++ b/Runtime/Core/Helper/CompensatedTime.cs @@ -0,0 +1,98 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Core.Helper +{ + using System; + using System.Runtime.CompilerServices; + + /// + /// Retains rounding residuals when comparing float clocks and durations. + /// + internal static class CompensatedTime + { + private const double FloatOverflowMidpoint = 3.4028235677973366e38d; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void Sum(double left, double right, out double high, out double low) + { + double total = left + right; + if (!double.IsFinite(total)) + { + high = total; + low = 0d; + return; + } + double roundedRight = total - left; + double remainder = (left - (total - roundedRight)) + (right - roundedRight); + high = total; + low = remainder; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool HasElapsed( + float startedAt, + float currentTime, + double duration, + double durationRemainder, + bool inclusive + ) + { + Sum(currentTime, -(double)startedAt, out double elapsed, out double elapsedRemainder); + if (double.IsNaN(duration) || double.IsNaN(elapsed)) + { + return false; + } + if (duration < elapsed) + { + return true; + } + if (elapsed < duration) + { + return false; + } + return inclusive + ? durationRemainder <= elapsedRemainder + : durationRemainder < elapsedRemainder; + } + + internal static float RemainingDuration(float startedAt, float duration, float currentTime) + { + if (!float.IsFinite(duration)) + { + return duration < 0f ? 0f : duration; + } + Sum(currentTime, -(double)startedAt, out double elapsed, out double elapsedRemainder); + Sum(duration, -elapsed, out double remaining, out double remainingRemainder); + Sum(remaining, remainingRemainder - elapsedRemainder, out double high, out double low); + return RoundNonnegative(high, low); + } + + private static float RoundNonnegative(double high, double low) + { + if (high < 0d || (high == 0d && low < 0d)) + { + return 0f; + } + int roundedBits = BitConverter.SingleToInt32Bits((float)high); + float rounded = BitConverter.Int32BitsToSingle(roundedBits); + if (low == 0d || !double.IsFinite(high)) + { + return rounded; + } + if (float.IsPositiveInfinity(rounded)) + { + return high == FloatOverflowMidpoint && low < 0d ? float.MaxValue : rounded; + } + if (rounded == 0f && low < 0d) + { + return rounded; + } + int bits = rounded == 0f ? 0 : roundedBits; + int adjacentBits = 0d < low ? bits + 1 : bits - 1; + float adjacent = BitConverter.Int32BitsToSingle(adjacentBits); + double midpoint = ((double)rounded + adjacent) * 0.5d; + return high == midpoint ? adjacent : rounded; + } + } +} diff --git a/Runtime/Core/Helper/CompensatedTime.cs.meta b/Runtime/Core/Helper/CompensatedTime.cs.meta new file mode 100644 index 000000000..1e4674b33 --- /dev/null +++ b/Runtime/Core/Helper/CompensatedTime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cda77228c68b94d023e038e6e910bc00 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/Helper/DurableFile.cs b/Runtime/Core/Helper/DurableFile.cs index bca253f6e..ebe948b42 100644 --- a/Runtime/Core/Helper/DurableFile.cs +++ b/Runtime/Core/Helper/DurableFile.cs @@ -70,12 +70,6 @@ public static class DurableFile encoderShouldEmitUTF8Identifier: false ); -#if UNITY_EDITOR - internal static Action BeforeStagedSwapForTests { get; set; } - internal static Action BeforeStagedCleanupForTests { get; set; } - internal static Action BeforeCompareReadForTests { get; set; } -#endif - /// /// Replaces a file's entire contents, staging and flushing before the swap. /// @@ -426,9 +420,6 @@ public static bool TryCopy(string sourcePath, string destinationPath, out Except staging.Flush(flushToDisk: true); } -#if UNITY_EDITOR - BeforeStagedSwapForTests?.Invoke(temporaryPath); -#endif Swap(temporaryPath, destinationPath); } @@ -437,9 +428,6 @@ public static bool TryCopy(string sourcePath, string destinationPath, out Except } catch (Exception e) { -#if UNITY_EDITOR - BeforeStagedCleanupForTests?.Invoke(temporaryPath); -#endif DiscardStagedFile(temporaryPath); error = e; return false; @@ -536,14 +524,8 @@ out Exception ownershipError /// The failure when this returns false; null otherwise. If staged bytes could not be /// published, identifies their path in Data. /// - /// Optional action invoked just before publication. /// True when the new file was published without replacing another file. - internal static bool TryCreateAllBytes( - string path, - byte[] contents, - out Exception error, - Action beforeMove = null - ) + internal static bool TryCreateAllBytes(string path, byte[] contents, out Exception error) { if (string.IsNullOrWhiteSpace(path)) { @@ -587,7 +569,6 @@ internal static bool TryCreateAllBytes( staging.Flush(flushToDisk: true); } - beforeMove?.Invoke(path); if ( !TryPublishStagedFileWithoutOverwrite( temporaryPath, @@ -600,12 +581,7 @@ out bool leavesStaged } ownsTemporary = leavesStaged; moved = true; - if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(contents)) - { - throw new IOException( - $"Created file at {path}, but its contents differ; inspect it before retrying." - ); - } + VerifyCreatedContents(path, contents); if (leavesStaged) { File.Delete(temporaryPath); @@ -773,9 +749,6 @@ out Exception error { return false; } -#if UNITY_EDITOR - BeforeCompareReadForTests?.Invoke(temporaryPath); -#endif byte[] current = File.ReadAllBytes(path); if (!BytesEqual(current, expected)) { @@ -791,9 +764,6 @@ out Exception error staging.Flush(flushToDisk: true); staging.Dispose(); staging = null; -#if UNITY_EDITOR - BeforeStagedSwapForTests?.Invoke(temporaryPath); -#endif Swap(temporaryPath, path); ownsStaging = false; error = null; @@ -884,9 +854,6 @@ out Exception error staging.Flush(flushToDisk: true); staging.Dispose(); staging = null; -#if UNITY_EDITOR - BeforeStagedSwapForTests?.Invoke(temporaryPath); -#endif Swap(temporaryPath, path); ownsStaging = false; error = null; @@ -989,20 +956,13 @@ await source staging.Flush(flushToDisk: true); } -#if UNITY_EDITOR - BeforeStagedSwapForTests?.Invoke(temporaryPath); -#endif - cancellationToken.ThrowIfCancellationRequested(); - Swap(temporaryPath, destinationPath); + PublishStagedFile(temporaryPath, destinationPath, cancellationToken); } return null; } catch (Exception e) { -#if UNITY_EDITOR - BeforeStagedCleanupForTests?.Invoke(temporaryPath); -#endif DiscardStagedFile(temporaryPath); return e; } @@ -1013,6 +973,108 @@ await source } } + /// Verifies a newly published document before its staged name is removed. + internal static void VerifyCreatedContents(string path, byte[] contents) + { + if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(contents)) + { + throw new IOException( + $"Created file at {path}, but its contents differ; inspect it before retrying." + ); + } + } + + // Only a successful exclusive staging open establishes cleanup ownership; failed opens may name another writer. + /// Opens the staging file exclusively for writing. + internal static FileStream OpenStagingStream(string temporaryPath, bool useAsync) + { + return new FileStream( + temporaryPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + DefaultBufferSize, + useAsync + ); + } + + // Isolate expected sharing failures from IL2CPP callers and async state machines. + /// Acquires cooperating-writer ownership until the returned stream is disposed. + [MethodImpl(MethodImplOptions.NoInlining)] + internal static bool TryOpenStagingOwnership( + string temporaryPath, + out FileStream ownership, + out Exception error + ) + { + try + { + ownership = new FileStream( + temporaryPath + OwnershipSuffix, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.Read, + bufferSize: 1, + FileOptions.DeleteOnClose + ); + error = null; + return true; + } + catch (IOException e) + { + ownership = null; + error = e; + return false; + } + catch (Exception e) + { + ownership = null; + error = e; + return false; + } + } + + /// Removes an owned staging file without masking the original failure. + internal static void DiscardStagedFile(string temporaryPath) + { + try + { + File.Delete(temporaryPath); + } + catch (Exception) { } + } + + /// Publishes completed staging contents unless cancellation was requested. + internal static void PublishStagedFile( + string temporaryPath, + string path, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + Swap(temporaryPath, path); + } + + // The destination can change after the existence probe; retry the matching alternative on that race. + /// Publishes a staging file over an absent or existing destination. + internal static void Swap(string temporaryPath, string path) + { + if (File.Exists(path)) + { + Replace(temporaryPath, path); + return; + } + + try + { + File.Move(temporaryPath, path); + } + catch (IOException) when (File.Exists(path)) + { + Replace(temporaryPath, path); + } + } + [DllImport("libc", EntryPoint = "link", ExactSpelling = true, SetLastError = true)] private static extern int CreateHardLinkLinux(string stagedPath, string destinationPath); @@ -1090,19 +1152,12 @@ await staging staging.Flush(flushToDisk: true); } -#if UNITY_EDITOR - BeforeStagedSwapForTests?.Invoke(temporaryPath); -#endif - cancellationToken.ThrowIfCancellationRequested(); - Swap(temporaryPath, path); + PublishStagedFile(temporaryPath, path, cancellationToken); return null; } catch (Exception e) { -#if UNITY_EDITOR - BeforeStagedCleanupForTests?.Invoke(temporaryPath); -#endif DiscardStagedFile(temporaryPath); return e; } @@ -1146,9 +1201,6 @@ out Exception error staging.Flush(flushToDisk: true); } -#if UNITY_EDITOR - BeforeStagedSwapForTests?.Invoke(temporaryPath); -#endif Swap(temporaryPath, path); error = null; @@ -1156,9 +1208,6 @@ out Exception error } catch (Exception e) { -#if UNITY_EDITOR - BeforeStagedCleanupForTests?.Invoke(temporaryPath); -#endif DiscardStagedFile(temporaryPath); error = e; return false; @@ -1264,54 +1313,6 @@ private static FileStream OpenSourceStream(string sourcePath, bool useAsync) ); } - // Only a successful exclusive staging open establishes cleanup ownership; failed opens may name another writer. - private static FileStream OpenStagingStream(string temporaryPath, bool useAsync) - { - return new FileStream( - temporaryPath, - FileMode.Create, - FileAccess.Write, - FileShare.None, - DefaultBufferSize, - useAsync - ); - } - - // Isolate expected sharing failures from IL2CPP callers and async state machines. - [MethodImpl(MethodImplOptions.NoInlining)] - private static bool TryOpenStagingOwnership( - string temporaryPath, - out FileStream ownership, - out Exception error - ) - { - try - { - ownership = new FileStream( - temporaryPath + OwnershipSuffix, - FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.Read, - bufferSize: 1, - FileOptions.DeleteOnClose - ); - error = null; - return true; - } - catch (IOException e) - { - ownership = null; - error = e; - return false; - } - catch (Exception e) - { - ownership = null; - error = e; - return false; - } - } - private static void ReleaseStagingOwnership(FileStream ownership) { ReleaseFileStream(ownership); @@ -1351,34 +1352,6 @@ private static void EnsureDirectory(string path) } } - private static void DiscardStagedFile(string temporaryPath) - { - try - { - File.Delete(temporaryPath); - } - catch (Exception) { } - } - - // The destination can change after the existence probe; retry the matching alternative on that race. - private static void Swap(string temporaryPath, string path) - { - if (File.Exists(path)) - { - Replace(temporaryPath, path); - return; - } - - try - { - File.Move(temporaryPath, path); - } - catch (IOException) when (File.Exists(path)) - { - Replace(temporaryPath, path); - } - } - private static void Replace(string temporaryPath, string path) { try diff --git a/Runtime/Core/Helper/Helpers.cs b/Runtime/Core/Helper/Helpers.cs index 35d22e575..158a2376c 100644 --- a/Runtime/Core/Helper/Helpers.cs +++ b/Runtime/Core/Helper/Helpers.cs @@ -46,14 +46,14 @@ private static readonly ConcurrentDictionary< internal static readonly Dictionary CachedLabels = new( StringComparer.OrdinalIgnoreCase ); + + internal static string[] CachedLayerNames = Array.Empty(); + internal static bool LayerCacheInitialized; private static readonly Object LogObject = new(); private static readonly Dictionary ObjectsByTag = new( StringComparer.Ordinal ); - private static string[] CachedLayerNames = Array.Empty(); - private static bool LayerCacheInitialized; - /// Gets the first value following a named process argument. /// The exact, case-sensitive argument name; blank names are ignored. /// The following value, or when none exists. @@ -67,11 +67,7 @@ public static string GetCommandLineArgument(string name) try { -#if UNITY_EDITOR || UNITY_INCLUDE_TESTS - return GetCommandLineArgument(CommandLineArgumentProvider(), name); -#else return GetCommandLineArgument(Environment.GetCommandLineArgs(), name); -#endif } catch { @@ -127,66 +123,6 @@ string name return values; } -#if UNITY_EDITOR || UNITY_INCLUDE_TESTS - internal static Func CommandLineArgumentProvider - { - get => _commandLineArgumentProvider ?? Environment.GetCommandLineArgs; - set => _commandLineArgumentProvider = value; - } - - internal static Func LayerNameProvider - { - get => _layerNameProvider ?? DefaultLayerNameProvider; - set => _layerNameProvider = value; - } - - private static Func _commandLineArgumentProvider; - private static Func _layerNameProvider; - -#if UNITY_EDITOR - private static readonly Func DefaultLayerNameProvider = () => - InternalEditorUtility.layers; -#else - private static readonly Func DefaultLayerNameProvider = () => Array.Empty(); -#endif - - internal static void ResetCommandLineArgumentProvider() - { - _commandLineArgumentProvider = null; - } - - internal static void ResetLayerNameProvider() - { - _layerNameProvider = null; - } -#else - [System.Diagnostics.CodeAnalysis.SuppressMessage("UnusedMember.Local", "")] - internal static void ResetLayerNameProvider() { } -#endif - - internal static Func JitterSampler - { - get => _jitterSampler ?? DefaultJitterSampler; - set => _jitterSampler = value; - } - - private static Func _jitterSampler; - - private static readonly Func DefaultJitterSampler = maxDelay => - { - if (maxDelay <= 0f) - { - return 0f; - } - - return PRNG.Instance.NextFloat(0f, maxDelay); - }; - - internal static void ResetJitterSampler() - { - _jitterSampler = null; - } - internal static void ResetLayerCache() { CachedLayerNames = Array.Empty(); @@ -377,10 +313,10 @@ public static string[] GetAllLayerNames() return CachedLayerNames; } -#if UNITY_EDITOR || UNITY_INCLUDE_TESTS +#if UNITY_EDITOR try { - string[] editorLayers = LayerNameProvider?.Invoke(); + string[] editorLayers = InternalEditorUtility.layers; if (editorLayers is { Length: > 0 }) { LayerCacheInitialized = true; @@ -1293,6 +1229,16 @@ internal static void ResetSpriteLabelCache() AllSpriteLabels = Array.Empty(); } + internal static float ClampInitialJitter(float jitter, float interval) + { + if (float.IsNaN(jitter) || jitter <= 0f) + { + return 0f; + } + + return Mathf.Min(jitter, interval); + } + private static IEnumerator FunctionAsCoroutine( Object context, Action action, @@ -1394,13 +1340,7 @@ private static float SampleInitialJitter(float interval) return 0f; } - float jitter = JitterSampler(interval); - if (float.IsNaN(jitter) || jitter <= 0f) - { - return 0f; - } - - return Mathf.Min(jitter, interval); + return ClampInitialJitter(PRNG.Instance.NextFloat(0f, interval), interval); } private static IEnumerator WaitForDelay(float duration) diff --git a/Runtime/Core/Helper/ReflectionHelpers.TypeDiscovery.cs b/Runtime/Core/Helper/ReflectionHelpers.TypeDiscovery.cs index 8ebc16ef6..ebe16ff37 100644 --- a/Runtime/Core/Helper/ReflectionHelpers.TypeDiscovery.cs +++ b/Runtime/Core/Helper/ReflectionHelpers.TypeDiscovery.cs @@ -39,8 +39,6 @@ public static partial class ReflectionHelpers /// process can never use. It would also be wrong: an assembly loaded afterwards can make a /// name resolvable, and a cached failure would outlive it. /// - internal static int ResolvedTypeCacheCountForTesting => TypeResolutionCache.Count; - private static readonly Func PlaceholderAssemblyResolver = ResolvePlaceholderAssembly; diff --git a/Runtime/Core/Helper/ReflectionHelpers.cs b/Runtime/Core/Helper/ReflectionHelpers.cs index ffe5e795e..c6d5fca93 100644 --- a/Runtime/Core/Helper/ReflectionHelpers.cs +++ b/Runtime/Core/Helper/ReflectionHelpers.cs @@ -97,12 +97,6 @@ namespace WallstopStudios.UnityHelpers.Core.Helper /// used, allowing the system to track which strategies have been attempted and failed for each member. /// A blocklist tracks strategies that failed for specific members, avoiding repeated failed attempts. /// - /// Testing Support - /// - /// The internal OverrideReflectionCapabilities method allows tests to force specific strategies, - /// enabling verification that all fallback paths work correctly. Use the returned IDisposable - /// to restore original capabilities after the test. - /// /// File Organization /// /// This is a partial class split across multiple files: @@ -137,11 +131,11 @@ public static partial class ReflectionHelpers private const string EnabledPropertyName = "enabled"; #if !SINGLE_THREADED - private static readonly ConcurrentDictionary TypeResolutionCache = new( + internal static readonly ConcurrentDictionary TypeResolutionCache = new( StringComparer.Ordinal ); #else - private static readonly Dictionary TypeResolutionCache = new( + internal static readonly Dictionary TypeResolutionCache = new( StringComparer.Ordinal ); #endif @@ -264,27 +258,9 @@ private static Action GetHashSetClearerCached(Type elementType) } #endif - internal static bool ExpressionsEnabled => - _expressionCapabilityOverride ?? CanCompileExpressions; - - internal static bool DynamicIlEnabled => _dynamicIlCapabilityOverride ?? DynamicIlSupported; + internal static bool ExpressionsEnabled { get; set; } = CheckExpressionCompilationSupport(); - private static readonly bool CanCompileExpressions = CheckExpressionCompilationSupport(); - private static readonly bool DynamicIlSupported = CheckDynamicIlSupport(); - private static bool? _expressionCapabilityOverride; - private static bool? _dynamicIlCapabilityOverride; - - internal static IDisposable OverrideReflectionCapabilities( - bool? expressions, - bool? dynamicIl - ) - { - bool? previousExpressions = _expressionCapabilityOverride; - bool? previousDynamicIl = _dynamicIlCapabilityOverride; - _expressionCapabilityOverride = expressions; - _dynamicIlCapabilityOverride = dynamicIl; - return new CapabilityOverrideScope(previousExpressions, previousDynamicIl); - } + internal static bool DynamicIlEnabled { get; set; } = CheckDynamicIlSupport(); #if SINGLE_THREADED private static readonly Dictionary< @@ -5851,31 +5827,6 @@ ConstructorInfo constructor } } - private sealed class CapabilityOverrideScope : IDisposable - { - private readonly bool? _previousExpressions; - private readonly bool? _previousDynamicIl; - private bool _disposed; - - internal CapabilityOverrideScope(bool? expressions, bool? dynamicIl) - { - _previousExpressions = expressions; - _previousDynamicIl = dynamicIl; - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _expressionCapabilityOverride = _previousExpressions; - _dynamicIlCapabilityOverride = _previousDynamicIl; - _disposed = true; - } - } - private static class TypedArrayBuilders where TSource : class { diff --git a/Runtime/Core/Helper/SingletonAutoLoader.cs b/Runtime/Core/Helper/SingletonAutoLoader.cs index 55542516f..0a0a443ec 100644 --- a/Runtime/Core/Helper/SingletonAutoLoader.cs +++ b/Runtime/Core/Helper/SingletonAutoLoader.cs @@ -15,68 +15,17 @@ namespace WallstopStudios.UnityHelpers.Core.Helper /// internal static class SingletonAutoLoader { - private static readonly Dictionary _cachedLoaders = new( + internal static readonly Dictionary _cachedLoaders = new( StringComparer.Ordinal ); - private static readonly HashSet _executedLoadTypes = new(); - private static readonly object _executionLock = new(); - private static readonly Dictionary _runtimeInstanceProperties = new(); - private static readonly Dictionary _scriptableInstanceProperties = + internal static readonly HashSet _executedLoadTypes = new(); + internal static readonly object _executionLock = new(); + internal static readonly Dictionary _runtimeInstanceProperties = new(); + internal static readonly Dictionary _scriptableInstanceProperties = new(); - private static readonly object _loaderBuildLock = new(); -#if UNITY_INCLUDE_TESTS - private static bool? _testPlayModeOverride; -#endif + internal static readonly object _loaderBuildLock = new(); - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - private static void AutoLoadSubsystemRegistration() - { - // SubsystemRegistration re-arms loaders each session when domain reload is disabled. - lock (_executionLock) - { - _executedLoadTypes.Clear(); - } - - ExecuteForLoadType(RuntimeInitializeLoadType.SubsystemRegistration); - } - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] - private static void AutoLoadAfterAssemblies() - { - ExecuteForLoadType(RuntimeInitializeLoadType.AfterAssembliesLoaded); - } - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSplashScreen)] - private static void AutoLoadBeforeSplashScreen() - { - ExecuteForLoadType(RuntimeInitializeLoadType.BeforeSplashScreen); - } - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] - private static void AutoLoadBeforeSceneLoad() - { - ExecuteForLoadType(RuntimeInitializeLoadType.BeforeSceneLoad); - } - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] - private static void AutoLoadAfterSceneLoad() - { - ExecuteForLoadType(RuntimeInitializeLoadType.AfterSceneLoad); - } - - private static void ExecuteForLoadType(RuntimeInitializeLoadType loadType) - { - AttributeMetadataCache cache = AttributeMetadataCache.Instance; - AttributeMetadataCache.AutoLoadSingletonEntry[] entries = - cache != null - ? cache.AutoLoadSingletons - : Array.Empty(); - entries ??= Array.Empty(); - - ExecuteEntries(entries, loadType, enforceSingleExecution: true, requirePlayMode: true); - } - - private static void ExecuteEntries( + internal static void ExecuteEntries( IReadOnlyList entries, RuntimeInitializeLoadType loadType, bool enforceSingleExecution, @@ -89,12 +38,6 @@ bool requirePlayMode } bool isPlayMode = Application.isPlaying; -#if UNITY_INCLUDE_TESTS - if (_testPlayModeOverride.HasValue) - { - isPlayMode = _testPlayModeOverride.Value; - } -#endif if (requirePlayMode && !isPlayMode) { @@ -134,6 +77,54 @@ bool requirePlayMode } } + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void AutoLoadSubsystemRegistration() + { + // SubsystemRegistration re-arms loaders each session when domain reload is disabled. + lock (_executionLock) + { + _executedLoadTypes.Clear(); + } + + ExecuteForLoadType(RuntimeInitializeLoadType.SubsystemRegistration); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + private static void AutoLoadAfterAssemblies() + { + ExecuteForLoadType(RuntimeInitializeLoadType.AfterAssembliesLoaded); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSplashScreen)] + private static void AutoLoadBeforeSplashScreen() + { + ExecuteForLoadType(RuntimeInitializeLoadType.BeforeSplashScreen); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void AutoLoadBeforeSceneLoad() + { + ExecuteForLoadType(RuntimeInitializeLoadType.BeforeSceneLoad); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void AutoLoadAfterSceneLoad() + { + ExecuteForLoadType(RuntimeInitializeLoadType.AfterSceneLoad); + } + + private static void ExecuteForLoadType(RuntimeInitializeLoadType loadType) + { + AttributeMetadataCache cache = AttributeMetadataCache.Instance; + AttributeMetadataCache.AutoLoadSingletonEntry[] entries = + cache != null + ? cache.AutoLoadSingletons + : Array.Empty(); + entries ??= Array.Empty(); + + ExecuteEntries(entries, loadType, enforceSingleExecution: true, requirePlayMode: true); + } + private static Action GetOrCreateLoader(AttributeMetadataCache.AutoLoadSingletonEntry entry) { if (entry == null || string.IsNullOrWhiteSpace(entry.typeName)) @@ -284,54 +275,5 @@ Type openGenericBase return null; } } - -#if UNITY_INCLUDE_TESTS - internal static void ExecuteEntriesForTests( - bool simulatePlayMode, - RuntimeInitializeLoadType loadType, - params AttributeMetadataCache.AutoLoadSingletonEntry[] entries - ) - { - bool? previousOverride = _testPlayModeOverride; - try - { - _testPlayModeOverride = simulatePlayMode; - ExecuteEntries( - entries, - loadType, - enforceSingleExecution: false, - requirePlayMode: true - ); - } - finally - { - _testPlayModeOverride = previousOverride; - } - } - - /// - /// Resets every static cache so a test that asserts a first-resolution log (e.g. the - /// "does not derive from RuntimeSingleton" / "Unable to resolve type" warnings) actually - /// re-emits it. Without this, the per-typeName loader cache and per- - /// instance-property caches suppress the warning on the second PlayMode test in the same - /// domain, leaving a unconsumed -- which then bleeds across the frame boundary and fails an innocent - /// bystander fixture. Production keeps the dedupe (correct anti-spam); only tests reset it. - /// - internal static void ClearCachesForTests() - { - lock (_loaderBuildLock) - { - _cachedLoaders.Clear(); - _runtimeInstanceProperties.Clear(); - _scriptableInstanceProperties.Clear(); - } - - lock (_executionLock) - { - _executedLoadTypes.Clear(); - } - } -#endif } } diff --git a/Runtime/Core/Helper/UnityMainThreadDispatcher.cs b/Runtime/Core/Helper/UnityMainThreadDispatcher.cs index 09e3c7105..66bbade32 100644 --- a/Runtime/Core/Helper/UnityMainThreadDispatcher.cs +++ b/Runtime/Core/Helper/UnityMainThreadDispatcher.cs @@ -26,7 +26,7 @@ public sealed class UnityMainThreadDispatcher : RuntimeSingleton _actions = new(); + internal readonly ConcurrentQueue _actions = new(); private int _pendingActionCount; private int _lastOverflowFrame = -1; #if UNITY_EDITOR @@ -88,79 +88,6 @@ internal static int GetLiveDispatcherCount() return Resources.FindObjectsOfTypeAll().Length; } - internal static int GetPendingActionCountForTesting() - { - int pendingActionCount = 0; - UnityMainThreadDispatcher[] dispatchers = - Resources.FindObjectsOfTypeAll(); - foreach (UnityMainThreadDispatcher dispatcher in dispatchers) - { - if (dispatcher == null) - { - continue; - } - - pendingActionCount += dispatcher.PendingActionCount; - } - - return pendingActionCount; - } - - internal static int DrainPendingActionsForTesting(int maxActions = 1024) - { - int remainingActionBudget = Math.Max(1, maxActions); - UnityMainThreadDispatcher[] dispatchers = - Resources.FindObjectsOfTypeAll(); - foreach (UnityMainThreadDispatcher dispatcher in dispatchers) - { - if (dispatcher == null || dispatcher.PendingActionCount <= 0) - { - continue; - } - - while ( - 0 < remainingActionBudget && dispatcher._actions.TryDequeue(out Action action) - ) - { - remainingActionBudget--; - dispatcher.ExecuteQueuedAction(action); - } - } - - return GetPendingActionCountForTesting(); - } - - internal static string DescribeLiveDispatchersForTesting() - { - UnityMainThreadDispatcher[] dispatchers = - Resources.FindObjectsOfTypeAll(); - if (dispatchers.Length == 0) - { - return "No dispatchers found."; - } - - int dispatchersLength = dispatchers.Length; - string[] descriptions = new string[dispatchersLength]; - for (int i = 0; i < dispatchersLength; ++i) - { - UnityMainThreadDispatcher dispatcher = dispatchers[i]; - if (dispatcher == null) - { - descriptions[i] = "null"; - continue; - } - - GameObject dispatcherObject = dispatcher.gameObject; - string sceneName = dispatcherObject == null ? "null" : dispatcherObject.scene.name; - descriptions[i] = - $"{dispatcher.name}#{dispatcher.GetUnityObjectId()} scene='{sceneName}' " - + $"active={dispatcherObject != null && dispatcherObject.activeInHierarchy} " - + $"pending={dispatcher.PendingActionCount}"; - } - - return string.Join(", ", descriptions); - } - #if UNITY_EDITOR private readonly EditorApplication.CallbackFunction _update; private bool _attachedEditorUpdate; @@ -199,41 +126,6 @@ public UnityMainThreadDispatcher() #endif } - /// - /// Creates a dispatcher test scope that follows the recommended pattern: disable auto-creation, destroy lingering instances immediately, re-enable auto-creation for the test body, and clean everything up on dispose. - /// - /// When true, uses for cleanup. Set to false in play mode so Unity can process destruction safely. - /// An that automatically restores the previous auto-creation state when disposed. - /// - /// - /// private UnityMainThreadDispatcher.AutoCreationScope _scope; - /// - /// [SetUp] - /// public void SetUp() - /// { - /// _scope = UnityMainThreadDispatcher.CreateTestScope(destroyImmediate: true); - /// } - /// - /// [TearDown] - /// public void TearDown() - /// { - /// _scope?.Dispose(); - /// _scope = null; - /// } - /// - /// - public static AutoCreationScope CreateTestScope(bool destroyImmediate = true) - { - AutoCreationScope scope = AutoCreationScope.Disabled( - destroyExistingInstanceOnEnter: true, - destroyInstancesOnDispose: true, - destroyImmediate: destroyImmediate - ); - - SetAutoCreationEnabled(true); - return scope; - } - internal static void SetAutoCreationEnabled(bool enabled) { AutoCreationEnabled = enabled; @@ -682,6 +574,22 @@ protected override void OnDestroy() base.OnDestroy(); } + internal void ExecuteQueuedAction(Action action) + { + try + { + action(); + } + catch (Exception e) + { + Debug.LogError($"UnityMainThreadDispatcher action threw an exception: {e}"); + } + finally + { + Interlocked.Decrement(ref _pendingActionCount); + } + } + private bool Enqueue(Action action, bool logOverflow) { if (action == null) @@ -779,22 +687,6 @@ private void Update() } } - private void ExecuteQueuedAction(Action action) - { - try - { - action(); - } - catch (Exception e) - { - Debug.LogError($"UnityMainThreadDispatcher action threw an exception: {e}"); - } - finally - { - Interlocked.Decrement(ref _pendingActionCount); - } - } - #if UNITY_EDITOR private void ApplyEditorHideFlags() { diff --git a/Runtime/Core/Serialization/Serializer.cs b/Runtime/Core/Serialization/Serializer.cs index 60c9a7f7d..f76c61ac8 100644 --- a/Runtime/Core/Serialization/Serializer.cs +++ b/Runtime/Core/Serialization/Serializer.cs @@ -303,6 +303,9 @@ public static class Serializer // Small payloads use protobuf-net buffer access; larger reads avoid repeated stream allocation. private const int ProtobufMemoryStreamThreshold = 4096; + internal static readonly ConcurrentDictionary ProtobufRootCache = new(); + internal static readonly ConcurrentDictionary ExplicitProtobufRootCache = new(); + private static readonly MethodInfo ProtoDeserializeTypeFromROM; private static readonly MethodInfo ProtoDeserializeTypeFromROS; private static readonly Func< @@ -315,9 +318,6 @@ private static readonly Func< ReadOnlySequence, object > ProtoDeserializeTypeFromROSFast; - - private static readonly ConcurrentDictionary ProtobufRootCache = new(); - private static readonly ConcurrentDictionary ExplicitProtobufRootCache = new(); private static readonly Type NoRootMarker = typeof(void); // Legacy protobuf-net delegates require Mono; WallstopProto supplies the AOT path. @@ -1336,29 +1336,6 @@ internal static SparseSet DeserializeSparseSetWrapper(byte[] data) return result; } - internal static void ClearProtobufRootCacheForTesting(params Type[] declaredTypes) - { - if (declaredTypes == null || declaredTypes.Length == 0) - { - ProtobufRootCache.Clear(); - ExplicitProtobufRootCache.Clear(); - WallstopProto.WProtoDeclaredRootProvider.ReleaseAllClaims(); - return; - } - - foreach (Type declaredType in declaredTypes) - { - if (declaredType == null) - { - continue; - } - - ProtobufRootCache.TryRemove(declaredType, out _); - ExplicitProtobufRootCache.TryRemove(declaredType, out _); - WallstopProto.WProtoDeclaredRootProvider.ReleaseClaim(declaredType); - } - } - /// /// Checks if the type is a serializable collection type that needs wrapper-based protobuf serialization. /// Returns true for SerializableHashSet, SerializableSortedSet, SerializableDictionary, SerializableSortedDictionary. diff --git a/Runtime/Core/Serialization/WallstopProto/WProtoDeclaredRootProvider.cs b/Runtime/Core/Serialization/WallstopProto/WProtoDeclaredRootProvider.cs index 621d99b3d..41fd433aa 100644 --- a/Runtime/Core/Serialization/WallstopProto/WProtoDeclaredRootProvider.cs +++ b/Runtime/Core/Serialization/WallstopProto/WProtoDeclaredRootProvider.cs @@ -90,17 +90,6 @@ public static bool TryGetFormatter(out IWProtoFormatter formatter) return formatter != null; } - /// - /// Clears the adapter registered for . - /// - /// The declared type. - /// Exists for tests; a declaration is otherwise registered once and kept. - public static void Unregister() - where T : class - { - Formatters.Value = null; - } - /// /// Records that owns for this program. /// @@ -180,7 +169,7 @@ public static bool TryGetRoot(Type declared, out Type root) return false; } - private static class Formatters + internal static class Formatters { internal static IWProtoFormatter Value; } diff --git a/Runtime/Core/Serialization/WallstopProto/WProtoMemberAttribute.cs b/Runtime/Core/Serialization/WallstopProto/WProtoMemberAttribute.cs index de37565b3..97cb9adbf 100644 --- a/Runtime/Core/Serialization/WallstopProto/WProtoMemberAttribute.cs +++ b/Runtime/Core/Serialization/WallstopProto/WProtoMemberAttribute.cs @@ -56,6 +56,15 @@ public sealed class WProtoMemberAttribute : Attribute /// public bool IsRequired { get; set; } + /// Indicates whether a repeated scalar member is written as a packed run. + /// + /// Defaults to true. Set to false to write one field key per element, + /// matching protobuf-net's default repeated encoding. Packable elements remain readable in + /// either form regardless of this setting. Non-packable elements, including strings, messages and chars, remain + /// unpacked; this setting has no effect on scalar members, maps or nested wrapper contents. + /// + public bool IsPacked { get; set; } = true; + /// /// Selects the encoding a signed integer member uses. /// diff --git a/Runtime/Core/Serialization/WallstopProto/WProtoNotSerializedAttribute.cs b/Runtime/Core/Serialization/WallstopProto/WProtoNotSerializedAttribute.cs index 3468f2099..938ee508a 100644 --- a/Runtime/Core/Serialization/WallstopProto/WProtoNotSerializedAttribute.cs +++ b/Runtime/Core/Serialization/WallstopProto/WProtoNotSerializedAttribute.cs @@ -23,7 +23,8 @@ namespace WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto /// /// It is a statement about this type alone, and it stops the walk: a subclass of an opted-out /// type has no serialized ancestor between it and the contract either, so nothing writes it as - /// the contract and nothing generates for it. + /// the contract and nothing generates for it. Cross-assembly inheritance diagnostics also + /// stop at this boundary rather than treating an unannotated descendant as a contract. /// /// /// A promise rather than an enforcement. A contract that is neither sealed nor a value type diff --git a/Runtime/Core/Serialization/WallstopProto/WProtoSchemaText.cs b/Runtime/Core/Serialization/WallstopProto/WProtoSchemaText.cs index 9afb20a15..533c4ede3 100644 --- a/Runtime/Core/Serialization/WallstopProto/WProtoSchemaText.cs +++ b/Runtime/Core/Serialization/WallstopProto/WProtoSchemaText.cs @@ -29,8 +29,9 @@ namespace WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto /// actually write, which replicate protobuf-net's .bcl.* conventions. /// /// - /// Two writer behaviors have no proto3 spelling and are deliberately not reflected in the - /// schema: , which changes only whether a default + /// Explicit opt-outs render as + /// [packed = false] on eligible repeated fields. Other writer behaviors are not reflected + /// in the schema: , which changes only whether a default /// value is written, and the never-packed rule for runs, which changes only /// whether runs are compressed. Neither changes a field's number or type, so a schema consumer /// decodes both correctly without knowing them. @@ -366,6 +367,36 @@ Dictionary>> declaredSubtypes _declaredSubtypes = declaredSubtypes; } + private static bool CanPackSchemaElement(Type type) + { + if (type == null) + { + return false; + } + if (type.IsEnum) + { + return true; + } + switch (Type.GetTypeCode(type)) + { + case TypeCode.Boolean: + case TypeCode.Char: + case TypeCode.SByte: + case TypeCode.Byte: + case TypeCode.Int16: + case TypeCode.UInt16: + case TypeCode.Int32: + case TypeCode.UInt32: + case TypeCode.Int64: + case TypeCode.UInt64: + case TypeCode.Single: + case TypeCode.Double: + return true; + default: + return false; + } + } + private static string SanitizeIdentifier(string desired) { StringBuilder builder = new StringBuilder(desired.Length); @@ -824,7 +855,13 @@ out string rectDefinition if (TryShapeRepeated(memberType, member, ownerName, out string repeatedType)) { - return $"repeated {repeatedType} {member.SchemaName} = {member.Tag};"; + string packingOption = + !member.IsPacked + && CanPackSchemaElement(GetElementType(memberType)) + && !TryResolveSurrogate(GetElementType(memberType), out _) + ? " [packed = false]" + : string.Empty; + return $"repeated {repeatedType} {member.SchemaName} = {member.Tag}{packingOption};"; } _diagnostics.Add( @@ -1401,6 +1438,7 @@ private MemberEntry BuildMember(MemberInfo member, string messageName) Tag = marker.Tag, MemberType = memberType, DataFormatZigZag = marker.DataFormat == WProtoDataFormat.ZigZag, + IsPacked = marker.IsPacked, }; } @@ -1585,6 +1623,7 @@ private sealed class MemberEntry public int Tag; public Type MemberType; public bool DataFormatZigZag; + public bool IsPacked = true; } } } diff --git a/Runtime/Core/Threading/SingleThreadedThreadPool.cs b/Runtime/Core/Threading/SingleThreadedThreadPool.cs index 3579cac3a..963bdf0d4 100644 --- a/Runtime/Core/Threading/SingleThreadedThreadPool.cs +++ b/Runtime/Core/Threading/SingleThreadedThreadPool.cs @@ -16,7 +16,8 @@ namespace WallstopStudios.UnityHelpers.Core.Threading /// /// Disposal discards queued work. and /// cancel the worker rather than draining it, so anything enqueued but not yet started is - /// dropped. The await inside waits only for the item already + /// removed from the queue immediately, releasing its captured references. The await + /// inside waits only for the item already /// in flight. That is the right behavior for work that can simply be redone, such as /// generation passes, and the wrong behavior for durable work such as persistence writes: /// call first when queued items must run. @@ -38,7 +39,6 @@ namespace WallstopStudios.UnityHelpers.Core.Threading /// public sealed class SingleThreadedThreadPool : IDisposable { - // Polling only during teardown avoids synchronization overhead in enqueue. private static readonly TimeSpan DrainPollInterval = TimeSpan.FromMilliseconds(1); /// @@ -62,6 +62,7 @@ public sealed class SingleThreadedThreadPool : IDisposable private volatile bool _isWorking; private volatile bool _disposed; + private readonly object _workGate = new(); private readonly Task _workerTask; private readonly ConcurrentQueue _work; private readonly SemaphoreSlim _workAvailable; @@ -102,13 +103,7 @@ public SingleThreadedThreadPool( /// The action to run. public void Enqueue(Action work) { - if (_disposed || !_acceptingWork) - { - return; - } - - _work.Enqueue(WorkItem.FromAction(work)); - Signal(); + EnqueueWork(WorkItem.FromAction(work)); } /// @@ -118,13 +113,7 @@ public void Enqueue(Action work) /// The asynchronous action to run. public void Enqueue(Func work) { - if (_disposed || !_acceptingWork) - { - return; - } - - _work.Enqueue(WorkItem.FromTask(work)); - Signal(); + EnqueueWork(WorkItem.FromTask(work)); } /// @@ -134,13 +123,7 @@ public void Enqueue(Func work) /// The asynchronous action to run. public void Enqueue(Func work) { - if (_disposed || !_acceptingWork) - { - return; - } - - _work.Enqueue(WorkItem.FromValueTask(work)); - Signal(); + EnqueueWork(WorkItem.FromValueTask(work)); } /// @@ -157,7 +140,7 @@ public void Enqueue(Func work) /// item is executing. The guarantee is that every item the calling thread enqueued before /// this call has run; a concurrent producer racing this call is not covered, so stop those /// producers first. Disposal is still required afterwards -- draining does not stop the - /// worker or release its handles. + /// worker or release its handles. Reports failure if disposal interrupts the drain. /// /// /// work) /// public async ValueTask DrainAsync(CancellationToken cancellationToken = default) { - if (_disposed) + lock (_workGate) { - return false; - } + if (_disposed) + { + return false; + } - _acceptingWork = false; + _acceptingWork = false; + } Signal(); while (!_work.IsEmpty || _isWorking) @@ -189,7 +175,7 @@ public async ValueTask DrainAsync(CancellationToken cancellationToken = de // A stopped worker will never drain what is left, so waiting for it would hang. if (_workerTask.IsCompleted && !_isWorking) { - return _work.IsEmpty; + return HasCompletedDrain(); } try @@ -202,26 +188,31 @@ public async ValueTask DrainAsync(CancellationToken cancellationToken = de } } - return true; + return HasCompletedDrain(); } /// /// Cancels the worker and releases its handles, discarding any work still queued. /// /// - /// Waits only for the item already in flight. Call first when - /// queued items must run. + /// Releases queued references before waiting for the item already in flight. Submissions + /// racing disposal cannot publish new queued work. Call first + /// when queued items must run. /// public async ValueTask DisposeAsync() { - if (_disposed) + lock (_workGate) { - return; - } + if (_disposed) + { + return; + } - _acceptingWork = false; - _active = false; - _disposed = true; + _acceptingWork = false; + _active = false; + _disposed = true; + _work.Clear(); + } _cancellationTokenSource.Cancel(); Signal(); @@ -257,6 +248,49 @@ public void Dispose() DisposeAsync().AsTask().GetAwaiter().GetResult(); } + private bool HasCompletedDrain() + { + lock (_workGate) + { + return !_disposed && _work.IsEmpty && !_isWorking; + } + } + + private void EnqueueWork(WorkItem work) + { + lock (_workGate) + { + if (_disposed || !_acceptingWork) + { + return; + } + + _work.Enqueue(work); + } + Signal(); + } + + private bool TryStartWork(out WorkItem work) + { + lock (_workGate) + { + if (!_active || _work.IsEmpty) + { + work = default; + return false; + } + + _isWorking = true; + if (_work.TryDequeue(out work)) + { + return true; + } + + _isWorking = false; + return false; + } + } + private void Signal() { try @@ -274,16 +308,11 @@ private async Task DoWorkAsync() { try { - // Mark busy before dequeue so DrainAsync cannot observe an empty queue while an item is in flight. - if (!_work.IsEmpty) + if (TryStartWork(out WorkItem workItem)) { - _isWorking = true; try { - if (_work.TryDequeue(out WorkItem workItem)) - { - await workItem.ExecuteAsync().ConfigureAwait(false); - } + await workItem.ExecuteAsync().ConfigureAwait(false); } catch (Exception e) { diff --git a/Runtime/Tags/AttributeMetadataCache.cs b/Runtime/Tags/AttributeMetadataCache.cs index d9629a172..0a7d3e421 100644 --- a/Runtime/Tags/AttributeMetadataCache.cs +++ b/Runtime/Tags/AttributeMetadataCache.cs @@ -92,6 +92,16 @@ public string[] AllAttributeNames internal RelationalTypeMetadata[] _relationalTypeMetadata = Array.Empty(); + internal readonly object _lookupLock = new(); + + internal Dictionary _typeFieldsLookup; + internal Dictionary _relationalFieldsLookup; + internal Dictionary< + Type, + ResolvedRelationalFieldMetadata[] + > _resolvedRelationalFieldsLookup; + internal Dictionary _elementTypeLookup; + [Header("Initialization")] [Tooltip( "If enabled, pre-warms RelationalComponent reflection caches at runtime before the first scene loads. Useful to avoid first-use stalls on IL2CPP or slow devices." @@ -115,13 +125,6 @@ public string[] AllAttributeNames private AutoLoadSingletonEntry[] _autoLoadSingletons = Array.Empty(); - private readonly object _lookupLock = new(); - - private Dictionary _typeFieldsLookup; - private Dictionary _relationalFieldsLookup; - private Dictionary _resolvedRelationalFieldsLookup; - private Dictionary _elementTypeLookup; - private void OnEnable() { _computedAllAttributeNames = null; @@ -154,130 +157,6 @@ private static void RuntimePrewarmRelationalIfEnabled() } #endif - private void BuildLookup() - { - lock (_lookupLock) - { - if ( - _typeFieldsLookup != null - && _relationalFieldsLookup != null - && _resolvedRelationalFieldsLookup != null - && _elementTypeLookup != null - ) - { - return; - } - - Dictionary typeFieldsLookup = new(_typeMetadata?.Length ?? 0); - Dictionary relationalFieldsLookup = new( - _relationalTypeMetadata?.Length ?? 0 - ); - Dictionary resolvedRelationalFieldsLookup = - new(_relationalTypeMetadata?.Length ?? 0); - Dictionary elementTypeLookup = new( - _relationalTypeMetadata?.Length ?? 0 - ); - - if (_typeMetadata != null) - { - foreach (TypeFieldMetadata metadata in _typeMetadata) - { - if ( - metadata == null - || !TryResolveType(metadata.typeName, out Type componentType) - ) - { - LogMissingType(metadata?.typeName, "attribute component"); - continue; - } - - string[] fieldNames = metadata.fieldNames ?? Array.Empty(); - typeFieldsLookup[componentType] = fieldNames; - } - } - - if (_relationalTypeMetadata != null) - { - foreach (RelationalTypeMetadata metadata in _relationalTypeMetadata) - { - if ( - metadata == null - || !TryResolveType(metadata.typeName, out Type relationalType) - ) - { - LogMissingType(metadata?.typeName, "relational component"); - continue; - } - - RelationalFieldMetadata[] fields = - metadata.fields ?? Array.Empty(); - - relationalFieldsLookup[relationalType] = fields; - - ResolvedRelationalFieldMetadata[] resolvedFields = - new ResolvedRelationalFieldMetadata[fields.Length]; - - int fieldsLength = fields.Length; - for (int i = 0; i < fieldsLength; ++i) - { - RelationalFieldMetadata field = fields[i]; - Type elementType = null; - - if (!string.IsNullOrWhiteSpace(field.elementTypeName)) - { - if (TryResolveType(field.elementTypeName, out elementType)) - { - elementTypeLookup[ - new ElementTypeKey(relationalType, field.fieldName) - ] = elementType; - } - else - { - LogMissingType(field.elementTypeName, "relational element"); - } - } - - resolvedFields[i] = new ResolvedRelationalFieldMetadata( - field.fieldName, - field.attributeKind, - field.fieldKind, - elementType, - field.isInterface - ); - } - - resolvedRelationalFieldsLookup[relationalType] = resolvedFields; - } - } - - _typeFieldsLookup = typeFieldsLookup; - _relationalFieldsLookup = relationalFieldsLookup; - _resolvedRelationalFieldsLookup = resolvedRelationalFieldsLookup; - _elementTypeLookup = elementTypeLookup; - } - } - -#if UNITY_INCLUDE_TESTS - /// - /// Rebuilds all cached lookup tables. Intended for editor and test usage. - /// - /// - /// This clears internal dictionaries and forces a full rebuild, ensuring test isolation. - /// - public void ForceRebuildForTests() - { - lock (_lookupLock) - { - _typeFieldsLookup = null; - _relationalFieldsLookup = null; - _resolvedRelationalFieldsLookup = null; - _elementTypeLookup = null; - } - - BuildLookup(); - } -#endif - private static bool TryResolveType(string typeName, out Type type) { if (string.IsNullOrWhiteSpace(typeName)) @@ -441,6 +320,109 @@ out elementType ); } + internal void BuildLookup() + { + lock (_lookupLock) + { + if ( + _typeFieldsLookup != null + && _relationalFieldsLookup != null + && _resolvedRelationalFieldsLookup != null + && _elementTypeLookup != null + ) + { + return; + } + + Dictionary typeFieldsLookup = new(_typeMetadata?.Length ?? 0); + Dictionary relationalFieldsLookup = new( + _relationalTypeMetadata?.Length ?? 0 + ); + Dictionary resolvedRelationalFieldsLookup = + new(_relationalTypeMetadata?.Length ?? 0); + Dictionary elementTypeLookup = new( + _relationalTypeMetadata?.Length ?? 0 + ); + + if (_typeMetadata != null) + { + foreach (TypeFieldMetadata metadata in _typeMetadata) + { + if ( + metadata == null + || !TryResolveType(metadata.typeName, out Type componentType) + ) + { + LogMissingType(metadata?.typeName, "attribute component"); + continue; + } + + string[] fieldNames = metadata.fieldNames ?? Array.Empty(); + typeFieldsLookup[componentType] = fieldNames; + } + } + + if (_relationalTypeMetadata != null) + { + foreach (RelationalTypeMetadata metadata in _relationalTypeMetadata) + { + if ( + metadata == null + || !TryResolveType(metadata.typeName, out Type relationalType) + ) + { + LogMissingType(metadata?.typeName, "relational component"); + continue; + } + + RelationalFieldMetadata[] fields = + metadata.fields ?? Array.Empty(); + + relationalFieldsLookup[relationalType] = fields; + + ResolvedRelationalFieldMetadata[] resolvedFields = + new ResolvedRelationalFieldMetadata[fields.Length]; + + int fieldsLength = fields.Length; + for (int i = 0; i < fieldsLength; ++i) + { + RelationalFieldMetadata field = fields[i]; + Type elementType = null; + + if (!string.IsNullOrWhiteSpace(field.elementTypeName)) + { + if (TryResolveType(field.elementTypeName, out elementType)) + { + elementTypeLookup[ + new ElementTypeKey(relationalType, field.fieldName) + ] = elementType; + } + else + { + LogMissingType(field.elementTypeName, "relational element"); + } + } + + resolvedFields[i] = new ResolvedRelationalFieldMetadata( + field.fieldName, + field.attributeKind, + field.fieldKind, + elementType, + field.isInterface + ); + } + + resolvedRelationalFieldsLookup[relationalType] = resolvedFields; + } + } + + _typeFieldsLookup = typeFieldsLookup; + _relationalFieldsLookup = relationalFieldsLookup; + _resolvedRelationalFieldsLookup = resolvedRelationalFieldsLookup; + _elementTypeLookup = elementTypeLookup; + } + } + #if UNITY_EDITOR private static string[] SortAttributeNames(string[] attributeNames) @@ -1210,7 +1192,7 @@ RuntimeInitializeLoadType loadType } } - private readonly struct ElementTypeKey : IEquatable + internal readonly struct ElementTypeKey : IEquatable { private readonly Type _componentType; private readonly string _fieldName; diff --git a/Runtime/Tags/EffectHandler.cs b/Runtime/Tags/EffectHandler.cs index e85c2d7f7..b07aa28cc 100644 --- a/Runtime/Tags/EffectHandler.cs +++ b/Runtime/Tags/EffectHandler.cs @@ -72,13 +72,17 @@ public sealed class EffectHandler : MonoBehaviour /// public event Action OnEffectRemoved; - internal int TraversalDepthForTesting => _traversalDepth; + // Defer pool returns until traversals finish so callbacks cannot clear or reuse an enumerated list. + internal readonly List>> _deferredBehaviorLeases = + new(); + internal readonly List< + PooledResource> + > _deferredPeriodicLeases = new(); + internal readonly List>> _deferredHandleLeases = new(); + internal readonly List>> _deferredCosmeticLeases = + new(); - internal int DeferredLeaseCountForTesting => - _deferredBehaviorLeases.Count - + _deferredPeriodicLeases.Count - + _deferredHandleLeases.Count - + _deferredCosmeticLeases.Count; + internal int _traversalDepth; [SiblingComponent] #pragma warning disable CS0649 @@ -103,7 +107,8 @@ private readonly Dictionary< private readonly Dictionary _stackKeyByHandleId = new(); // Iterating effect IDs is cheaper than iterating full handles. - private readonly Dictionary _effectExpirations = new(); + private readonly Dictionary _effectExpirations = + new(); private readonly Dictionary _effectHandlesById = new(); private readonly List _expiredEffectIds = new(); @@ -117,17 +122,6 @@ private readonly Dictionary< PooledResource> > _behaviorsByHandleId = new(); - // Defer pool returns until traversals finish so callbacks cannot clear or reuse an enumerated list. - private readonly List>> _deferredBehaviorLeases = new(); - private readonly List< - PooledResource> - > _deferredPeriodicLeases = new(); - private readonly List>> _deferredHandleLeases = new(); - private readonly List>> _deferredCosmeticLeases = - new(); - - private int _traversalDepth; - private bool _initialized; private static void DestroyCosmeticInstance(CosmeticEffectData data) @@ -383,6 +377,7 @@ public List GetActiveEffects(List buffer = null) /// The handle to inspect. /// When this method returns, contains the remaining time in seconds, or zero if unavailable. /// true if the handle has a tracked duration; otherwise, false. + /// Finite clock offsets remain significant beside large lifetimes; remaining time rounds to the nearest float. Infinite effects have no tracked duration. public bool TryGetRemainingDuration(EffectHandle handle, out float remainingDuration) { return TryGetRemainingDuration(handle, Time.time, out remainingDuration); @@ -461,9 +456,106 @@ internal void Remove(AttributesComponent attributesComponent) _attributes?.Remove(attributesComponent); } - internal EffectHandle? ApplyEffectForTesting(AttributeEffect effect, float currentTime) + internal EffectHandle? ApplyEffect(AttributeEffect effect, float currentTime) { - return ApplyEffect(effect, currentTime); + if (effect == null) + { + return null; + } + + if (effect.durationType == ModifierDurationType.Instant) + { + // Warn once per asset; the Inspector reports the same fixable configuration error. + if (effect.ShouldReportInstantWithHandleData()) + { + this.LogWarn( + $"Effect {effect.name} defines periodic or behaviour data but is Instant. These features require a Duration or Infinite effect.", + stackTrace: false + ); + } + + InternalApplyEffect(effect); + return null; + } + + EffectStackKey stackKey = effect.GetStackKey(); + List existingHandles = TryGetStackHandles(stackKey); + + switch (effect.stackingMode) + { + case EffectStackingMode.Ignore: + { + if (existingHandles is { Count: > 0 }) + { + return existingHandles[0]; + } + + break; + } + case EffectStackingMode.Refresh: + { + if (existingHandles is { Count: > 0 }) + { + EffectHandle handle = existingHandles[0]; + InternalApplyEffect(handle, currentTime); + return handle; + } + + break; + } + case EffectStackingMode.Replace: + { + if (existingHandles is { Count: > 0 }) + { + using PooledResource> handleBufferResource = + Buffers.List.Get(out List handleBuffer); + handleBuffer.AddRange(existingHandles); + foreach (EffectHandle handle in handleBuffer) + { + RemoveEffect(handle); + } + } + + break; + } + case EffectStackingMode.Stack: + { + if ( + 0 < effect.maximumStacks + && !TryEvictToStackCap(effect, stackKey, existingHandles) + ) + { + return null; + } + + break; + } + } + + EffectHandle newHandle = EffectHandle.CreateInstance(effect); + RegisterStackHandle(stackKey, newHandle); + try + { + InternalApplyEffect(newHandle, currentTime); + } + catch + { + // Unwind failed applications because no caller receives the handle; preserve the original exception. + Exception rollbackFailure = RemoveEffectCore(newHandle); + // Only this index can have been registered before the failure; removal is idempotent. + DetachStackHandle(newHandle); + if (rollbackFailure != null) + { + this.LogError( + $"Rolling back a failed application of {effect.name} raised a second exception.", + rollbackFailure + ); + } + + throw; + } + + return newHandle; } internal bool TryGetRemainingDuration( @@ -473,19 +565,31 @@ out float remainingDuration ) { long handleId = handle.id; - if (!_effectExpirations.TryGetValue(handleId, out float expiration)) + if ( + !_effectExpirations.TryGetValue( + handleId, + out (float startTime, float duration) timing + ) + ) { remainingDuration = 0f; return false; } - float timeRemaining = expiration - currentTime; + double timeRemaining = + float.IsFinite(timing.startTime) && float.IsFinite(currentTime) + ? CompensatedTime.RemainingDuration( + timing.startTime, + timing.duration, + currentTime + ) + : (float)((double)timing.startTime + timing.duration) - currentTime; if (timeRemaining < 0f) { timeRemaining = 0f; } - remainingDuration = timeRemaining; + remainingDuration = (float)timeRemaining; return true; } @@ -548,128 +652,237 @@ float currentTime return false; } - float newExpiration = currentTime + effect.duration; - _effectExpirations[handleId] = newExpiration; + _effectExpirations[handleId] = (currentTime, effect.duration); _effectHandlesById[handleId] = handle; return true; } - internal int ProcessBehaviorTicksForTesting(float deltaTime) + internal int ProcessBehaviorTicks(float deltaTime) { - return ProcessBehaviorTicks(deltaTime); - } + if (_behaviorsByHandleId.Count <= 0) + { + return 0; + } - internal int ProcessPeriodicEffectsForTesting(float currentTime, float deltaTime) - { - return ProcessPeriodicEffects(currentTime, deltaTime); - } + int processedTicks = 0; + ++_traversalDepth; + try + { + using PooledResource> behaviorHandleIdsResource = Buffers.List.Get( + out List behaviorHandleIdsBuffer + ); + behaviorHandleIdsBuffer.AddRange(_behaviorsByHandleId.Keys); - private void Awake() - { - this.AssignRelationalComponents(); - _initialized = true; + foreach (long handleId in behaviorHandleIdsBuffer) + { + if (!_effectHandlesById.TryGetValue(handleId, out EffectHandle handle)) + { + continue; + } + + if ( + !_behaviorsByHandleId.TryGetValue( + handleId, + out PooledResource> behaviorLease + ) + ) + { + continue; + } + + List behaviors = behaviorLease.resource; + EffectBehaviorContext context = new(this, handle, deltaTime); + foreach (EffectBehavior behavior in behaviors) + { + if (behavior == null) + { + continue; + } + + behavior.OnTick(context); + ++processedTicks; + // OnTick can remove the handle and destroy every remaining clone. + if (!_effectHandlesById.ContainsKey(handleId)) + { + break; + } + } + } + } + finally + { + EndTraversal(); + } + + return processedTicks; } - private EffectHandle? ApplyEffect(AttributeEffect effect, float currentTime) + internal int ProcessPeriodicEffects(float currentTime, float deltaTime) { - if (effect == null) + if (_periodicEffectStates.Count <= 0) { - return null; + return 0; } - if (effect.durationType == ModifierDurationType.Instant) + int consumedTicks = 0; + ++_traversalDepth; + try { - // Warn once per asset; the Inspector reports the same fixable configuration error. - if (effect.ShouldReportInstantWithHandleData()) + using PooledResource> periodicRemovalResource = Buffers.List.Get( + out List periodicRemovalBuffer + ); + using PooledResource> periodHandleIdsResource = Buffers.List.Get( + out List periodicHandleIdsBuffer + ); + periodicHandleIdsBuffer.AddRange(_periodicEffectStates.Keys); + + foreach (long handleId in periodicHandleIdsBuffer) { - this.LogWarn( - $"Effect {effect.name} defines periodic or behaviour data but is Instant. These features require a Duration or Infinite effect.", - stackTrace: false - ); - } + if (!_effectHandlesById.TryGetValue(handleId, out EffectHandle handle)) + { + periodicRemovalBuffer.Add(handleId); + continue; + } - InternalApplyEffect(effect); - return null; - } + if ( + !_periodicEffectStates.TryGetValue( + handleId, + out PooledResource> runtimesLease + ) + ) + { + continue; + } - EffectStackKey stackKey = effect.GetStackKey(); - List existingHandles = TryGetStackHandles(stackKey); + List runtimes = runtimesLease.resource; + bool hasActive = false; + bool stillApplied = true; - switch (effect.stackingMode) - { - case EffectStackingMode.Ignore: - { - if (existingHandles is { Count: > 0 }) + foreach (PeriodicEffectRuntimeState runtimeState in runtimes) { - return existingHandles[0]; + if (runtimeState == null) + { + continue; + } + + int consumedTicksThisUpdate = 0; + while ( + consumedTicksThisUpdate < MaxPeriodicCatchUpTicksPerUpdate + && _effectHandlesById.ContainsKey(handleId) + && runtimeState.TryConsumeTick(currentTime) + ) + { + ++consumedTicksThisUpdate; + ++consumedTicks; + ApplyPeriodicTick(handle, runtimeState, currentTime, deltaTime); + } + + // A periodic callback can cancel the effect; remaining definitions must not tick afterward. + if (!_effectHandlesById.ContainsKey(handleId)) + { + stillApplied = false; + break; + } + + if (!runtimeState.IsComplete) + { + hasActive = true; + } } - break; - } - case EffectStackingMode.Refresh: - { - if (existingHandles is { Count: > 0 }) + if (stillApplied && !hasActive) { - EffectHandle handle = existingHandles[0]; - InternalApplyEffect(handle, currentTime); - return handle; + periodicRemovalBuffer.Add(handleId); } - - break; } - case EffectStackingMode.Replace: + + foreach (long periodicHandleId in periodicRemovalBuffer) { - if (existingHandles is { Count: > 0 }) + if ( + _periodicEffectStates.Remove( + periodicHandleId, + out PooledResource> lease + ) + ) { - using PooledResource> handleBufferResource = - Buffers.List.Get(out List handleBuffer); - handleBuffer.AddRange(existingHandles); - foreach (EffectHandle handle in handleBuffer) - { - RemoveEffect(handle); - } + ReleasePeriodicStateList(lease); } + } + } + finally + { + EndTraversal(); + } - break; + return consumedTicks; + } + + internal void ProcessEffectExpirations(float currentTime) + { + if (_effectExpirations.Count <= 0) + { + return; + } + + _expiredEffectIds.Clear(); + foreach ( + KeyValuePair entry in _effectExpirations + ) + { + (float startTime, float duration) timing = entry.Value; + bool expired = + float.IsFinite(timing.startTime) && float.IsFinite(currentTime) + ? CompensatedTime.HasElapsed( + timing.startTime, + currentTime, + timing.duration, + 0d, + inclusive: true + ) + : (float)((double)timing.startTime + timing.duration) <= currentTime; + if (expired) + { + _expiredEffectIds.Add(entry.Key); } - case EffectStackingMode.Stack: + } + + Exception teardownFailure = null; + try + { + // Teardown callbacks can clear this list through OnDestroy; finish expirations before reporting failures. +#pragma warning disable WUH013 // Teardown callbacks can clear this list through OnDestroy. + for (int i = 0; i < _expiredEffectIds.Count; ++i) { if ( - 0 < effect.maximumStacks - && !TryEvictToStackCap(effect, stackKey, existingHandles) + _effectHandlesById.TryGetValue( + _expiredEffectIds[i], + out EffectHandle expiredHandle + ) ) { - return null; + teardownFailure = RecordFailure( + teardownFailure, + RemoveEffectCore(expiredHandle) + ); } - - break; } +#pragma warning restore WUH013 } - - EffectHandle newHandle = EffectHandle.CreateInstance(effect); - RegisterStackHandle(stackKey, newHandle); - try + finally { - InternalApplyEffect(newHandle, currentTime); + _expiredEffectIds.Clear(); } - catch - { - // Unwind failed applications because no caller receives the handle; preserve the original exception. - Exception rollbackFailure = RemoveEffectCore(newHandle); - // Only this index can have been registered before the failure; removal is idempotent. - DetachStackHandle(newHandle); - if (rollbackFailure != null) - { - this.LogError( - $"Rolling back a failed application of {effect.name} raised a second exception.", - rollbackFailure - ); - } - throw; + if (teardownFailure != null) + { + ExceptionDispatchInfo.Capture(teardownFailure).Throw(); } + } - return newHandle; + private void Awake() + { + this.AssignRelationalComponents(); + _initialized = true; } private List TryGetStackHandles(EffectStackKey stackKey) @@ -1073,7 +1286,7 @@ private void InternalApplyEffect(EffectHandle handle, float currentTime) { if (!exists || effect.resetDurationOnReapplication) { - _effectExpirations[handleId] = currentTime + effect.duration; + _effectExpirations[handleId] = (currentTime, effect.duration); } } @@ -1674,52 +1887,7 @@ private void Update() private void ProcessEffectExpirations() { - if (_effectExpirations.Count <= 0) - { - return; - } - - _expiredEffectIds.Clear(); - float currentTime = Time.time; - foreach (KeyValuePair entry in _effectExpirations) - { - if (entry.Value <= currentTime) - { - _expiredEffectIds.Add(entry.Key); - } - } - - Exception teardownFailure = null; - try - { - // Teardown callbacks can clear this list through OnDestroy; finish expirations before reporting failures. -#pragma warning disable WUH013 // Teardown callbacks can clear this list through OnDestroy. - for (int i = 0; i < _expiredEffectIds.Count; ++i) - { - if ( - _effectHandlesById.TryGetValue( - _expiredEffectIds[i], - out EffectHandle expiredHandle - ) - ) - { - teardownFailure = RecordFailure( - teardownFailure, - RemoveEffectCore(expiredHandle) - ); - } - } -#pragma warning restore WUH013 - } - finally - { - _expiredEffectIds.Clear(); - } - - if (teardownFailure != null) - { - ExceptionDispatchInfo.Capture(teardownFailure).Throw(); - } + ProcessEffectExpirations(Time.time); } private void ProcessBehaviorTicks() @@ -1727,169 +1895,9 @@ private void ProcessBehaviorTicks() _ = ProcessBehaviorTicks(Time.deltaTime); } - private int ProcessBehaviorTicks(float deltaTime) - { - if (_behaviorsByHandleId.Count <= 0) - { - return 0; - } - - int processedTicks = 0; - ++_traversalDepth; - try - { - using PooledResource> behaviorHandleIdsResource = Buffers.List.Get( - out List behaviorHandleIdsBuffer - ); - behaviorHandleIdsBuffer.AddRange(_behaviorsByHandleId.Keys); - - foreach (long handleId in behaviorHandleIdsBuffer) - { - if (!_effectHandlesById.TryGetValue(handleId, out EffectHandle handle)) - { - continue; - } - - if ( - !_behaviorsByHandleId.TryGetValue( - handleId, - out PooledResource> behaviorLease - ) - ) - { - continue; - } - - List behaviors = behaviorLease.resource; - EffectBehaviorContext context = new(this, handle, deltaTime); - foreach (EffectBehavior behavior in behaviors) - { - if (behavior == null) - { - continue; - } - - behavior.OnTick(context); - ++processedTicks; - // OnTick can remove the handle and destroy every remaining clone. - if (!_effectHandlesById.ContainsKey(handleId)) - { - break; - } - } - } - } - finally - { - EndTraversal(); - } - - return processedTicks; - } - private void ProcessPeriodicEffects() { _ = ProcessPeriodicEffects(Time.time, Time.deltaTime); } - - private int ProcessPeriodicEffects(float currentTime, float deltaTime) - { - if (_periodicEffectStates.Count <= 0) - { - return 0; - } - - int consumedTicks = 0; - ++_traversalDepth; - try - { - using PooledResource> periodicRemovalResource = Buffers.List.Get( - out List periodicRemovalBuffer - ); - using PooledResource> periodHandleIdsResource = Buffers.List.Get( - out List periodicHandleIdsBuffer - ); - periodicHandleIdsBuffer.AddRange(_periodicEffectStates.Keys); - - foreach (long handleId in periodicHandleIdsBuffer) - { - if (!_effectHandlesById.TryGetValue(handleId, out EffectHandle handle)) - { - periodicRemovalBuffer.Add(handleId); - continue; - } - - if ( - !_periodicEffectStates.TryGetValue( - handleId, - out PooledResource> runtimesLease - ) - ) - { - continue; - } - - List runtimes = runtimesLease.resource; - bool hasActive = false; - bool stillApplied = true; - - foreach (PeriodicEffectRuntimeState runtimeState in runtimes) - { - if (runtimeState == null) - { - continue; - } - - int consumedTicksThisUpdate = 0; - while ( - consumedTicksThisUpdate < MaxPeriodicCatchUpTicksPerUpdate - && _effectHandlesById.ContainsKey(handleId) - && runtimeState.TryConsumeTick(currentTime) - ) - { - ++consumedTicksThisUpdate; - ++consumedTicks; - ApplyPeriodicTick(handle, runtimeState, currentTime, deltaTime); - } - - // A periodic callback can cancel the effect; remaining definitions must not tick afterward. - if (!_effectHandlesById.ContainsKey(handleId)) - { - stillApplied = false; - break; - } - - if (!runtimeState.IsComplete) - { - hasActive = true; - } - } - - if (stillApplied && !hasActive) - { - periodicRemovalBuffer.Add(handleId); - } - } - - foreach (long periodicHandleId in periodicRemovalBuffer) - { - if ( - _periodicEffectStates.Remove( - periodicHandleId, - out PooledResource> lease - ) - ) - { - ReleasePeriodicStateList(lease); - } - } - } - finally - { - EndTraversal(); - } - - return consumedTicks; - } } } diff --git a/Runtime/Tags/PeriodicEffectRuntimeState.cs b/Runtime/Tags/PeriodicEffectRuntimeState.cs index 7c3426ff1..b57cd1e26 100644 --- a/Runtime/Tags/PeriodicEffectRuntimeState.cs +++ b/Runtime/Tags/PeriodicEffectRuntimeState.cs @@ -15,30 +15,37 @@ namespace WallstopStudios.UnityHelpers.Tags /// periodic definition independently (with unique timers and tick counts). /// /// - /// Intervals are clamped to a minimum of 0.01 seconds to prevent zero‑division and busy loops, - /// and is initialized using . + /// Intervals are clamped to a minimum of 0.01 seconds. Finite timing retains the initial delay + /// and cadence independently of absolute float clock precision. Refreshing the owning handle + /// preserves the periodic phase; creating a new handle starts a new schedule. /// /// internal sealed class PeriodicEffectRuntimeState { + private const long OrdinalLimbMask = (1L << 24) - 1L; + private const double OrdinalMiddleScale = 1L << 24; + private const double OrdinalHighScale = 1L << 48; + /// /// Indicates whether the runtime has executed the maximum allowed ticks for the definition. /// internal bool IsComplete => 0 < definition.maxTicks && definition.maxTicks <= ExecutedTicks; - /// - /// The absolute timestamp (in seconds) when the next tick should execute. - /// - internal float NextTickTime { get; private set; } - /// /// The number of ticks that have successfully executed so far. /// - internal int ExecutedTicks { get; private set; } + internal int ExecutedTicks { get; set; } internal readonly PeriodicEffectDefinition definition; internal readonly float interval; + internal long _scheduleOrdinal; + + private readonly double _origin; + private readonly double _originRemainder; + private readonly bool _usesNonfiniteTiming; + private float _legacyNextTickTime; + /// /// Creates runtime tracking for a periodic definition, clamping invalid authoring values. /// @@ -50,7 +57,22 @@ internal PeriodicEffectRuntimeState(PeriodicEffectDefinition definition, float s ExecutedTicks = 0; float clampedInterval = Mathf.Max(0.01f, definition.interval); interval = clampedInterval; - NextTickTime = startTime + Mathf.Max(0f, definition.initialDelay); + float initialDelay = Mathf.Max(0f, definition.initialDelay); + _legacyNextTickTime = (float)((double)startTime + initialDelay); + _usesNonfiniteTiming = + !float.IsFinite(startTime) + || !float.IsFinite(initialDelay) + || !float.IsFinite(interval); + TwoSum(startTime, initialDelay, out _origin, out _originRemainder); + } + + private static void TwoSum(double left, double right, out double sum, out double remainder) + { + double total = left + right; + double roundedRight = total - left; + double error = (left - (total - roundedRight)) + (right - roundedRight); + sum = total; + remainder = error; } /// @@ -60,14 +82,74 @@ internal PeriodicEffectRuntimeState(PeriodicEffectDefinition definition, float s /// true when a tick was consumed and incremented; otherwise, false. internal bool TryConsumeTick(float currentTime) { - if (currentTime < NextTickTime || IsComplete) + if (IsComplete) + { + return false; + } + + if (_usesNonfiniteTiming || !float.IsFinite(currentTime)) + { + if (currentTime < _legacyNextTickTime) + { + return false; + } + } + else if (!IsTickDue(currentTime)) { return false; } ++ExecutedTicks; - NextTickTime += interval; + ++_scheduleOrdinal; + _legacyNextTickTime = (float)((double)_legacyNextTickTime + interval); return true; } + + private bool IsTickDue(float currentTime) + { + TwoSum(currentTime, -_origin, out double elapsed, out double elapsedError); + TwoSum( + elapsed, + -_originRemainder, + out double adjustedElapsed, + out double adjustmentError + ); + TwoSum( + adjustedElapsed, + elapsedError + adjustmentError, + out double elapsedHigh, + out double elapsedLow + ); + GetCadenceOffset(out double scheduledHigh, out double scheduledLow); + if (elapsedHigh < scheduledHigh) + { + return false; + } + if (scheduledHigh < elapsedHigh) + { + return true; + } + return scheduledLow <= elapsedLow; + } + + private void GetCadenceOffset(out double offset, out double remainder) + { + double ordinalHigh = _scheduleOrdinal >> 48; + double ordinalMiddle = (_scheduleOrdinal >> 24) & OrdinalLimbMask; + double ordinalLow = _scheduleOrdinal & OrdinalLimbMask; + double highProduct = interval * ordinalHigh * OrdinalHighScale; + double middleProduct = interval * ordinalMiddle * OrdinalMiddleScale; + double lowProduct = interval * ordinalLow; + TwoSum(highProduct, middleProduct, out double combined, out double combinationError); + TwoSum(combined, lowProduct, out double total, out double additionError); + TwoSum( + total, + combinationError + additionError, + out double normalizedOffset, + out double normalizedRemainder + ); + offset = normalizedOffset; + remainder = normalizedRemainder; + } } } diff --git a/Runtime/Utils/Buffers.cs b/Runtime/Utils/Buffers.cs index be04e49d2..e64d78933 100644 --- a/Runtime/Utils/Buffers.cs +++ b/Runtime/Utils/Buffers.cs @@ -163,27 +163,28 @@ public static int ComparerPoolMaxDistinctEntries onRelease: builder => builder.Clear() ); - private static readonly Dictionary< + internal static readonly Dictionary< float, WaitInstructionCacheEntry > WaitForSeconds = new(); - private static readonly Dictionary< + internal static readonly Dictionary< float, WaitInstructionCacheEntry > WaitForSecondsRealtime = new(); - private static readonly LinkedList WaitForSecondsOrder = new(); - private static readonly LinkedList WaitForSecondsRealtimeOrder = new(); + internal static readonly LinkedList WaitForSecondsOrder = new(); + internal static readonly LinkedList WaitForSecondsRealtimeOrder = new(); + + internal static int _waitForSecondsLimitHits; + internal static int _waitForSecondsRealtimeLimitHits; + internal static int _waitForSecondsEvictions; + internal static int _waitForSecondsRealtimeEvictions; + internal static readonly object WaitInstructionCacheLock = new(); private static float _waitInstructionQuantizationStepSeconds; private static int _waitInstructionMaxDistinctEntries = WaitInstructionDefaultMaxDistinctEntries; private static int _waitInstructionUseLruEvictionFlag; private static int _comparerPoolMaxDistinctEntries = ComparerPoolDefaultMaxDistinctEntries; - private static int _waitForSecondsLimitHits; - private static int _waitForSecondsRealtimeLimitHits; - private static int _waitForSecondsEvictions; - private static int _waitForSecondsRealtimeEvictions; - private static readonly object WaitInstructionCacheLock = new(); /// /// Gets a pooled StringBuilder with at least the requested capacity. @@ -302,33 +303,6 @@ internal static Cache CreateCache() return cache; } - internal static void ResetWaitInstructionCachesForTesting() - { -#if !SINGLE_THREADED - lock (WaitInstructionCacheLock) - { -#endif - WaitForSeconds.Clear(); - WaitForSecondsRealtime.Clear(); - WaitForSecondsOrder.Clear(); - WaitForSecondsRealtimeOrder.Clear(); -#if !SINGLE_THREADED - } -#endif - WaitInstructionQuantizationStepSeconds = 0f; - WaitInstructionMaxDistinctEntries = WaitInstructionDefaultMaxDistinctEntries; - WaitInstructionUseLruEviction = false; - Volatile.Write(ref _waitForSecondsLimitHits, 0); - Volatile.Write(ref _waitForSecondsRealtimeLimitHits, 0); - Volatile.Write(ref _waitForSecondsEvictions, 0); - Volatile.Write(ref _waitForSecondsRealtimeEvictions, 0); - } - - internal static IDisposable BeginWaitInstructionTestScope() - { - return new WaitInstructionTestScope(); - } - private static WaitForSeconds CreateWaitForSeconds(float seconds) { return new WaitForSeconds(seconds); @@ -499,158 +473,6 @@ int evictions ); } - private sealed class WaitInstructionTestScope : IDisposable - { - private readonly WaitInstructionCacheSnapshot _waitForSecondsSnapshot; - private readonly WaitInstructionCacheSnapshot _waitForSecondsRealtimeSnapshot; - private readonly float _quantizationStepSnapshot; - private readonly int _maxDistinctEntriesSnapshot; - private readonly bool _useLruSnapshot; - private readonly int _waitForSecondsLimitHitsSnapshot; - private readonly int _waitForSecondsRealtimeLimitHitsSnapshot; - private readonly int _waitForSecondsEvictionsSnapshot; - private readonly int _waitForSecondsRealtimeEvictionsSnapshot; - private bool _disposed; - - internal WaitInstructionTestScope() - { - _waitForSecondsSnapshot = SnapshotCache(WaitForSeconds, WaitForSecondsOrder); - _waitForSecondsRealtimeSnapshot = SnapshotCache( - WaitForSecondsRealtime, - WaitForSecondsRealtimeOrder - ); - _quantizationStepSnapshot = WaitInstructionQuantizationStepSeconds; - _maxDistinctEntriesSnapshot = WaitInstructionMaxDistinctEntries; - _useLruSnapshot = WaitInstructionUseLruEviction; - _waitForSecondsLimitHitsSnapshot = Volatile.Read(ref _waitForSecondsLimitHits); - _waitForSecondsRealtimeLimitHitsSnapshot = Volatile.Read( - ref _waitForSecondsRealtimeLimitHits - ); - _waitForSecondsEvictionsSnapshot = Volatile.Read(ref _waitForSecondsEvictions); - _waitForSecondsRealtimeEvictionsSnapshot = Volatile.Read( - ref _waitForSecondsRealtimeEvictions - ); - - ResetWaitInstructionCachesForTesting(); - } - - private static WaitInstructionCacheSnapshot SnapshotCache( - Dictionary> cache, - LinkedList order - ) - where TInstruction : class - { -#if !SINGLE_THREADED - lock (WaitInstructionCacheLock) - { -#endif - Dictionary entries = new(cache.Count); - foreach ( - KeyValuePair> pair in cache - ) - { - entries[pair.Key] = pair.Value._value; - } - - List ordering = new(order); - return new WaitInstructionCacheSnapshot(entries, ordering); -#if !SINGLE_THREADED - } -#endif - } - - private static void RestoreCache( - Dictionary> cache, - LinkedList order, - WaitInstructionCacheSnapshot snapshot - ) - where TInstruction : class - { -#if !SINGLE_THREADED - lock (WaitInstructionCacheLock) - { -#endif - cache.Clear(); - order.Clear(); - - if (snapshot.Order == null || snapshot.Entries == null) - { - return; - } - - Dictionary> nodes = new(snapshot.Order.Count); - foreach (float key in snapshot.Order) - { - LinkedListNode node = order.AddLast(key); - nodes[key] = node; - } - - foreach (KeyValuePair pair in snapshot.Entries) - { - if (!nodes.TryGetValue(pair.Key, out LinkedListNode node)) - { - node = order.AddLast(pair.Key); - nodes[pair.Key] = node; - } - - cache[pair.Key] = new WaitInstructionCacheEntry( - pair.Value, - node - ); - } -#if !SINGLE_THREADED - } -#endif - } - - public void Dispose() - { - if (_disposed) - { - return; - } - _disposed = true; - - WaitInstructionQuantizationStepSeconds = _quantizationStepSnapshot; - WaitInstructionMaxDistinctEntries = _maxDistinctEntriesSnapshot; - WaitInstructionUseLruEviction = _useLruSnapshot; - Volatile.Write(ref _waitForSecondsLimitHits, _waitForSecondsLimitHitsSnapshot); - Volatile.Write( - ref _waitForSecondsRealtimeLimitHits, - _waitForSecondsRealtimeLimitHitsSnapshot - ); - Volatile.Write(ref _waitForSecondsEvictions, _waitForSecondsEvictionsSnapshot); - Volatile.Write( - ref _waitForSecondsRealtimeEvictions, - _waitForSecondsRealtimeEvictionsSnapshot - ); - - RestoreCache(WaitForSeconds, WaitForSecondsOrder, _waitForSecondsSnapshot); - RestoreCache( - WaitForSecondsRealtime, - WaitForSecondsRealtimeOrder, - _waitForSecondsRealtimeSnapshot - ); - } - - private readonly struct WaitInstructionCacheSnapshot - where TInstruction : class - { - internal Dictionary Entries { get; } - - internal List Order { get; } - - internal WaitInstructionCacheSnapshot( - Dictionary entries, - List order - ) - { - Entries = entries; - Order = order; - } - } - } - /// /// Stores a cached wait instruction alongside its position in the LRU ordering. /// @@ -664,7 +486,7 @@ List order /// /// This avoids the O(n) traversal that would be required if we only stored keys and had to search for them. /// - private readonly struct WaitInstructionCacheEntry + internal readonly struct WaitInstructionCacheEntry { internal readonly TInstruction _value; internal readonly LinkedListNode _node; @@ -852,11 +674,11 @@ public static class SetBuffers onRelease: set => set.Clear() ); - private static readonly Cache< + internal static readonly Cache< IComparer, WallstopGenericPool> > SortedSetCache = Buffers.CreateCache, WallstopGenericPool>>(); - private static readonly Cache< + internal static readonly Cache< IEqualityComparer, WallstopGenericPool> > HashSetCache = Buffers.CreateCache< @@ -977,12 +799,6 @@ public static bool DestroySortedSetPool(IComparer comparer) pool.Dispose(); return true; } - - internal static void ClearPoolsForTesting() - { - HashSetCache.Clear(); - SortedSetCache.Clear(); - } } /// @@ -1032,14 +848,14 @@ public static readonly WallstopGenericPool< onRelease: sortedDictionary => sortedDictionary.Clear() ); - private static readonly Cache< + internal static readonly Cache< IEqualityComparer, WallstopGenericPool> > DictionaryCache = Buffers.CreateCache< IEqualityComparer, WallstopGenericPool> >(); - private static readonly Cache< + internal static readonly Cache< IComparer, WallstopGenericPool> > SortedDictionaryCache = Buffers.CreateCache< @@ -1174,12 +990,6 @@ out WallstopGenericPool> pool pool.Dispose(); return true; } - - internal static void ClearPoolsForTesting() - { - DictionaryCache.Clear(); - SortedDictionaryCache.Clear(); - } } #if SINGLE_THREADED @@ -1427,12 +1237,32 @@ public WallstopGenericPool( _lastPeriodicPurge = _timeProvider(); float warmTime = _timeProvider(); - for (int i = 0; i < preWarmCount; ++i) + try { - T value = _producer(); - _onGet?.Invoke(value); - _onRelease?.Invoke(value); - _pool.Add(new PooledEntry { Value = value, ReturnTime = warmTime }); + for (int i = 0; i < preWarmCount; ++i) + { + T value = _producer(); + try + { + _onGet?.Invoke(value); + _onRelease?.Invoke(value); + _pool.Add(new PooledEntry { Value = value, ReturnTime = warmTime }); + } + catch + { + InvokeOnDispose(value); + throw; + } + } + } + catch + { + foreach (PooledEntry entry in _pool) + { + InvokeOnDispose(entry.Value); + } + _pool.Clear(); + throw; } int warmCount = _pool.Count; if (_peakSize < warmCount) @@ -1453,6 +1283,9 @@ private static float DefaultTimeProvider() /// If the pool is empty, a new instance is created using the producer function. /// /// + /// A failed producer or acquisition callback balances active rental tracking. An item whose + /// acquisition callback fails is retired through the disposal callback before the original + /// failure propagates. Acquisition callbacks execute outside the storage lock. /// The configured release callback may dispose this pool. In that case the returned item is /// retired through the disposal callback instead of being retained. /// @@ -1467,6 +1300,9 @@ public PooledResource Get() /// If the pool is empty, a new instance is created using the producer function. /// /// + /// A failed producer or acquisition callback balances active rental tracking. An item whose + /// acquisition callback fails is retired through the disposal callback before the original + /// failure propagates. Acquisition callbacks execute outside the storage lock. /// The configured release callback may dispose this pool. In that case the returned item is /// retired through the disposal callback instead of being retained. /// @@ -1477,7 +1313,15 @@ public PooledResource Get(out T value) if (_disposed) { T produced = _producer(); - _onGet?.Invoke(produced); + try + { + _onGet?.Invoke(produced); + } + catch + { + InvokeOnDispose(produced); + throw; + } value = produced; return new PooledResource(produced, _untrackedReturnAction); } @@ -1499,25 +1343,30 @@ public PooledResource Get(out T value) _usageTracker.RecordRent(currentTime); T rented; - int poolCount = _pool.Count; - if (0 < poolCount) + try { - int lastIndex = poolCount - 1; - rented = _pool[lastIndex].Value; - _pool.RemoveAt(lastIndex); + rented = AcquireValue(); } - else + catch + { + _usageTracker.RecordRetiredReturn(); + throw; + } + try { - rented = _producer(); - int totalInCirculation = _pool.Count + _usageTracker.CurrentlyRented; if (_peakSize < totalInCirculation) { _peakSize = totalInCirculation; } + _onGet?.Invoke(rented); + } + catch + { + _usageTracker.RecordRetiredReturn(); + InvokeOnDispose(rented); + throw; } - - _onGet?.Invoke(rented); value = rented; return new PooledResource(rented, _returnAction); } @@ -2064,6 +1913,19 @@ private void InvokeOnPurge(T value, PurgeReason reason) catch { } } + private T AcquireValue() + { + int count = _pool.Count; + if (0 < count) + { + int index = count - 1; + T value = _pool[index].Value; + _pool.RemoveAt(index); + return value; + } + return _producer(); + } + private void InvokeOnDispose(T value) { if (_onDispose == null) @@ -2390,12 +2252,32 @@ public WallstopGenericPool( _lastPeriodicPurge = _timeProvider(); float warmTime = _timeProvider(); - for (int i = 0; i < preWarmCount; ++i) + try { - T value = _producer(); - _onGet?.Invoke(value); - _onRelease?.Invoke(value); - _pool.Add(new PooledEntry { Value = value, ReturnTime = warmTime }); + for (int i = 0; i < preWarmCount; ++i) + { + T value = _producer(); + try + { + _onGet?.Invoke(value); + _onRelease?.Invoke(value); + _pool.Add(new PooledEntry { Value = value, ReturnTime = warmTime }); + } + catch + { + InvokeOnDispose(value); + throw; + } + } + } + catch + { + foreach (PooledEntry entry in _pool) + { + InvokeOnDispose(entry.Value); + } + _pool.Clear(); + throw; } int warmCount = _pool.Count; if (_peakSize < warmCount) @@ -2417,6 +2299,9 @@ private static float DefaultTimeProvider() /// This method is thread-safe. /// /// + /// A failed producer or acquisition callback balances active rental tracking. An item whose + /// acquisition callback fails is retired through the disposal callback before the original + /// failure propagates. Acquisition callbacks execute outside the storage lock. /// The configured release callback may dispose this pool. In that case the returned item is /// retired through the disposal callback instead of being retained. /// @@ -2432,6 +2317,9 @@ public PooledResource Get() /// This method is thread-safe. /// /// + /// A failed producer or acquisition callback balances active rental tracking. An item whose + /// acquisition callback fails is retired through the disposal callback before the original + /// failure propagates. Acquisition callbacks execute outside the storage lock. /// The configured release callback may dispose this pool. In that case the returned item is /// retired through the disposal callback instead of being retained. /// @@ -2442,7 +2330,15 @@ public PooledResource Get(out T value) if (Volatile.Read(ref _disposed) != 0) { T produced = _producer(); - _onGet?.Invoke(produced); + try + { + _onGet?.Invoke(produced); + } + catch + { + InvokeOnDispose(produced); + throw; + } value = produced; return new PooledResource(produced, _untrackedReturnAction); } @@ -2464,36 +2360,43 @@ public PooledResource Get(out T value) Volatile.Write(ref _lastAccessTime, currentTime); _usageTracker.RecordRent(currentTime); - lock (_lock) + T rented; + try { - int poolCount = _pool.Count; - if (0 < poolCount) - { - int lastIndex = poolCount - 1; - T pooled = _pool[lastIndex].Value; - _pool.RemoveAt(lastIndex); - _onGet?.Invoke(pooled); - value = pooled; - return new PooledResource(pooled, _returnAction); - } + rented = AcquireValue(); } - - T created = _producer(); - - int totalInCirculation = _pool.Count + _usageTracker.CurrentlyRented; - int peak = _peakSize; - while (peak < totalInCirculation) + catch { - int original = Interlocked.CompareExchange(ref _peakSize, totalInCirculation, peak); - if (original == peak) + _usageTracker.RecordRetiredReturn(); + throw; + } + try + { + int totalInCirculation = CurrentPooledCount + _usageTracker.CurrentlyRented; + int peak = _peakSize; + while (peak < totalInCirculation) { - break; + int original = Interlocked.CompareExchange( + ref _peakSize, + totalInCirculation, + peak + ); + if (original == peak) + { + break; + } + peak = original; } - peak = original; + _onGet?.Invoke(rented); + } + catch + { + _usageTracker.RecordRetiredReturn(); + InvokeOnDispose(rented); + throw; } - _onGet?.Invoke(created); - value = created; - return new PooledResource(created, _returnAction); + value = rented; + return new PooledResource(rented, _returnAction); } /// @@ -3144,6 +3047,22 @@ private void InvokeOnPurge(T value, PurgeReason reason) catch { } } + private T AcquireValue() + { + lock (_lock) + { + int count = _pool.Count; + if (0 < count) + { + int index = count - 1; + T value = _pool[index].Value; + _pool.RemoveAt(index); + return value; + } + } + return _producer(); + } + private void InvokeOnDispose(T value) { if (_onDispose == null) @@ -3808,7 +3727,7 @@ private static void Release(T[] resource) public static class WallstopFastArrayPool where T : unmanaged { - private static readonly PoolBucketTable Pool = new(); + internal static readonly PoolBucketTable Pool = new(); private static readonly Action OnRelease = Release; /// @@ -3861,15 +3780,6 @@ public static PooledArray Get(int size, out T[] array) return new PooledArray(rented, size, OnRelease); } - /// - /// Clears all pooled arrays for testing purposes. Internal visibility for test assemblies. - /// Thread-safe implementation. - /// - internal static void ClearForTesting() - { - Pool.ClearAll(); - } - private static void Release(T[] resource) { Pool.Bucket(resource.Length).Return(resource); diff --git a/Runtime/Utils/ChildSpawner.cs b/Runtime/Utils/ChildSpawner.cs index 0e687b8e4..b985c8c69 100644 --- a/Runtime/Utils/ChildSpawner.cs +++ b/Runtime/Utils/ChildSpawner.cs @@ -75,18 +75,11 @@ public sealed class ChildSpawner : MonoBehaviour private readonly HashSet _spawnedPrefabs = new(); [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] - private static void ClearSpawnedPrefabs() + internal static void ClearSpawnedPrefabs() { SpawnedPrefabs.Clear(); } -#if UNITY_INCLUDE_TESTS - internal static void ClearSpawnedPrefabsForTesting() - { - SpawnedPrefabs.Clear(); - } -#endif - /// /// Removes Unity's default "(Clone)" suffix from instantiated prefab names. /// diff --git a/Runtime/Utils/DisposalLease.cs b/Runtime/Utils/DisposalLease.cs index e33bbdfe4..e2d1b7909 100644 --- a/Runtime/Utils/DisposalLease.cs +++ b/Runtime/Utils/DisposalLease.cs @@ -53,26 +53,10 @@ internal bool IsHeld get => _slot != 0 && DisposalLeases.CurrentGeneration(_slot) == _generation; } - /// - /// The slot behind this lease, so a test can assert on ownership. Not part of the disposal - /// contract. - /// - internal int SlotForTests - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _slot; - } - - internal int OwnerThreadIdForTests - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _ownerThreadId; - } - // Slot 0 is never handed out, so `default` reads as "not held" without a second field. - private readonly int _slot; - private readonly int _ownerThreadId; - private readonly long _generation; + internal readonly int _slot; + internal readonly int _ownerThreadId; + internal readonly long _generation; internal DisposalLease(int slot, int ownerThreadId, long generation) { @@ -81,16 +65,6 @@ internal DisposalLease(int slot, int ownerThreadId, long generation) _generation = generation; } - /// - /// Claims without recycling the slot, so a test can control exactly when it becomes - /// reusable and observe who holds it in between. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal bool TryClaimWithoutRelease() - { - return _slot != 0 && DisposalLeases.TryClaim(_slot, _generation); - } - /// /// Claims the right to dispose and hands the slot back for reuse, for the one copy still /// holding the current generation. diff --git a/Runtime/Utils/MemoryPressureMonitor.cs b/Runtime/Utils/MemoryPressureMonitor.cs index ad144555b..a492af7cd 100644 --- a/Runtime/Utils/MemoryPressureMonitor.cs +++ b/Runtime/Utils/MemoryPressureMonitor.cs @@ -304,7 +304,7 @@ public static MemoryPressureLevel ForceUpdate() /// Resets all monitoring state and settings to defaults. /// /// - /// Primarily used for testing. Clears tracked memory values and resets all configuration. + /// Clears tracked memory values and resets all configuration. /// public static void Reset() { @@ -324,16 +324,19 @@ public static void Reset() /// /// Calculates the pressure level from provided metrics without querying the GC. - /// Used for testing pressure calculation logic with controlled inputs. /// /// Current memory as ratio of threshold (e.g., 0.9 = 90% of threshold). - /// GC rate as multiple of threshold (e.g., 2.0 = 2x the threshold rate). - /// Growth rate as multiple of threshold (e.g., 1.5 = 1.5x the threshold rate). + /// The measured GC collection rate. + /// The measured memory growth rate. + /// The GC rate threshold. + /// The memory growth threshold. /// The calculated pressure level. internal static MemoryPressureLevel CalculatePressureFromMetrics( float memoryRatio, float gcRateMultiplier, - float growthRateMultiplier + float growthRateMultiplier, + float gcRateThreshold = 1f, + float growthRateThreshold = 1f ) { int pressureScore = 0; @@ -355,20 +358,20 @@ float growthRateMultiplier pressureScore += LowMemoryScoreContribution; } - if (HighGCRateMultiplier <= gcRateMultiplier) + if (gcRateThreshold * HighGCRateMultiplier <= gcRateMultiplier) { pressureScore += HighGCRateScoreContribution; } - else if (1f <= gcRateMultiplier) + else if (gcRateThreshold <= gcRateMultiplier) { pressureScore += MediumGCRateScoreContribution; } - if (HighGrowthRateMultiplier <= growthRateMultiplier) + if (growthRateThreshold * HighGrowthRateMultiplier <= growthRateMultiplier) { pressureScore += HighGrowthRateScoreContribution; } - else if (1f <= growthRateMultiplier) + else if (growthRateThreshold <= growthRateMultiplier) { pressureScore += MediumGrowthRateScoreContribution; } @@ -413,90 +416,20 @@ private static void CalculatePressure(float currentTime, float lastCheckTime) elapsed = CheckIntervalSeconds; } - int pressureScore = 0; - long threshold = MemoryPressureThresholdBytes; - if (0 < threshold) - { - float memoryRatio = (float)totalMemory / threshold; - - if (CriticalMemoryRatio <= memoryRatio) - { - pressureScore += CriticalMemoryScoreContribution; - } - else if (HighMemoryRatio <= memoryRatio) - { - pressureScore += HighMemoryScoreContribution; - } - else if (MediumMemoryRatio <= memoryRatio) - { - pressureScore += MediumMemoryScoreContribution; - } - else if (LowMemoryRatio <= memoryRatio) - { - pressureScore += LowMemoryScoreContribution; - } - } - - if (0 < previousGCCount) - { - int gcDelta = gcCount - previousGCCount; - if (0 < gcDelta) - { - float gcRate = gcDelta / elapsed; - float gcRateThreshold = GCCollectionRateThreshold; - - if (gcRateThreshold * HighGCRateMultiplier <= gcRate) - { - pressureScore += HighGCRateScoreContribution; - } - else if (gcRateThreshold <= gcRate) - { - pressureScore += MediumGCRateScoreContribution; - } - } - } - - if (0 < previousMemory) - { - long memoryDelta = totalMemory - previousMemory; - if (0 < memoryDelta) - { - float growthRate = memoryDelta / elapsed; - long growthThreshold = MemoryGrowthRateThreshold; - - if (growthThreshold * HighGrowthRateMultiplier <= growthRate) - { - pressureScore += HighGrowthRateScoreContribution; - } - else if (growthThreshold <= growthRate) - { - pressureScore += MediumGrowthRateScoreContribution; - } - } - } - - MemoryPressureLevel level; - if (CriticalScoreThreshold <= pressureScore) - { - level = MemoryPressureLevel.Critical; - } - else if (HighScoreThreshold <= pressureScore) - { - level = MemoryPressureLevel.High; - } - else if (MediumScoreThreshold <= pressureScore) - { - level = MemoryPressureLevel.Medium; - } - else if (LowScoreThreshold <= pressureScore) - { - level = MemoryPressureLevel.Low; - } - else - { - level = MemoryPressureLevel.None; - } + float memoryRatio = 0 < threshold ? (float)totalMemory / threshold : float.NaN; + int gcDelta = gcCount - previousGCCount; + float gcRate = 0 < previousGCCount && 0 < gcDelta ? gcDelta / elapsed : float.NaN; + long memoryDelta = totalMemory - previousMemory; + float growthRate = + 0 < previousMemory && 0 < memoryDelta ? memoryDelta / elapsed : float.NaN; + MemoryPressureLevel level = CalculatePressureFromMetrics( + memoryRatio, + gcRate, + growthRate, + GCCollectionRateThreshold, + MemoryGrowthRateThreshold + ); Volatile.Write(ref _currentPressure, (int)level); } diff --git a/Runtime/Utils/PoolPurgeSettings.cs b/Runtime/Utils/PoolPurgeSettings.cs index 0eb511c73..a20ab4326 100644 --- a/Runtime/Utils/PoolPurgeSettings.cs +++ b/Runtime/Utils/PoolPurgeSettings.cs @@ -426,12 +426,22 @@ public static bool PurgeOnSceneUnload /// internal static bool BuiltInDefaultsInitialized => Volatile.Read(ref _builtInDefaultsInitialized) != 0; + internal static int _lifecycleHooksRegistered; + internal static readonly object ConfigLock = new object(); + + // Built-in type-aware defaults (lowest priority - applied before user configuration) + internal static readonly Dictionary BuiltInTypeConfigurations = + new Dictionary(); + internal static readonly Dictionary< + Type, + PoolPurgeTypeOptions + > BuiltInGenericTypeConfigurations = new Dictionary(); + internal static int _builtInDefaultsInitialized; private static int _globalEnabled = 0; private static int _purgeOnLowMemory = 1; private static int _purgeOnAppBackground = 1; private static int _purgeOnSceneUnload = 1; - private static int _lifecycleHooksRegistered; private static float _defaultIdleTimeoutSeconds = DefaultIdleTimeoutSeconds; private static int _defaultMinRetainCount = DefaultMinRetainCount; private static int _defaultWarmRetainCount = DefaultWarmRetainCount; @@ -446,9 +456,8 @@ public static bool PurgeOnSceneUnload private static float _largeObjectIdleTimeoutMultiplier = DefaultLargeObjectIdleTimeoutMultiplier; private static int _largeObjectWarmRetainCount = DefaultLargeObjectWarmRetainCount; - private static int _sizeAwarePoliciesEnabled = 1; - private static readonly object ConfigLock = new object(); + private static int _sizeAwarePoliciesEnabled = 1; private static readonly Dictionary TypeConfigurations = new Dictionary(); private static readonly Dictionary GenericTypeConfigurations = @@ -464,15 +473,6 @@ private static readonly Dictionary< > SettingsGenericTypeConfigurations = new Dictionary(); private static readonly HashSet SettingsDisabledTypes = new HashSet(); - // Built-in type-aware defaults (lowest priority - applied before user configuration) - private static readonly Dictionary BuiltInTypeConfigurations = - new Dictionary(); - private static readonly Dictionary< - Type, - PoolPurgeTypeOptions - > BuiltInGenericTypeConfigurations = new Dictionary(); - private static int _builtInDefaultsInitialized; - private static readonly Dictionary< Type, (bool HasAttribute, bool Enabled, PoolPurgeTypeOptions Options) @@ -1221,31 +1221,6 @@ public static int PurgeAllPools() return PurgeAllPools(respectHysteresis: true, reason: PurgeReason.Explicit); } - /// - /// Clears all built-in type-aware default configurations and resets the initialization flag. - /// This is primarily used for testing. - /// - internal static void ClearBuiltInTypeConfigurations() - { - lock (ConfigLock) - { - BuiltInTypeConfigurations.Clear(); - BuiltInGenericTypeConfigurations.Clear(); - } - - Volatile.Write(ref _builtInDefaultsInitialized, 0); - } - - /// - /// Forces re-initialization of built-in type-aware defaults. - /// This is primarily used for testing. - /// - internal static void ReinitializeBuiltInDefaults() - { - ClearBuiltInTypeConfigurations(); - EnsureBuiltInDefaultsInitialized(); - } - /// /// Registers application lifecycle hooks for automatic pool purging. /// This method is automatically called during runtime initialization via . @@ -1276,21 +1251,6 @@ internal static void RegisterLifecycleHooks() SceneManager.sceneUnloaded += OnSceneUnloaded; } - /// - /// Unregisters application lifecycle hooks. Primarily used for testing. - /// - internal static void UnregisterLifecycleHooks() - { - if (Interlocked.Exchange(ref _lifecycleHooksRegistered, 0) == 0) - { - return; - } - - Application.lowMemory -= OnLowMemory; - Application.focusChanged -= OnFocusChanged; - SceneManager.sceneUnloaded -= OnSceneUnloaded; - } - /// /// Ensures the built-in type-aware defaults are initialized. /// This method is called automatically when getting effective options. @@ -1311,7 +1271,7 @@ internal static void UnregisterLifecycleHooks() /// These defaults have the lowest priority and are overridden by any user configuration. /// /// - private static void EnsureBuiltInDefaultsInitialized() + internal static void EnsureBuiltInDefaultsInitialized() { if (Volatile.Read(ref _builtInDefaultsInitialized) != 0) { @@ -1330,6 +1290,62 @@ private static void EnsureBuiltInDefaultsInitialized() } } + internal static void OnLowMemory() + { + if (!PurgeOnLowMemory) + { + return; + } + + // Memory pressure requires immediate cleanup, bypassing gradual purge limits. + GlobalPoolRegistry.ForceFullPurgeAll( + respectHysteresis: false, + reason: PurgeReason.MemoryPressure + ); + } + + internal static void OnFocusChanged(bool hasFocus) + { + if (hasFocus || !PurgeOnAppBackground) + { + return; + } + + PurgeAllPools(respectHysteresis: true, reason: PurgeReason.AppBackgrounded); + } + + /// + /// Handles scene unloaded events by triggering a purge on all pools. + /// + /// + /// The scene that was unloaded. This parameter is unused because purge operations + /// are global (affecting all pools) rather than scene-specific. The parameter is + /// required by the delegate signature. + /// + /// + /// + /// When a scene is unloaded, pooled objects that were primarily used in that scene + /// may no longer be needed. This handler triggers a purge check on all pools to + /// reclaim memory from items that are no longer actively used. + /// + /// + /// The purge respects hysteresis settings to avoid purge-allocate cycles during + /// rapid scene transitions. Set to false + /// to disable this behavior. + /// + /// + internal static void OnSceneUnloaded(Scene scene) + { + _ = scene; + + if (!PurgeOnSceneUnload) + { + return; + } + + PurgeAllPools(respectHysteresis: true, reason: PurgeReason.SceneUnloaded); + } + /// /// Initializes the built-in type-aware defaults for common types. /// This method is called once during lazy initialization. @@ -1514,62 +1530,6 @@ out PoolPurgeTypeOptions attributeOptions attributeOptions = options; return hasAttribute; } - - private static void OnLowMemory() - { - if (!PurgeOnLowMemory) - { - return; - } - - // Memory pressure requires immediate cleanup, bypassing gradual purge limits. - GlobalPoolRegistry.ForceFullPurgeAll( - respectHysteresis: false, - reason: PurgeReason.MemoryPressure - ); - } - - private static void OnFocusChanged(bool hasFocus) - { - if (hasFocus || !PurgeOnAppBackground) - { - return; - } - - PurgeAllPools(respectHysteresis: true, reason: PurgeReason.AppBackgrounded); - } - - /// - /// Handles scene unloaded events by triggering a purge on all pools. - /// - /// - /// The scene that was unloaded. This parameter is unused because purge operations - /// are global (affecting all pools) rather than scene-specific. The parameter is - /// required by the delegate signature. - /// - /// - /// - /// When a scene is unloaded, pooled objects that were primarily used in that scene - /// may no longer be needed. This handler triggers a purge check on all pools to - /// reclaim memory from items that are no longer actively used. - /// - /// - /// The purge respects hysteresis settings to avoid purge-allocate cycles during - /// rapid scene transitions. Set to false - /// to disable this behavior. - /// - /// - private static void OnSceneUnloaded(Scene scene) - { - _ = scene; - - if (!PurgeOnSceneUnload) - { - return; - } - - PurgeAllPools(respectHysteresis: true, reason: PurgeReason.SceneUnloaded); - } } /// @@ -1685,9 +1645,10 @@ public static int RegisteredCount } } - private static readonly object RegistryLock = new object(); - private static readonly List> RegisteredPools = + internal static readonly object RegistryLock = new object(); + internal static readonly List> RegisteredPools = new List>(); + internal static float _lastBudgetEnforcementTime; private static readonly List BudgetEnforcementPools = new List(); @@ -1696,7 +1657,6 @@ public static int RegisteredCount private static int _budgetEnforcementInProgress; private static float _budgetEnforcementIntervalSeconds = DefaultBudgetEnforcementIntervalSeconds; - private static float _lastBudgetEnforcementTime; private static readonly System.Diagnostics.Stopwatch RegistryStopwatch = System.Diagnostics.Stopwatch.StartNew(); @@ -2005,25 +1965,6 @@ public static void ResetBudgetSettings() Volatile.Write(ref _lastBudgetEnforcementTime, 0f); } - /// - /// Clears all registered pools from the registry. - /// This does not dispose or purge the pools, only removes their registrations. - /// Primarily used for testing. - /// - internal static void Clear() - { - lock (RegistryLock) - { - RegisteredPools.Clear(); - } - Volatile.Write(ref _lastBudgetEnforcementTime, 0f); - } - - internal static bool IsRegistryLockHeldByCurrentThread() - { - return Monitor.IsEntered(RegistryLock); - } - private static int EnforceBudgetSingleFlight(long maxItems) { long currentTotal = 0; diff --git a/Runtime/Utils/PoolSizeEstimator.cs b/Runtime/Utils/PoolSizeEstimator.cs index 1ec21cec9..058643978 100644 --- a/Runtime/Utils/PoolSizeEstimator.cs +++ b/Runtime/Utils/PoolSizeEstimator.cs @@ -64,24 +64,24 @@ public static class PoolSizeEstimator /// private const int MinArrayOverhead = MinObjectOverhead + 8; - /// - /// Pointer size in bytes for reference calculations. - /// Used as the size for reference type fields and as a fallback for unknown types. - /// - private static readonly int PointerSize = IntPtr.Size; - /// /// Cache for computed type size estimates to avoid repeated reflection. /// - private static readonly ConcurrentDictionary SizeCache = + internal static readonly ConcurrentDictionary SizeCache = new ConcurrentDictionary(); /// /// Cache for LOH classification to avoid repeated size checks. /// - private static readonly ConcurrentDictionary LohCache = + internal static readonly ConcurrentDictionary LohCache = new ConcurrentDictionary(); + /// + /// Pointer size in bytes for reference calculations. + /// Used as the size for reference type fields and as a fallback for unknown types. + /// + private static readonly int PointerSize = IntPtr.Size; + /// /// Estimates the size in bytes of a single instance of type . /// @@ -258,15 +258,6 @@ public static int GetLohThresholdLength(Type elementType) return availableForElements / elementSize; } - /// - /// Clears the internal caches. Primarily used for testing. - /// - internal static void ClearCaches() - { - SizeCache.Clear(); - LohCache.Clear(); - } - private static int ComputeEstimatedSize(Type type) { if (type.IsValueType) diff --git a/Runtime/Utils/PoolTypeResolver.cs b/Runtime/Utils/PoolTypeResolver.cs index 873818c06..2594cd173 100644 --- a/Runtime/Utils/PoolTypeResolver.cs +++ b/Runtime/Utils/PoolTypeResolver.cs @@ -63,9 +63,7 @@ public static int MaxCachedTypeNames } } - internal static int CachedTypeNameCountForTesting => SimplifiedTypeNameCache.Count; - - private static readonly Cache SimplifiedTypeNameCache = CacheBuilder< + internal static readonly Cache SimplifiedTypeNameCache = CacheBuilder< string, Type > diff --git a/Runtime/Utils/RuntimeSingletonRegistry.cs b/Runtime/Utils/RuntimeSingletonRegistry.cs index cab226a0e..6cda655c6 100644 --- a/Runtime/Utils/RuntimeSingletonRegistry.cs +++ b/Runtime/Utils/RuntimeSingletonRegistry.cs @@ -21,16 +21,16 @@ internal static class RuntimeSingletonRegistry { internal static bool IsClearingInstances => _isClearingInstances; + internal static bool _isApplicationQuitting; + private static readonly Dictionary _registrations = new(); private static readonly Queue _pendingClears = new(); private static readonly HashSet _clearingActions = new(); private static bool _isClearingInstances; - - private static bool _isApplicationQuitting; #if UNITY_EDITOR - private static bool _isEditorQuitting; + internal static bool _isEditorQuitting; #endif internal static bool IsApplicationQuitting @@ -85,7 +85,7 @@ internal static void Register( } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - private static void OnSubsystemRegistration() + internal static void OnSubsystemRegistration() { _isApplicationQuitting = false; #if UNITY_EDITOR @@ -94,6 +94,11 @@ private static void OnSubsystemRegistration() SubscribeToLifecycleEvents(); } + internal static void OnApplicationQuitting() + { + _isApplicationQuitting = true; + } + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void OnBeforeSceneLoad() { @@ -113,11 +118,6 @@ private static void SubscribeToLifecycleEvents() #endif } - private static void OnApplicationQuitting() - { - _isApplicationQuitting = true; - } - #if UNITY_EDITOR private static void OnEditorQuitting() { @@ -138,28 +138,6 @@ private static void OnPlayModeStateChanged(PlayModeStateChange state) } #endif - internal static void NotifyApplicationQuittingForTesting() - { - OnApplicationQuitting(); -#if UNITY_EDITOR - _isEditorQuitting = true; -#endif - } - - internal static void PrepareForSceneLoadForTesting() - { - OnSubsystemRegistration(); - ResetAllRegisteredCaches(); - } - - internal static void NotifyReturnedToEditModeForTesting() - { - _isApplicationQuitting = false; -#if UNITY_EDITOR - _isEditorQuitting = false; -#endif - } - /// /// Resets every registered singleton cache without destroying live instances. /// @@ -236,69 +214,7 @@ internal static void ClearInstances(Action clearAction) } } - internal static string DescribeLiveInstancesForTesting() - { - StringBuilder builder = null; - foreach (RuntimeSingletonRegistration registration in GetRegistrationsSnapshot()) - { - UnityEngine.Object cachedInstance = null; - try - { - cachedInstance = registration.getCachedInstance(); - } - catch (Exception ex) - { - builder ??= new StringBuilder(); - builder.Append(registration.type.FullName); - builder.Append(" cache inspection failed: "); - builder.Append(ex); - builder.AppendLine(); - } - - UnityEngine.Object[] liveInstances = Array.Empty(); - try - { - liveInstances = - registration.findLiveInstances() ?? Array.Empty(); - } - catch (Exception ex) - { - builder ??= new StringBuilder(); - builder.Append(registration.type.FullName); - builder.Append(" live-instance inspection failed: "); - builder.Append(ex); - builder.AppendLine(); - } - - foreach (UnityEngine.Object liveInstance in liveInstances) - { - if (liveInstance == null) - { - continue; - } - - builder ??= new StringBuilder(); - builder.Append(registration.type.FullName); - builder.Append(" '"); - builder.Append(liveInstance.name); - builder.Append("'#"); - builder.Append(liveInstance.GetUnityObjectId()); - if (liveInstance is Component component && component.gameObject != null) - { - builder.Append(" scene='"); - builder.Append(component.gameObject.scene.name); - builder.Append("'"); - } - builder.Append(" cached="); - builder.Append(cachedInstance == liveInstance); - builder.AppendLine(); - } - } - - return builder?.ToString().Trim(); - } - - private static RuntimeSingletonRegistration[] GetRegistrationsSnapshot() + internal static RuntimeSingletonRegistration[] GetRegistrationsSnapshot() { lock (_registrations) { @@ -310,7 +226,7 @@ private static RuntimeSingletonRegistration[] GetRegistrationsSnapshot() } } - private sealed class RuntimeSingletonRegistration + internal sealed class RuntimeSingletonRegistration { internal readonly Type type; internal readonly Action resetCacheAction; diff --git a/Runtime/Utils/ScriptableObjectSingleton.cs b/Runtime/Utils/ScriptableObjectSingleton.cs index bc62c08f6..43d2c88e6 100644 --- a/Runtime/Utils/ScriptableObjectSingleton.cs +++ b/Runtime/Utils/ScriptableObjectSingleton.cs @@ -31,6 +31,7 @@ namespace WallstopStudios.UnityHelpers.Utils /// Lookup order (lazy): /// 1) Load from a custom Resources subfolder when the type is decorated with /// . + /// Blank subfolders, including whitespace wrapped in slashes, use the Resources root. /// 2) Load from a folder named after the type (Resources/<TypeName>). /// 3) Load by exact type name in Resources root, then fallback to all matches in Resources. /// @@ -124,6 +125,40 @@ internal static Lazy CreateLazy() return new Lazy(() => LoadInstance()); } + internal static string BuildLoadPath(string resourcesPath, string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(resourcesPath)) + { + return typeName; + } + + string trimmed = resourcesPath.Trim().Trim('/'); + return string.IsNullOrWhiteSpace(trimmed) ? typeName : $"{trimmed}/{typeName}"; + } + +#if UNITY_EDITOR + internal static string BuildCanonicalAssetPath(string loadPath) + { + if (string.IsNullOrWhiteSpace(loadPath)) + { + return null; + } + + string sanitized = loadPath.SanitizePath().Trim('/'); + if (string.IsNullOrWhiteSpace(sanitized)) + { + return null; + } + + return $"Assets/Resources/{sanitized}.asset".Replace("//", "/"); + } +#endif + private static string GetResourcesPath() { Type type = typeof(T); @@ -262,23 +297,7 @@ private static void TryAddCandidate(List candidates, T candidate) private static T LoadFromResourcesPath(string resourcesPath, string typeName) { string loadPath = BuildLoadPath(resourcesPath, typeName); - return string.IsNullOrEmpty(loadPath) ? null : Resources.Load(loadPath); - } - - private static string BuildLoadPath(string resourcesPath, string typeName) - { - if (string.IsNullOrWhiteSpace(typeName)) - { - return null; - } - - if (string.IsNullOrWhiteSpace(resourcesPath)) - { - return typeName; - } - - string trimmed = resourcesPath.Trim().Trim('/'); - return string.IsNullOrEmpty(trimmed) ? typeName : $"{trimmed}/{typeName}"; + return string.IsNullOrWhiteSpace(loadPath) ? null : Resources.Load(loadPath); } /// @@ -664,22 +683,6 @@ ScriptableObjectSingletonMetadata.Entry entry $"ScriptableObjectSingleton detected duplicate assets for {type.FullName} under '{assetFolder}'. Using '{canonicalLabel}'. Remove extra copies or add [AllowDuplicateCleanup] attribute for automatic cleanup:{Environment.NewLine} - {string.Join(Environment.NewLine + " - ", duplicates)}" ); } - - private static string BuildCanonicalAssetPath(string loadPath) - { - if (string.IsNullOrWhiteSpace(loadPath)) - { - return null; - } - - string sanitized = loadPath.SanitizePath().Trim('/'); - if (string.IsNullOrEmpty(sanitized)) - { - return null; - } - - return $"Assets/Resources/{sanitized}.asset".Replace("//", "/"); - } #endif } } diff --git a/Runtime/Utils/SetTextureImportData.cs b/Runtime/Utils/SetTextureImportData.cs index 55cc3545c..85a8485b5 100644 --- a/Runtime/Utils/SetTextureImportData.cs +++ b/Runtime/Utils/SetTextureImportData.cs @@ -20,7 +20,7 @@ public static void SetReadable(Texture2D texture) #if UNITY_EDITOR string assetPath = AssetDatabase.GetAssetPath(texture); - if (string.IsNullOrEmpty(assetPath)) + if (string.IsNullOrWhiteSpace(assetPath)) { return; } @@ -46,7 +46,7 @@ public static void SetTextureImporterFormat(Texture2D texture, bool isReadable = } string assetPath = AssetDatabase.GetAssetPath(texture); - if (string.IsNullOrEmpty(assetPath)) + if (string.IsNullOrWhiteSpace(assetPath)) { return; } diff --git a/Runtime/Utils/SpriteMaskReader.cs b/Runtime/Utils/SpriteMaskReader.cs index eeeaf6834..3a94c6d0c 100644 --- a/Runtime/Utils/SpriteMaskReader.cs +++ b/Runtime/Utils/SpriteMaskReader.cs @@ -54,7 +54,7 @@ public static bool TryRead( #if UNITY_EDITOR string assetPath = AssetDatabase.GetAssetPath(sprite); if ( - !string.IsNullOrEmpty(assetPath) + !string.IsNullOrWhiteSpace(assetPath) && string.Equals( Path.GetExtension(assetPath), ".png", diff --git a/Runtime/Utils/TextureScale.cs b/Runtime/Utils/TextureScale.cs index a8ec7ea00..29476fa67 100644 --- a/Runtime/Utils/TextureScale.cs +++ b/Runtime/Utils/TextureScale.cs @@ -34,9 +34,6 @@ namespace WallstopStudios.UnityHelpers.Utils /// public static class TextureScale { - // This hook injects slice failures into both execution modes to verify identical error handling. - internal static Action SliceStartedForTesting; - /// /// Scales a texture using point (nearest neighbor) sampling. /// @@ -84,84 +81,17 @@ public static void Bilinear(Texture2D tex, int newWidth, int newHeight, bool app } } - /// - /// Keeps the first slice failure, and keeps a later one so it can be logged. - /// - /// - /// Only one exception can reach the caller, and the first is the informative one. The - /// loser is recorded rather than logged here, because this runs on a worker while the main - /// thread is inside countdown.Wait(): a consumer whose log handler marshals to the - /// main thread would deadlock the two against each other. The caller logs it after the - /// wait. - /// - private static void RecordSliceFailure( - ref Exception first, - ref Exception discarded, - Exception failure - ) - { - if (Interlocked.CompareExchange(ref first, failure, null) == null) - { - return; - } - - Interlocked.CompareExchange(ref discarded, failure, null); - } - - private static void ValidateInputs(Texture2D tex, int newWidth, int newHeight) - { - if (tex == null) - { - throw new ArgumentNullException(nameof(tex)); - } - - if (newWidth <= 0) - { - throw new ArgumentOutOfRangeException( - nameof(newWidth), - newWidth, - "Width must be positive." - ); - } - - if (newHeight <= 0) - { - throw new ArgumentOutOfRangeException( - nameof(newHeight), - newHeight, - "Height must be positive." - ); - } - - if (!tex.isReadable) - { - throw new UnityException("Texture is not readable"); - } - } - - private static void ThreadedScale( - Texture2D tex, + internal static void ScalePixels( + Color[] texColors, + Color[] newColors, + int sourceWidth, + int sourceHeight, int newWidth, int newHeight, - bool useBilinear + bool useBilinear, + int cores ) { - // Skipping unchanged dimensions preserves exact pixel values. - if (tex.width == newWidth && tex.height == newHeight) - { - return; - } - - Color[] texColors = tex.GetPixels(); - int sourceWidth = tex.width; - int sourceHeight = tex.height; - - int newSize = newWidth * newHeight; - using PooledArray pooledColors = SystemArrayPool.Get( - newSize, - out Color[] newColors - ); - // Both filters map each destination pixel to sourceSize / destSize of the source. float ratioX = (float)sourceWidth / newWidth; float ratioY = (float)sourceHeight / newHeight; @@ -173,8 +103,6 @@ out Color[] newColors out Color[] premultipliedColors ); - int cores = Mathf.Min(SystemInfo.processorCount, newHeight); - if (1 < cores) { int slice = newHeight / cores; @@ -195,7 +123,6 @@ out Color[] premultipliedColors { try { - SliceStartedForTesting?.Invoke(start); if (useBilinear) { BilinearScale( @@ -246,7 +173,6 @@ out Color[] premultipliedColors int finalStart = slice * (cores - 1); try { - SliceStartedForTesting?.Invoke(finalStart); if (useBilinear) { BilinearScale( @@ -322,7 +248,6 @@ out Color[] premultipliedColors } else { - SliceStartedForTesting?.Invoke(0); if (useBilinear) { BilinearScale( @@ -353,6 +278,96 @@ out Color[] premultipliedColors ); } } + } + + /// + /// Keeps the first slice failure, and keeps a later one so it can be logged. + /// + /// + /// Only one exception can reach the caller, and the first is the informative one. The + /// loser is recorded rather than logged here, because this runs on a worker while the main + /// thread is inside countdown.Wait(): a consumer whose log handler marshals to the + /// main thread would deadlock the two against each other. The caller logs it after the + /// wait. + /// + private static void RecordSliceFailure( + ref Exception first, + ref Exception discarded, + Exception failure + ) + { + if (Interlocked.CompareExchange(ref first, failure, null) == null) + { + return; + } + + Interlocked.CompareExchange(ref discarded, failure, null); + } + + private static void ValidateInputs(Texture2D tex, int newWidth, int newHeight) + { + if (tex == null) + { + throw new ArgumentNullException(nameof(tex)); + } + + if (newWidth <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(newWidth), + newWidth, + "Width must be positive." + ); + } + + if (newHeight <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(newHeight), + newHeight, + "Height must be positive." + ); + } + + if (!tex.isReadable) + { + throw new UnityException("Texture is not readable"); + } + } + + private static void ThreadedScale( + Texture2D tex, + int newWidth, + int newHeight, + bool useBilinear + ) + { + // Skipping unchanged dimensions preserves exact pixel values. + if (tex.width == newWidth && tex.height == newHeight) + { + return; + } + + Color[] texColors = tex.GetPixels(); + int sourceWidth = tex.width; + int sourceHeight = tex.height; + + int newSize = newWidth * newHeight; + using PooledArray pooledColors = SystemArrayPool.Get( + newSize, + out Color[] newColors + ); + + ScalePixels( + texColors, + newColors, + sourceWidth, + sourceHeight, + newWidth, + newHeight, + useBilinear, + Mathf.Min(SystemInfo.processorCount, newHeight) + ); // Use float texture storage to preserve computed values without 8-bit quantization. #if UNITY_2020_1_OR_NEWER diff --git a/Runtime/Visuals/UGUI/EnhancedImage.cs b/Runtime/Visuals/UGUI/EnhancedImage.cs index ee1044757..99fdb89ea 100644 --- a/Runtime/Visuals/UGUI/EnhancedImage.cs +++ b/Runtime/Visuals/UGUI/EnhancedImage.cs @@ -90,7 +90,6 @@ public Color HdrColor UpdateMaterialInstance(); } } - internal Material CachedMaterialInstanceForTests => _cachedMaterialInstance; /// /// Optional shape mask texture assigned to the material's `_ShapeMask` slot to drive custom shader based masking. @@ -114,7 +113,7 @@ public Color HdrColor /// Stores the dedicated material instance produced by . /// This is a runtime-only object that doesn't survive domain reloads. /// - private Material _cachedMaterialInstance; + internal Material _cachedMaterialInstance; /// /// Stores the original user-assigned material before we replace it with our instance. @@ -122,7 +121,7 @@ public Color HdrColor /// [SerializeField] [HideInInspector] - private Material _baseMaterial; + internal Material _baseMaterial; /// protected override void Start() @@ -157,22 +156,10 @@ internal void ForceRefreshMaterialInstance() } #endif - internal Material BaseMaterialForTests => _baseMaterial; - - internal void InvokeStartForTests() - { - Start(); - } - - internal void InvokeOnDestroyForTests() - { - OnDestroy(); - } - /// /// Ensures this component owns a dedicated material instance and reapplies mask and color data. /// - private void UpdateMaterialInstance() + internal void UpdateMaterialInstance() { Material currentMaterial = material; @@ -278,7 +265,7 @@ private void UpdateMaterialInstance() /// /// Releases the cached material instance created for this image. /// - private void CleanupMaterialInstance() + internal void CleanupMaterialInstance() { if (_cachedMaterialInstance != null) { diff --git a/Runtime/Visuals/UIToolkit/LayeredImage.cs b/Runtime/Visuals/UIToolkit/LayeredImage.cs index e0e3c2308..35567a7d2 100644 --- a/Runtime/Visuals/UIToolkit/LayeredImage.cs +++ b/Runtime/Visuals/UIToolkit/LayeredImage.cs @@ -46,20 +46,18 @@ public float Fps StartSelfUpdate(); } } - internal Texture2D[] ComputedTexturesForTests => _computed; - internal bool SelfUpdateActiveForTests => _selfUpdateItem != null; + internal readonly Texture2D[] _computed; + internal readonly Stopwatch _timer; + + internal TimeSpan _lastTick; + internal IVisualElementScheduledItem _selfUpdateItem; private readonly AnimatedSpriteLayer[] _layers; - private readonly Texture2D[] _computed; private readonly Color _backgroundColor; private readonly Rect? _largestArea; - private readonly Stopwatch _timer; private readonly bool _updatesSelf; private readonly float _pixelCutoff; - - private TimeSpan _lastTick; - private IVisualElementScheduledItem _selfUpdateItem; private float _fps; private int _index; @@ -122,75 +120,7 @@ internal static bool ShouldBlendInParallel(int spriteWidth, int spriteHeight) && ParallelBlendThreshold <= (long)spriteWidth * spriteHeight; } - internal static void ComposeSpriteOntoBufferForTests( - Color[] bufferPixels, - int bufferWidth, - int bufferHeight, - Color[] spritePixels, - int spriteWidth, - int spriteHeight, - float baseX, - float baseY, - float layerAlpha, - float pixelCutoff, - bool useParallel - ) - { - ComposeSpriteOntoBuffer( - bufferPixels, - bufferWidth, - bufferHeight, - spritePixels, - spriteWidth, - spriteHeight, - baseX, - baseY, - layerAlpha, - pixelCutoff, - useParallel - ); - } - - /// - /// The tolerance covers float drift only. A half-8-bit-step of slack used to be added on top, - /// which discarded an extra alpha level at some cutoffs and none at others - so the same cutoff - /// selected different pixels here than selects - /// everywhere else in the package, and a pixel the caller asked to keep was dropped. Composited - /// alpha does not land on the 8-bit grid, so the plain comparison is the rule; drift is the only - /// thing needing slack. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsAlphaEffectivelyInvisible(float alpha, float cutoff) - { - float maxMagnitude = Mathf.Max(Mathf.Abs(alpha), Mathf.Abs(cutoff)); - float fudge = Mathf.Max(1e-6f * maxMagnitude, Mathf.Epsilon * 8f); - return alpha <= cutoff + fudge; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool CanPlayTimed(float fps) - { - if (fps <= 0 || float.IsNaN(fps) || float.IsInfinity(fps)) - { - return false; - } - - double frameMilliseconds = 1000d / fps; - return 0 < frameMilliseconds - && frameMilliseconds <= TimeSpan.MaxValue.TotalMilliseconds; - } - - private static long GetFrameIntervalMilliseconds(float fps) - { - return Math.Max(1L, (long)Math.Round(1000d / fps)); - } - - private static TimeSpan GetFrameInterval(float fps) - { - return TimeSpan.FromMilliseconds(GetFrameIntervalMilliseconds(fps)); - } - - private static void ComposeSpriteOntoBuffer( + internal static void ComposeSpriteOntoBuffer( Color[] bufferPixels, int bufferWidth, int bufferHeight, @@ -223,7 +153,7 @@ float pixelCutoff ); } - private static void ComposeSpriteOntoBuffer( + internal static void ComposeSpriteOntoBuffer( Color[] bufferPixels, int bufferWidth, int bufferHeight, @@ -258,6 +188,45 @@ bool useParallel job.RunSequential(spriteHeight); } + /// + /// The tolerance covers float drift only. A half-8-bit-step of slack used to be added on top, + /// which discarded an extra alpha level at some cutoffs and none at others - so the same cutoff + /// selected different pixels here than selects + /// everywhere else in the package, and a pixel the caller asked to keep was dropped. Composited + /// alpha does not land on the 8-bit grid, so the plain comparison is the rule; drift is the only + /// thing needing slack. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAlphaEffectivelyInvisible(float alpha, float cutoff) + { + float maxMagnitude = Mathf.Max(Mathf.Abs(alpha), Mathf.Abs(cutoff)); + float fudge = Mathf.Max(1e-6f * maxMagnitude, Mathf.Epsilon * 8f); + return alpha <= cutoff + fudge; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool CanPlayTimed(float fps) + { + if (fps <= 0 || float.IsNaN(fps) || float.IsInfinity(fps)) + { + return false; + } + + double frameMilliseconds = 1000d / fps; + return 0 < frameMilliseconds + && frameMilliseconds <= TimeSpan.MaxValue.TotalMilliseconds; + } + + private static long GetFrameIntervalMilliseconds(float fps) + { + return Math.Max(1L, (long)Math.Round(1000d / fps)); + } + + private static TimeSpan GetFrameInterval(float fps) + { + return TimeSpan.FromMilliseconds(GetFrameIntervalMilliseconds(fps)); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void BlendSpriteRow( Color[] bufferPixels, @@ -410,12 +379,6 @@ public void Update(bool force = false) Render(_index); } - internal void SetElapsedSinceLastFrameForTests(TimeSpan elapsedSinceLastFrame) - { - _timer.Stop(); - _lastTick = _timer.Elapsed - elapsedSinceLastFrame; - } - private bool CanSelfUpdate() { return _updatesSelf && 1 < _computed.Length && CanPlayTimed(_fps); diff --git a/Tests/Core/AssemblyInfo.cs b/Tests/Core/AssemblyInfo.cs index 99b5e6203..c20e541b2 100644 --- a/Tests/Core/AssemblyInfo.cs +++ b/Tests/Core/AssemblyInfo.cs @@ -4,3 +4,39 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo( "WallstopStudios.UnityHelpers.Tests.Runtime" )] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor" +)] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor.Windows" +)] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor.AssetProcessors" +)] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor.Utils" +)] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor.Sprites.TextureTools" +)] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor.Sprites.TextureSettings" +)] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor.Tools" +)] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor.Sprites.PivotAdjuster" +)] + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "WallstopStudios.UnityHelpers.Tests.Editor.Sprites.Cropper" +)] diff --git a/Tests/Core/AttributeMetadataCacheTestUtilities.cs b/Tests/Core/AttributeMetadataCacheTestUtilities.cs new file mode 100644 index 000000000..0d5e64ac3 --- /dev/null +++ b/Tests/Core/AttributeMetadataCacheTestUtilities.cs @@ -0,0 +1,25 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Core +{ + using NUnit.Framework; + using WallstopStudios.UnityHelpers.Tags; + + public static class AttributeMetadataCacheTestUtilities + { + /// Rebuilds cached metadata after fixture inputs change. + public static void Rebuild(AttributeMetadataCache cache) + { + Assert.IsTrue(cache != null); + lock (cache._lookupLock) + { + cache._typeFieldsLookup = null; + cache._relationalFieldsLookup = null; + cache._resolvedRelationalFieldsLookup = null; + cache._elementTypeLookup = null; + cache.BuildLookup(); + } + } + } +} diff --git a/Tests/Core/AttributeMetadataCacheTestUtilities.cs.meta b/Tests/Core/AttributeMetadataCacheTestUtilities.cs.meta new file mode 100644 index 000000000..14719cb3c --- /dev/null +++ b/Tests/Core/AttributeMetadataCacheTestUtilities.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e148e1c739a51d218feac111a036be41 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Core/BatchedEditorTestBase.cs b/Tests/Core/BatchedEditorTestBase.cs index b8109c45d..d6d04be34 100644 --- a/Tests/Core/BatchedEditorTestBase.cs +++ b/Tests/Core/BatchedEditorTestBase.cs @@ -74,7 +74,7 @@ public override void OneTimeTearDown() finally { // Flush deletes before the next fixture can observe stale handler state, even if base teardown throws. - AssetPostprocessorDeferral.FlushForTesting(); + EditorStateTestUtilities.FlushDeferredAssetActions(); } } } diff --git a/Tests/Core/CommonTestBase.cs b/Tests/Core/CommonTestBase.cs index 6b343f805..9bd0612f1 100644 --- a/Tests/Core/CommonTestBase.cs +++ b/Tests/Core/CommonTestBase.cs @@ -383,8 +383,7 @@ public virtual void BaseSetUp() AssetDatabaseBatchHelper.ResetBatchDepth(); } - ScriptableObjectSingletonCreator.ResetAssetEditingScopeDepthForTesting(); - ScriptableObjectSingletonMetadataUtility.ResetAssetEditingDepthForTesting(); + EditorStateTestUtilities.ResetSingletonCreationState(); } catch { } #endif @@ -423,8 +422,7 @@ public virtual void TearDown() AssetDatabaseBatchHelper.ResetBatchDepth(); } - ScriptableObjectSingletonCreator.ResetAssetEditingScopeDepthForTesting(); - ScriptableObjectSingletonMetadataUtility.ResetAssetEditingDepthForTesting(); + EditorStateTestUtilities.ResetSingletonCreationState(); } catch { } @@ -571,8 +569,7 @@ and lose results.xml. AssetDatabaseBatchHelper.ResetBatchDepth(); } - ScriptableObjectSingletonCreator.ResetAssetEditingScopeDepthForTesting(); - ScriptableObjectSingletonMetadataUtility.ResetAssetEditingDepthForTesting(); + EditorStateTestUtilities.ResetSingletonCreationState(); } catch { } @@ -609,11 +606,11 @@ dispatcher objects so the next test cannot reuse them. } string singletonLeaksBeforeClear = - RuntimeSingletonRegistry.DescribeLiveInstancesForTesting(); + RuntimeStateTestUtilities.DescribeLiveSingletons(); RuntimeSingletonRegistry.ClearAllRegisteredInstances(); int singletonDestroyFrames = TrackedObjectDestroyMaxFrames; string singletonLeaksAfterClear = - RuntimeSingletonRegistry.DescribeLiveInstancesForTesting(); + RuntimeStateTestUtilities.DescribeLiveSingletons(); while ( !string.IsNullOrWhiteSpace(singletonLeaksAfterClear) && 0 < singletonDestroyFrames @@ -621,8 +618,7 @@ dispatcher objects so the next test cannot reuse them. { singletonDestroyFrames--; yield return null; - singletonLeaksAfterClear = - RuntimeSingletonRegistry.DescribeLiveInstancesForTesting(); + singletonLeaksAfterClear = RuntimeStateTestUtilities.DescribeLiveSingletons(); } if (!string.IsNullOrWhiteSpace(singletonLeaksAfterClear)) @@ -652,7 +648,7 @@ dispatcher objects so the next test cannot reuse them. $"[uh-leak] {residentDispatchers} UnityMainThreadDispatcher object(s) " + "still resident after teardown of " + $"{TestContext.CurrentContext.Test.FullName}. " - + UnityMainThreadDispatcher.DescribeLiveDispatchersForTesting(); + + RuntimeStateTestUtilities.DescribeLiveDispatchers(); } } @@ -1231,18 +1227,16 @@ private static string DrainUnityMainThreadDispatchersForTeardown() const int MaxDrainPasses = 8; for (int i = 0; i < MaxDrainPasses; ++i) { - int pendingActionCount = - UnityMainThreadDispatcher.GetPendingActionCountForTesting(); + int pendingActionCount = RuntimeStateTestUtilities.GetPendingActionCount(); if (pendingActionCount <= 0) { return null; } - UnityMainThreadDispatcher.DrainPendingActionsForTesting(); + RuntimeStateTestUtilities.DrainPendingActions(); } - int remainingPendingActionCount = - UnityMainThreadDispatcher.GetPendingActionCountForTesting(); + int remainingPendingActionCount = RuntimeStateTestUtilities.GetPendingActionCount(); if (remainingPendingActionCount <= 0) { return null; @@ -1251,7 +1245,7 @@ private static string DrainUnityMainThreadDispatchersForTeardown() return $"[uh-leak] {remainingPendingActionCount} UnityMainThreadDispatcher action(s) " + "remained queued after teardown drain of " + $"{TestContext.CurrentContext.Test.FullName}. " - + UnityMainThreadDispatcher.DescribeLiveDispatchersForTesting(); + + RuntimeStateTestUtilities.DescribeLiveDispatchers(); } private static async ValueTask UnloadSceneAsync(Scene scene) @@ -1293,8 +1287,7 @@ public virtual void OneTimeTearDown() { AssetDatabaseBatchHelper.ForceResetAssetDatabase(); - ScriptableObjectSingletonCreator.ResetAssetEditingScopeDepthForTesting(); - ScriptableObjectSingletonMetadataUtility.ResetAssetEditingDepthForTesting(); + EditorStateTestUtilities.ResetSingletonCreationState(); } catch { } @@ -1313,7 +1306,7 @@ fixture ends. */ try { - WallstopStudios.UnityHelpers.Editor.AssetProcessors.AssetPostprocessorDeferral.FlushForTesting(); + EditorStateTestUtilities.FlushDeferredAssetActions(); } catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) @@ -1358,7 +1351,7 @@ remainder of cleanup. private void InitializeDispatcherScope() { DisposeDispatcherScope(); - _dispatcherScope = UnityMainThreadDispatcher.CreateTestScope( + _dispatcherScope = RuntimeStateTestUtilities.CreateDispatcherScope( destroyImmediate: !Application.isPlaying ); } diff --git a/Tests/Core/DetectAssetChangeProcessorTestAccess.cs b/Tests/Core/DetectAssetChangeProcessorTestAccess.cs new file mode 100644 index 000000000..b18b9cc74 --- /dev/null +++ b/Tests/Core/DetectAssetChangeProcessorTestAccess.cs @@ -0,0 +1,255 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Core +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.Reflection; + using System.Text; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Editor.AssetProcessors; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.AssetProcessors.DetectAssetChangeProcessor; + + /// Provides test-side setup and inspection for DetectAssetChangeProcessor. + internal static class DetectAssetChangeProcessorTestAccess + { + internal static void ProcessChanges( + string[] imported, + string[] deleted, + string[] moved, + string[] movedFrom + ) + { + EnsureInitialized(); + EnqueueAssetChanges( + imported ?? Array.Empty(), + deleted ?? Array.Empty(), + moved ?? Array.Empty(), + movedFrom ?? Array.Empty() + ); + DetectAssetChangeProcessor.ProcessPendingAssetChangesCore(); + } + + internal static AssetWatcherSettings GetSettings() + { + return new AssetWatcherSettings + { + Initialized = _initialized, + + WatchersByAssetType = CloneWatchers(WatchersByAssetType), + PendingAssetChanges = ClonePendingChanges(PendingAssetChanges), + ProcessingAssetChanges = _processingAssetChanges, + LoopProtectionActive = _loopProtectionActive, + ConsecutiveChangeBatches = _consecutiveChangeBatches, + LastChangeProcessTimestamp = _lastChangeProcessTimestamp, + + DiagnosticsEnabled = _diagnosticsEnabled, + EnabledOverride = _enabledOverride, + }; + } + + internal static void Reset(AssetWatcherSettings settings = null) + { + _initialized = settings?.Initialized ?? false; + + WatchersByAssetType.Clear(); + if (settings?.WatchersByAssetType != null) + { + foreach (KeyValuePair pair in settings.WatchersByAssetType) + { + WatchersByAssetType.Add(pair.Key, CloneWatcher(pair.Value)); + } + } + PendingAssetChanges.Clear(); + if (settings?.PendingAssetChanges != null) + { + foreach (PendingAssetChangeSet pendingChange in settings.PendingAssetChanges) + { + PendingAssetChanges.Enqueue(ClonePendingChange(pendingChange)); + } + } + _processingAssetChanges = settings?.ProcessingAssetChanges ?? false; + _loopProtectionActive = settings?.LoopProtectionActive ?? false; + _consecutiveChangeBatches = settings?.ConsecutiveChangeBatches ?? 0; + _lastChangeProcessTimestamp = settings?.LastChangeProcessTimestamp ?? 0; + + DiagnosticsEnabled = settings?.DiagnosticsEnabled ?? false; + _enabledOverride = settings?.EnabledOverride; + } + + internal static void EnsureInitialized() + { + if (!_initialized) + { + _initialized = true; + DetectAssetChangeProcessor.BuildWatchers(); + } + } + + internal static bool ValidateMethodSignature(Type declaringType, string methodName) + { + if (declaringType == null) + { + throw new ArgumentNullException(nameof(declaringType)); + } + + if (string.IsNullOrWhiteSpace(methodName)) + { + throw new ArgumentException(nameof(methodName)); + } + + BindingFlags flags = + BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic; + MethodInfo method = declaringType.GetMethod(methodName, flags); + if (method == null) + { + throw new ArgumentException( + string.Format( + CultureInfo.InvariantCulture, + "Method {0}.{1} was not found.", + declaringType.FullName, + methodName + ), + nameof(methodName) + ); + } + + return TryResolveParameterMode(declaringType, method, out _, out _); + } + + internal static Dictionary CloneWatchers( + IReadOnlyDictionary source + ) + { + Dictionary clones = new(source.Count); + foreach (KeyValuePair pair in source) + { + clones.Add(pair.Key, CloneWatcher(pair.Value)); + } + return clones; + } + + internal static AssetWatcher CloneWatcher(AssetWatcher source) + { + if (source == null) + { + return null; + } + + AssetWatcher clone = new(source.AssetType, source.IncludeAssignableTypes); + clone.SubAssetSearchFilter = source.SubAssetSearchFilter; + if (source.SearchPrefabs) + { + clone.EnablePrefabSearch(); + } + if (source.SearchSceneObjects) + { + clone.EnableSceneObjectSearch(); + } + clone.KnownAssetPaths.UnionWith(source.KnownAssetPaths); + foreach (MethodSubscription subscription in source.Subscriptions) + { + clone.Subscriptions.Add( + subscription == null ? null : CloneSubscription(subscription) + ); + } + return clone; + } + + internal static Queue ClonePendingChanges( + IEnumerable source + ) + { + Queue clones = new(); + foreach (PendingAssetChangeSet pendingChange in source) + { + clones.Enqueue(ClonePendingChange(pendingChange)); + } + return clones; + } + + internal static PendingAssetChangeSet ClonePendingChange(PendingAssetChangeSet source) + { + if (source == null) + { + return null; + } + + return new PendingAssetChangeSet( + CopyPaths(source.Imported), + CopyPaths(source.Deleted), + CopyPaths(source.Moved), + CopyPaths(source.MovedFrom) + ); + } + + internal static MethodSubscription CloneSubscription(MethodSubscription source) + { + return new MethodSubscription + { + _declaringType = source._declaringType, + _method = source._method, + _flags = source._flags, + _parameterMode = source._parameterMode, + _createdParameterElementType = source._createdParameterElementType, + _searchPrefabs = source._searchPrefabs, + _searchSceneObjects = source._searchSceneObjects, + }; + } + + private static string[] CopyPaths(IReadOnlyList source) + { + int sourceCount = source.Count; + string[] copy = new string[sourceCount]; + if (source is string[] sourceArray) + { + Array.Copy(sourceArray, copy, sourceArray.Length); + return copy; + } + if (source is ICollection sourceCollection) + { + sourceCollection.CopyTo(copy, 0); + return copy; + } + for (int index = 0; index < sourceCount; ++index) + { + copy[index] = source[index]; + } + return copy; + } + + internal sealed class AssetWatcherSettings + { + internal bool IncludeAssignableTypes { get; set; } + internal bool SearchPrefabs { get; set; } + internal bool SearchSceneObjects { get; set; } + internal HashSet KnownAssetPaths { get; set; } + internal List Subscriptions { get; set; } + internal Dictionary WatchersByAssetType { get; set; } = new(); + internal Queue PendingAssetChanges { get; set; } = new(); + internal bool Initialized { get; set; } + + internal bool ProcessingAssetChanges { get; set; } + internal bool LoopProtectionActive { get; set; } + internal int ConsecutiveChangeBatches { get; set; } + internal double LastChangeProcessTimestamp { get; set; } + + internal bool DiagnosticsEnabled { get; set; } + internal bool? EnabledOverride { get; set; } + } + } +#endif +} diff --git a/Tests/Core/DetectAssetChangeProcessorTestAccess.cs.meta b/Tests/Core/DetectAssetChangeProcessorTestAccess.cs.meta new file mode 100644 index 000000000..3bd8f7b0b --- /dev/null +++ b/Tests/Core/DetectAssetChangeProcessorTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12836d691702208bbba770f6d12afee0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Core/Editor/AssetDatabaseBatchScopeTests.cs b/Tests/Core/Editor/AssetDatabaseBatchScopeTests.cs index 2546aa963..0301b48c5 100644 --- a/Tests/Core/Editor/AssetDatabaseBatchScopeTests.cs +++ b/Tests/Core/Editor/AssetDatabaseBatchScopeTests.cs @@ -736,7 +736,7 @@ public void RapidNestedOpenCloseCyclesWorkCorrectly(int cycleCount) [Test] public void IncrementBatchDepthReturnsCorrectlyForOutermostScope() { - bool isOutermost = AssetDatabaseBatchHelper.IncrementBatchDepth(); + bool isOutermost = AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( isOutermost, @@ -749,15 +749,15 @@ public void IncrementBatchDepthReturnsCorrectlyForOutermostScope() "Depth should be 1 after first increment" ); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); } [Test] public void IncrementBatchDepthReturnsCorrectlyForNestedScope() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); - bool isOutermost = AssetDatabaseBatchHelper.IncrementBatchDepth(); + bool isOutermost = AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( isOutermost, @@ -770,8 +770,8 @@ public void IncrementBatchDepthReturnsCorrectlyForNestedScope() "Depth should be 2 after second increment" ); - AssetDatabaseBatchHelper.DecrementBatchDepth(); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); } [Test] @@ -784,7 +784,7 @@ public void MultipleIncrementsReturnCorrectOutermostFlag(int incrementCount) for (int i = 0; i < incrementCount; ++i) { - results.Add(AssetDatabaseBatchHelper.IncrementBatchDepth()); + results.Add(AssetDatabaseBatchTestAccess.IncrementBatchDepth()); } Assert.That(results[0], Is.True, "First increment should return true"); @@ -808,9 +808,9 @@ public void MultipleIncrementsReturnCorrectOutermostFlag(int incrementCount) [Test] public void DecrementBatchDepthReturnsCorrectlyWhenReturningToZero() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); - bool wasOutermost = AssetDatabaseBatchHelper.DecrementBatchDepth(); + bool wasOutermost = AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That( wasOutermost, @@ -827,10 +827,10 @@ public void DecrementBatchDepthReturnsCorrectlyWhenReturningToZero() [Test] public void DecrementBatchDepthReturnsCorrectlyWhenNotReturningToZero() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); - bool wasOutermost = AssetDatabaseBatchHelper.DecrementBatchDepth(); + bool wasOutermost = AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That( wasOutermost, @@ -843,13 +843,13 @@ public void DecrementBatchDepthReturnsCorrectlyWhenNotReturningToZero() "Depth should be 1 after decrement" ); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); } [Test] public void DecrementBelowZeroResetsToZero() { - bool wasOutermost = AssetDatabaseBatchHelper.DecrementBatchDepth(); + bool wasOutermost = AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That(wasOutermost, Is.False, "Decrement below zero should return false"); Assert.That( @@ -872,7 +872,7 @@ public void MultipleDecrementsFromZeroRemainAtZero(int decrementCount) { for (int i = 0; i < decrementCount; ++i) { - bool result = AssetDatabaseBatchHelper.DecrementBatchDepth(); + bool result = AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That(result, Is.False, $"Decrement {i} from zero should return false"); Assert.That( @@ -892,9 +892,9 @@ public void MultipleDecrementsFromZeroRemainAtZero(int decrementCount) [Test] public void ResetBatchDepthResetsToZero() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -924,7 +924,7 @@ public void ResetBatchDepthWorksFromAnyDepth(int initialDepth) { for (int i = 0; i < initialDepth; ++i) { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); } Assert.That( @@ -967,9 +967,9 @@ public void ResetBatchDepthWhenAlreadyZeroRemainsZero() [Test] public void ResetBatchDepthProperlyCleansUpState() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -1018,8 +1018,8 @@ public void ResetBatchDepthProperlyCleansUpState() [Test] public void MultipleResetBatchDepthCallsAreIdempotent() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -1068,7 +1068,7 @@ public void MultipleResetBatchDepthCallsAreIdempotent() [Test] public void ResetBatchDepthInterleavedWithIncrementsWorksCorrectly() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); AssetDatabaseBatchHelper.ResetBatchDepth(); Assert.That( @@ -1077,9 +1077,9 @@ public void ResetBatchDepthInterleavedWithIncrementsWorksCorrectly() "After first increment+reset: depth should be 0" ); - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); AssetDatabaseBatchHelper.ResetBatchDepth(); Assert.That( @@ -1088,7 +1088,7 @@ public void ResetBatchDepthInterleavedWithIncrementsWorksCorrectly() "After second increment+reset: depth should be 0" ); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -1649,9 +1649,9 @@ int iterationsPerThread for (int i = 0; i < iterationsPerThread; ++i) { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Thread.SpinWait(10); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); } } catch (Exception ex) @@ -2081,9 +2081,9 @@ int totalDepth [Test] public void ScopeCreationAfterResetWorksCorrectly() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); AssetDatabaseBatchHelper.ResetBatchDepth(); @@ -2111,7 +2111,7 @@ public void ScopeCreationAfterResetWorksCorrectly() [Test] public void InterleaveIncrementDecrementWithScopes() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, Is.EqualTo(1), @@ -2126,14 +2126,14 @@ public void InterleaveIncrementDecrementWithScopes() "Depth should be 2 after scope" ); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, Is.EqualTo(3), "Depth should be 3 after nested increment" ); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, Is.EqualTo(2), @@ -2147,7 +2147,7 @@ public void InterleaveIncrementDecrementWithScopes() "Depth should be 1 after scope exits" ); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, Is.EqualTo(0), @@ -2230,7 +2230,7 @@ public void HighDepthValuesWorkCorrectly() for (int i = 0; i < highDepth; ++i) { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); } Assert.That( @@ -2246,7 +2246,7 @@ public void HighDepthValuesWorkCorrectly() for (int i = 0; i < highDepth; ++i) { - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); } Assert.That( @@ -2320,7 +2320,7 @@ public void DisposeCalledOnScopeWithoutIncrementingHandledGracefully() { int depthBefore = AssetDatabaseBatchHelper.CurrentBatchDepth; - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -2332,16 +2332,16 @@ public void DisposeCalledOnScopeWithoutIncrementingHandledGracefully() [Test] public void IncrementAndDecrementReturnValuesAreSymmetric() { - bool firstIncrement = AssetDatabaseBatchHelper.IncrementBatchDepth(); + bool firstIncrement = AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That(firstIncrement, Is.True, "First increment returns true (outermost)"); - bool secondIncrement = AssetDatabaseBatchHelper.IncrementBatchDepth(); + bool secondIncrement = AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That(secondIncrement, Is.False, "Second increment returns false (nested)"); - bool firstDecrement = AssetDatabaseBatchHelper.DecrementBatchDepth(); + bool firstDecrement = AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That(firstDecrement, Is.False, "First decrement returns false (still nested)"); - bool secondDecrement = AssetDatabaseBatchHelper.DecrementBatchDepth(); + bool secondDecrement = AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That(secondDecrement, Is.True, "Second decrement returns true (now at zero)"); } @@ -2351,7 +2351,7 @@ public void BatchingStateTransitionsAreAtomic() bool previouslyBatching = AssetDatabaseBatchHelper.IsCurrentlyBatching; Assert.That(previouslyBatching, Is.False, "Should not be batching initially"); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); bool nowBatching = AssetDatabaseBatchHelper.IsCurrentlyBatching; Assert.That(nowBatching, Is.True, "Should be batching after increment"); @@ -2359,7 +2359,7 @@ public void BatchingStateTransitionsAreAtomic() int depth = AssetDatabaseBatchHelper.CurrentBatchDepth; Assert.That(depth, Is.EqualTo(1), "Depth should be 1"); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); bool afterDecrement = AssetDatabaseBatchHelper.IsCurrentlyBatching; Assert.That(afterDecrement, Is.False, "Should not be batching after decrement"); @@ -2378,7 +2378,7 @@ public void ActualUnityBatchDepthTracksUnityApiCalls() ); // Manual increment doesn't affect ActualUnityBatchDepth - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, Is.EqualTo(1), @@ -2390,7 +2390,7 @@ public void ActualUnityBatchDepthTracksUnityApiCalls() "ActualUnityBatchDepth should still be 0 after manual increment" ); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); } /// @@ -2399,8 +2399,8 @@ public void ActualUnityBatchDepthTracksUnityApiCalls() [Test] public void ResetCountersOnlyDoesNotCallUnityApis() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -2439,8 +2439,8 @@ public void ResetCountersOnlyDoesNotCallUnityApis() public void ResetCountersOnlyFollowedByResetBatchDepthIsSafe() { // Simulate stale state that might persist after domain reload - AssetDatabaseBatchHelper.IncrementBatchDepth(); - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -2482,7 +2482,7 @@ public void ResetCountersOnlyFollowedByResetBatchDepthIsSafe() [Test] public void MixedCounterOnlyAndBeginBatchTracksCorrectly() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, Is.EqualTo(1), @@ -2515,7 +2515,7 @@ public void MixedCounterOnlyAndBeginBatchTracksCorrectly() "After BeginBatch dispose: depth should be 1" ); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -3212,7 +3212,7 @@ public void InterleavedBatchAndPauseScopesWorkCorrectly() [Test] public void ManualIncrementFollowedByBeginBatchWorksCorrectly() { - AssetDatabaseBatchHelper.IncrementBatchDepth(); + AssetDatabaseBatchTestAccess.IncrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, @@ -3245,7 +3245,7 @@ public void ManualIncrementFollowedByBeginBatchWorksCorrectly() "After BeginBatch scope: depth should be 1" ); - AssetDatabaseBatchHelper.DecrementBatchDepth(); + AssetDatabaseBatchTestAccess.DecrementBatchDepth(); Assert.That( AssetDatabaseBatchHelper.CurrentBatchDepth, diff --git a/Tests/Core/Editor/AssetDatabaseBatchTestAccess.cs b/Tests/Core/Editor/AssetDatabaseBatchTestAccess.cs new file mode 100644 index 000000000..c15276f58 --- /dev/null +++ b/Tests/Core/Editor/AssetDatabaseBatchTestAccess.cs @@ -0,0 +1,55 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE +namespace WallstopStudios.UnityHelpers.Tests.Core +{ +#if UNITY_EDITOR + using WallstopStudios.UnityHelpers.Editor.Utils; + using static WallstopStudios.UnityHelpers.Editor.Utils.AssetDatabaseBatchHelper; + + public static class AssetDatabaseBatchTestAccess + { + /// + /// Increments the batch depth counter and returns whether this is the outermost scope. + /// This method ONLY increments the counter - it does NOT call Unity's AssetDatabase APIs. + /// + /// + /// Warning: Using this method directly creates a mismatch between the + /// tracked counter and Unity's actual state. Prefer using instead, + /// which properly manages both the counter and Unity's state. + /// This method exists primarily for testing the counter logic in isolation. + /// + /// true if this is the outermost (first) scope; otherwise, false. + public static bool IncrementBatchDepth() + { + lock (Lock) + { + int previousDepth = _batchDepth; + ++_batchDepth; + return previousDepth == 0; + } + } + + /// + /// Decrements the batch depth counter and returns whether this was the outermost scope. + /// This method ONLY decrements the counter - use + /// when Unity cleanup will be performed. + /// + /// true if this was the outermost scope (depth is now 0); otherwise, false. + public static bool DecrementBatchDepth() + { + lock (Lock) + { + _batchDepth--; + + if (_batchDepth < 0) + { + _batchDepth = 0; + return false; + } + + return _batchDepth == 0; + } + } + } +#endif +} diff --git a/Tests/Core/Editor/AssetDatabaseBatchTestAccess.cs.meta b/Tests/Core/Editor/AssetDatabaseBatchTestAccess.cs.meta new file mode 100644 index 000000000..fcc52289c --- /dev/null +++ b/Tests/Core/Editor/AssetDatabaseBatchTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 35fc5518643b5f5acde5d17b0906098c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Core/EditorStateTestUtilities.cs b/Tests/Core/EditorStateTestUtilities.cs new file mode 100644 index 000000000..d3a91f509 --- /dev/null +++ b/Tests/Core/EditorStateTestUtilities.cs @@ -0,0 +1,59 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Core +{ +#if UNITY_EDITOR + using UnityEngine; + using WallstopStudios.UnityHelpers.Editor.AssetProcessors; + using WallstopStudios.UnityHelpers.Editor.Utils; + + /// Restores shared editor state between package fixtures. + public static class EditorStateTestUtilities + { + private const int FlushIterationCap = 32; + + /// Clears actual singleton creation state between fixtures. + public static void ResetSingletonCreationState() + { + ScriptableObjectSingletonCreator._isEnsuring = false; + ScriptableObjectSingletonCreator.CancelScheduledEnsureInvocation(); + ScriptableObjectSingletonCreator._retryAttempts = 0; + ScriptableObjectSingletonCreator._consecutiveZeroProgressRetries = 0; + } + + /// Drains actual deferred asset actions within a bounded setup budget. + public static void FlushDeferredAssetActions() + { + if (AssetPostprocessorDeferral._draining) + { + Debug.LogWarning( + "Flush called reentrantly during drain — flush is a no-op; " + + "ensure tests don't call Flush from a handler callback." + ); + return; + } + + for (int iteration = 0; iteration < FlushIterationCap; ++iteration) + { + AssetPostprocessorDeferral._scheduled = false; + AssetPostprocessorDeferral.DrainPending(); + if (AssetPostprocessorDeferral.PendingDrains.Count == 0) + { + AssetPostprocessorDeferral._scheduled = false; + return; + } + } + + Debug.LogWarning( + "Flush hit the iteration cap (" + + FlushIterationCap + + ") with " + + AssetPostprocessorDeferral.PendingDrains.Count + + " drain(s) still pending. A drain handler is likely re-scheduling itself. " + + "Remaining drains will fire on the next editor tick, which may pollute the next test." + ); + } + } +#endif +} diff --git a/Tests/Core/EditorStateTestUtilities.cs.meta b/Tests/Core/EditorStateTestUtilities.cs.meta new file mode 100644 index 000000000..46bac8370 --- /dev/null +++ b/Tests/Core/EditorStateTestUtilities.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 257775f2ceacb3e807de9edf37d15525 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Core/ReflectionCapabilityScope.cs b/Tests/Core/ReflectionCapabilityScope.cs new file mode 100644 index 000000000..955059416 --- /dev/null +++ b/Tests/Core/ReflectionCapabilityScope.cs @@ -0,0 +1,34 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Core +{ + using System; + using WallstopStudios.UnityHelpers.Core.Helper; + + public sealed class ReflectionCapabilityScope : IDisposable + { + private readonly bool _previousExpressions; + private readonly bool _previousDynamicIl; + private bool _disposed; + + public ReflectionCapabilityScope(bool? expressions, bool? dynamicIl) + { + _previousExpressions = ReflectionHelpers.ExpressionsEnabled; + _previousDynamicIl = ReflectionHelpers.DynamicIlEnabled; + ReflectionHelpers.ExpressionsEnabled = expressions ?? _previousExpressions; + ReflectionHelpers.DynamicIlEnabled = dynamicIl ?? _previousDynamicIl; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + ReflectionHelpers.ExpressionsEnabled = _previousExpressions; + ReflectionHelpers.DynamicIlEnabled = _previousDynamicIl; + _disposed = true; + } + } +} diff --git a/Tests/Core/ReflectionCapabilityScope.cs.meta b/Tests/Core/ReflectionCapabilityScope.cs.meta new file mode 100644 index 000000000..b58589b07 --- /dev/null +++ b/Tests/Core/ReflectionCapabilityScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 64638539a5759d6d8644a1bc4d8d7a8d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Core/RelationalExactBindingTestBase.cs b/Tests/Core/RelationalExactBindingTestBase.cs index b3110ff68..cd0faf41f 100644 --- a/Tests/Core/RelationalExactBindingTestBase.cs +++ b/Tests/Core/RelationalExactBindingTestBase.cs @@ -108,7 +108,7 @@ private static List ExactComponentsOn(Transform owner) protected void VerifyExactTypeAssignment(int depth, bool inactive, int capability) { - using IDisposable scope = ReflectionHelpers.OverrideReflectionCapabilities( + using IDisposable scope = new ReflectionCapabilityScope( capability == 0, capability == 1 ); diff --git a/Tests/Core/RuntimeStateTestUtilities.cs b/Tests/Core/RuntimeStateTestUtilities.cs new file mode 100644 index 000000000..f68ff0241 --- /dev/null +++ b/Tests/Core/RuntimeStateTestUtilities.cs @@ -0,0 +1,189 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Core +{ + using System; + using System.Text; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Utils; + + public static class RuntimeStateTestUtilities + { + public static UnityMainThreadDispatcher.AutoCreationScope CreateDispatcherScope( + bool destroyImmediate = true + ) + { + UnityMainThreadDispatcher.AutoCreationScope scope = + UnityMainThreadDispatcher.AutoCreationScope.Disabled( + destroyExistingInstanceOnEnter: true, + destroyInstancesOnDispose: true, + destroyImmediate: destroyImmediate + ); + + UnityMainThreadDispatcher.SetAutoCreationEnabled(true); + return scope; + } + + public static void SimulateApplicationQuitting() + { + RuntimeSingletonRegistry.OnApplicationQuitting(); +#if UNITY_EDITOR + RuntimeSingletonRegistry._isEditorQuitting = true; +#endif + } + + public static void PrepareForSceneLoad() + { + RuntimeSingletonRegistry.OnSubsystemRegistration(); + RuntimeSingletonRegistry.ResetAllRegisteredCaches(); + } + + public static void ReturnToEditMode() + { + RuntimeSingletonRegistry._isApplicationQuitting = false; +#if UNITY_EDITOR + RuntimeSingletonRegistry._isEditorQuitting = false; +#endif + } + + public static string DescribeLiveSingletons() + { + StringBuilder builder = null; + foreach ( + RuntimeSingletonRegistry.RuntimeSingletonRegistration registration in RuntimeSingletonRegistry.GetRegistrationsSnapshot() + ) + { + UnityEngine.Object cachedInstance = null; + try + { + cachedInstance = registration.getCachedInstance(); + } + catch (Exception ex) + { + builder ??= new StringBuilder(); + builder.Append(registration.type.FullName); + builder.Append(" cache inspection failed: "); + builder.Append(ex); + builder.AppendLine(); + } + + UnityEngine.Object[] liveInstances = Array.Empty(); + try + { + liveInstances = + registration.findLiveInstances() ?? Array.Empty(); + } + catch (Exception ex) + { + builder ??= new StringBuilder(); + builder.Append(registration.type.FullName); + builder.Append(" live-instance inspection failed: "); + builder.Append(ex); + builder.AppendLine(); + } + + foreach (UnityEngine.Object liveInstance in liveInstances) + { + if (liveInstance == null) + { + continue; + } + + builder ??= new StringBuilder(); + builder.Append(registration.type.FullName); + builder.Append(" '"); + builder.Append(liveInstance.name); + builder.Append("'#"); + builder.Append(liveInstance.GetUnityObjectId()); + if (liveInstance is Component component && component.gameObject != null) + { + builder.Append(" scene='"); + builder.Append(component.gameObject.scene.name); + builder.Append("'"); + } + builder.Append(" cached="); + builder.Append(cachedInstance == liveInstance); + builder.AppendLine(); + } + } + + return builder?.ToString().Trim(); + } + + public static int GetPendingActionCount() + { + int pendingActionCount = 0; + UnityMainThreadDispatcher[] dispatchers = + Resources.FindObjectsOfTypeAll(); + foreach (UnityMainThreadDispatcher dispatcher in dispatchers) + { + if (dispatcher == null) + { + continue; + } + + pendingActionCount += dispatcher.PendingActionCount; + } + + return pendingActionCount; + } + + public static int DrainPendingActions(int maxActions = 1024) + { + int remainingActionBudget = Math.Max(1, maxActions); + UnityMainThreadDispatcher[] dispatchers = + Resources.FindObjectsOfTypeAll(); + foreach (UnityMainThreadDispatcher dispatcher in dispatchers) + { + if (dispatcher == null || dispatcher.PendingActionCount <= 0) + { + continue; + } + + while ( + 0 < remainingActionBudget && dispatcher._actions.TryDequeue(out Action action) + ) + { + --remainingActionBudget; + dispatcher.ExecuteQueuedAction(action); + } + } + + return GetPendingActionCount(); + } + + public static string DescribeLiveDispatchers() + { + UnityMainThreadDispatcher[] dispatchers = + Resources.FindObjectsOfTypeAll(); + if (dispatchers.Length == 0) + { + return "No dispatchers found."; + } + + int dispatchersLength = dispatchers.Length; + string[] descriptions = new string[dispatchersLength]; + for (int i = 0; i < dispatchersLength; ++i) + { + UnityMainThreadDispatcher dispatcher = dispatchers[i]; + if (dispatcher == null) + { + descriptions[i] = "null"; + continue; + } + + GameObject dispatcherObject = dispatcher.gameObject; + string sceneName = dispatcherObject == null ? "null" : dispatcherObject.scene.name; + descriptions[i] = + $"{dispatcher.name}#{dispatcher.GetUnityObjectId()} scene='{sceneName}' " + + $"active={dispatcherObject != null && dispatcherObject.activeInHierarchy} " + + $"pending={dispatcher.PendingActionCount}"; + } + + return string.Join(", ", descriptions); + } + } +} diff --git a/Tests/Core/RuntimeStateTestUtilities.cs.meta b/Tests/Core/RuntimeStateTestUtilities.cs.meta new file mode 100644 index 000000000..1e34c564c --- /dev/null +++ b/Tests/Core/RuntimeStateTestUtilities.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c798cb7d0d1c9fd6a7907ac673f4f70d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/AssetProcessors/AssetChangeDetectionEnablementTests.cs b/Tests/Editor/AssetProcessors/AssetChangeDetectionEnablementTests.cs index a0d04f90c..89c18c91c 100644 --- a/Tests/Editor/AssetProcessors/AssetChangeDetectionEnablementTests.cs +++ b/Tests/Editor/AssetProcessors/AssetChangeDetectionEnablementTests.cs @@ -16,7 +16,7 @@ namespace WallstopStudios.UnityHelpers.Tests.AssetProcessors /// Unity's import phase has crashed a headless editor natively. The play-mode guard covered one /// door into that scan; batch mode went through the other. These tests pin the resulting /// policy and the consumer opt-out, including that the package's own suite -- which runs under - /// batch mode in CI -- still drives the processor through its explicit test entry point. + /// batch mode in CI -- still drives the processor through fixture-owned watcher setup. /// [TestFixture] public sealed class AssetChangeDetectionEnablementTests : BatchedEditorTestBase @@ -39,7 +39,7 @@ public override void BaseSetUp() public override void TearDown() { // These tests drive initialization directly, so hand the next fixture a clean slate. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); _watcherScope?.Dispose(); _watcherScope = null; base.TearDown(); @@ -83,38 +83,38 @@ public void ResettingDropsAPreviousOverride() [Test] public void DisablingPreventsInitialization() { - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); AssetChangeDetectionUtility.Enabled = false; - DetectAssetChangeProcessor.EnsureInitializedForTesting(); + DetectAssetChangeProcessor.EnsureInitialized(); - Assert.IsFalse(DetectAssetChangeProcessor.GetSettingsForTesting().Initialized); + Assert.IsFalse(DetectAssetChangeProcessorTestAccess.GetSettings().Initialized); } [Test] public void EnablingAllowsInitialization() { - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); AssetChangeDetectionUtility.Enabled = true; - DetectAssetChangeProcessor.EnsureInitializedForTesting(); + DetectAssetChangeProcessor.EnsureInitialized(); - Assert.IsTrue(DetectAssetChangeProcessor.GetSettingsForTesting().Initialized); + Assert.IsTrue(DetectAssetChangeProcessorTestAccess.GetSettings().Initialized); } /* - The explicit test entry point must initialize in batch mode or watcher fixtures would pass without + Fixture-owned watcher setup must build handlers in batch mode or watcher fixtures would pass without exercising any handlers. */ [Test] - public void TheTestEntryPointInitializesEvenWhenTheWatcherIsDisabled() + public void FixtureWatcherSetupBuildsHandlersWhenAutomaticWatcherIsDisabled() { - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); AssetChangeDetectionUtility.Enabled = false; - DetectAssetChangeProcessor.ProcessChangesForTesting(null, null, null, null); + DetectAssetChangeProcessorTestAccess.ProcessChanges(null, null, null, null); - Assert.IsTrue(DetectAssetChangeProcessor.GetSettingsForTesting().Initialized); + Assert.IsTrue(DetectAssetChangeProcessorTestAccess.GetSettings().Initialized); } } } diff --git a/Tests/Editor/AssetProcessors/AssetPostprocessorContractTests.cs b/Tests/Editor/AssetProcessors/AssetPostprocessorContractTests.cs index 132a2a261..4245f5fac 100644 --- a/Tests/Editor/AssetProcessors/AssetPostprocessorContractTests.cs +++ b/Tests/Editor/AssetProcessors/AssetPostprocessorContractTests.cs @@ -43,7 +43,7 @@ public sealed class AssetPostprocessorContractTests { private const string DeferralCallExpression = "AssetPostprocessorDeferral.Schedule("; - private const string FlushCallExpression = "AssetPostprocessorDeferral.FlushForTesting("; + private const string FlushCallExpression = "AssetPostprocessorDeferralTestAccess.Flush("; private static readonly string[] EditorAssemblyNames = { @@ -189,7 +189,7 @@ such drain contract. /// flush-equivalents in must /// themselves actually flush. If a future author refactors one of these /// helpers so its body no longer reaches - /// AssetPostprocessorDeferral.FlushForTesting() (directly or via + /// AssetPostprocessorDeferralTestAccess.Flush() (directly or via /// another audited helper), the whole teardown-flush contract silently /// degrades to a no-op for every caller of that helper. /// @@ -198,7 +198,7 @@ such drain contract. /// /// Terminal helpers /// (FlushAndClearAll, AssertCleanAndClearAll) must - /// contain a DIRECT FlushForTesting() call — they may not + /// contain a DIRECT Flush() call — they may not /// delegate. If these are mutated to delegate elsewhere, the contract /// root is lost. /// Delegating helpers @@ -1222,7 +1222,7 @@ public void AllAssetPostprocessorCallbacksAvoidForbiddenSynchronousApis() /// FlushEquivalentExpressions): /// /// A direct - /// AssetPostprocessorDeferral.FlushForTesting() call. + /// AssetPostprocessorDeferralTestAccess.Flush() call. /// A call to one of the centralized helpers known to /// flush internally before clearing: /// AssetPostprocessorTestHandlers.FlushAndClearAll, @@ -1331,7 +1331,7 @@ public void TestTeardownsThatClearHandlerStateFlushDeferralsFirst() /// that performs an asset mutation (CreateAsset, DeleteAsset, Refresh, /// ImportAsset, CreateFolder, SaveAndRefreshIfNotBatching, /// RefreshIfNotBatching) must end with a DIRECT - /// AssetPostprocessorDeferral.FlushForTesting() call so drains + /// AssetPostprocessorDeferralTestAccess.Flush() call so drains /// scheduled by those mutations do not leak into the next fixture. /// Chaining to base.<method>( is NOT sufficient — the base /// may or may not flush, and the indirection produces false negatives diff --git a/Tests/Editor/AssetProcessors/AssetPostprocessorDeferralTests.cs b/Tests/Editor/AssetProcessors/AssetPostprocessorDeferralTests.cs index 0b2d9a60e..0307dc524 100644 --- a/Tests/Editor/AssetProcessors/AssetPostprocessorDeferralTests.cs +++ b/Tests/Editor/AssetProcessors/AssetPostprocessorDeferralTests.cs @@ -48,7 +48,7 @@ private static void SkipIfDeferralDisabled() public void SetUp() { // Reset before an inconclusive skip so inherited state cannot roll forward to another fixture. - AssetPostprocessorDeferral.ResetForTesting(); + AssetPostprocessorDeferralTestAccess.Reset(); SkipIfDeferralDisabled(); } @@ -56,7 +56,7 @@ public void SetUp() public void TearDown() { // Cap-hit tests deliberately leave a drain queued. - AssetPostprocessorDeferral.ResetForTesting(); + AssetPostprocessorDeferralTestAccess.Reset(); /* LogAssert state is process-global, so an un-consumed expectation leaks into the next test unless an unmatched log fails here. @@ -68,12 +68,12 @@ next test unless an unmatched log fails here. /// Verifies the reentrant fan-out happy path: when a drain re-schedules /// a fresh delegate (distinct from itself, so the per-caller dedup does /// not short-circuit the append), each reentrant append is processed in - /// a subsequent iteration of FlushForTesting's outer loop. With a + /// a subsequent iteration of Flush's outer loop. With a /// finite fan-out well below the cap, the queue drains cleanly with no /// warning and no items remaining. /// [Test] - public void FlushForTestingReEntrantAppendsUnderCapDrainsCleanly() + public void FlushReEntrantAppendsUnderCapDrainsCleanly() { int callCount = 0; int remaining = 10; @@ -95,13 +95,13 @@ Action CreateSelfReschedulingAction() AssetPostprocessorDeferral.Schedule(CreateSelfReschedulingAction()); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.AreEqual(10, callCount, "All 10 reentrant drains should have executed."); Assert.AreEqual(0, remaining, "Re-schedule counter should have exhausted."); Assert.AreEqual( 0, - AssetPostprocessorDeferral.PendingDrainCountForTesting, + AssetPostprocessorDeferralTestAccess.PendingDrainCount, "Queue should be empty after clean drain." ); } @@ -114,7 +114,7 @@ Action CreateSelfReschedulingAction() /// FlushIterationCap fails the test. /// [Test] - public void FlushForTestingUnboundedReEntrantRescheduleHitsCapAndWarns() + public void FlushUnboundedReEntrantRescheduleHitsCapAndWarns() { int callCount = 0; @@ -131,8 +131,8 @@ Action CreateNeverTerminatingAction() AssetPostprocessorDeferral.Schedule(CreateNeverTerminatingAction()); - LogAssert.Expect(LogType.Warning, new Regex("FlushForTesting hit the iteration cap")); - AssetPostprocessorDeferral.FlushForTesting(); + LogAssert.Expect(LogType.Warning, new Regex("Flush hit the iteration cap")); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.AreEqual( 32, @@ -142,7 +142,7 @@ Action CreateNeverTerminatingAction() ); Assert.AreEqual( 1, - AssetPostprocessorDeferral.PendingDrainCountForTesting, + AssetPostprocessorDeferralTestAccess.PendingDrainCount, "Cap-hit path should leave exactly one drain pending (the last re-schedule)." ); } @@ -163,7 +163,7 @@ public void ScheduleSameDelegateMultipleTimesDeduplicatesToSingleDrain() AssetPostprocessorDeferral.Schedule(drain); AssetPostprocessorDeferral.Schedule(drain); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.AreEqual( 1, @@ -197,7 +197,7 @@ public void ScheduleSameDelegateDuringDrainReschedulesForNextIteration() AssetPostprocessorDeferral.Schedule(drain); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.AreEqual( 5, @@ -211,7 +211,7 @@ public void ScheduleSameDelegateDuringDrainReschedulesForNextIteration() ); Assert.AreEqual( 0, - AssetPostprocessorDeferral.PendingDrainCountForTesting, + AssetPostprocessorDeferralTestAccess.PendingDrainCount, "Queue should be empty after all self-rescheduled iterations run." ); } @@ -235,7 +235,7 @@ public void ScheduleDedupPreservesOriginalInsertionOrder() AssetPostprocessorDeferral.Schedule(b); AssetPostprocessorDeferral.Schedule(a); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); CollectionAssert.AreEqual( new[] { "A", "B" }, @@ -279,7 +279,7 @@ public void ScheduleStructurallyEqualButDistinctDelegatesAreNotDeduplicated() AssetPostprocessorDeferral.Schedule(first); AssetPostprocessorDeferral.Schedule(second); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.AreEqual( 2, @@ -296,7 +296,7 @@ public void ScheduleStructurallyEqualButDistinctDelegatesAreNotDeduplicated() /// filter. /// [Test] - public void FlushForTestingDrainThrowsIsSwallowedAndLaterDrainsStillRun() + public void FlushDrainThrowsIsSwallowedAndLaterDrainsStillRun() { int secondCallCount = 0; @@ -309,7 +309,7 @@ public void FlushForTestingDrainThrowsIsSwallowedAndLaterDrainsStillRun() LogType.Exception, new Regex("InvalidOperationException: synthetic drain failure") ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.AreEqual( 1, diff --git a/Tests/Editor/AssetProcessors/AssetPostprocessorLogHygieneTests.cs b/Tests/Editor/AssetProcessors/AssetPostprocessorLogHygieneTests.cs index beb71d261..c4dbd6328 100644 --- a/Tests/Editor/AssetProcessors/AssetPostprocessorLogHygieneTests.cs +++ b/Tests/Editor/AssetProcessors/AssetPostprocessorLogHygieneTests.cs @@ -87,7 +87,7 @@ public override void CommonOneTimeSetUp() EnsureTestFolder(); TrackFolder(TestRoot); // Folder creation can queue drains that would pollute the first test. - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); } [SetUp] @@ -103,28 +103,24 @@ Flush setup mutations before enabling the processor so queued work cannot repopu state during the test. */ AssetPostprocessorTestHandlers.FlushAndClearAll(); - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); /* The native callback declines initialization in batch mode; force it on so this regression test reaches the watcher. */ _watcherScope = AssetChangeDetectionUtility.EnabledScope(true); - DetectAssetChangeProcessor.IncludeTestAssets = true; - // Limit observed assets to this fixture to prevent cross-fixture handler invocations. - DetectAssetChangeProcessor.TestAssetFolderAllowlist = new[] { TestRoot + "/" }; } [TearDown] public override void TearDown() { - DetectAssetChangeProcessor.TestAssetFolderAllowlist = null; - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); _watcherScope?.Dispose(); _watcherScope = null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); base.TearDown(); // Flush after base teardown because destroying tracked assets can enqueue more drains. - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); AssetPostprocessorTestHandlers.FlushAndClearAll(); } @@ -185,13 +181,13 @@ emitted SendMessage warnings. AssetDatabase.CreateAsset(payload, payloadPath); }); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); logScope.AssertNoSendMessageWarnings(); } /// - /// Drives sub-asset matching through ProcessChangesForTesting and checks that imported + /// Drives sub-asset matching through ProcessChanges and checks that imported /// textures do not emit SendMessage warnings. This complements the prefab test above. /// [Test] @@ -242,7 +238,7 @@ out bool success } }); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); logScope.AssertNoSendMessageWarnings(); } diff --git a/Tests/Editor/AssetProcessors/AssetPostprocessorTestHandlers.cs b/Tests/Editor/AssetProcessors/AssetPostprocessorTestHandlers.cs index a2dd48b54..9801be701 100644 --- a/Tests/Editor/AssetProcessors/AssetPostprocessorTestHandlers.cs +++ b/Tests/Editor/AssetProcessors/AssetPostprocessorTestHandlers.cs @@ -111,7 +111,7 @@ public static void AssertCleanAndClearAll() { IReadOnlyList entries = LazyEntries.Value; ResetShouldThrowAll(entries); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); IReadOnlyList pollutionErrors = DescribePollution(); ClearAllInternal(entries); if (0 < pollutionErrors.Count) @@ -144,7 +144,7 @@ internal static void FlushAndClearAll() ResetShouldThrowAll(entries); // Pending drains must finish before handler state is cleared or they can repopulate it afterward. - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); ClearAllInternal(entries); } diff --git a/Tests/Editor/AssetProcessors/DetectAssetChangePrefabAndSceneTests.cs b/Tests/Editor/AssetProcessors/DetectAssetChangePrefabAndSceneTests.cs index 7946f6fb8..53a93c4b5 100644 --- a/Tests/Editor/AssetProcessors/DetectAssetChangePrefabAndSceneTests.cs +++ b/Tests/Editor/AssetProcessors/DetectAssetChangePrefabAndSceneTests.cs @@ -15,6 +15,7 @@ namespace WallstopStudios.UnityHelpers.Tests.AssetProcessors using WallstopStudios.UnityHelpers.Core.Helper; using WallstopStudios.UnityHelpers.Editor.AssetProcessors; using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Tests.Core; using WallstopStudios.UnityHelpers.Tests.Editor.TestAssets; using Object = UnityEngine.Object; @@ -28,20 +29,6 @@ public sealed class DetectAssetChangePrefabAndSceneTests : DetectAssetChangeTest { private const string TestScenePath = TestRoot + "/TestScene.unity"; - /// - /// Folder prefixes this fixture is allowed to drive the processor through. - /// Covers (a) the fixture's own test root for payload assets, (b) the - /// committed shared-prefab fixtures under Packages, and (c) the dynamic - /// prefab workspace. Everything else is structurally ignored even with - /// on. - /// - private static readonly string[] PrefabSceneFixtureAllowlist = - { - TestRoot + "/", - "Packages/com.wallstop-studios.unity-helpers/Tests/Editor/TestAssets/Prefabs/", - "Assets/Temp/DynamicPrefabFixtures/", - }; - private static readonly (Type HandlerType, string HumanName)[] HandlerTypesUnderTest = { (typeof(TestPrefabAssetChangeHandler), "TestPrefabAssetChangeHandler"), @@ -53,8 +40,8 @@ private static readonly (Type HandlerType, string HumanName)[] HandlerTypesUnder protected override string DefaultPayloadAssetPath => TestRoot + "/Payload.asset"; private readonly List _instantiatedSceneObjects = new(); - private DetectAssetChangeProcessor.AssetWatcherSettings _fixtureSettings; - private DetectAssetChangeProcessor.AssetWatcherSettings _settings; + private DetectAssetChangeProcessorTestAccess.AssetWatcherSettings _fixtureSettings; + private DetectAssetChangeProcessorTestAccess.AssetWatcherSettings _settings; /// /// Counts how many times the specified handler instances were invoked. @@ -124,19 +111,18 @@ params T[] expectedInstances public override void CommonOneTimeSetUp() { base.CommonOneTimeSetUp(); - _settings = DetectAssetChangeProcessor.GetSettingsForTesting(); + _settings = DetectAssetChangeProcessorTestAccess.GetSettings(); SharedPrefabTestFixtures.AcquireFixtures(); CleanupTestFolders(); AssetDatabaseBatchHelper.RefreshIfNotBatching(); // Flush cleanup mutations before the first test can observe a late drain. - AssetPostprocessorDeferral.FlushForTesting(); - DetectAssetChangeProcessor.ResetForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); + DetectAssetChangeProcessorTestAccess.Reset(); DetectAssetChangeProcessor.EnabledOverride = true; - DetectAssetChangeProcessor.IncludeTestAssets = true; - DetectAssetChangeProcessor.TestAssetFolderAllowlist = PrefabSceneFixtureAllowlist; - DetectAssetChangeProcessor.EnsureInitializedForTesting(); - _fixtureSettings = DetectAssetChangeProcessor.GetSettingsForTesting(); - DetectAssetChangeProcessor.ResetForTesting(_settings); + + DetectAssetChangeProcessorTestAccess.EnsureInitialized(); + _fixtureSettings = DetectAssetChangeProcessorTestAccess.GetSettings(); + DetectAssetChangeProcessorTestAccess.Reset(_settings); } [OneTimeTearDown] @@ -149,11 +135,11 @@ public override void OneTimeTearDown() SharedPrefabTestFixtures.ReleaseFixtures(); CleanupDeferredAssetsAndFolders(); // Cleanup queues drains that must finish before the next fixture can observe handler state. - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); } finally { - DetectAssetChangeProcessor.ResetForTesting(_settings); + DetectAssetChangeProcessorTestAccess.Reset(_settings); } } @@ -176,14 +162,13 @@ Check inherited handler pollution before base setup or processor configuration c // Setup mutations can queue drains; the shared helper flushes before clearing handler state. AssetPostprocessorTestHandlers.FlushAndClearAll(); - // Configure the allowlist last so setup drains run while the processor is still unconfigured. - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); } [TearDown] public override void TearDown() { - DetectAssetChangeProcessor.ResetForTesting(_settings); + DetectAssetChangeProcessorTestAccess.Reset(_settings); foreach (GameObject go in _instantiatedSceneObjects) { @@ -224,9 +209,9 @@ public void PrefabHandlerInvokesInstanceMethodWhenAssetCreated() ClearTestState(); // Need to reset processor so it finds the handler - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -267,9 +252,9 @@ public void PrefabHandlerInvokesInstanceMethodWhenAssetDeleted() ClearTestState(); // Reset processor so it finds the handler - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -277,7 +262,7 @@ public void PrefabHandlerInvokesInstanceMethodWhenAssetDeleted() ); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, new[] { DefaultPayloadAssetPath }, null, @@ -310,9 +295,9 @@ public void PrefabHandlerFindsNestedComponents() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -346,9 +331,9 @@ public void PrefabHandlerFindsMultipleComponentsOnSamePrefab() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -400,9 +385,9 @@ public void PrefabHandlerFindsHandlersAcrossMultiplePrefabs() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -436,9 +421,9 @@ public void PrefabHandlerDoesNotInvokeWithoutSearchPrefabsOption() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -463,9 +448,9 @@ public void SceneHandlerInvokesInstanceMethodWhenAssetCreated() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -499,9 +484,9 @@ public void SceneHandlerInvokesInstanceMethodWhenAssetDeleted() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -509,7 +494,7 @@ public void SceneHandlerInvokesInstanceMethodWhenAssetDeleted() ); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, new[] { DefaultPayloadAssetPath }, null, @@ -553,9 +538,9 @@ public void SceneHandlerFindsNestedChildComponents() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -594,9 +579,9 @@ public void SceneHandlerFindsMultipleHandlersInScene() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -638,9 +623,9 @@ public void SceneHandlerFindsInactiveObjects() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -691,9 +676,9 @@ public void CombinedHandlerFindsBothPrefabAndSceneObjects() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -752,9 +737,9 @@ public void CombinedHandlerDoesNotDuplicateWhenSameInstanceInPrefabAndScene() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -780,11 +765,11 @@ public void HandlerHandlesNullComponentsGracefully() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); Assert.DoesNotThrow(() => { - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -800,11 +785,11 @@ public void HandlerHandlesEmptyScenesGracefully() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); Assert.DoesNotThrow(() => { - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -823,14 +808,14 @@ public void HandlerHandlesDestroyedObjectsDuringEnumeration() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); // Tracking still guarantees cleanup if the attempted early destruction fails. Object.DestroyImmediate(go); // UNH-SUPPRESS: Testing destroyed object handling Assert.DoesNotThrow(() => { - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -845,11 +830,11 @@ public void HandlerNonComponentTypeDoesNotSearchPrefabs() CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); Assert.DoesNotThrow(() => { - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -907,9 +892,9 @@ int expectedInvocations CreatePayloadAsset(); ClearTestState(); - ResetProcessorWithPrefabSceneFixtureAllowlist(); + ResetProcessorForPrefabSceneFixture(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { DefaultPayloadAssetPath }, null, null, @@ -994,24 +979,14 @@ public void SharedPrefabFixturesAreValid() ); } - /// - /// Resets the processor to a clean state while preserving this fixture's - /// . Every in-test call site that resets the - /// processor MUST go through this helper — calling - /// directly drops - /// the allowlist, which silently opens the structural defense against - /// cross-fixture pollution for the remainder of that test. - /// - private void ResetProcessorWithPrefabSceneFixtureAllowlist() + private void ResetProcessorForPrefabSceneFixture() { - DetectAssetChangeProcessor.ResetForTesting(_fixtureSettings); + DetectAssetChangeProcessorTestAccess.Reset(_fixtureSettings); /* Reset clears the enablement override; force it back on because CI runs these watcher tests in batch mode. */ DetectAssetChangeProcessor.EnabledOverride = true; - DetectAssetChangeProcessor.IncludeTestAssets = true; - DetectAssetChangeProcessor.TestAssetFolderAllowlist = PrefabSceneFixtureAllowlist; } private GameObject InstantiateInScene(GameObject prefab) diff --git a/Tests/Editor/AssetProcessors/DetectAssetChangePrefabSubAssetTests.cs b/Tests/Editor/AssetProcessors/DetectAssetChangePrefabSubAssetTests.cs index 3cd17728f..6ce9200f0 100644 --- a/Tests/Editor/AssetProcessors/DetectAssetChangePrefabSubAssetTests.cs +++ b/Tests/Editor/AssetProcessors/DetectAssetChangePrefabSubAssetTests.cs @@ -164,7 +164,7 @@ public override void CommonOneTimeSetUp() EnsureTestFolder(); TrackFolder(TestRoot); // Folder creation can queue drains that would pollute the first test. - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); } [SetUp] @@ -179,12 +179,10 @@ Flush setup mutations before configuring the processor so queued work cannot rep state during the test. */ AssetPostprocessorTestHandlers.FlushAndClearAll(); - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); // Force the watcher on because CI runs this fixture in batch mode. _watcherScope = AssetChangeDetectionUtility.EnabledScope(true); - DetectAssetChangeProcessor.IncludeTestAssets = true; - // Restrict observed paths so other fixtures’ assets cannot invoke this handler. - DetectAssetChangeProcessor.TestAssetFolderAllowlist = new[] { TestRoot + "/" }; + TestOnValidateCountingComponent.Clear(); } @@ -192,14 +190,14 @@ state during the test. public override void TearDown() { TestOnValidateCountingComponent.Clear(); - DetectAssetChangeProcessor.TestAssetFolderAllowlist = null; - DetectAssetChangeProcessor.ResetForTesting(); + + DetectAssetChangeProcessorTestAccess.Reset(); _watcherScope?.Dispose(); _watcherScope = null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); base.TearDown(); // Flush after base teardown because tracked-asset destruction can enqueue additional drains. - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); AssetPostprocessorTestHandlers.FlushAndClearAll(); } @@ -221,9 +219,9 @@ public void PrefabIsNeverDeserializedWhenMatchedAgainstWatchers() $"Prefab '{prefabPath}' should report GameObject as its main asset type" ); - DetectAssetChangeProcessor.EnsureInitializedForTesting(); + DetectAssetChangeProcessorTestAccess.EnsureInitialized(); Dictionary watchers = - DetectAssetChangeProcessor.GetSettingsForTesting().WatchersByAssetType; + DetectAssetChangeProcessorTestAccess.GetSettings().WatchersByAssetType; Assert.IsTrue( watchers.ContainsKey(typeof(TestDetectableAsset)), "Expected a registered watcher on a non-GameObject type, otherwise no watcher " @@ -234,16 +232,16 @@ public void PrefabIsNeverDeserializedWhenMatchedAgainstWatchers() Prefab import legitimately invokes OnValidate; reset its counter after draining to isolate watcher matching. */ - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); TestOnValidateCountingComponent.Clear(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { prefabPath }, null, null, null ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.AreEqual( 0, @@ -266,20 +264,20 @@ public void PrefabMatchedAgainstWatchersEmitsNoSendMessageWarnings() string prefabPath = TestRoot + "/SendMessageProbe.prefab"; CreateProbePrefab(prefabPath); - DetectAssetChangeProcessor.EnsureInitializedForTesting(); - AssetPostprocessorDeferral.FlushForTesting(); + DetectAssetChangeProcessorTestAccess.EnsureInitialized(); + AssetPostprocessorDeferralTestAccess.Flush(); TestOnValidateCountingComponent.Clear(); TestOnValidateCountingComponent.EmitSendMessageDuringValidate = true; using EditorLogScope logScope = new(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { prefabPath }, null, null, null ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); logScope.AssertNoSendMessageWarnings(); AssertNoSendMessageErrors(logScope); @@ -332,16 +330,16 @@ public void NestedScriptableObjectSubAssetStillMatchesWatcherOnNestedType() $"'{containerPath}' should carry a nested {nameof(TestDetectableAsset)} sub-asset" ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); AssetPostprocessorTestHandlers.FlushAndClearAll(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { containerPath }, null, null, null ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.IsTrue( RecordedCreatedPathsContain( @@ -374,11 +372,11 @@ public void NestedSubAssetMatchingDoesNotDeserializeContainer() AssetDatabase.ImportAsset(containerPath, ImportAssetOptions.ForceSynchronousImport); }); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); AssetPostprocessorTestHandlers.FlushAndClearAll(); - DetectAssetChangeProcessor.EnsureInitializedForTesting(); - DetectAssetChangeProcessor.AssetWatcherSettings settings = - DetectAssetChangeProcessor.GetSettingsForTesting(); + DetectAssetChangeProcessorTestAccess.EnsureInitialized(); + DetectAssetChangeProcessorTestAccess.AssetWatcherSettings settings = + DetectAssetChangeProcessorTestAccess.GetSettings(); Assert.IsTrue( settings.WatchersByAssetType.TryGetValue( typeof(TestDetectableAsset), @@ -395,19 +393,19 @@ out DetectAssetChangeProcessor.AssetWatcher watcher } } Assert.AreEqual(1, watcher.Subscriptions.Count); - DetectAssetChangeProcessor.ResetForTesting(settings); + DetectAssetChangeProcessorTestAccess.Reset(settings); Resources.UnloadAsset(container); Resources.UnloadAsset(nested); TestSubAssetContainerAsset.ResetOnValidateCount(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { containerPath }, null, null, null ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.AreEqual(0, TestSubAssetContainerAsset.OnValidateCount); Assert.IsTrue( @@ -436,16 +434,16 @@ public void ContainerWithoutMatchingSubAssetDoesNotNotifyNestedTypeWatcher() AssetDatabase.CreateAsset(container, containerPath); }); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); AssetPostprocessorTestHandlers.FlushAndClearAll(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { containerPath }, null, null, null ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.IsFalse( RecordedCreatedPathsContain( @@ -507,16 +505,16 @@ public void TextureSpriteSubAssetStillMatchesSpriteWatcher() + "Sprite watcher would match on the main type and never reach the sub-asset probe" ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); AssetPostprocessorTestHandlers.FlushAndClearAll(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { texturePath }, null, null, null ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); Assert.IsTrue( RecordedCreatedPathsContain( diff --git a/Tests/Editor/AssetProcessors/DetectAssetChangeProcessorTests.cs b/Tests/Editor/AssetProcessors/DetectAssetChangeProcessorTests.cs index 36727c036..01e00a297 100644 --- a/Tests/Editor/AssetProcessors/DetectAssetChangeProcessorTests.cs +++ b/Tests/Editor/AssetProcessors/DetectAssetChangeProcessorTests.cs @@ -14,6 +14,7 @@ namespace WallstopStudios.UnityHelpers.Tests.AssetProcessors using WallstopStudios.UnityHelpers.Editor.AssetProcessors; using WallstopStudios.UnityHelpers.Editor.Settings; using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Tests.Core; using Object = UnityEngine.Object; [TestFixture] @@ -27,8 +28,8 @@ public sealed class DetectAssetChangeProcessorTests : DetectAssetChangeTestBase private const string AlternatePayloadPath = TestRoot + "/AlternatePayload.asset"; private const string AssignableHandlerAssetPath = TestRoot + "/AssignableHandler.asset"; - private DetectAssetChangeProcessor.AssetWatcherSettings _settings; - private DetectAssetChangeProcessor.AssetWatcherSettings _fixtureSettings; + private DetectAssetChangeProcessorTestAccess.AssetWatcherSettings _settings; + private DetectAssetChangeProcessorTestAccess.AssetWatcherSettings _fixtureSettings; private float _originalLoopWindowSeconds; private AssetChangeDetectionEnabledScope _watcherScope; @@ -36,7 +37,7 @@ public sealed class DetectAssetChangeProcessorTests : DetectAssetChangeTestBase public override void CommonOneTimeSetUp() { base.CommonOneTimeSetUp(); - _settings = DetectAssetChangeProcessor.GetSettingsForTesting(); + _settings = DetectAssetChangeProcessorTestAccess.GetSettings(); ClearTestState(); CleanupTestFolders(); EnsureTestFolder(); @@ -48,14 +49,13 @@ public override void CommonOneTimeSetUp() Setup asset mutation can queue late drains; flush before the first test observes inherited handler state. */ - AssetPostprocessorDeferral.FlushForTesting(); - DetectAssetChangeProcessor.ResetForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); + DetectAssetChangeProcessorTestAccess.Reset(); DetectAssetChangeProcessor.EnabledOverride = true; - DetectAssetChangeProcessor.IncludeTestAssets = true; - DetectAssetChangeProcessor.TestAssetFolderAllowlist = FixtureAllowlist; - DetectAssetChangeProcessor.EnsureInitializedForTesting(); - _fixtureSettings = DetectAssetChangeProcessor.GetSettingsForTesting(); - DetectAssetChangeProcessor.ResetForTesting(_settings); + + DetectAssetChangeProcessorTestAccess.EnsureInitialized(); + _fixtureSettings = DetectAssetChangeProcessorTestAccess.GetSettings(); + DetectAssetChangeProcessorTestAccess.Reset(_settings); } [SetUp] @@ -70,22 +70,14 @@ Delete previous payload assets before reset can discover them in tests that requ */ DeleteAssetIfExists(PayloadPath); DeleteAssetIfExists(AlternatePayloadPath); - /* - Register the folder before enabling test assets so processor initialization does not warn about - a missing folder. - */ + EnsureTestFolder(); - /* - Drain setup mutations before configuring the allowlist so recorded invocations cannot appear - mid-test. - */ + AssetPostprocessorTestHandlers.FlushAndClearAll(); - DetectAssetChangeProcessor.ResetForTesting(_fixtureSettings); + DetectAssetChangeProcessorTestAccess.Reset(_fixtureSettings); // Force the watcher on because CI runs this fixture in batch mode. _watcherScope = AssetChangeDetectionUtility.EnabledScope(true); - DetectAssetChangeProcessor.IncludeTestAssets = true; - // Restrict observed paths so other fixtures’ assets cannot invoke this handler. - DetectAssetChangeProcessor.TestAssetFolderAllowlist = FixtureAllowlist; + _originalLoopWindowSeconds = UnityHelpersSettings .instance .DetectAssetChangeLoopWindowSeconds; @@ -94,10 +86,9 @@ Drain setup mutations before configuring the allowlist so recorded invocations c [TearDown] public override void TearDown() { - DetectAssetChangeProcessor.TestAssetFolderAllowlist = null; _watcherScope?.Dispose(); _watcherScope = null; - DetectAssetChangeProcessor.ResetForTesting(_settings); + DetectAssetChangeProcessorTestAccess.Reset(_settings); UnityHelpersSettings settings = UnityHelpersSettings.instance; if ( @@ -124,7 +115,7 @@ public override void OneTimeTearDown() InternalTeardown(); CleanupTestFolders(); // Flush fixture cleanup mutations before the next fixture begins. - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); } finally { @@ -139,7 +130,7 @@ public void InvokesHandlersWhenAssetsAreCreated() // Clear state after asset creation since Unity's OnPostprocessAllAssets may have fired ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -171,7 +162,7 @@ public void CreatedPathsRemainStableAfterLaterChangeBatch() CreatePayloadAssetAt(AlternatePayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -182,7 +173,7 @@ public void CreatedPathsRemainStableAfterLaterChangeBatch() AssetChangeContext firstContext = TestDetectAssetChangeHandler.RecordedContexts[0]; CollectionAssert.AreEqual(new[] { PayloadPath }, firstContext.CreatedAssetPaths); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { AlternatePayloadPath }, null, null, @@ -202,7 +193,7 @@ public void DeletedContextPathsAreIndependentFromDetailedHandlerArray() { CreatePayloadAssetAt(PayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -210,7 +201,7 @@ public void DeletedContextPathsAreIndependentFromDetailedHandlerArray() ); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, new[] { PayloadPath }, null, @@ -258,7 +249,7 @@ public void InvokesHandlersWhenAssetsAreDeleted() // Clear state after asset creation since Unity's OnPostprocessAllAssets may have fired ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -267,7 +258,7 @@ public void InvokesHandlersWhenAssetsAreDeleted() TestDetectAssetChangeHandler.Clear(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, new[] { PayloadPath }, null, @@ -299,7 +290,7 @@ public void StaticHandlersReceiveNotificationsForAssetChanges() // Clear state after asset creation since Unity's OnPostprocessAllAssets may have fired ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -319,7 +310,7 @@ public void StaticHandlersReceiveNotificationsForAssetChanges() TestStaticAssetChangeHandler.Clear(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, new[] { PayloadPath }, null, @@ -345,7 +336,7 @@ public void DetailedSignatureReceivesCreatedAssetsAndDeletedPaths() // Clear state after asset creation since Unity's OnPostprocessAllAssets may have fired ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -374,7 +365,7 @@ public void DetailedSignatureReceivesCreatedAssetsAndDeletedPaths() TestDetailedSignatureHandler.Clear(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, new[] { PayloadPath }, null, @@ -401,7 +392,7 @@ public void SingleMethodCanWatchMultipleAssetTypes() // Clear state after asset creation since Unity's OnPostprocessAllAssets may have fired ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -424,7 +415,7 @@ public void SingleMethodCanWatchMultipleAssetTypes() TestMultiAttributeHandler.Clear(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { AlternatePayloadPath }, null, null, @@ -437,7 +428,7 @@ public void SingleMethodCanWatchMultipleAssetTypes() "TestAlternateDetectableAsset should not trigger Created flag handler" ); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, new[] { AlternatePayloadPath }, null, @@ -469,7 +460,7 @@ public void ReentrantHandlersQueueChangesInsteadOfRecursing() ResetProcessorWithFixtureState(); TestReentrantHandler.Configure(PayloadPath); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -486,33 +477,16 @@ public void ReentrantHandlersQueueChangesInsteadOfRecursing() [Test] public void ChangeBatchesWithGapsLongerThanWindowAreNotSuppressed() { - CreatePayloadAssetAt(PayloadPath); - // Clear state after asset creation since Unity's OnPostprocessAllAssets may have fired - ClearTestState(); - - ResetProcessorWithOnlyLoopHandler(); - - double fakeTime = 0; - DetectAssetChangeProcessor.TimeProvider = () => fakeTime; - DetectAssetChangeProcessor.LoopWindowSecondsOverride = 5d; - - int iterations = DetectAssetChangeProcessor.MaxConsecutiveChangeSetsWithinWindow + 1; - for (int i = 0; i < iterations; ++i) + for ( + int i = 0; + i <= DetectAssetChangeProcessor.MaxConsecutiveChangeSetsWithinWindow; + ++i + ) { - fakeTime += 6d; - DetectAssetChangeProcessor.ProcessChangesForTesting( - new[] { PayloadPath }, - null, - null, - null - ); + DetectAssetChangeProcessor.UpdateLoopWindow(1, i * 6d, 5d); + Assert.IsFalse(DetectAssetChangeProcessor._loopProtectionActive); + Assert.AreEqual(1, DetectAssetChangeProcessor._consecutiveChangeBatches); } - - Assert.AreEqual( - iterations, - TestLoopingHandler.InvocationCount, - $"Expected {iterations} invocations (gaps prevent loop detection) but got {TestLoopingHandler.InvocationCount}" - ); } [Test] @@ -522,27 +496,19 @@ public void PublicResetLoopProtectionResumesDispatchWithoutDroppingSubscriptions ClearTestState(); ResetProcessorWithOnlyLoopHandler(); - double fakeTime = 0; - DetectAssetChangeProcessor.TimeProvider = () => fakeTime; - DetectAssetChangeProcessor.LoopWindowSecondsOverride = 30d; LogAssert.Expect( LogType.Error, new Regex("potentially infinite asset change loop", RegexOptions.Singleline) ); - int iterations = DetectAssetChangeProcessor.MaxConsecutiveChangeSetsWithinWindow; - for (int i = 0; i < iterations; ++i) - { - DetectAssetChangeProcessor.ProcessChangesForTesting( - new[] { PayloadPath }, - null, - null, - null - ); - } + DetectAssetChangeProcessor.UpdateLoopWindow( + DetectAssetChangeProcessor.MaxConsecutiveChangeSetsWithinWindow, + 0d, + 30d + ); - DetectAssetChangeProcessor.AssetWatcherSettings protectedSettings = - DetectAssetChangeProcessor.GetSettingsForTesting(); + DetectAssetChangeProcessorTestAccess.AssetWatcherSettings protectedSettings = + DetectAssetChangeProcessorTestAccess.GetSettings(); Assert.IsTrue(protectedSettings.LoopProtectionActive); Assert.IsTrue( protectedSettings.WatchersByAssetType.TryGetValue( @@ -553,8 +519,8 @@ out DetectAssetChangeProcessor.AssetWatcher snapshotWatcher int activeSubscriptionCount = snapshotWatcher.Subscriptions.Count; snapshotWatcher.Subscriptions.Clear(); Assert.IsTrue( - DetectAssetChangeProcessor - .GetSettingsForTesting() + DetectAssetChangeProcessorTestAccess + .GetSettings() .WatchersByAssetType.TryGetValue( typeof(TestDetectableAsset), out DetectAssetChangeProcessor.AssetWatcher activeWatcher @@ -563,7 +529,7 @@ out DetectAssetChangeProcessor.AssetWatcher activeWatcher Assert.AreEqual(activeSubscriptionCount, activeWatcher.Subscriptions.Count); int invocationCountAfterLoopProtection = TestLoopingHandler.InvocationCount; - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -573,13 +539,13 @@ out DetectAssetChangeProcessor.AssetWatcher activeWatcher AssetChangeDetectionUtility.ResetLoopProtection(); - DetectAssetChangeProcessor.AssetWatcherSettings resetSettings = - DetectAssetChangeProcessor.GetSettingsForTesting(); + DetectAssetChangeProcessorTestAccess.AssetWatcherSettings resetSettings = + DetectAssetChangeProcessorTestAccess.GetSettings(); Assert.IsFalse(resetSettings.LoopProtectionActive); Assert.AreEqual(0, resetSettings.ConsecutiveChangeBatches); Assert.AreEqual(0, resetSettings.PendingAssetChanges.Count); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -591,18 +557,18 @@ out DetectAssetChangeProcessor.AssetWatcher activeWatcher } [Test] - public void ResetForTestingToleratesNullCollectionsAndEntries() + public void ResetToleratesNullCollectionsAndEntries() { - DetectAssetChangeProcessor.AssetWatcherSettings settings = new() + DetectAssetChangeProcessorTestAccess.AssetWatcherSettings settings = new() { WatchersByAssetType = null, PendingAssetChanges = null, }; - Assert.DoesNotThrow(() => DetectAssetChangeProcessor.ResetForTesting(settings)); + Assert.DoesNotThrow(() => DetectAssetChangeProcessorTestAccess.Reset(settings)); - DetectAssetChangeProcessor.AssetWatcherSettings emptySnapshot = - DetectAssetChangeProcessor.GetSettingsForTesting(); + DetectAssetChangeProcessorTestAccess.AssetWatcherSettings emptySnapshot = + DetectAssetChangeProcessorTestAccess.GetSettings(); Assert.AreEqual(0, emptySnapshot.WatchersByAssetType.Count); Assert.AreEqual(0, emptySnapshot.PendingAssetChanges.Count); @@ -611,10 +577,10 @@ public void ResetForTestingToleratesNullCollectionsAndEntries() settings.PendingAssetChanges = new(); settings.PendingAssetChanges.Enqueue(null); - Assert.DoesNotThrow(() => DetectAssetChangeProcessor.ResetForTesting(settings)); + Assert.DoesNotThrow(() => DetectAssetChangeProcessorTestAccess.Reset(settings)); - DetectAssetChangeProcessor.AssetWatcherSettings nullEntrySnapshot = - DetectAssetChangeProcessor.GetSettingsForTesting(); + DetectAssetChangeProcessorTestAccess.AssetWatcherSettings nullEntrySnapshot = + DetectAssetChangeProcessorTestAccess.GetSettings(); Assert.IsTrue( nullEntrySnapshot.WatchersByAssetType.TryGetValue( typeof(TestDetectableAsset), @@ -640,7 +606,7 @@ public void LogsErrorWhenMethodReturnsNonVoid() ); LogAssert.Expect(LogType.Error, expected); - bool isValid = DetectAssetChangeProcessor.ValidateMethodSignatureForTesting( + bool isValid = DetectAssetChangeProcessorTestAccess.ValidateMethodSignature( typeof(TestInvalidReturnTypeHandler), "OnInvalidReturnType" ); @@ -658,7 +624,7 @@ public void LogsErrorWhenMethodHasUnsupportedSingleParameter() ); LogAssert.Expect(LogType.Error, expected); - bool isValid = DetectAssetChangeProcessor.ValidateMethodSignatureForTesting( + bool isValid = DetectAssetChangeProcessorTestAccess.ValidateMethodSignature( typeof(TestInvalidParameterHandler), "OnInvalidSingleParameter" ); @@ -676,7 +642,7 @@ public void LogsErrorWhenCreatedAssetParameterIsNotArray() ); LogAssert.Expect(LogType.Error, expected); - bool isValid = DetectAssetChangeProcessor.ValidateMethodSignatureForTesting( + bool isValid = DetectAssetChangeProcessorTestAccess.ValidateMethodSignature( typeof(TestInvalidCreatedParameterHandler), "OnInvalidCreated" ); @@ -738,7 +704,7 @@ bool expectedValid ); } - bool isValid = DetectAssetChangeProcessor.ValidateMethodSignatureForTesting( + bool isValid = DetectAssetChangeProcessorTestAccess.ValidateMethodSignature( declaringType, methodName ); @@ -802,7 +768,7 @@ AssetChangeFlags expectedFlags // Deletion lookup requires the asset path to have been tracked before deletion. if (hasDeleted && !hasCreated) { - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -811,7 +777,7 @@ AssetChangeFlags expectedFlags ClearTestState(); } - DetectAssetChangeProcessor.ProcessChangesForTesting(created, deleted, moved, movedFrom); + DetectAssetChangeProcessorTestAccess.ProcessChanges(created, deleted, moved, movedFrom); if (expectedFlags == AssetChangeFlags.None) { @@ -837,7 +803,7 @@ public void EmptyChangeListsDoNotTriggerHandlers() CreatePayloadAssetAt(PayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( Array.Empty(), Array.Empty(), Array.Empty(), @@ -857,7 +823,7 @@ public void NullChangeListsDoNotTriggerHandlers() CreatePayloadAssetAt(PayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting(null, null, null, null); + DetectAssetChangeProcessorTestAccess.ProcessChanges(null, null, null, null); Assert.AreEqual( 0, @@ -871,7 +837,7 @@ public void ProcessingNonExistentPathsDoesNotCrash() { ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { "Assets/__DoesNotExist__/fake.asset" }, null, null, @@ -891,7 +857,7 @@ public void MixedValidAndInvalidPathsProcessesCorrectly() CreatePayloadAssetAt(PayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath, "Assets/__DoesNotExist__/fake.asset" }, null, null, @@ -912,7 +878,7 @@ public void InPlaceAssetRenameTriggersMovedEvent() CreatePayloadAssetAt(PayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -922,7 +888,7 @@ public void InPlaceAssetRenameTriggersMovedEvent() string renamedPath = TestRoot + "/PayloadRenamed.asset"; - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, null, new[] { renamedPath }, @@ -945,7 +911,7 @@ public void MultipleAssetsMoveInSameBatchTriggersHandlers() CreateAlternatePayloadAssetAt(AlternatePayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath, AlternatePayloadPath }, null, null, @@ -956,7 +922,7 @@ public void MultipleAssetsMoveInSameBatchTriggersHandlers() string movedPath1 = subFolderPath + "/Payload.asset"; string movedPath2 = subFolderPath + "/AlternatePayload.asset"; - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( null, null, new[] { movedPath1, movedPath2 }, @@ -991,7 +957,7 @@ public void MixedHandlerTypesInSingleEventBatchProcessCorrectly() ClearTestState(); ResetProcessorWithFixtureState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath, AlternatePayloadPath }, null, null, @@ -1030,7 +996,7 @@ import in the batch. Assert.DoesNotThrow( () => - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { fakeScenePath }, null, null, @@ -1057,7 +1023,7 @@ public void SceneFileInMixedBatchDoesNotInterfereWithNormalAssets() CreatePayloadAssetAt(PayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath, fakeScenePath }, null, null, @@ -1092,7 +1058,7 @@ bool hasMovedFrom CreatePayloadAssetAt(PayloadPath); ClearTestState(); - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { PayloadPath }, null, null, @@ -1107,7 +1073,7 @@ bool hasMovedFrom string[] moved = hasMoved ? new[] { movedPath } : null; string[] movedFrom = hasMovedFrom ? new[] { PayloadPath } : null; - DetectAssetChangeProcessor.ProcessChangesForTesting(created, deleted, moved, movedFrom); + DetectAssetChangeProcessorTestAccess.ProcessChanges(created, deleted, moved, movedFrom); Assert.GreaterOrEqual( TestDetectAssetChangeHandler.RecordedContexts.Count, @@ -1120,7 +1086,7 @@ bool hasMovedFrom private void InternalTeardown() { ClearTestState(); - DetectAssetChangeProcessor.ResetForTesting(_settings); + DetectAssetChangeProcessorTestAccess.Reset(_settings); UnityHelpersSettings settings = UnityHelpersSettings.instance; if ( @@ -1137,18 +1103,16 @@ private void InternalTeardown() private void ResetProcessorWithFixtureState() { - DetectAssetChangeProcessor.ResetForTesting(_fixtureSettings); + DetectAssetChangeProcessorTestAccess.Reset(_fixtureSettings); EnsureTestFolder(); DetectAssetChangeProcessor.EnabledOverride = true; - DetectAssetChangeProcessor.IncludeTestAssets = true; - DetectAssetChangeProcessor.TestAssetFolderAllowlist = FixtureAllowlist; } private void ResetProcessorWithOnlyLoopHandler() { ResetProcessorWithFixtureState(); - DetectAssetChangeProcessor.AssetWatcherSettings settings = - DetectAssetChangeProcessor.GetSettingsForTesting(); + DetectAssetChangeProcessorTestAccess.AssetWatcherSettings settings = + DetectAssetChangeProcessorTestAccess.GetSettings(); Assert.IsTrue( settings.WatchersByAssetType.TryGetValue( typeof(TestDetectableAsset), @@ -1165,7 +1129,7 @@ out DetectAssetChangeProcessor.AssetWatcher payloadWatcher } } Assert.AreEqual(1, payloadWatcher.Subscriptions.Count); - DetectAssetChangeProcessor.ResetForTesting(settings); + DetectAssetChangeProcessorTestAccess.Reset(settings); } private abstract class InheritedHandlerBase diff --git a/Tests/Editor/AssetProcessors/DetectAssetChangeTestBase.cs b/Tests/Editor/AssetProcessors/DetectAssetChangeTestBase.cs index ab7da6d51..8609b4b84 100644 --- a/Tests/Editor/AssetProcessors/DetectAssetChangeTestBase.cs +++ b/Tests/Editor/AssetProcessors/DetectAssetChangeTestBase.cs @@ -30,17 +30,6 @@ public abstract class DetectAssetChangeTestBase : BatchedEditorTestBase protected const string DefaultAlternatePayloadAssetPath = TestRoot + "/AlternatePayload.asset"; - /// - /// Path prefixes this fixture family is allowed to drive the processor through. - /// Scoped to so assets created by any OTHER fixture are - /// structurally ignored even when - /// is . - /// Every setup / reset path in this base class and its derivatives must restore - /// this allowlist after calling - /// (which clears it). - /// - protected static readonly string[] FixtureAllowlist = { TestRoot + "/" }; - /// /// Default path for the payload test asset. /// @@ -86,23 +75,12 @@ protected static void EnsureTestFolder() } } - /// - /// Resets the processor with a clean state and ensures the test folder is properly registered. - /// This method should be called when a test needs to reinitialize the processor after the - /// standard SetUp has already run. It ensures the test folder exists before enabling test - /// asset inclusion to avoid "Folder not found" warnings from AssetDatabase.FindAssets. - /// Re-applies after the reset so the structural - /// defense against cross-fixture pollution is preserved for the remainder of - /// the test. - /// protected static void ResetProcessorWithCleanState() { - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); EnsureTestFolder(); // Force the watcher on because CI runs this fixture in batch mode. DetectAssetChangeProcessor.EnabledOverride = true; - DetectAssetChangeProcessor.IncludeTestAssets = true; - DetectAssetChangeProcessor.TestAssetFolderAllowlist = FixtureAllowlist; } /// @@ -176,7 +154,7 @@ protected static bool VerifyTestFolderCleanedUp() /// Relationship to the teardown-flush contract: the contract test /// TestTeardownsThatClearHandlerStateFlushDeferralsFirst accepts /// three call sites as flush-equivalents — a direct - /// AssetPostprocessorDeferral.FlushForTesting() call, + /// AssetPostprocessorDeferralTestAccess.Flush() call, /// , or /// . /// Because this method's body IS a call to FlushAndClearAll, diff --git a/Tests/Editor/AssetProcessors/LlmArtifactCleanerTests.cs b/Tests/Editor/AssetProcessors/LlmArtifactCleanerTests.cs index 87e7e5257..f9ae4a676 100644 --- a/Tests/Editor/AssetProcessors/LlmArtifactCleanerTests.cs +++ b/Tests/Editor/AssetProcessors/LlmArtifactCleanerTests.cs @@ -296,7 +296,7 @@ public override void BaseSetUp() { // Check inherited handler pollution before base setup changes its attribution. AssetPostprocessorTestHandlers.AssertCleanAndClearAll(); - LlmArtifactCleaner.ResetForTesting(); + LlmArtifactCleanerTestAccess.Reset(); base.BaseSetUp(); EnsureFolder(AssetsRoot); } @@ -308,8 +308,8 @@ public override void TearDown() AssetDatabaseBatchHelper.RefreshIfNotBatching(); base.TearDown(); // Every asset op above scheduled a drain that would otherwise leak forward. - AssetPostprocessorDeferral.FlushForTesting(); - LlmArtifactCleaner.ResetForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); + LlmArtifactCleanerTestAccess.Reset(); } [Test] @@ -323,7 +323,7 @@ public void DeletesLlmArtifactsInsidePackage() Assert.IsTrue(LlmArtifactCleaner.ShouldDelete(assetPath)); // Manually invoke deletion logic since OnPostprocessAllAssets timing is unreliable in tests - LlmArtifactCleaner.DeleteBlockedAssets(new[] { assetPath }); + LlmArtifactCleanerTestAccess.DeleteBlockedAssets(new[] { assetPath }); AssetDatabaseBatchHelper.RefreshIfNotBatching( ImportAssetOptions.ForceSynchronousImport ); @@ -386,71 +386,50 @@ public void ShouldDeleteHandlesNullPathGracefully(string assetPath) public void DeleteBlockedAssetsHandlesEdgeCasesGracefully(string[] assetPaths) { Assert.DoesNotThrow( - () => LlmArtifactCleaner.DeleteBlockedAssets(assetPaths), + () => LlmArtifactCleanerTestAccess.DeleteBlockedAssets(assetPaths), "DeleteBlockedAssets should handle edge cases without throwing" ); } [Test] - public void DeleteBlockedAssetsDeduplicatesQueuedPaths() + public void BusyDeletionDeduplicatesPendingPaths() { - string assetPath = PackagePrefix + "_llm_duplicate.txt"; - int deleteCount = 0; - - RestorableGlobal> deleteAsset = new( - () => LlmArtifactCleaner.DeleteAssetAction, - action => LlmArtifactCleaner.DeleteAssetAction = action - ); - using (deleteAsset.Borrow(_ => deleteCount++)) + LlmArtifactCleaner._isDeleting = true; + try { - LlmArtifactCleaner.DeleteBlockedAssets(new[] { assetPath, assetPath, assetPath }); + LlmArtifactCleanerTestAccess.DeleteBlockedAssets( + new[] { PackagePrefix + "_llm_first.txt", PackagePrefix + "_llm_first.txt" } + ); + Assert.AreEqual(1, LlmArtifactCleaner.PendingDeletions.Count); } - - Assert.AreEqual(1, deleteCount, "Duplicate paths should be deleted only once."); - Assert.AreEqual( - 0, - LlmArtifactCleaner.PendingDeletionCountForTesting, - "Pending queue should be empty after the deduplicated delete pass." - ); + finally + { + LlmArtifactCleaner._isDeleting = false; + } + LlmArtifactCleaner.DrainPendingDeletions(); + Assert.AreEqual(0, LlmArtifactCleaner.PendingDeletions.Count); } [Test] - public void DeleteBlockedAssetsReentrantQueueDrainsBeforeReturning() + public void BusyDeletionRetainsNewPathsUntilDrain() { - string firstPath = PackagePrefix + "_llm_first.txt"; - string secondPath = PackagePrefix + "_llm_second.txt"; - List deletedPaths = new(); - bool queuedSecondPath = false; - - RestorableGlobal> deleteAsset = new( - () => LlmArtifactCleaner.DeleteAssetAction, - action => LlmArtifactCleaner.DeleteAssetAction = action - ); - using ( - deleteAsset.Borrow(assetPath => - { - deletedPaths.Add(assetPath); - if (!queuedSecondPath) - { - queuedSecondPath = true; - LlmArtifactCleaner.DeleteBlockedAssets(new[] { secondPath, secondPath }); - } - }) - ) + LlmArtifactCleaner._isDeleting = true; + try { - LlmArtifactCleaner.DeleteBlockedAssets(new[] { firstPath }); + LlmArtifactCleanerTestAccess.DeleteBlockedAssets( + new[] { PackagePrefix + "_llm_first.txt", PackagePrefix + "_llm_first.txt" } + ); + LlmArtifactCleanerTestAccess.DeleteBlockedAssets( + new[] { PackagePrefix + "_llm_second.txt" } + ); + Assert.AreEqual(2, LlmArtifactCleaner.PendingDeletions.Count); } - - CollectionAssert.AreEquivalent( - new[] { firstPath, secondPath }, - deletedPaths, - "Reentrant deletes should stay queued and drain before the outer delete pass returns." - ); - Assert.AreEqual( - 0, - LlmArtifactCleaner.PendingDeletionCountForTesting, - "Pending queue should be empty after reentrant deletes drain." - ); + finally + { + LlmArtifactCleaner._isDeleting = false; + } + LlmArtifactCleaner.DrainPendingDeletions(); + Assert.AreEqual(0, LlmArtifactCleaner.PendingDeletions.Count); } [Test] diff --git a/Tests/Editor/AssetProcessors/SpriteLabelProcessorTests.cs b/Tests/Editor/AssetProcessors/SpriteLabelProcessorTests.cs index 54a0351ba..086f9976e 100644 --- a/Tests/Editor/AssetProcessors/SpriteLabelProcessorTests.cs +++ b/Tests/Editor/AssetProcessors/SpriteLabelProcessorTests.cs @@ -54,8 +54,8 @@ private static IEnumerable EnqueuePathFilteringCases() private static string[] SnapshotNormalizedPendingPaths() { - return SpriteLabelProcessor - .SnapshotPendingImportedPathsForTesting() + return SpriteLabelProcessorTestAccess + .SnapshotPendingImportedPaths() .Select(path => path.ToLowerInvariant()) .OrderBy(path => path, StringComparer.Ordinal) .ToArray(); @@ -64,7 +64,7 @@ private static string[] SnapshotNormalizedPendingPaths() private static string DescribePendingState() { string[] normalized = SnapshotNormalizedPendingPaths(); - return $"PendingImportedPaths.Count={SpriteLabelProcessor.PendingImportedPathCountForTesting}; " + return $"PendingImportedPaths.Count={SpriteLabelProcessorTestAccess.PendingImportedPathCount}; " + $"Normalized=[{string.Join(", ", normalized)}]"; } @@ -73,31 +73,31 @@ public void SetUp() { // First in SetUp, so leaked handler statics are attributed to the prior fixture. AssetPostprocessorTestHandlers.AssertCleanAndClearAll(); - SpriteLabelProcessor.ResetForTesting(); + SpriteLabelProcessorTestAccess.Reset(); } [TearDown] public void TearDown() { - AssetPostprocessorDeferral.FlushForTesting(); - SpriteLabelProcessor.ResetForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); + SpriteLabelProcessorTestAccess.Reset(); } [Test] [TestCaseSource(nameof(EnqueuePathFilteringCases))] - public void EnqueueImportedPathsForTestingFiltersAndDeduplicatesPaths( + public void EnqueueImportedPathsFiltersAndDeduplicatesPaths( string[] importedPaths, string[] expectedNormalizedPaths ) { - SpriteLabelProcessor.EnqueueImportedPathsForTesting(importedPaths); + SpriteLabelProcessorTestAccess.EnqueueImportedPaths(importedPaths); string[] normalizedPaths = SnapshotNormalizedPendingPaths(); string details = DescribePendingState(); Assert.AreEqual( expectedNormalizedPaths.Length, - SpriteLabelProcessor.PendingImportedPathCountForTesting, + SpriteLabelProcessorTestAccess.PendingImportedPathCount, "Only unique sprite candidate assets under Assets/ should remain queued. " + details ); CollectionAssert.AreEquivalent( @@ -108,30 +108,30 @@ string[] expectedNormalizedPaths } [Test] - public void EnqueueImportedPathsForTestingNullOrEmptyInputDoesNotMutateState() + public void EnqueueImportedPathsNullOrEmptyInputDoesNotMutateState() { Assert.AreEqual( 0, - SpriteLabelProcessor.PendingImportedPathCountForTesting, + SpriteLabelProcessorTestAccess.PendingImportedPathCount, "Expected clean state at test start. " + DescribePendingState() ); - SpriteLabelProcessor.EnqueueImportedPathsForTesting( + SpriteLabelProcessorTestAccess.EnqueueImportedPaths( new[] { "Assets/Sprites/Hero.png" } ); Assert.AreEqual( 1, - SpriteLabelProcessor.PendingImportedPathCountForTesting, + SpriteLabelProcessorTestAccess.PendingImportedPathCount, "Expected a seeded pending path before null/empty no-op checks. " + DescribePendingState() ); - SpriteLabelProcessor.EnqueueImportedPathsForTesting(null); - SpriteLabelProcessor.EnqueueImportedPathsForTesting(Array.Empty()); + SpriteLabelProcessorTestAccess.EnqueueImportedPaths(null); + SpriteLabelProcessorTestAccess.EnqueueImportedPaths(Array.Empty()); Assert.AreEqual( 1, - SpriteLabelProcessor.PendingImportedPathCountForTesting, + SpriteLabelProcessorTestAccess.PendingImportedPathCount, "Null/empty inputs should leave pending state unchanged. " + DescribePendingState() ); CollectionAssert.AreEquivalent( @@ -142,12 +142,12 @@ public void EnqueueImportedPathsForTestingNullOrEmptyInputDoesNotMutateState() } [Test] - public void EnqueueImportedPathsForTestingDeduplicatesAcrossMultipleCalls() + public void EnqueueImportedPathsDeduplicatesAcrossMultipleCalls() { - SpriteLabelProcessor.EnqueueImportedPathsForTesting( + SpriteLabelProcessorTestAccess.EnqueueImportedPaths( new[] { "Assets/Sprites/Hero.png", "Assets/Sprites/Villain.jpg" } ); - SpriteLabelProcessor.EnqueueImportedPathsForTesting( + SpriteLabelProcessorTestAccess.EnqueueImportedPaths( new[] { "assets/sprites/HERO.PNG", "Assets/Sprites/Sidekick.jpeg" } ); @@ -164,7 +164,7 @@ public void EnqueueImportedPathsForTestingDeduplicatesAcrossMultipleCalls() ); Assert.AreEqual( 3, - SpriteLabelProcessor.PendingImportedPathCountForTesting, + SpriteLabelProcessorTestAccess.PendingImportedPathCount, "Expected exactly three unique candidate paths after two enqueue batches. " + DescribePendingState() ); diff --git a/Tests/Editor/AssetProcessors/TestTypes/TestReentrantHandler.cs b/Tests/Editor/AssetProcessors/TestTypes/TestReentrantHandler.cs index 8e665ce06..2abddd01e 100644 --- a/Tests/Editor/AssetProcessors/TestTypes/TestReentrantHandler.cs +++ b/Tests/Editor/AssetProcessors/TestTypes/TestReentrantHandler.cs @@ -6,6 +6,7 @@ namespace WallstopStudios.UnityHelpers.Tests.AssetProcessors { using WallstopStudios.UnityHelpers.Core.Attributes; using WallstopStudios.UnityHelpers.Editor.AssetProcessors; + using WallstopStudios.UnityHelpers.Tests.Core; /// /// Test handler that triggers reentrant asset change processing. @@ -41,7 +42,7 @@ private static void OnReentrantChange(AssetChangeContext context) && !string.IsNullOrEmpty(_watchedPath) ) { - DetectAssetChangeProcessor.ProcessChangesForTesting( + DetectAssetChangeProcessorTestAccess.ProcessChanges( new[] { _watchedPath }, null, null, diff --git a/Tests/Editor/Attributes/WGroupLayoutBuilderTests.cs b/Tests/Editor/Attributes/WGroupLayoutBuilderTests.cs index 5b1028fd2..223ae2d0b 100644 --- a/Tests/Editor/Attributes/WGroupLayoutBuilderTests.cs +++ b/Tests/Editor/Attributes/WGroupLayoutBuilderTests.cs @@ -36,7 +36,7 @@ public override void BaseSetUp() public override void TearDown() { WGroupLayoutBuilder.ClearCache(); - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( _previousConfiguration.Mode, _previousConfiguration.RowCount ); @@ -63,7 +63,7 @@ public override void TearDown() [Test] public void GroupingWorksThroughABackingFieldAttribute() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -104,7 +104,7 @@ public void GroupingWorksThroughABackingFieldAttribute() [Test] public void FiniteAutoIncludeCapturesConfiguredCount() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.Finite, 2 ); @@ -137,7 +137,7 @@ public void FiniteAutoIncludeCapturesConfiguredCount() [Test] public void InfiniteAutoIncludeTerminatesAtEndAttribute() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -168,7 +168,7 @@ public void InfiniteAutoIncludeTerminatesAtEndAttribute() [Test] public void NamedGroupEndStopsSpecifiedGroup() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -211,7 +211,7 @@ public void NamedGroupEndStopsSpecifiedGroup() [Test] public void MultipleDeclarationsAnchorToFirstOccurrence() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -255,7 +255,7 @@ public void MultipleDeclarationsAnchorToFirstOccurrence() [Test] public void CollapsibleMetadataIsPreserved() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); diff --git a/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs b/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs index 69018bc97..0141c0c9e 100644 --- a/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs +++ b/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs @@ -24,7 +24,10 @@ public sealed class WShowIfPropertyDrawerTests : CommonTestBase private static WShowIfPropertyDrawer CreateDrawer(WShowIfAttribute attribute) { WShowIfPropertyDrawer drawer = new(); - drawer.InitializeForTesting(attribute); + WallstopStudios.UnityHelpers.Tests.TestUtils.PropertyDrawerTestHelper.AssignAttribute( + drawer, + attribute + ); return drawer; } diff --git a/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs b/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs index c1080e84f..a167fcc56 100644 --- a/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +++ b/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs @@ -259,7 +259,9 @@ private AttributeMetadataCache CreateCacheWithSiblingSelfInclusionMetadata() new[] { relationalMetadata }, Array.Empty() ); - cache.ForceRebuildForTests(); + global::WallstopStudios.UnityHelpers.Tests.Core.AttributeMetadataCacheTestUtilities.Rebuild( + cache + ); return cache; } } diff --git a/Tests/Editor/CustomDrawers/DropDownFormattedOptionCacheBoundTests.cs b/Tests/Editor/CustomDrawers/DropDownFormattedOptionCacheBoundTests.cs index a2849bcfc..1195c74cf 100644 --- a/Tests/Editor/CustomDrawers/DropDownFormattedOptionCacheBoundTests.cs +++ b/Tests/Editor/CustomDrawers/DropDownFormattedOptionCacheBoundTests.cs @@ -35,7 +35,7 @@ private static IEnumerable FormattedOptionCaches() ).SetName("Cache.DropDownShared"); yield return new TestCaseData( - (Func)WValueDropDownDrawer.TestHooks.FormatOptionCached + (Func)WValueDropDownDrawerTestAccess.FormatOptionCached ).SetName("Cache.WValueDropDown"); } @@ -66,7 +66,7 @@ public void FormatOptionEvictsLeastRecentlyUsedOption() _ = DropDownShared.FormatOption(option); } - int controlCount = DropDownShared.TestHooks.FormattedOptionCacheCount; + int controlCount = DropDownSharedTestAccess.FormattedOptionCacheCount; Assert.AreEqual( ControlOptionCount, controlCount, @@ -84,7 +84,7 @@ public void FormatOptionEvictsLeastRecentlyUsedOption() _ = DropDownShared.FormatOption(option); } - int churnedCount = DropDownShared.TestHooks.FormattedOptionCacheCount; + int churnedCount = DropDownSharedTestAccess.FormattedOptionCacheCount; Assert.That( churnedCount, Is.GreaterThan(0), @@ -182,7 +182,7 @@ public void FormatOptionDoesNotRootDestroyedObjects() Assert.AreEqual( DestroyedOptionCount, - DropDownShared.TestHooks.FormattedOptionCacheCount, + DropDownSharedTestAccess.FormattedOptionCacheCount, "Every option must be cached before it is destroyed, or this fixture measures an " + "empty cache." ); @@ -198,7 +198,7 @@ public void FormatOptionDoesNotRootDestroyedObjects() _ = DropDownShared.FormatOption(option); } - int churnedCount = DropDownShared.TestHooks.FormattedOptionCacheCount; + int churnedCount = DropDownSharedTestAccess.FormattedOptionCacheCount; Assert.That( churnedCount, Is.GreaterThan(0), diff --git a/Tests/Editor/CustomDrawers/NestedCollectionSerializationTests.cs b/Tests/Editor/CustomDrawers/NestedCollectionSerializationTests.cs index 7f38bf43e..376d44470 100644 --- a/Tests/Editor/CustomDrawers/NestedCollectionSerializationTests.cs +++ b/Tests/Editor/CustomDrawers/NestedCollectionSerializationTests.cs @@ -457,7 +457,7 @@ public void UnitySerializesItemsWrappedInASerializableList() public void DictionaryDrawerAcceptsTheListWrapper() { Assert.That( - SerializableDictionaryPropertyDrawer.HasDroppedValuesArrayForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasDroppedValuesArray( FindCollection(nameof(NestedCollectionSerializationHost.wrappedValues)) ), Is.False @@ -472,7 +472,7 @@ public void DictionaryDrawerAcceptsTheListWrapper() public void DictionaryDrawerAcceptsACollectionValueType() { Assert.That( - SerializableDictionaryPropertyDrawer.HasDroppedValuesArrayForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasDroppedValuesArray( FindCollection(nameof(NestedCollectionSerializationHost.droppedValues)) ), Is.False @@ -489,7 +489,7 @@ public void DictionaryDrawerAcceptsACollectionValueType() public void DictionaryDrawerReportsAValueTypeBoxingCannotRepair() { Assert.That( - SerializableDictionaryPropertyDrawer.HasDroppedValuesArrayForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasDroppedValuesArray( FindCollection(nameof(NestedCollectionSerializationHost.nestedListValues)) ), Is.True, @@ -502,7 +502,7 @@ public void DictionaryDrawerReportsAValueTypeBoxingCannotRepair() public void SortedDictionaryDrawerReportsAValueTypeBoxingCannotRepair() { Assert.That( - SerializableDictionaryPropertyDrawer.HasDroppedValuesArrayForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasDroppedValuesArray( FindCollection(nameof(NestedCollectionSerializationHost.sortedNestedListValues)) ), Is.True, @@ -520,7 +520,7 @@ public void SortedDictionaryDrawerReportsAValueTypeBoxingCannotRepair() public void DictionaryDrawerStaysSilentOnAnEmptyBoxedCollectionValue() { Assert.That( - SerializableDictionaryPropertyDrawer.HasDroppedValuesArrayForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasDroppedValuesArray( FindCollection(nameof(NestedCollectionSerializationHost.sortedDroppedValues)) ), Is.False, @@ -532,7 +532,7 @@ public void DictionaryDrawerStaysSilentOnAnEmptyBoxedCollectionValue() public void DictionaryDrawerAcceptsASupportedValueType() { Assert.That( - SerializableDictionaryPropertyDrawer.HasDroppedValuesArrayForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasDroppedValuesArray( FindCollection(nameof(NestedCollectionSerializationHost.control)) ), Is.False @@ -543,7 +543,7 @@ public void DictionaryDrawerAcceptsASupportedValueType() public void DictionaryDrawerAcceptsTheCacheForm() { Assert.That( - SerializableDictionaryPropertyDrawer.HasDroppedValuesArrayForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasDroppedValuesArray( FindCollection(nameof(NestedCollectionSerializationHost.cachedValues)) ), Is.False @@ -580,10 +580,7 @@ public void SetDrawerReportsADroppedItemsArray() SerializableHashSetSerializedPropertyNames.Items ); - Assert.That( - SerializableSetPropertyDrawer.HasDroppedItemsArrayForTests(true, items), - Is.True - ); + Assert.That(SerializableSetPropertyDrawer.HasDroppedItemsArray(true, items), Is.True); } [Test] @@ -594,10 +591,7 @@ public void SetDrawerAcceptsASupportedElementType() SerializableHashSetSerializedPropertyNames.Items ); - Assert.That( - SerializableSetPropertyDrawer.HasDroppedItemsArrayForTests(true, items), - Is.False - ); + Assert.That(SerializableSetPropertyDrawer.HasDroppedItemsArray(true, items), Is.False); } /// @@ -607,10 +601,7 @@ public void SetDrawerAcceptsASupportedElementType() [Test] public void SetDrawerIgnoresAPropertyThatIsNotASet() { - Assert.That( - SerializableSetPropertyDrawer.HasDroppedItemsArrayForTests(false, null), - Is.False - ); + Assert.That(SerializableSetPropertyDrawer.HasDroppedItemsArray(false, null), Is.False); } /// diff --git a/Tests/Editor/CustomDrawers/Odin/WInLineEditorOdinDrawerTests.cs b/Tests/Editor/CustomDrawers/Odin/WInLineEditorOdinDrawerTests.cs index 4696cc981..6a1563ffc 100644 --- a/Tests/Editor/CustomDrawers/Odin/WInLineEditorOdinDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/Odin/WInLineEditorOdinDrawerTests.cs @@ -30,13 +30,13 @@ public sealed class WInLineEditorOdinDrawerTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - WInLineEditorOdinDrawer.ClearCachedStateForTesting(); + WInLineEditorOdinDrawerTestAccess.ClearCachedState(); } [TearDown] public override void TearDown() { - WInLineEditorOdinDrawer.ClearCachedStateForTesting(); + WInLineEditorOdinDrawerTestAccess.ClearCachedState(); base.TearDown(); } @@ -288,11 +288,11 @@ public void FoldoutStatePersistsAcrossCalls() { string foldoutKey = "test::persistenceTest"; - WInLineEditorOdinDrawer.SetFoldoutStateForTesting(foldoutKey, true); - bool state1 = WInLineEditorOdinDrawer.GetFoldoutStateForTesting(foldoutKey); + WInLineEditorOdinDrawerTestAccess.SetFoldoutState(foldoutKey, true); + bool state1 = WInLineEditorOdinDrawerTestAccess.GetFoldoutState(foldoutKey); - WInLineEditorOdinDrawer.SetFoldoutStateForTesting(foldoutKey, false); - bool state2 = WInLineEditorOdinDrawer.GetFoldoutStateForTesting(foldoutKey); + WInLineEditorOdinDrawerTestAccess.SetFoldoutState(foldoutKey, false); + bool state2 = WInLineEditorOdinDrawerTestAccess.GetFoldoutState(foldoutKey); Assert.That(state1, Is.True, "First state should be true"); Assert.That(state2, Is.False, "Second state should be false after modification"); @@ -438,7 +438,7 @@ public void EditorCachingWorks() } finally { - WInLineEditorOdinDrawer.ClearCachedStateForTesting(); + WInLineEditorOdinDrawerTestAccess.ClearCachedState(); } } @@ -824,16 +824,16 @@ public IEnumerator InspectorHandlesDestroyedReferencedObjectGracefully() } [Test] - public void ClearCachedStateForTestingClearsFoldoutStates() + public void ClearCachedStateClearsFoldoutStates() { string foldoutKey = "test::clearTest"; - WInLineEditorOdinDrawer.SetFoldoutStateForTesting(foldoutKey, true); + WInLineEditorOdinDrawerTestAccess.SetFoldoutState(foldoutKey, true); - bool stateBefore = WInLineEditorOdinDrawer.GetFoldoutStateForTesting(foldoutKey); + bool stateBefore = WInLineEditorOdinDrawerTestAccess.GetFoldoutState(foldoutKey); - WInLineEditorOdinDrawer.ClearCachedStateForTesting(); + WInLineEditorOdinDrawerTestAccess.ClearCachedState(); - bool stateAfter = WInLineEditorOdinDrawer.GetFoldoutStateForTesting(foldoutKey); + bool stateAfter = WInLineEditorOdinDrawerTestAccess.GetFoldoutState(foldoutKey); Assert.That(stateBefore, Is.True, "State should be true before clearing"); Assert.That( @@ -849,11 +849,11 @@ public void DifferentFoldoutKeysAreIndependent() string key1 = "test::independent1"; string key2 = "test::independent2"; - WInLineEditorOdinDrawer.SetFoldoutStateForTesting(key1, true); - WInLineEditorOdinDrawer.SetFoldoutStateForTesting(key2, false); + WInLineEditorOdinDrawerTestAccess.SetFoldoutState(key1, true); + WInLineEditorOdinDrawerTestAccess.SetFoldoutState(key2, false); - bool state1 = WInLineEditorOdinDrawer.GetFoldoutStateForTesting(key1); - bool state2 = WInLineEditorOdinDrawer.GetFoldoutStateForTesting(key2); + bool state1 = WInLineEditorOdinDrawerTestAccess.GetFoldoutState(key1); + bool state2 = WInLineEditorOdinDrawerTestAccess.GetFoldoutState(key2); Assert.That(state1, Is.True, "Key1 should be true"); Assert.That(state2, Is.False, "Key2 should be false"); diff --git a/Tests/Editor/CustomDrawers/Odin/WShowIfOdinDrawerTests.cs b/Tests/Editor/CustomDrawers/Odin/WShowIfOdinDrawerTests.cs index 97bfddc20..d2887edbd 100644 --- a/Tests/Editor/CustomDrawers/Odin/WShowIfOdinDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/Odin/WShowIfOdinDrawerTests.cs @@ -34,7 +34,7 @@ public sealed class WShowIfOdinDrawerTests : CommonTestBase WShowIfAttribute attribute ) { - object conditionValue = WShowIfOdinDrawer.GetConditionValueForTest( + object conditionValue = WShowIfOdinDrawer.GetConditionValue( target, attribute.conditionField ); @@ -1332,10 +1332,7 @@ public void NonExistentConditionFieldDoesNotThrow() { OdinShowIfBoolTarget target = CreateScriptableObject(); - object conditionValue = WShowIfOdinDrawer.GetConditionValueForTest( - target, - "nonExistentField" - ); + object conditionValue = WShowIfOdinDrawer.GetConditionValue(target, "nonExistentField"); Assert.That(conditionValue, Is.Null, "Non-existent field should return null"); } @@ -1343,7 +1340,7 @@ public void NonExistentConditionFieldDoesNotThrow() [Test] public void NullParentValueHandledGracefully() { - object conditionValue = WShowIfOdinDrawer.GetConditionValueForTest(null, "someField"); + object conditionValue = WShowIfOdinDrawer.GetConditionValue(null, "someField"); Assert.That(conditionValue, Is.Null, "Null parent should return null condition value"); } @@ -1353,10 +1350,7 @@ public void EmptyConditionFieldNameHandledGracefully() { OdinShowIfBoolTarget target = CreateScriptableObject(); - object conditionValue = WShowIfOdinDrawer.GetConditionValueForTest( - target, - string.Empty - ); + object conditionValue = WShowIfOdinDrawer.GetConditionValue(target, string.Empty); Assert.That(conditionValue, Is.Null, "Empty condition field name should return null"); } diff --git a/Tests/Editor/CustomDrawers/SerializableCollectionChildHeightSignalTests.cs b/Tests/Editor/CustomDrawers/SerializableCollectionChildHeightSignalTests.cs index 26b24d8cd..62f657cc2 100644 --- a/Tests/Editor/CustomDrawers/SerializableCollectionChildHeightSignalTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableCollectionChildHeightSignalTests.cs @@ -26,15 +26,15 @@ public sealed class SerializableCollectionChildHeightSignalTests : CommonTestBas public override void BaseSetUp() { base.BaseSetUp(); - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); } [TearDown] public override void TearDown() { - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); base.TearDown(); } @@ -44,7 +44,7 @@ public void DictionarySignalChildHeightChangedMethodExists() SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); int frameValue = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.GreaterOrEqual( frameValue, 0, @@ -57,7 +57,7 @@ public void SetSignalChildHeightChangedMethodExists() { SerializableSetPropertyDrawer.SignalChildHeightChanged(); - int frameValue = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + int frameValue = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.GreaterOrEqual( frameValue, 0, @@ -68,13 +68,13 @@ public void SetSignalChildHeightChangedMethodExists() [Test] public void DictionarySignalSetsFrameCounterToCurrentFrame() { - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); int expectedFrame = Time.frameCount; SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); int actualFrame = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( expectedFrame, actualFrame, @@ -85,12 +85,12 @@ public void DictionarySignalSetsFrameCounterToCurrentFrame() [Test] public void SetSignalSetsFrameCounterToCurrentFrame() { - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); int expectedFrame = Time.frameCount; SerializableSetPropertyDrawer.SignalChildHeightChanged(); - int actualFrame = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + int actualFrame = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( expectedFrame, actualFrame, @@ -103,10 +103,10 @@ public void DictionaryResetClearsFrameCounter() { SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); int frameValue = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual(-1, frameValue, "Reset should set frame counter to -1."); } @@ -115,9 +115,9 @@ public void SetResetClearsFrameCounter() { SerializableSetPropertyDrawer.SignalChildHeightChanged(); - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); - int frameValue = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + int frameValue = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual(-1, frameValue, "Reset should set frame counter to -1."); } @@ -131,7 +131,7 @@ public void DictionaryMultipleSignalsInSameFrameDoNotCauseIssues() SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); int actualFrame = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( expectedFrame, actualFrame, @@ -148,7 +148,7 @@ public void SetMultipleSignalsInSameFrameDoNotCauseIssues() SerializableSetPropertyDrawer.SignalChildHeightChanged(); SerializableSetPropertyDrawer.SignalChildHeightChanged(); - int actualFrame = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + int actualFrame = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( expectedFrame, actualFrame, @@ -159,10 +159,10 @@ public void SetMultipleSignalsInSameFrameDoNotCauseIssues() [Test] public void DictionaryFrameCounterInitializesToNegativeOne() { - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); int frameValue = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual(-1, frameValue, "Frame counter should initialize to -1."); } @@ -170,9 +170,9 @@ public void DictionaryFrameCounterInitializesToNegativeOne() [Test] public void SetFrameCounterInitializesToNegativeOne() { - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); - int frameValue = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + int frameValue = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual(-1, frameValue, "Frame counter should initialize to -1."); } @@ -180,15 +180,15 @@ public void SetFrameCounterInitializesToNegativeOne() [Test] public void DictionarySignalIsIdempotentWithinSameFrame() { - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); int firstCallFrame = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); int secondCallFrame = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( firstCallFrame, @@ -200,14 +200,15 @@ public void DictionarySignalIsIdempotentWithinSameFrame() [Test] public void SetSignalIsIdempotentWithinSameFrame() { - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); SerializableSetPropertyDrawer.SignalChildHeightChanged(); - int firstCallFrame = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + int firstCallFrame = + SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); SerializableSetPropertyDrawer.SignalChildHeightChanged(); int secondCallFrame = - SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( firstCallFrame, @@ -219,12 +220,12 @@ public void SetSignalIsIdempotentWithinSameFrame() [Test] public void DictionarySignalValueIsNonNegativeAfterSignaling() { - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); int frameValue = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.GreaterOrEqual( frameValue, 0, @@ -235,11 +236,11 @@ public void DictionarySignalValueIsNonNegativeAfterSignaling() [Test] public void SetSignalValueIsNonNegativeAfterSignaling() { - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); SerializableSetPropertyDrawer.SignalChildHeightChanged(); - int frameValue = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + int frameValue = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.GreaterOrEqual( frameValue, 0, @@ -250,14 +251,14 @@ public void SetSignalValueIsNonNegativeAfterSignaling() [Test] public void BothDrawersCanBeSignaledIndependently() { - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); int dictionaryFrame = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); - int setFrame = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); + int setFrame = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreNotEqual( dictionaryFrame, @@ -271,15 +272,15 @@ public void BothDrawersCanBeSignaledIndependently() [Test] public void SignalingBothDrawersUpdatesFrameIndependently() { - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); SerializableSetPropertyDrawer.SignalChildHeightChanged(); int dictionaryFrame = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); - int setFrame = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); + int setFrame = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( dictionaryFrame, @@ -300,12 +301,12 @@ public void DictionaryResetDoesNotAffectSetDrawer() SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); SerializableSetPropertyDrawer.SignalChildHeightChanged(); int setFrameBeforeReset = - SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); int setFrameAfterReset = - SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( setFrameBeforeReset, setFrameAfterReset, @@ -319,12 +320,12 @@ public void SetResetDoesNotAffectDictionaryDrawer() SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); SerializableSetPropertyDrawer.SignalChildHeightChanged(); int dictionaryFrameBeforeReset = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); int dictionaryFrameAfterReset = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); Assert.AreEqual( dictionaryFrameBeforeReset, dictionaryFrameAfterReset, @@ -335,13 +336,13 @@ public void SetResetDoesNotAffectDictionaryDrawer() [Test] public void DictionarySignalMatchesCurrentTimeFrameCount() { - SerializableDictionaryPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); int frameBefore = Time.frameCount; SerializableDictionaryPropertyDrawer.SignalChildHeightChanged(); int signalFrame = - SerializableDictionaryPropertyDrawer.GetChildHeightChangedFrameForTests(); + SerializableDictionaryPropertyDrawerTestAccess.GetChildHeightChangedFrame(); int frameAfter = Time.frameCount; Assert.GreaterOrEqual( @@ -359,12 +360,12 @@ public void DictionarySignalMatchesCurrentTimeFrameCount() [Test] public void SetSignalMatchesCurrentTimeFrameCount() { - SerializableSetPropertyDrawer.ResetChildHeightChangedFrameForTests(); + SerializableSetPropertyDrawerTestAccess.ResetChildHeightChangedFrame(); int frameBefore = Time.frameCount; SerializableSetPropertyDrawer.SignalChildHeightChanged(); - int signalFrame = SerializableSetPropertyDrawer.GetChildHeightChangedFrameForTests(); + int signalFrame = SerializableSetPropertyDrawerTestAccess.GetChildHeightChangedFrame(); int frameAfter = Time.frameCount; Assert.GreaterOrEqual( diff --git a/Tests/Editor/CustomDrawers/SerializableCollectionIndentAlignmentTests.cs b/Tests/Editor/CustomDrawers/SerializableCollectionIndentAlignmentTests.cs index 7a15a217b..2e9d25989 100644 --- a/Tests/Editor/CustomDrawers/SerializableCollectionIndentAlignmentTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableCollectionIndentAlignmentTests.cs @@ -30,15 +30,15 @@ public sealed class SerializableCollectionIndentAlignmentTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableDictionaryPropertyDrawerTestAccess.ResetLayoutTracking(); + SerializableSetPropertyDrawerTestAccess.ResetLayoutTracking(); } [TearDown] public override void TearDown() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); base.TearDown(); } @@ -52,9 +52,9 @@ public void DictionaryAtIndentZeroWithoutWGroupAlignsWithUnityLists() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -88,9 +88,9 @@ public void SetAtIndentZeroWithoutWGroupAlignsWithUnityLists() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -129,7 +129,7 @@ out float rightPadding try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -139,11 +139,10 @@ out float rightPadding ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedX = controlRect.x + leftPadding; TestContext.WriteLine( @@ -188,7 +187,7 @@ out float rightPadding try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -198,7 +197,7 @@ out float rightPadding ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -245,7 +244,7 @@ public void DictionaryNestedInMultipleWGroupsAccumulatesPadding() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -262,11 +261,10 @@ public void DictionaryNestedInMultipleWGroupsAccumulatesPadding() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float totalLeftPadding = OuterLeftPadding + InnerLeftPadding; float totalRightPadding = OuterRightPadding + InnerRightPadding; @@ -313,7 +311,7 @@ public void SetNestedInMultipleWGroupsAccumulatesPadding() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -330,7 +328,7 @@ public void SetNestedInMultipleWGroupsAccumulatesPadding() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -375,9 +373,9 @@ public void DictionaryWithNonZeroIndentAndNoWGroupUsesUnityIndent() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -414,9 +412,9 @@ public void SetWithNonZeroIndentAndNoWGroupUsesUnityIndent() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -455,7 +453,7 @@ public void DictionaryWidthIsNeverNegativeWithExcessivePadding() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -465,11 +463,10 @@ public void DictionaryWidthIsNeverNegativeWithExcessivePadding() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[DictionaryWidthIsNeverNegativeWithExcessivePadding] " @@ -504,7 +501,7 @@ public void SetWidthIsNeverNegativeWithExcessivePadding() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -514,7 +511,7 @@ public void SetWidthIsNeverNegativeWithExcessivePadding() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -538,81 +535,10 @@ public void SetWidthIsNeverNegativeWithExcessivePadding() } } - [UnityTest] - public IEnumerator DictionarySettingsContextAppliesOnlyWGroupPadding() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - if (paletteProp == null) - { - Assert.Ignore("WButtonCustomColors property not found in settings."); - yield break; - } - - paletteProp.isExpanded = true; - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Palette"); - - const float TestLeftPadding = 15f; - const float TestRightPadding = 15f; - - int previousIndentLevel = EditorGUI.indentLevel; - Rect resolvedRect = default; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - - using ( - GroupGUIWidthUtility.PushContentPadding( - TestLeftPadding + TestRightPadding, - TestLeftPadding, - TestRightPadding - ) - ) - { - try - { - drawer.OnGUI(controlRect, paletteProp, label); - resolvedRect = drawer.LastResolvedPosition; - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - }); - - float expectedX = controlRect.x + TestLeftPadding; - Assert.AreEqual( - expectedX, - resolvedRect.x, - PixelTolerance, - "Dictionary in settings context should apply only WGroup padding." - ); - - float expectedWidth = controlRect.width - TestLeftPadding - TestRightPadding; - Assert.AreEqual( - expectedWidth, - resolvedRect.width, - PixelTolerance, - "Dictionary in settings context should reduce width by WGroup padding." - ); - } - [Test] public void GroupGUIWidthUtilityScopeDepthTracksCorrectly() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Assert.AreEqual( 0, @@ -654,7 +580,7 @@ public void GroupGUIWidthUtilityScopeDepthTracksCorrectly() [Test] public void GroupGUIWidthUtilityPaddingAccumulatesCorrectly() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Assert.AreEqual( 0f, @@ -736,7 +662,7 @@ public void GroupGUIWidthUtilityPaddingAccumulatesCorrectly() [Test] public void ApplyCurrentPaddingReturnsOriginalRectWhenNoPadding() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect original = new(10f, 20f, 300f, 100f); Rect result = GroupGUIWidthUtility.ApplyCurrentPadding(original); @@ -765,7 +691,7 @@ public void ApplyCurrentPaddingReturnsOriginalRectWhenNoPadding() [Test] public void ApplyCurrentPaddingAdjustsRectWithPadding() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); const float LeftPadding = 12f; const float RightPadding = 8f; @@ -829,7 +755,7 @@ public IEnumerator DictionaryIndentRestoredAfterOnGUI() yield return TestIMGUIExecutor.Run(() => { EditorGUI.indentLevel = 3; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); try { @@ -874,7 +800,7 @@ public IEnumerator SetIndentRestoredAfterOnGUI() yield return TestIMGUIExecutor.Run(() => { EditorGUI.indentLevel = 3; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); try { @@ -897,7 +823,7 @@ public IEnumerator SetIndentRestoredAfterOnGUI() [Test] public void ZeroPaddingPushDoesNotIncreaseScopeDepth() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Assert.AreEqual( 0, @@ -924,7 +850,7 @@ public void ZeroPaddingPushDoesNotIncreaseScopeDepth() [Test] public void NegativePaddingIsClamped() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(-10f, -5f, -5f)) { @@ -1033,17 +959,16 @@ int indentLevel try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect dictResolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect dictResolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); - Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1120,17 +1045,16 @@ public void WGroupPropertyContextSmallWidthBothCollections(float smallWidth) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect dictResolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect dictResolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); - Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1189,17 +1113,16 @@ public void WGroupPropertyContextLargeWidthBothCollections(float largeWidth) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect dictResolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect dictResolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); - Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1255,17 +1178,16 @@ public void WGroupPropertyContextNegativeXBoundaryBothCollections() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect dictResolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect dictResolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); - Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1325,17 +1247,16 @@ public void WGroupPropertyContextXEqualsOffsetBothCollections() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect dictResolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect dictResolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); - Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect setResolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); diff --git a/Tests/Editor/CustomDrawers/SerializableCollectionLayoutCalculationTests.cs b/Tests/Editor/CustomDrawers/SerializableCollectionLayoutCalculationTests.cs new file mode 100644 index 000000000..91f01d378 --- /dev/null +++ b/Tests/Editor/CustomDrawers/SerializableCollectionLayoutCalculationTests.cs @@ -0,0 +1,130 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE +namespace WallstopStudios.UnityHelpers.Tests.CustomDrawers +{ +#if UNITY_EDITOR + using System.Collections; + using NUnit.Framework; + using UnityEditor; + using UnityEngine; + using UnityEngine.TestTools; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Tests.CustomDrawers.TestTypes; + using WallstopStudios.UnityHelpers.Tests.EditorFramework; + using WallstopStudios.UnityHelpers.Editor.CustomDrawers; + using WallstopStudios.UnityHelpers.Tests.Core; + + [TestFixture] + public sealed class SerializableCollectionLayoutCalculationTests : CommonTestBase + { + [TestCase(-10f, 32f)] + [TestCase(0f, 32f)] + [TestCase(100f, 32f)] + [TestCase(300f, 90f)] + [TestCase(1000f, 96f)] + public void DictionaryChildLabelWidthStaysWithinReadableBounds( + float columnWidth, + float expected + ) + { + Assert.AreEqual( + expected, + SerializableDictionaryPropertyDrawer.CalculateRowChildLabelWidth(columnWidth, null), + 0.001f + ); + } + + [TestCase(10f, 480f, 480f)] + [TestCase(-10f, 480f, 480f)] + [TestCase(280f, 0f, 284f)] + [TestCase(280f, -50f, 284f)] + [TestCase(280f, 100f, 284f)] + [TestCase(284f, 900f, 284f)] + [TestCase(480f, 900f, 480f)] + public void DictionaryRowWidthPreservesDragFallbackAndWideRows( + float rowWidth, + float listContentWidth, + float expected + ) + { + Assert.AreEqual( + expected, + SerializableDictionaryPropertyDrawer.ResolveRowContentWidth( + rowWidth, + listContentWidth + ), + 0.001f + ); + } + + [Test] + public void OrdinaryInspectorUsesItsOwnPendingFoldoutOffsets() + { + Assert.AreEqual( + 17.5f, + SerializableDictionaryPropertyDrawer.ResolvePendingFoldoutToggleOffset(null), + 0.001f + ); + Assert.AreEqual( + -5.5f, + SerializableDictionaryPropertyDrawer.ResolvePendingFoldoutLabelContentOffset(null), + 0.001f + ); + } + + [Test] + public void SettingsInspectorUsesItsOwnPendingFoldoutOffsets() + { + UnityHelpersSettings settings = UnityHelpersSettings.instance; + SerializedObject serializedObject = TrackDisposable(new SerializedObject(settings)); + SerializedProperty property = serializedObject.FindProperty( + UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors + ); + Assert.IsTrue(property != null); + Assert.AreEqual( + 7.5f, + SerializableDictionaryPropertyDrawer.ResolvePendingFoldoutToggleOffset(property), + 0.001f + ); + Assert.AreEqual( + -3f, + SerializableDictionaryPropertyDrawer.ResolvePendingFoldoutLabelContentOffset( + property + ), + 0.001f + ); + } + + [UnityTest] + public IEnumerator SerializedChildLabelsUseMeasuredDisplayNames() + { + FoldoutInteractionSetHost host = CreateScriptableObject(); + UnityHelpersSettings settings = UnityHelpersSettings.instance; + SerializedObject shortObject = TrackDisposable(new SerializedObject(host)); + SerializedObject longObject = TrackDisposable(new SerializedObject(settings)); + SerializedProperty shortLabel = shortObject.FindProperty( + nameof(FoldoutInteractionSetHost.set) + ); + SerializedProperty longLabel = longObject.FindProperty( + UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors + ); + Assert.IsTrue(shortLabel != null); + Assert.IsTrue(longLabel != null); + yield return TestIMGUIExecutor.Run(() => + { + float shortWidth = SerializableDictionaryPropertyDrawer.CalculateRowChildLabelWidth( + 300f, + shortLabel + ); + float longWidth = SerializableDictionaryPropertyDrawer.CalculateRowChildLabelWidth( + 300f, + longLabel + ); + Assert.Greater(longWidth, shortWidth); + Assert.GreaterOrEqual(shortWidth, 32f); + Assert.LessOrEqual(longWidth, 96f); + }); + } + } +#endif +} diff --git a/Tests/Editor/CustomDrawers/SerializableCollectionLayoutCalculationTests.cs.meta b/Tests/Editor/CustomDrawers/SerializableCollectionLayoutCalculationTests.cs.meta new file mode 100644 index 000000000..5774bf2c2 --- /dev/null +++ b/Tests/Editor/CustomDrawers/SerializableCollectionLayoutCalculationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c89f02f644ab276e1e1f7d65b9e5ac9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/CustomDrawers/SerializableCollectionPendingFoldoutInteractionTests.cs b/Tests/Editor/CustomDrawers/SerializableCollectionPendingFoldoutInteractionTests.cs index 9f9776862..27b203c28 100644 --- a/Tests/Editor/CustomDrawers/SerializableCollectionPendingFoldoutInteractionTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableCollectionPendingFoldoutInteractionTests.cs @@ -65,7 +65,7 @@ string phase ) { Assert.IsTrue( - drawer.TryGetPendingAnimationStateForTests( + drawer.TryGetPendingAnimationState( property, out bool isExpanded, out float animProgress, @@ -76,8 +76,7 @@ out bool hasAnimBool Assert.AreEqual( expectedExpanded, isExpanded, - $"Unexpected dictionary pending foldout state {phase}. " - + BuildDictionaryLayoutDiagnostics() + $"Unexpected dictionary pending foldout state {phase}." ); Assert.IsTrue(hasAnimBool, $"Expected dictionary pending AnimBool {phase}."); Assert.GreaterOrEqual( @@ -95,7 +94,7 @@ string phase ) { Assert.IsTrue( - drawer.TryGetPendingAnimationStateForTests( + drawer.TryGetPendingAnimationState( property, out bool isExpanded, out float animProgress, @@ -128,13 +127,11 @@ private static string BuildExpandedFailureMessage(Vector2 mousePosition) + $"local={LocalLabelRect}, absolute={AbsoluteLabelRect}."; } - private static string BuildDictionaryLayoutDiagnostics() + private static string DescribeMouseEvent(Event mouseDown) { - return "Recorded dictionary rects: " - + $"header={SerializableDictionaryPropertyDrawer.LastPendingHeaderRect}, " - + $"toggle={SerializableDictionaryPropertyDrawer.LastPendingFoldoutToggleRect}, " - + $"localLabel={SerializableDictionaryPropertyDrawer.LastPendingLabelHitRect}, " - + $"absoluteLabel={SerializableDictionaryPropertyDrawer.LastPendingAbsoluteLabelHitRect}."; + return $"type={mouseDown.type}, rawType={mouseDown.rawType}, button={mouseDown.button}, " + + $"position={mouseDown.mousePosition}, localContains={LocalLabelRect.Contains(mouseDown.mousePosition)}, " + + $"absoluteContains={AbsoluteLabelRect.Contains(mouseDown.mousePosition)}"; } [TestCaseSource(nameof(DictionaryPendingFoldoutClickCases))] @@ -142,17 +139,23 @@ public void DictionaryPendingFoldoutLabelClickToggles(Vector2 mousePosition) { bool expanded = false; Event mouseDown = CreateMouseDown(mousePosition); + string eventSnapshot = DescribeMouseEvent(mouseDown); - bool toggled = - SerializableDictionaryPropertyDrawer.TryTogglePendingFoldoutLabelForTests( - mouseDown, - LocalLabelRect, - AbsoluteLabelRect, - ref expanded - ); + bool toggled = SerializableDictionaryPropertyDrawer.TryTogglePendingFoldoutLabel( + mouseDown, + LocalLabelRect, + AbsoluteLabelRect, + ref expanded + ); - Assert.IsTrue(toggled, BuildToggleFailureMessage(mousePosition)); - Assert.IsTrue(expanded, BuildExpandedFailureMessage(mousePosition)); + Assert.IsTrue( + toggled, + BuildToggleFailureMessage(mousePosition) + " Event before call: " + eventSnapshot + ); + Assert.IsTrue( + expanded, + BuildExpandedFailureMessage(mousePosition) + " Event before call: " + eventSnapshot + ); } [TestCaseSource(nameof(SetPendingFoldoutClickCases))] @@ -160,15 +163,22 @@ public void SetPendingFoldoutLabelClickToggles(Vector2 mousePosition) { bool expanded = false; Event mouseDown = CreateMouseDown(mousePosition); + string eventSnapshot = DescribeMouseEvent(mouseDown); - bool toggled = SerializableSetPropertyDrawer.TryToggleManualEntryFoldoutLabelForTests( + bool toggled = SerializableSetPropertyDrawer.TryToggleManualEntryFoldoutLabel( mouseDown, LocalLabelRect, ref expanded ); - Assert.IsTrue(toggled, BuildToggleFailureMessage(mousePosition)); - Assert.IsTrue(expanded, BuildExpandedFailureMessage(mousePosition)); + Assert.IsTrue( + toggled, + BuildToggleFailureMessage(mousePosition) + " Event before call: " + eventSnapshot + ); + Assert.IsTrue( + expanded, + BuildExpandedFailureMessage(mousePosition) + " Event before call: " + eventSnapshot + ); } [Test] @@ -182,13 +192,12 @@ public void PendingFoldoutLabelClickIgnoresNonPrimaryButton() button = 1, }; - bool toggled = - SerializableDictionaryPropertyDrawer.TryTogglePendingFoldoutLabelForTests( - mouseDown, - LocalLabelRect, - AbsoluteLabelRect, - ref expanded - ); + bool toggled = SerializableDictionaryPropertyDrawer.TryTogglePendingFoldoutLabel( + mouseDown, + LocalLabelRect, + AbsoluteLabelRect, + ref expanded + ); Assert.IsFalse(toggled); Assert.IsFalse(expanded); @@ -200,7 +209,7 @@ public void PendingFoldoutLabelClickIgnoresMiss() bool expanded = false; Event mouseDown = CreateMouseDown(new Vector2(400f, 400f)); - bool toggled = SerializableSetPropertyDrawer.TryToggleManualEntryFoldoutLabelForTests( + bool toggled = SerializableSetPropertyDrawer.TryToggleManualEntryFoldoutLabel( mouseDown, LocalLabelRect, ref expanded @@ -221,159 +230,53 @@ EventType expectedEventType { Assert.AreEqual( expectedEventType, - SerializableDictionaryPropertyDrawer.GetEffectiveMouseEventTypeForTests( + SerializableDictionaryPropertyDrawer.GetEffectiveMouseEventType( eventType, rawEventType ) ); Assert.AreEqual( expectedEventType, - SerializableSetPropertyDrawer.GetEffectiveMouseEventTypeForTests( - eventType, - rawEventType - ) - ); - } - - /// - /// Verifies the dictionary drawer's real OnGUI path records a non-degenerate pending - /// foldout label hit rect that the production hit-test accepts at its absolute center. - /// - /// - /// It deliberately does not pump a MouseDown through the offscreen panel: a windowless - /// IMGUIContainer does not reproduce the editor's GUI.BeginGroup/GUIClip event translation, - /// so an absolute-space click misses the local-space hit rect (recorded localLabel y=6 vs - /// absoluteLabel y=86). The click-to-toggle logic for both the local and group-offset rects - /// is covered deterministically by DictionaryPendingFoldoutLabelClickToggles above. - /// - [UnityTest] - public IEnumerator DictionaryPendingFoldoutDrawerRecordsHittableLabelRect() - { - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - FoldoutInteractionDictionaryHost host = - CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - SerializedProperty property = serializedObject.FindProperty( - nameof(FoldoutInteractionDictionaryHost.dictionary) - ); - property.isExpanded = true; - - SerializableDictionaryPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(FoldoutInteractionDictionaryHost), - nameof(FoldoutInteractionDictionaryHost.dictionary) - ); - - Action draw = () => - { - serializedObject.Update(); - drawer.OnGUI(new Rect(40f, 60f, 500f, 240f), property, GUIContent.none); - serializedObject.ApplyModifiedProperties(); - }; - - yield return TestIMGUIExecutor.Run(draw); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingHeaderRect, - "Pending header rect should be recorded by the actual drawer OnGUI path." - ); - - Rect localRect = SerializableDictionaryPropertyDrawer.LastPendingLabelHitRect; - Rect absoluteRect = - SerializableDictionaryPropertyDrawer.LastPendingAbsoluteLabelHitRect; - Assert.Greater( - absoluteRect.width, - 0f, - "Dictionary label hit rect should have width. " + BuildDictionaryLayoutDiagnostics() - ); - Assert.Greater( - absoluteRect.height, - 0f, - "Dictionary label hit rect should have height. " - + BuildDictionaryLayoutDiagnostics() - ); - AssertDictionaryPendingState( - drawer, - property, - expectedExpanded: false, - "before label click" - ); - - // The far-outside hit test rejects degenerate or unbounded OnGUI capture rectangles. - bool hitExpanded = false; - bool hit = SerializableDictionaryPropertyDrawer.TryTogglePendingFoldoutLabelForTests( - CreateMouseDown(absoluteRect.center), - localRect, - absoluteRect, - ref hitExpanded - ); - Assert.IsTrue( - hit && hitExpanded, - "A click at the recorded dictionary label center should toggle. " - + BuildDictionaryLayoutDiagnostics() - ); - - bool missExpanded = false; - Vector2 farOutside = new(absoluteRect.xMax + 500f, absoluteRect.yMax + 500f); - bool missed = SerializableDictionaryPropertyDrawer.TryTogglePendingFoldoutLabelForTests( - CreateMouseDown(farOutside), - localRect, - absoluteRect, - ref missExpanded - ); - Assert.IsFalse( - missed || missExpanded, - "A click far outside the recorded dictionary label rect should not toggle. " - + BuildDictionaryLayoutDiagnostics() + SerializableSetPropertyDrawer.GetEffectiveMouseEventType(eventType, rawEventType) ); } [UnityTest] public IEnumerator SetPendingFoldoutDrawerLabelClickTogglesProductionState() { - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - + GroupGUIWidthUtilityTestAccess.Reset(); FoldoutInteractionSetHost host = CreateScriptableObject(); SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); SerializedProperty property = serializedObject.FindProperty( nameof(FoldoutInteractionSetHost.set) ); property.isExpanded = true; - SerializableSetPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, typeof(FoldoutInteractionSetHost), nameof(FoldoutInteractionSetHost.set) ); - + Rect position = new(35f, 55f, 480f, 220f); Action draw = () => { serializedObject.Update(); - drawer.OnGUI(new Rect(35f, 55f, 480f, 220f), property, GUIContent.none); + drawer.OnGUI(position, property, GUIContent.none); serializedObject.ApplyModifiedProperties(); }; - yield return TestIMGUIExecutor.Run(draw); - - Assert.IsTrue( - SerializableSetPropertyDrawer.HasLastManualEntryHeaderRect, - "Manual entry header rect should be recorded by the actual drawer OnGUI path." + AssertSetPendingState(drawer, property, false, "before label click"); + Rect content = SerializableSetPropertyDrawer.ResolveContentRect(position); + Vector2 mouse = new( + content.center.x, + content.y + + EditorGUIUtility.singleLineHeight + + SerializableSetPropertyDrawer.SectionSpacing + + SerializableSetPropertyDrawer.ResolveManualEntrySectionPadding(property) + + EditorGUIUtility.singleLineHeight * 0.5f ); - - Rect labelRect = BuildLabelRect( - SerializableSetPropertyDrawer.LastManualEntryHeaderRect, - SerializableSetPropertyDrawer.LastManualEntryToggleRect - ); - AssertSetPendingState(drawer, property, expectedExpanded: false, "before label click"); - - yield return TestIMGUIExecutor.RunMouseDown(draw, labelRect.center); - - AssertSetPendingState(drawer, property, expectedExpanded: true, "after label click"); + yield return TestIMGUIExecutor.RunMouseDown(draw, mouse); + AssertSetPendingState(drawer, property, true, "after label click"); } } } diff --git a/Tests/Editor/CustomDrawers/SerializableCollectionTweenAnimationTests.cs b/Tests/Editor/CustomDrawers/SerializableCollectionTweenAnimationTests.cs index 54ef13b30..126269b29 100644 --- a/Tests/Editor/CustomDrawers/SerializableCollectionTweenAnimationTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableCollectionTweenAnimationTests.cs @@ -88,10 +88,10 @@ private static void SetFoldoutSpeed(string propertyPath, float speed) public override void BaseSetUp() { base.BaseSetUp(); - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - WGroupAnimationState.ClearCache(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableDictionaryPropertyDrawerTestAccess.ResetLayoutTracking(); + SerializableSetPropertyDrawerTestAccess.ResetLayoutTracking(); + WGroupAnimationState.FoldoutAnimations.Clear(); _originalDictionaryTweenEnabled = UnityHelpersSettings.ShouldTweenSerializableDictionaryFoldouts(); @@ -137,8 +137,8 @@ public override void TearDown() SetFoldoutSpeed(SerializableSortedSetFoldoutSpeedPropertyPath, _originalSortedSetSpeed); UnityHelpersSettings.instance.WGroupFoldoutSpeed = _originalWGroupSpeed; - WGroupAnimationState.ClearCache(); - GroupGUIWidthUtility.ResetForTests(); + WGroupAnimationState.FoldoutAnimations.Clear(); + GroupGUIWidthUtilityTestAccess.Reset(); base.TearDown(); } @@ -147,7 +147,7 @@ public void DictionaryTweenEnabledSettingIsRespected() { UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ), "Dictionary tweening should be enabled when setting is true." @@ -155,7 +155,7 @@ public void DictionaryTweenEnabledSettingIsRespected() UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ), "Dictionary tweening should be disabled when setting is false." @@ -167,7 +167,7 @@ public void SortedDictionaryTweenEnabledSettingIsRespected() { UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(true); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: true ), "Sorted dictionary tweening should be enabled when setting is true." @@ -175,7 +175,7 @@ public void SortedDictionaryTweenEnabledSettingIsRespected() UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(false); Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: true ), "Sorted dictionary tweening should be disabled when setting is false." @@ -187,13 +187,13 @@ public void SetTweenEnabledSettingIsRespected() { UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false), "Set tweening should be enabled when setting is true." ); UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); Assert.IsFalse( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false), "Set tweening should be disabled when setting is false." ); } @@ -203,13 +203,13 @@ public void SortedSetTweenEnabledSettingIsRespected() { UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: true), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: true), "Sorted set tweening should be enabled when setting is true." ); UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(false); Assert.IsFalse( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: true), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: true), "Sorted set tweening should be disabled when setting is false." ); } @@ -237,13 +237,13 @@ public void DictionaryTweenIsIndependentOfSortedDictionaryTween() UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(false); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ), "Dictionary tweening should be enabled." ); Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: true ), "Sorted dictionary tweening should be disabled." @@ -253,13 +253,13 @@ public void DictionaryTweenIsIndependentOfSortedDictionaryTween() UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(true); Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ), "Dictionary tweening should be disabled." ); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: true ), "Sorted dictionary tweening should be enabled." @@ -273,11 +273,11 @@ public void SetTweenIsIndependentOfSortedSetTween() UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(false); Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false), "Set tweening should be enabled." ); Assert.IsFalse( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: true), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: true), "Sorted set tweening should be disabled." ); @@ -285,11 +285,11 @@ public void SetTweenIsIndependentOfSortedSetTween() UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); Assert.IsFalse( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false), "Set tweening should be disabled." ); Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: true), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: true), "Sorted set tweening should be enabled." ); } @@ -306,13 +306,13 @@ public void WGroupTweenIsIndependentOfCollectionTweens() "WGroup tweening should be enabled independently." ); Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ), "Dictionary tweening should remain disabled." ); Assert.IsFalse( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false), "Set tweening should remain disabled." ); } @@ -321,7 +321,7 @@ public void WGroupTweenIsIndependentOfCollectionTweens() public void WGroupAnimationStateGetFadeProgressReturnsImmediatelyWhenTweenDisabled() { UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(false); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); WGroupDefinition definition = CreateGroupDefinition("TestGroup"); @@ -346,7 +346,7 @@ public void WGroupAnimationStateGetFadeProgressReturnsImmediatelyWhenTweenDisabl public void WGroupAnimationStateCreatesAnimBoolWhenTweenEnabled() { UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(true); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); WGroupDefinition definition = CreateGroupDefinition("TestGroup"); @@ -366,7 +366,7 @@ public void WGroupAnimationStateCacheCanBeCleared() AnimBool anim1 = WGroupAnimationState.GetOrCreateAnim(definition, expanded: true); Assert.IsTrue(anim1 != null, "First AnimBool should be created."); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); AnimBool anim2 = WGroupAnimationState.GetOrCreateAnim(definition, expanded: false); Assert.IsTrue(anim2 != null, "Second AnimBool should be created after cache clear."); @@ -382,7 +382,7 @@ public void WGroupAnimationStateSpeedIsConfigurable() const float TestSpeed = 7.5f; UnityHelpersSettings.instance.WGroupFoldoutSpeed = TestSpeed; - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); WGroupDefinition definition = CreateGroupDefinition("TestGroup"); AnimBool anim = WGroupAnimationState.GetOrCreateAnim(definition, expanded: true); @@ -496,7 +496,7 @@ public void DictionaryTweenSettingPersistsAcrossSettingsReload() public void WGroupAnimationStateHandlesSameDefinitionMultipleTimes() { UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(true); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); WGroupDefinition definition = CreateGroupDefinition("TestGroup"); @@ -512,7 +512,7 @@ public void WGroupAnimationStateHandlesSameDefinitionMultipleTimes() public void WGroupAnimationStateDifferentDefinitionsHaveSeparateState() { UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(true); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); WGroupDefinition definition1 = CreateGroupDefinition("Group1"); WGroupDefinition definition2 = CreateGroupDefinition("Group2"); @@ -538,42 +538,42 @@ public void CollectionTweenSettingsAreIndependentPerType() UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ) ); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: true ) ); Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false) + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false) ); Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: true) + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: true) ); UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ), "Regular dictionary should be disabled." ); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: true ), "Sorted dictionary should still be enabled." ); Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false), "Regular set should still be enabled." ); Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: true), + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: true), "Sorted set should still be enabled." ); } @@ -592,11 +592,12 @@ public void DictionaryFoldoutProgressWithTweenDisabledIsImmediate() nameof(TweenAnimationSimpleDictionaryHost.dictionary) ); - float progress = SerializableDictionaryPropertyDrawer.GetPendingFoldoutProgressForTests( - dictionaryProperty, - expanded: true, - isSorted: false - ); + float progress = + SerializableDictionaryPropertyDrawerTestAccess.GetPendingFoldoutProgress( + dictionaryProperty, + expanded: true, + isSorted: false + ); Assert.AreEqual( 1f, @@ -605,7 +606,7 @@ public void DictionaryFoldoutProgressWithTweenDisabledIsImmediate() "With tween disabled, expanded progress should be 1." ); - progress = SerializableDictionaryPropertyDrawer.GetPendingFoldoutProgressForTests( + progress = SerializableDictionaryPropertyDrawerTestAccess.GetPendingFoldoutProgress( dictionaryProperty, expanded: false, isSorted: false @@ -633,7 +634,7 @@ public void SetFoldoutProgressWithTweenDisabledIsImmediate() nameof(TweenAnimationSimpleSetHost.set) ); - float progress = SerializableSetPropertyDrawer.GetPendingFoldoutProgressForTests( + float progress = SerializableSetPropertyDrawerTestAccess.GetPendingFoldoutProgress( setProperty, expanded: true, isSorted: false @@ -646,7 +647,7 @@ public void SetFoldoutProgressWithTweenDisabledIsImmediate() "With tween disabled, expanded progress should be 1." ); - progress = SerializableSetPropertyDrawer.GetPendingFoldoutProgressForTests( + progress = SerializableSetPropertyDrawerTestAccess.GetPendingFoldoutProgress( setProperty, expanded: false, isSorted: false @@ -726,15 +727,15 @@ public void PendingFoldoutStateIsIsolatedPerSerializedObject() isSortedDictionary: false ); - drawer.SetPendingExpandedStateForTests(propertyA, true); + drawer.SetPendingExpandedState(propertyA, true); - bool foundA = drawer.TryGetPendingAnimationStateForTests( + bool foundA = drawer.TryGetPendingAnimationState( propertyA, out bool isExpandedA, out float progressA, out bool hasAnimA ); - bool foundB = drawer.TryGetPendingAnimationStateForTests( + bool foundB = drawer.TryGetPendingAnimationState( propertyB, out bool isExpandedB, out float progressB, @@ -770,7 +771,7 @@ out bool hasAnimB public void DictionaryMainFoldoutAnimationIsIsolatedByTargetObject() { UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); TweenAnimationSimpleDictionaryHost hostA = CreateScriptableObject(); @@ -782,13 +783,13 @@ public void DictionaryMainFoldoutAnimationIsIsolatedByTargetObject() string propertyPath = nameof(TweenAnimationSimpleDictionaryHost.dictionary); - SerializableDictionaryPropertyDrawer.GetMainFoldoutProgressForTests( + SerializableDictionaryPropertyDrawer.GetMainFoldoutProgress( serializedObjectA, propertyPath, isExpanded: true, isSortedDictionary: false ); - SerializableDictionaryPropertyDrawer.GetMainFoldoutProgressForTests( + SerializableDictionaryPropertyDrawer.GetMainFoldoutProgress( serializedObjectB, propertyPath, isExpanded: false, @@ -796,27 +797,27 @@ public void DictionaryMainFoldoutAnimationIsIsolatedByTargetObject() ); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasMainFoldoutAnimBoolForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasMainFoldoutAnimBool( serializedObjectA, propertyPath ), "First object should have its own main foldout AnimBool." ); Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasMainFoldoutAnimBoolForTests( + SerializableDictionaryPropertyDrawerTestAccess.HasMainFoldoutAnimBool( serializedObjectB, propertyPath ), "Second object should have its own main foldout AnimBool." ); - float progressA = SerializableDictionaryPropertyDrawer.GetMainFoldoutProgressForTests( + float progressA = SerializableDictionaryPropertyDrawer.GetMainFoldoutProgress( serializedObjectA, propertyPath, isExpanded: true, isSortedDictionary: false ); - float progressB = SerializableDictionaryPropertyDrawer.GetMainFoldoutProgressForTests( + float progressB = SerializableDictionaryPropertyDrawer.GetMainFoldoutProgress( serializedObjectB, propertyPath, isExpanded: false, @@ -839,7 +840,7 @@ public void DictionaryMainFoldoutAnimationIsIsolatedByTargetObject() public void SetMainFoldoutAnimationIsIsolatedByTargetObject() { UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - SerializableSetPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + SerializableSetPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); TweenAnimationSimpleSetHost hostA = CreateScriptableObject(); @@ -851,13 +852,13 @@ public void SetMainFoldoutAnimationIsIsolatedByTargetObject() string propertyPath = nameof(TweenAnimationSimpleSetHost.set); - SerializableSetPropertyDrawer.GetMainFoldoutProgressForTests( + SerializableSetPropertyDrawer.GetMainFoldoutProgress( serializedObjectA, propertyPath, isExpanded: true, isSortedSet: false ); - SerializableSetPropertyDrawer.GetMainFoldoutProgressForTests( + SerializableSetPropertyDrawer.GetMainFoldoutProgress( serializedObjectB, propertyPath, isExpanded: false, @@ -865,27 +866,27 @@ public void SetMainFoldoutAnimationIsIsolatedByTargetObject() ); Assert.IsTrue( - SerializableSetPropertyDrawer.HasMainFoldoutAnimBoolForTests( + SerializableSetPropertyDrawerTestAccess.HasMainFoldoutAnimBool( serializedObjectA, propertyPath ), "First object should have its own main foldout AnimBool." ); Assert.IsTrue( - SerializableSetPropertyDrawer.HasMainFoldoutAnimBoolForTests( + SerializableSetPropertyDrawerTestAccess.HasMainFoldoutAnimBool( serializedObjectB, propertyPath ), "Second object should have its own main foldout AnimBool." ); - float progressA = SerializableSetPropertyDrawer.GetMainFoldoutProgressForTests( + float progressA = SerializableSetPropertyDrawer.GetMainFoldoutProgress( serializedObjectA, propertyPath, isExpanded: true, isSortedSet: false ); - float progressB = SerializableSetPropertyDrawer.GetMainFoldoutProgressForTests( + float progressB = SerializableSetPropertyDrawer.GetMainFoldoutProgress( serializedObjectB, propertyPath, isExpanded: false, @@ -917,14 +918,16 @@ public void DictionaryAnimationCacheKeyIncludesInstanceId() string propertyPath = nameof(TweenAnimationSimpleDictionaryHost.dictionary); - string cacheKeyA = SerializableDictionaryPropertyDrawer.GetMainFoldoutCacheKeyForTests( - serializedObjectA, - propertyPath - ); - string cacheKeyB = SerializableDictionaryPropertyDrawer.GetMainFoldoutCacheKeyForTests( - serializedObjectB, - propertyPath - ); + string cacheKeyA = + SerializableDictionaryPropertyDrawerTestAccess.GetMainFoldoutCacheKey( + serializedObjectA, + propertyPath + ); + string cacheKeyB = + SerializableDictionaryPropertyDrawerTestAccess.GetMainFoldoutCacheKey( + serializedObjectB, + propertyPath + ); Assert.AreNotEqual( cacheKeyA, @@ -967,11 +970,11 @@ public void SetAnimationCacheKeyIncludesInstanceId() string propertyPath = nameof(TweenAnimationSimpleSetHost.set); - string cacheKeyA = SerializableSetPropertyDrawer.GetMainFoldoutCacheKeyForTests( + string cacheKeyA = SerializableSetPropertyDrawerTestAccess.GetMainFoldoutCacheKey( serializedObjectA, propertyPath ); - string cacheKeyB = SerializableSetPropertyDrawer.GetMainFoldoutCacheKeyForTests( + string cacheKeyB = SerializableSetPropertyDrawerTestAccess.GetMainFoldoutCacheKey( serializedObjectB, propertyPath ); diff --git a/Tests/Editor/CustomDrawers/SerializableDictionaryIndentationTests.cs b/Tests/Editor/CustomDrawers/SerializableDictionaryIndentationTests.cs index 86c4ee3d5..ac84603c4 100644 --- a/Tests/Editor/CustomDrawers/SerializableDictionaryIndentationTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableDictionaryIndentationTests.cs @@ -121,9 +121,9 @@ private static IEnumerable IndentedRectBehaviorTestCases() public override void BaseSetUp() { base.BaseSetUp(); - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableDictionaryPropertyDrawerTestAccess.ResetLayoutTracking(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); } [Test] @@ -136,7 +136,7 @@ public void ResolveContentRectNormalContextAppliesIndentation() { EditorGUI.indentLevel = 2; - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -163,7 +163,7 @@ public void ResolveContentRectSettingsContextSkipsExtraIndentation() { EditorGUI.indentLevel = 2; - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: true ); @@ -195,7 +195,7 @@ public void ResolveContentRectNormalContextZeroIndentAppliesAlignmentOffset() { EditorGUI.indentLevel = 0; - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -225,7 +225,7 @@ public void ResolveContentRectSettingsContextZeroIndentNoMinimumIndent() { EditorGUI.indentLevel = 0; - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: true ); @@ -243,105 +243,6 @@ public void ResolveContentRectSettingsContextZeroIndentNoMinimumIndent() } } - [UnityTest] - public IEnumerator OnGUINormalContextRespectsIndentLevel() - { - TestDictionaryHost host = CreateScriptableObject(); - host.dictionary[1] = "value1"; - host.dictionary[2] = "value2"; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 2; - try - { - serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - capturedRect = drawer.LastResolvedPosition; - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.Greater( - capturedRect.x, - controlRect.x, - "OnGUI should respect indentLevel in normal context and indent the content." - ); - } - - [UnityTest] - public IEnumerator OnGUISettingsContextSkipsExtraIndentation() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - Assert.IsTrue( - paletteProp != null, - "Settings should have the WButtonCustomColors dictionary property." - ); - - if (paletteProp == null) - { - yield break; - } - - paletteProp.isExpanded = true; - serializedSettings.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Palette"); - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 2; - try - { - serializedSettings.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, paletteProp, label); - capturedRect = drawer.LastResolvedPosition; - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.AreEqual( - controlRect.x, - capturedRect.x, - 0.01f, - "OnGUI should skip extra indentation when targeting settings context." - ); - } - [Test] public void NormalContextWithNestedIndentLevelStaysConsistent() { @@ -351,13 +252,13 @@ public void NormalContextWithNestedIndentLevelStaysConsistent() try { EditorGUI.indentLevel = 1; - Rect rectAtLevel1 = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect rectAtLevel1 = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); EditorGUI.indentLevel = 3; - Rect rectAtLevel3 = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect rectAtLevel3 = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -388,19 +289,19 @@ public void SettingsContextIgnoresAllIndentLevels() try { EditorGUI.indentLevel = 0; - Rect rectAtLevel0 = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect rectAtLevel0 = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: true ); EditorGUI.indentLevel = 1; - Rect rectAtLevel1 = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect rectAtLevel1 = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: true ); EditorGUI.indentLevel = 3; - Rect rectAtLevel3 = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect rectAtLevel3 = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: true ); @@ -446,7 +347,7 @@ public void WidthIsPreservedInSettingsContext() { EditorGUI.indentLevel = 5; - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: true ); @@ -474,7 +375,7 @@ public void NormalContextReducesWidthWithIndentation() { EditorGUI.indentLevel = 3; - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -513,11 +414,10 @@ public void WGroupContextZeroIndentSkipsMinimumIndentWhenPaddingApplied() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedX = controlRect.x + GroupLeftPadding; Assert.AreEqual( @@ -564,11 +464,10 @@ public void WGroupContextWithIndentLevelAppliesIndentWithoutDoubleOffset() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); Assert.Greater( resolvedRect.x, @@ -599,8 +498,8 @@ public void WGroupContextVsNoGroupContextIndentationDifference() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - Rect noGroupRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + GroupGUIWidthUtilityTestAccess.Reset(); + Rect noGroupRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -609,7 +508,7 @@ public void WGroupContextVsNoGroupContextIndentationDifference() const float GroupRightPadding = 8f; float horizontalPadding = GroupLeftPadding + GroupRightPadding; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( horizontalPadding, @@ -618,11 +517,10 @@ public void WGroupContextVsNoGroupContextIndentationDifference() ) ) { - Rect withGroupRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect withGroupRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedNoGroupX = controlRect.x; float expectedWithGroupX = controlRect.x + GroupLeftPadding; @@ -663,7 +561,7 @@ public void NestedWGroupContextAccumulatesPaddingCorrectly() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( OuterLeftPadding + OuterRightPadding, @@ -680,11 +578,10 @@ public void NestedWGroupContextAccumulatesPaddingCorrectly() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedTotalLeftPadding = OuterLeftPadding + InnerLeftPadding; float expectedTotalRightPadding = OuterRightPadding + InnerRightPadding; @@ -716,67 +613,6 @@ public void NestedWGroupContextAccumulatesPaddingCorrectly() } } - [UnityTest] - public IEnumerator OnGUIInWGroupContextSkipsMinimumIndentAtZeroLevel() - { - TestDictionaryHost host = CreateScriptableObject(); - host.dictionary[1] = "value1"; - host.dictionary[2] = "value2"; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); - - const float GroupLeftPadding = 15f; - const float GroupRightPadding = 10f; - float horizontalPadding = GroupLeftPadding + GroupRightPadding; - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 0; - try - { - serializedObject.UpdateIfRequiredOrScript(); - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - GroupLeftPadding, - GroupRightPadding - ) - ) - { - drawer.OnGUI(controlRect, dictionaryProperty, label); - capturedRect = drawer.LastResolvedPosition; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - float expectedX = controlRect.x + GroupLeftPadding; - Assert.AreEqual( - expectedX, - capturedRect.x, - 0.01f, - "OnGUI in WGroup context at indentLevel 0 should use WGroup padding." - ); - } - [Test] public void SettingsContextAppliesWGroupPaddingButIgnoresIndentation() { @@ -791,7 +627,7 @@ public void SettingsContextAppliesWGroupPaddingButIgnoresIndentation() { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( horizontalPadding, @@ -801,11 +637,10 @@ public void SettingsContextAppliesWGroupPaddingButIgnoresIndentation() ) { // Unity's layout does not apply our WGroup padding, so it still applies under skipIndentation. - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); Assert.AreEqual( controlRect.x + GroupLeftPadding, @@ -828,81 +663,6 @@ public void SettingsContextAppliesWGroupPaddingButIgnoresIndentation() } } - [UnityTest] - public IEnumerator OnGUISettingsContextAppliesWGroupPaddingButIgnoresIndentation() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - Assert.IsTrue( - paletteProp != null, - "Settings should have the WButtonCustomColors dictionary property." - ); - - if (paletteProp == null) - { - yield break; - } - - paletteProp.isExpanded = true; - serializedSettings.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(5f, 10f, 500f, 300f); - GUIContent label = new("Palette"); - - const float GroupLeftPadding = 20f; - const float GroupRightPadding = 15f; - float horizontalPadding = GroupLeftPadding + GroupRightPadding; - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 3; - try - { - serializedSettings.UpdateIfRequiredOrScript(); - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - GroupLeftPadding, - GroupRightPadding - ) - ) - { - drawer.OnGUI(controlRect, paletteProp, label); - capturedRect = drawer.LastResolvedPosition; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.AreEqual( - controlRect.x + GroupLeftPadding, - capturedRect.x, - 0.01f, - "OnGUI in settings context should apply WGroup left padding." - ); - - Assert.AreEqual( - controlRect.width - horizontalPadding, - capturedRect.width, - 0.01f, - "OnGUI in settings context should apply WGroup horizontal padding to width." - ); - } - [Test] public void SettingsContextPreservesNonZeroRectPositionWithoutWGroup() { @@ -913,9 +673,9 @@ public void SettingsContextPreservesNonZeroRectPositionWithoutWGroup() { EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: true ); @@ -968,7 +728,7 @@ public void SettingsContextWithWGroupAppliesPadding() { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( horizontalPadding, @@ -977,11 +737,10 @@ public void SettingsContextWithWGroupAppliesPadding() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); Assert.AreEqual( controlRect.x + GroupLeftPadding, @@ -1026,7 +785,7 @@ public void SettingsContextWithNestedPaddingScopesAppliesAccumulatedPadding() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( OuterLeftPadding + OuterRightPadding, @@ -1043,11 +802,10 @@ public void SettingsContextWithNestedPaddingScopesAppliesAccumulatedPadding() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); float totalLeftPadding = OuterLeftPadding + InnerLeftPadding; float totalRightPadding = OuterRightPadding + InnerRightPadding; @@ -1084,9 +842,9 @@ public void ResolveContentRectDataDriven(int indentLevel, float inputX, float in try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1174,7 +932,7 @@ float inputWidth { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); if (0f < leftPadding || 0f < rightPadding) { @@ -1186,11 +944,10 @@ float inputWidth ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); TestContext.WriteLine( $"[SettingsDataDriven] padding=({leftPadding}, {rightPadding}), " @@ -1215,11 +972,10 @@ float inputWidth } else { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); TestContext.WriteLine( $"[SettingsDataDriven] no padding, input=({inputX}, {inputWidth}), " @@ -1252,8 +1008,8 @@ public void ResolveContentRectWithZeroWidthPreservesMinimumBounds() { Rect controlRect = new(0f, 0f, 0f, 300f); - GroupGUIWidthUtility.ResetForTests(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + GroupGUIWidthUtilityTestAccess.Reset(); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1270,8 +1026,8 @@ public void ResolveContentRectWithNegativeInputHandledGracefully() { Rect controlRect = new(-10f, 0f, 100f, 300f); - GroupGUIWidthUtility.ResetForTests(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + GroupGUIWidthUtilityTestAccess.Reset(); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1294,7 +1050,7 @@ public void ResolveContentRectWithLargePaddingClampedToZeroWidth() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( LargeLeftPadding + LargeRightPadding, @@ -1303,11 +1059,10 @@ public void ResolveContentRectWithLargePaddingClampedToZeroWidth() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); Assert.AreEqual( 0f, @@ -1333,8 +1088,8 @@ public void ResolveContentRectWithMaxIndentLevelDoesNotCrash() { EditorGUI.indentLevel = 100; - GroupGUIWidthUtility.ResetForTests(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + GroupGUIWidthUtilityTestAccess.Reset(); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1372,15 +1127,14 @@ public void WGroupPropertyContextAppliesAlignmentOffset() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextAppliesAlignmentOffset] " @@ -1440,15 +1194,14 @@ public void WGroupPropertyContextIgnoresIndentLevel(int indentLevel) try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextIgnoresIndentLevel] " @@ -1497,7 +1250,7 @@ public void WGroupPropertyContextIgnoresPaddingValues() try { EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1509,11 +1262,10 @@ public void WGroupPropertyContextIgnoresPaddingValues() { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextIgnoresPaddingValues] " @@ -1562,15 +1314,14 @@ public void WGroupPropertyContextZeroPaddingZeroIndent() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextZeroPaddingZeroIndent] " @@ -1619,7 +1370,7 @@ public void WGroupPropertyContextWithMaxPaddingValues() try { EditorGUI.indentLevel = 5; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1631,11 +1382,10 @@ public void WGroupPropertyContextWithMaxPaddingValues() { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextWithMaxPaddingValues] " @@ -1682,15 +1432,14 @@ public void WGroupPropertyContextWithVeryHighIndentLevel() try { EditorGUI.indentLevel = 50; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextWithVeryHighIndentLevel] " @@ -1736,17 +1485,16 @@ public void WGroupPropertyContextNestedScopesApplyAlignmentOffset() try { EditorGUI.indentLevel = 3; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextNestedScopesApplyAlignmentOffset] " @@ -1790,21 +1538,19 @@ public void WGroupPropertyContextRestoredAfterDisposal() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect rectWithoutContext = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect rectWithoutContext = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect rectWithContext = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect rectWithContext = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); Assert.AreEqual( expectedXWithContext, @@ -1814,11 +1560,10 @@ public void WGroupPropertyContextRestoredAfterDisposal() ); } - Rect rectAfterContext = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect rectAfterContext = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextRestoredAfterDisposal] " @@ -1866,7 +1611,7 @@ public void WGroupPropertyContextWithSkipIndentationTrue() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1878,11 +1623,10 @@ public void WGroupPropertyContextWithSkipIndentationTrue() { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); TestContext.WriteLine( $"[WGroupPropertyContextWithSkipIndentationTrue] " @@ -1928,7 +1672,7 @@ public void PushContentPaddingOnlyAppliesPadding() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1938,11 +1682,10 @@ public void PushContentPaddingOnlyAppliesPadding() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[PushContentPaddingOnlyAppliesPadding] " @@ -1988,7 +1731,7 @@ public void PushContentPaddingNestedAccumulation() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -2006,11 +1749,10 @@ public void PushContentPaddingNestedAccumulation() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float totalLeftPadding = OuterLeftPadding + InnerLeftPadding; float totalRightPadding = OuterRightPadding + InnerRightPadding; @@ -2059,7 +1801,7 @@ public void PushContentPaddingCombinedWithIndentLevel() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -2069,11 +1811,10 @@ public void PushContentPaddingCombinedWithIndentLevel() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[PushContentPaddingCombinedWithIndentLevel] " @@ -2112,7 +1853,7 @@ public void PushContentPaddingZeroPaddingDoesNotIncreaseScopeDepth() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); int initialScopeDepth = GroupGUIWidthUtility.CurrentScopeDepth; @@ -2160,7 +1901,7 @@ public void ContrastWGroupPropertyContextVsPushContentPaddingOnly() try { EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect rectWithPaddingOnly; using ( @@ -2171,14 +1912,13 @@ public void ContrastWGroupPropertyContextVsPushContentPaddingOnly() ) ) { - rectWithPaddingOnly = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + rectWithPaddingOnly = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); } - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect rectWithWGroupContext; using ( @@ -2192,7 +1932,7 @@ public void ContrastWGroupPropertyContextVsPushContentPaddingOnly() using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { rectWithWGroupContext = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -2252,7 +1992,7 @@ public void PushContentPaddingWithSkipIndentationTrue() try { EditorGUI.indentLevel = 3; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -2262,11 +2002,10 @@ public void PushContentPaddingWithSkipIndentationTrue() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); TestContext.WriteLine( $"[PushContentPaddingWithSkipIndentationTrue] " @@ -2311,15 +2050,14 @@ public void WGroupPropertyContextWithRectAtOrigin() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextWithRectAtOrigin] " @@ -2369,17 +2107,16 @@ public void WGroupPropertyContextWithNarrowRect() try { EditorGUI.indentLevel = 3; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(40f, 20f, 20f)) { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextWithNarrowRect] " @@ -2421,7 +2158,7 @@ public void TripleNestedPaddingScopesWithoutWGroupContext() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -2448,7 +2185,7 @@ public void TripleNestedPaddingScopesWithoutWGroupContext() ) { Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -2501,7 +2238,7 @@ public void AsymmetricPaddingWithoutWGroupContext() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -2511,11 +2248,10 @@ public void AsymmetricPaddingWithoutWGroupContext() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[AsymmetricPaddingWithoutWGroupContext] " @@ -2543,96 +2279,6 @@ public void AsymmetricPaddingWithoutWGroupContext() } } - /// - /// Tests WGroupPropertyContext combined with Settings context (UnityHelpersSettings). - /// - [UnityTest] - public IEnumerator WGroupPropertyContextInSettingsContext() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - Assert.IsTrue( - paletteProp != null, - "Settings should have the WButtonCustomColors dictionary property." - ); - - if (paletteProp == null) - { - yield break; - } - - paletteProp.isExpanded = true; - serializedSettings.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(15f, 25f, 500f, 300f); - GUIContent label = new("Palette"); - - const float GroupLeftPadding = 20f; - const float GroupRightPadding = 15f; - float horizontalPadding = GroupLeftPadding + GroupRightPadding; - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 2; - try - { - serializedSettings.UpdateIfRequiredOrScript(); - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - GroupLeftPadding, - GroupRightPadding - ) - ) - { - using (GroupGUIWidthUtility.PushWGroupPropertyContext()) - { - drawer.OnGUI(controlRect, paletteProp, label); - capturedRect = drawer.LastResolvedPosition; - } - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - TestContext.WriteLine( - $"[WGroupPropertyContextInSettingsContext] " - + $"controlRect=({controlRect.x:F3}, {controlRect.width:F3}), " - + $"capturedRect=({capturedRect.x:F3}, {capturedRect.width:F3})" - ); - - const float WGroupAlignmentOffset = -4f; - float expectedX = controlRect.x + WGroupAlignmentOffset; - float expectedWidth = controlRect.width - WGroupAlignmentOffset; - - Assert.AreEqual( - expectedX, - capturedRect.x, - 0.01f, - "WGroupPropertyContext in settings context should apply -4f alignment offset to x." - ); - Assert.AreEqual( - expectedWidth, - capturedRect.width, - 0.01f, - "WGroupPropertyContext in settings context should increase width by 4f." - ); - } - /// /// Data-driven test for WGroupPropertyContext alignment offset with various starting x values. /// Tests that the -4f offset is consistently applied regardless of starting x position. @@ -2725,15 +2371,14 @@ int indentLevel try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextAlignmentOffsetDataDriven] " @@ -2781,15 +2426,14 @@ public void WGroupPropertyContextSmallWidthHandling(float smallWidth) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextSmallWidthHandling] " @@ -2844,15 +2488,14 @@ public void WGroupPropertyContextLargeWidthHandling(float largeWidth) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextLargeWidthHandling] " @@ -2905,15 +2548,14 @@ public void WGroupPropertyContextNegativeXBoundary() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextNegativeXBoundary] " @@ -2957,15 +2599,14 @@ public void WGroupPropertyContextXEqualsOffset() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextXEqualsOffset] " @@ -3103,15 +2744,14 @@ int indentLevel try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[EdgeCase] input=({inputX}, {inputWidth}), indent={indentLevel}, " @@ -3153,9 +2793,9 @@ float expectedWidth try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3502,9 +3142,9 @@ public void ResolveContentRectHandlesIndentLevel0Correctly() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3595,9 +3235,9 @@ public void ResolveContentRectZeroWidthInput() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3634,9 +3274,9 @@ public void ResolveContentRectNegativeWidthInput() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3674,9 +3314,9 @@ public void ResolveContentRectVeryLargeWidthInput(float largeWidth) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3717,9 +3357,9 @@ public void ResolveContentRectNegativeXAtIndentLevel0() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3758,9 +3398,9 @@ public void ResolveContentRectXEqualsUnityListAlignmentOffset() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3799,9 +3439,9 @@ public void ResolveContentRectFractionalXValues(float inputX) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3844,9 +3484,9 @@ public void UnityListAlignmentOffsetNotAppliedAtHigherIndentLevels(int indentLev try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3882,9 +3522,9 @@ public void UnityListAlignmentOffsetShiftsXLeftAtIndentLevel0() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3922,9 +3562,9 @@ public void UnityListAlignmentOffsetIncreasesWidthAtIndentLevel0() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -3964,9 +3604,9 @@ public void UnityListAlignmentOffsetClampingBehavior(float inputX, float expecte try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -4003,7 +3643,7 @@ public void UnityListAlignmentOffsetNotAppliedInsideWGroup() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -4013,11 +3653,10 @@ public void UnityListAlignmentOffsetNotAppliedInsideWGroup() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedX = controlRect.x + LeftPadding; @@ -4052,16 +3691,16 @@ public void SetDrawerWGroupPropertyContextMatchesDictionaryDrawer() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect dictRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect dictRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); - Rect setRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect setRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -4104,14 +3743,14 @@ public void SetDrawerUnityListAlignmentOffsetMatchesDictionaryDrawer() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect dictRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect dictRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); - Rect setRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect setRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); diff --git a/Tests/Editor/CustomDrawers/SerializableDictionaryPendingPaddingTests.cs b/Tests/Editor/CustomDrawers/SerializableDictionaryPendingPaddingTests.cs index 3b5ce22fd..b14b19569 100644 --- a/Tests/Editor/CustomDrawers/SerializableDictionaryPendingPaddingTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableDictionaryPendingPaddingTests.cs @@ -29,9 +29,9 @@ public sealed class SerializableDictionaryPendingPaddingTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableDictionaryPropertyDrawerTestAccess.ResetLayoutTracking(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); } [Test] @@ -137,81 +137,6 @@ public void PendingFoldoutToggleOffsetDiffersForSettingsContext() ); } - [UnityTest] - public IEnumerator OnGUINormalContextDrawsPendingEntryWithFullPadding() - { - TestDictionaryHost host = CreateScriptableObject(); - host.dictionary[1] = "value1"; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); - - Rect capturedRect = default; - - yield return TestIMGUIExecutor.Run(() => - { - serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - capturedRect = drawer.LastResolvedPosition; - }); - - Assert.Greater(capturedRect.width, 0f, "Resolved position should have valid width."); - } - - [UnityTest] - public IEnumerator OnGUISettingsContextDrawsPendingEntryWithReducedPadding() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - Assert.IsTrue( - paletteProp != null, - "Settings should have the WButtonCustomColors dictionary property." - ); - - if (paletteProp == null) - { - yield break; - } - - paletteProp.isExpanded = true; - serializedSettings.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Palette"); - - Rect capturedRect = default; - - yield return TestIMGUIExecutor.Run(() => - { - serializedSettings.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, paletteProp, label); - capturedRect = drawer.LastResolvedPosition; - }); - - Assert.Greater( - capturedRect.width, - 0f, - "Resolved position should have valid width in settings context." - ); - } - [Test] public void NullPropertyDoesNotCrashPaddingResolution() { @@ -369,14 +294,14 @@ public void ExpandedDictionaryWithMultipleEntriesInSettingsContext() return; } - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); paletteProp.isExpanded = false; SerializableDictionaryPropertyDrawer drawer = new(); GUIContent label = new("Palette"); float collapsedHeight = drawer.GetPropertyHeight(paletteProp, label); - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); paletteProp.isExpanded = true; float expandedHeight = drawer.GetPropertyHeight(paletteProp, label); @@ -409,60 +334,5 @@ public void ExpandedDictionaryWithMultipleEntriesInSettingsContext() ); } } - - [UnityTest] - public IEnumerator DrawerMaintainsConsistentPaddingAcrossMultipleRepaints() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - if (paletteProp == null) - { - Assert.Inconclusive("Settings property not found."); - yield break; - } - - paletteProp.isExpanded = true; - serializedSettings.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Palette"); - - Rect firstRect = default; - Rect secondRect = default; - - yield return TestIMGUIExecutor.Run(() => - { - serializedSettings.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, paletteProp, label); - firstRect = drawer.LastResolvedPosition; - }); - - yield return TestIMGUIExecutor.Run(() => - { - serializedSettings.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, paletteProp, label); - secondRect = drawer.LastResolvedPosition; - }); - - Assert.AreEqual( - firstRect.x, - secondRect.x, - 0.01f, - "Resolved x position should be consistent across repaints." - ); - Assert.AreEqual( - firstRect.width, - secondRect.width, - 0.01f, - "Resolved width should be consistent across repaints." - ); - } } } diff --git a/Tests/Editor/CustomDrawers/SerializableDictionaryPropertyDrawerTests.cs b/Tests/Editor/CustomDrawers/SerializableDictionaryPropertyDrawerTests.cs index 2c40b6157..593c742fe 100644 --- a/Tests/Editor/CustomDrawers/SerializableDictionaryPropertyDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableDictionaryPropertyDrawerTests.cs @@ -42,35 +42,6 @@ string fieldName PropertyDrawerTestHelper.AssignFieldInfo(drawer, hostType, fieldName); } - private static string BuildDictionaryDrawerDiagnostics( - SerializedProperty dictionaryProperty, - SerializableDictionaryPropertyDrawer drawer - ) - { - if (dictionaryProperty == null) - { - return "[DictionaryProperty=null]"; - } - - SerializedProperty keysProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Keys - ); - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - - string keysSummary = - keysProperty == null - ? "keys:null" - : $"keys:array={keysProperty.isArray},size={keysProperty.arraySize}"; - string valuesSummary = - valuesProperty == null - ? "values:null" - : $"values:array={valuesProperty.isArray},size={valuesProperty.arraySize}"; - Rect lastRect = drawer.HasLastListRect ? drawer.LastListRect : Rect.zero; - return $"[expanded={dictionaryProperty.isExpanded},propertyPath={dictionaryProperty.propertyPath},{keysSummary},{valuesSummary},hasLastRect={drawer.HasLastListRect},lastRect={lastRect}]"; - } - private static void AssertColorsApproximately( Color expected, Color actual, @@ -307,9 +278,9 @@ public override void BaseSetUp() { _sharedHost = CreateScriptableObject(); _sharedSerializedObject = new SerializedObject(_sharedHost); - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableDictionaryPropertyDrawerTestAccess.ResetLayoutTracking(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); ResetHostState(); base.BaseSetUp(); } @@ -2522,137 +2493,6 @@ public void GetPropertyHeightAutoExpandsComplexRowsOnFirstDraw() ); } - [UnityTest] - public IEnumerator DictionaryRowComplexValueChildControlsHaveSpaceOnFirstDraw() - { - ComplexValueDictionaryHost host = CreateScriptableObject(); - host.dictionary.Add( - "Accent", - new ComplexValue { button = Color.cyan, text = Color.black } - ); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ComplexValueDictionaryHost.dictionary) - ); - ForcePopulateComplexDictionarySerializedData(host, dictionaryProperty); - dictionaryProperty.isExpanded = true; - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - Assert.Greater(valuesProperty.arraySize, 0, "Dictionary should contain test entries."); - SerializedProperty valueProperty = valuesProperty.GetArrayElementAtIndex(0); - valueProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ComplexValueDictionaryHost), - nameof(ComplexValueDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 520f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - drawer.GetPropertyHeight(dictionaryProperty, label); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - bool hasRowRects = SerializableDictionaryPropertyDrawer.HasLastRowRects; - bool hasChildRects = SerializableDictionaryPropertyDrawer.HasLastRowChildContentRect; - bool usedFoldoutLabel = - SerializableDictionaryPropertyDrawer.LastRowValueUsedFoldoutLabel; - float valueHeight = hasRowRects - ? SerializableDictionaryPropertyDrawer.LastRowValueRect.height - : 0f; - float childWidth = hasChildRects - ? SerializableDictionaryPropertyDrawer.LastRowChildContentRect.width - : 0f; - float singleLineHeight = EditorGUIUtility.singleLineHeight; - - TestContext.WriteLine( - $"[DictionaryRowComplexValueChildControlsHaveSpaceOnFirstDraw] " - + $"hasRowRects={hasRowRects}, hasChildRects={hasChildRects}, " - + $"usedFoldoutLabel={usedFoldoutLabel}, valueHeight={valueHeight:F3}, " - + $"childWidth={childWidth:F3}, singleLineHeight={singleLineHeight:F3}" - ); - - Assert.IsTrue( - hasChildRects, - "First draw should record child layout information for complex values." - ); - Assert.Greater( - valueHeight, - singleLineHeight * 2f, - $"Complex value rows should reserve space for expanded children. Observed height: {valueHeight:F3}" - ); - Assert.Greater( - childWidth, - 160f, - "Complex value child drawers should reserve a reasonable amount of width on the first draw." - ); - } - - [UnityTest] - public IEnumerator PendingEntryComplexValueAllocatesFoldoutGutter() - { - ComplexValueDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ComplexValueDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ComplexValueDictionaryHost), - nameof(ComplexValueDictionaryHost.dictionary) - ); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = GetPendingEntry( - drawer, - dictionaryProperty, - typeof(string), - typeof(ComplexValue), - isSortedDictionary: false - ); - pending.isExpanded = true; - - Rect controlRect = new(0f, 0f, 360f, 520f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.LastPendingValueUsedFoldoutLabel, - "Pending value should reserve a foldout gutter for complex value types." - ); - Assert.That( - SerializableDictionaryPropertyDrawer.LastPendingValueFoldoutOffset, - Is.EqualTo(SerializableDictionaryPropertyDrawer.PendingExpandableValueFoldoutGutter) - .Within(0.0001f), - "Pending value foldout gutter should match the configured width." - ); - } - [UnityTest] public IEnumerator DictionaryDrawerDoesNotResetUnappliedScalarChanges() { @@ -2679,7 +2519,7 @@ public IEnumerator DictionaryDrawerDoesNotResetUnappliedScalarChanges() Rect controlRect = new(0f, 0f, 360f, 320f); GUIContent label = new("Dictionary"); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); + SerializableDictionaryPropertyDrawerTestAccess.ResetLayoutTracking(); drawer.GetPropertyHeight(dictionaryProperty, label); scalarProperty.intValue = 1337; @@ -2867,2518 +2707,102 @@ void OnSettingsSaved() } } - [UnityTest] - public IEnumerator DictionaryRowComplexValueAllocatesFoldoutGutter() + [Test] + public void GetOrCreateListRebuildsAfterCommit() { ComplexValueDictionaryHost host = CreateScriptableObject(); - host.dictionary.Add( - "Accent", - new ComplexValue { button = Color.cyan, text = Color.black } - ); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); + SerializedProperty dictionaryProperty = serializedObject.FindProperty( nameof(ComplexValueDictionaryHost.dictionary) ); - ForcePopulateComplexDictionarySerializedData(host, dictionaryProperty); - dictionaryProperty.isExpanded = true; + SerializedProperty keysProperty = dictionaryProperty.FindPropertyRelative( + SerializableDictionarySerializedPropertyNames.Keys + ); SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( SerializableDictionarySerializedPropertyNames.Values ); - Assert.Greater(valuesProperty.arraySize, 0, "Dictionary should contain test entries."); - SerializedProperty valueProperty = valuesProperty.GetArrayElementAtIndex(0); - valueProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ComplexValueDictionaryHost), - nameof(ComplexValueDictionaryHost.dictionary) + ReorderableList initialList = drawer.GetOrCreateList(dictionaryProperty); + Assert.IsTrue( + initialList != null, + "Initial call should create a ReorderableList instance." ); + Assert.That(initialList.count, Is.EqualTo(0)); - Rect controlRect = new(0f, 0f, 360f, 520f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); + SerializableDictionaryPropertyDrawer.CommitResult result = drawer.CommitEntry( + keysProperty, + valuesProperty, + typeof(string), + typeof(ComplexValue), + "Inline", + new ComplexValue { button = Color.cyan, text = Color.black }, + dictionaryProperty + ); + Assert.IsTrue(result.added, "Expected CommitEntry to add a new entry."); - bool usedFoldoutLabel = - SerializableDictionaryPropertyDrawer.LastRowValueUsedFoldoutLabel; - float foldoutOffset = SerializableDictionaryPropertyDrawer.LastRowValueFoldoutOffset; - bool hasRowRects = SerializableDictionaryPropertyDrawer.HasLastRowRects; + serializedObject.ApplyModifiedPropertiesWithoutUndo(); + serializedObject.Update(); - TestContext.WriteLine( - $"[DictionaryRowComplexValueAllocatesFoldoutGutter] " - + $"hasRowRects={hasRowRects}, usedFoldoutLabel={usedFoldoutLabel}, " - + $"foldoutOffset={foldoutOffset:F3}, " - + $"expectedGutter={SerializableDictionaryPropertyDrawer.RowExpandableValueFoldoutGutter:F3}" + dictionaryProperty = serializedObject.FindProperty( + nameof(ComplexValueDictionaryHost.dictionary) ); - - Assert.IsTrue( - usedFoldoutLabel, - "Row values should reserve a foldout gutter for complex value types." + keysProperty = dictionaryProperty.FindPropertyRelative( + SerializableDictionarySerializedPropertyNames.Keys ); - Assert.That( - foldoutOffset, - Is.EqualTo(SerializableDictionaryPropertyDrawer.RowExpandableValueFoldoutGutter) - .Within(0.0001f), - "Row value foldout gutter should match the configured width." + valuesProperty = dictionaryProperty.FindPropertyRelative( + SerializableDictionarySerializedPropertyNames.Values ); - } - [UnityTest] - public IEnumerator PendingEntryHeaderIgnoresEditorIndent() - { - const int IndentDepth = 3; - - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) + ReorderableList refreshedList = drawer.GetOrCreateList(dictionaryProperty); + Assert.IsTrue(refreshedList != null); + Assert.AreNotSame( + initialList, + refreshedList, + "ReorderableList cache should rebuild after committing new entries." ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); + Assert.That(refreshedList.count, Is.EqualTo(1)); - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) + float resolvedHeight = + refreshedList.elementHeightCallback?.Invoke(0) ?? refreshedList.elementHeight; + Assert.That( + resolvedHeight, + Is.GreaterThan(0f), + "Row height should resolve using up-to-date serialized properties." ); - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Dictionary"); - - float baselineOffset = 0f; - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - baselineOffset = - SerializableDictionaryPropertyDrawer.LastPendingFoldoutToggleRect.xMin - - SerializableDictionaryPropertyDrawer.LastPendingHeaderRect.xMin; - }); - - float indentedOffset = 0f; - int indentLevelAfter = -1; - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - EditorGUI.indentLevel = IndentDepth; - drawer.OnGUI(controlRect, dictionaryProperty, label); - indentedOffset = - SerializableDictionaryPropertyDrawer.LastPendingFoldoutToggleRect.xMin - - SerializableDictionaryPropertyDrawer.LastPendingHeaderRect.xMin; - indentLevelAfter = EditorGUI.indentLevel; - EditorGUI.indentLevel = 0; - }); + SerializedProperty keyElement = keysProperty.GetArrayElementAtIndex(0); + SerializedProperty valueElement = valuesProperty.GetArrayElementAtIndex(0); - Assert.That( - indentedOffset, - Is.EqualTo(baselineOffset).Within(0.0001f), - "Pending header offset should be stable regardless of external indent." - ); - Assert.That( - indentLevelAfter, - Is.EqualTo(IndentDepth), - "Drawer should restore the caller's indent level." + Assert.That(keyElement.stringValue, Is.EqualTo("Inline")); + SerializedProperty buttonColorProperty = valueElement.FindPropertyRelative( + nameof(ComplexValue.button) ); + Assert.IsTrue(buttonColorProperty != null); + Assert.That(buttonColorProperty.colorValue, Is.EqualTo(Color.cyan)); } - [UnityTest] - public IEnumerator PendingEntryToggleOffsetAdjustsForSettingsContext() + [Test] + public void GetPropertyHeightIncreasesWhenPendingEntryIsExpanded() { - _sharedSerializedObject.Update(); - SerializedProperty hostDictionary = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Dictionary"); - hostDictionary.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - float hostOffset = 0f; - yield return TestIMGUIExecutor.Run(() => + using (new DictionaryTweenDisabledScope()) { - hostDictionary.serializedObject.UpdateIfRequiredOrScript(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer.OnGUI(controlRect, hostDictionary, label); - hostOffset = - SerializableDictionaryPropertyDrawer.LastPendingFoldoutToggleRect.xMin - - SerializableDictionaryPropertyDrawer.LastPendingHeaderRect.xMin; - }); - - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject settingsSerialized = TrackDisposable(new SerializedObject(settings)); - settingsSerialized.Update(); - SerializedProperty paletteProperty = settingsSerialized.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - AssignDictionaryFieldInfo( - drawer, - typeof(UnityHelpersSettings), - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - paletteProperty.isExpanded = true; - settingsSerialized.ApplyModifiedPropertiesWithoutUndo(); + bool tweenEnabled = SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( + isSortedDictionary: false + ); + Assert.IsFalse( + tweenEnabled, + "Tween should be disabled by DictionaryTweenDisabledScope for accurate height tests." + ); - float settingsOffset = 0f; - yield return TestIMGUIExecutor.Run(() => - { - paletteProperty.serializedObject.UpdateIfRequiredOrScript(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer.OnGUI(controlRect, paletteProperty, new GUIContent("Palette")); - settingsOffset = - SerializableDictionaryPropertyDrawer.LastPendingFoldoutToggleRect.xMin - - SerializableDictionaryPropertyDrawer.LastPendingHeaderRect.xMin; - }); - - Assert.That( - hostOffset, - Is.EqualTo(SerializableDictionaryPropertyDrawer.PendingFoldoutToggleOffset) - .Within(0.0001f), - "Default context should use the standard pending toggle offset." - ); - Assert.That( - settingsOffset, - Is.EqualTo( - SerializableDictionaryPropertyDrawer.PendingFoldoutToggleOffsetProjectSettings - ) - .Within(0.0001f), - "UnityHelpersSettings context should use the project-settings offset." - ); - } - - [UnityTest] - public IEnumerator PendingEntryFoldoutToggleRespectsOffset() - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 480f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingHeaderRect, - "Expected pending header layout to be tracked." - ); - - float offset = - SerializableDictionaryPropertyDrawer.LastPendingFoldoutToggleRect.xMin - - SerializableDictionaryPropertyDrawer.LastPendingHeaderRect.xMin; - - Assert.That( - offset, - Is.EqualTo(SerializableDictionaryPropertyDrawer.PendingFoldoutToggleOffset) - .Within(0.0001f), - "Pending header toggle should honor the configured offset." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowFieldsApplyPadding() - { - _sharedHost.dictionary.Add(1, "One"); - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - ForcePopulateTestDictionarySerializedData(_sharedHost, dictionaryProperty); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 520f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Expected dictionary row layout to be tracked." - ); - - float keyPadding = - SerializableDictionaryPropertyDrawer.LastRowKeyRect.xMin - - SerializableDictionaryPropertyDrawer.LastRowOriginalRect.xMin; - Assert.That( - keyPadding, - Is.EqualTo(SerializableDictionaryPropertyDrawer.DictionaryRowFieldPadding) - .Within(0.0001f), - "Key column should include the configured row padding." - ); - - float valuePadding = - SerializableDictionaryPropertyDrawer.LastRowValueRect.xMin - - SerializableDictionaryPropertyDrawer.LastRowValueBaseX; - Assert.That( - valuePadding, - Is.EqualTo(SerializableDictionaryPropertyDrawer.DictionaryRowFieldPadding) - .Within(0.0001f), - "Value column should include the configured row padding." - ); - } - - [UnityTest] - public IEnumerator PendingEntryHeaderHonorsGroupPadding() - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingHeaderRect, - "Baseline draw should capture pending header layout." - ); - - Rect baselineHeader = SerializableDictionaryPropertyDrawer.LastPendingHeaderRect; - - const float LeftPadding = 20f; - const float RightPadding = 12f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingHeaderRect, - "Grouped draw should capture pending header layout." - ); - - Rect groupedHeader = SerializableDictionaryPropertyDrawer.LastPendingHeaderRect; - - TestContext.WriteLine( - $"[PendingEntryHeaderHonorsGroupPadding] " - + $"baselineHeader.xMin={baselineHeader.xMin:F3}, groupedHeader.xMin={groupedHeader.xMin:F3}, " - + $"baselineHeader.width={baselineHeader.width:F3}, groupedHeader.width={groupedHeader.width:F3}, " - + $"LeftPadding={LeftPadding:F3}" - ); - - float expectedXShift = LeftPadding; - float actualXShift = groupedHeader.xMin - baselineHeader.xMin; - Assert.That( - actualXShift, - Is.EqualTo(expectedXShift).Within(1.0f), - $"Pending header xMin shift should be approximately LeftPadding ({LeftPadding}). Actual shift: {actualXShift}" - ); - - float expectedWidthReduction = horizontalPadding; - float actualWidthReduction = baselineHeader.width - groupedHeader.width; - Assert.That( - actualWidthReduction, - Is.EqualTo(expectedWidthReduction).Within(12.0f), - $"Pending header width should decrease by approximately horizontalPadding = {expectedWidthReduction}. Actual reduction: {actualWidthReduction}" - ); - - Assert.Less( - groupedHeader.width, - baselineHeader.width, - "Grouped header should be narrower than baseline due to WGroup padding." - ); - } - - [UnityTest] - public IEnumerator PendingEntryHeaderHonorsGroupPaddingInSettingsContext() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - SerializedProperty paletteProperty = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - paletteProperty.isExpanded = true; - serializedSettings.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(UnityHelpersSettings), - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Palette"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - paletteProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, paletteProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingHeaderRect, - "Baseline draw should capture pending header layout for settings dictionaries." - ); - - Rect baselineHeader = SerializableDictionaryPropertyDrawer.LastPendingHeaderRect; - - const float LeftPadding = 18f; - const float RightPadding = 12f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - paletteProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer.OnGUI(controlRect, paletteProperty, label); - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingHeaderRect, - "Grouped draw should capture pending header layout for settings dictionaries." - ); - - Rect groupedHeader = SerializableDictionaryPropertyDrawer.LastPendingHeaderRect; - - Assert.That( - groupedHeader.xMin, - Is.EqualTo(baselineHeader.xMin + LeftPadding).Within(0.0001f), - "Pending header in Project Settings should respect group padding." - ); - Assert.That( - groupedHeader.width, - Is.EqualTo(Mathf.Max(0f, baselineHeader.width - horizontalPadding)).Within(0.0001f), - "Pending header width in Project Settings should shrink by the applied padding." - ); - } - - [UnityTest] - public IEnumerator PendingEntryFieldsHonorGroupPadding() - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(int), - typeof(string), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Baseline draw should capture pending key/value rects." - ); - - Rect baselineKey = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect baselineValue = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - const float LeftPadding = 18f; - const float RightPadding = 14f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Grouped draw should capture pending key/value rects." - ); - - Rect groupedKey = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect groupedValue = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - TestContext.WriteLine( - $"[PendingEntryFieldsHonorGroupPadding] " - + $"baselineKey.xMin={baselineKey.xMin:F3}, groupedKey.xMin={groupedKey.xMin:F3}, " - + $"baselineKey.width={baselineKey.width:F3}, groupedKey.width={groupedKey.width:F3}, " - + $"LeftPadding={LeftPadding:F3}" - ); - - float expectedXShift = LeftPadding; - float actualKeyXShift = groupedKey.xMin - baselineKey.xMin; - Assert.That( - actualKeyXShift, - Is.EqualTo(expectedXShift).Within(1.0f), - $"Pending key field xMin shift should be approximately {expectedXShift}. Actual: {actualKeyXShift}" - ); - - float actualValueXShift = groupedValue.xMin - baselineValue.xMin; - Assert.That( - actualValueXShift, - Is.EqualTo(expectedXShift).Within(1.0f), - $"Pending value field xMin shift should be approximately LeftPadding ({LeftPadding}). Actual: {actualValueXShift}" - ); - - Assert.Less( - groupedKey.width, - baselineKey.width + 1.0f, - "Pending key width should be narrower with WGroup padding." - ); - Assert.Less( - groupedValue.width, - baselineValue.width + 1.0f, - "Pending value width should be narrower with WGroup padding." - ); - } - - [UnityTest] - public IEnumerator PendingEntryFieldsHonorGroupPaddingInSettingsContext() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - SerializedProperty paletteProperty = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - paletteProperty.isExpanded = true; - serializedSettings.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(UnityHelpersSettings), - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Palette"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - paletteProperty, - typeof(string), - typeof(UnityHelpersSettings.WButtonCustomColor), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - paletteProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, paletteProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Baseline draw should capture pending key/value rects for settings dictionaries." - ); - - Rect baselineKey = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect baselineValue = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - const float LeftPadding = 20f; - const float RightPadding = 10f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - paletteProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, paletteProperty, label); - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Grouped draw should capture pending key/value rects for settings dictionaries." - ); - - Rect groupedKey = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect groupedValue = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - Assert.That( - groupedKey.xMin, - Is.EqualTo(baselineKey.xMin + LeftPadding).Within(0.0001f), - "Pending key field in Project Settings should shift by the applied left padding." - ); - Assert.That( - groupedValue.xMin, - Is.EqualTo(baselineValue.xMin + LeftPadding).Within(0.0001f), - "Pending value field in Project Settings should shift by the applied left padding." - ); - Assert.That( - groupedKey.width, - Is.EqualTo(Mathf.Max(0f, baselineKey.width - horizontalPadding)).Within(0.0001f), - "Pending key width in Project Settings should shrink by the total group padding." - ); - Assert.That( - groupedValue.width, - Is.EqualTo(Mathf.Max(0f, baselineValue.width - horizontalPadding)).Within(0.0001f), - "Pending value width in Project Settings should shrink by the total group padding." - ); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueFieldsAreAligned() - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(int), - typeof(string), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Draw should capture pending key/value rects." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - float actualShift = keyRect.xMin - valueRect.xMin; - - TestContext.WriteLine( - $"[PendingEntryKeyAndValueFieldsAreAligned] " - + $"keyRect.xMin={keyRect.xMin:F3}, valueRect.xMin={valueRect.xMin:F3}, " - + $"keyRect.width={keyRect.width:F3}, valueRect.width={valueRect.width:F3}, " - + $"actualShift={actualShift:F3} (expected 0 at indent level 0)" - ); - - Assert.That( - actualShift, - Is.EqualTo(0f).Within(1f), - "At indent level 0, pending key and value fields should start at the same position (no room for left shift)." - ); - } - - [UnityTest] - public IEnumerator PendingEntryAnimBoolSynchronizesWithExpandedState() - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(int), - typeof(string), - isSortedDictionary: false - ); - pending.isExpanded = false; - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - bool rectsAfterCollapsed = - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects; - TestContext.WriteLine( - $"[PendingEntryAnimBoolSynchronizesWithExpandedState] after collapsed draw: " - + $"hasPendingRects={rectsAfterCollapsed}" - ); - - pending.isExpanded = true; - - // Assigning value rather than target skips the animation instead of starting it. - if (pending.foldoutAnim != null) - { - pending.foldoutAnim.value = pending.isExpanded; - } - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - bool rectsAfterExpanded = SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects; - TestContext.WriteLine( - $"[PendingEntryAnimBoolSynchronizesWithExpandedState] after expanded draw: " - + $"hasPendingRects={rectsAfterExpanded}, isExpanded={pending.isExpanded}" - ); - - Assert.IsTrue( - rectsAfterExpanded, - "Pending field rects should be captured after expansion when animation completes." - ); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueFieldsAreAlignedInSettingsContext() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - SerializedProperty paletteProperty = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - paletteProperty.isExpanded = true; - serializedSettings.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(UnityHelpersSettings), - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Palette"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - paletteProperty, - typeof(string), - typeof(UnityHelpersSettings.WButtonCustomColor), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - paletteProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, paletteProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Draw should capture pending key/value rects in settings context." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - TestContext.WriteLine( - $"[PendingEntryKeyAndValueFieldsAreAlignedInSettingsContext] " - + $"keyRect.xMin={keyRect.xMin:F3}, valueRect.xMin={valueRect.xMin:F3}, " - + $"keyRect.width={keyRect.width:F3}, valueRect.width={valueRect.width:F3}" - ); - - Assert.That( - keyRect.xMin, - Is.EqualTo(valueRect.xMin).Within(1f), - "Pending key and value field left edges should be aligned in settings context." - ); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedAtIndentLevel1() - { - yield return VerifyPendingEntryAlignmentAtIndentLevel(1); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedAtIndentLevel2() - { - yield return VerifyPendingEntryAlignmentAtIndentLevel(2); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedAtIndentLevel3() - { - yield return VerifyPendingEntryAlignmentAtIndentLevel(3); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedAtIndentLevel5() - { - yield return VerifyPendingEntryAlignmentAtIndentLevel(5); - } - - [UnityTest] - public IEnumerator PendingEntryAlignmentDiagnostics( - [Values(0, 1, 2, 3, 5, 10)] int indentLevel - ) - { - using (new DictionaryTweenDisabledScope()) - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(int), - typeof(string), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - int previousIndent = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = indentLevel; - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - bool hasRects = SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects; - - TestContext.WriteLine( - $"[PendingEntryAlignmentDiagnostics@IndentLevel{indentLevel}]" - ); - TestContext.WriteLine($" HasLastPendingFieldRects = {hasRects}"); - - if (hasRects) - { - Rect keyRect = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - float sectionPadding = - SerializableDictionaryPropertyDrawer.GetPendingSectionPaddingForTests(); - float actualShift = keyRect.xMin - valueRect.xMin; - - TestContext.WriteLine($" keyRect = {keyRect}"); - TestContext.WriteLine($" valueRect = {valueRect}"); - TestContext.WriteLine($" SectionPadding = {sectionPadding:F3}px"); - TestContext.WriteLine($" actualShift = {actualShift:F3}px"); - - // The old indent-dependent column shift was the alignment defect, not an expected offset. - Assert.That( - actualShift, - Is.EqualTo(0f).Within(0.01f), - $"At indent level {indentLevel}, Key and Value must share an origin but differed by {actualShift:F3}px." - ); - } - else - { - Assert.IsTrue( - hasRects, - $"Draw at indent level {indentLevel} should capture pending key/value rects." - ); - } - } - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedWithComplexFoldoutValueAtIndentLevel0() - { - yield return VerifyPendingEntryAlignmentWithComplexValueAtIndentLevel(0); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedWithComplexFoldoutValueAtIndentLevel1() - { - yield return VerifyPendingEntryAlignmentWithComplexValueAtIndentLevel(1); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedWithComplexFoldoutValueAtIndentLevel2() - { - yield return VerifyPendingEntryAlignmentWithComplexValueAtIndentLevel(2); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedWithComplexFoldoutValueAtIndentLevel3() - { - yield return VerifyPendingEntryAlignmentWithComplexValueAtIndentLevel(3); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedWithWGroupPaddingAndIndent() - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 400f, 450f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(int), - typeof(string), - isSortedDictionary: false - ); - pending.isExpanded = true; - - const float LeftPadding = 20f; - const float RightPadding = 10f; - const int IndentLevel = 2; - - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - int previousIndent = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = IndentLevel; - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - LeftPadding + RightPadding, - LeftPadding, - RightPadding - ) - ) - { - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Draw with WGroup padding and indent should capture pending key/value rects." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - float actualShift = keyRect.xMin - valueRect.xMin; - - TestContext.WriteLine( - $"[PendingEntryKeyAndValueAlignedWithWGroupPaddingAndIndent] " - + $"keyRect.xMin={keyRect.xMin:F3}, valueRect.xMin={valueRect.xMin:F3}, " - + $"keyRect.width={keyRect.width:F3}, valueRect.width={valueRect.width:F3}, " - + $"LeftPadding={LeftPadding}, IndentLevel={IndentLevel}, " - + $"actualShift={actualShift:F3}" - ); - - // A dictionary inside a WGroup reproduces the reporter’s indent-1 misalignment. - Assert.That( - actualShift, - Is.EqualTo(0f).Within(0.01f), - "Key and Value must share an origin with WGroup padding and indent (#284)." - ); - - Assert.That( - valueRect.width - keyRect.width, - Is.EqualTo(0f).Within(0.01f), - "Key and Value must share a width with WGroup padding and indent (#284)." - ); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedWithNestedWGroups() - { - ColorDataDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ColorDataDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ColorDataDictionaryHost), - nameof(ColorDataDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 450f, 500f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(string), - typeof(ColorData), - isSortedDictionary: false - ); - pending.isExpanded = true; - - const float OuterLeftPadding = 16f; - const float OuterRightPadding = 8f; - const float InnerLeftPadding = 12f; - const float InnerRightPadding = 6f; - const int IndentLevel = 3; - - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - int previousIndent = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = IndentLevel; - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - OuterLeftPadding + OuterRightPadding, - OuterLeftPadding, - OuterRightPadding - ) - ) - using ( - GroupGUIWidthUtility.PushContentPadding( - InnerLeftPadding + InnerRightPadding, - InnerLeftPadding, - InnerRightPadding - ) - ) - { - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Draw with nested WGroups and indent should capture pending key/value rects." - ); - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.LastPendingValueUsedFoldoutLabel, - "Complex value should use foldout label with nested WGroups." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - TestContext.WriteLine( - $"[PendingEntryKeyAndValueAlignedWithNestedWGroups] " - + $"keyRect.xMin={keyRect.xMin:F3}, valueRect.xMin={valueRect.xMin:F3}, " - + $"keyRect.width={keyRect.width:F3}, valueRect.width={valueRect.width:F3}, " - + $"TotalLeftPadding={OuterLeftPadding + InnerLeftPadding}, IndentLevel={IndentLevel}" - ); - - Assert.That( - keyRect.xMin, - Is.EqualTo(valueRect.xMin).Within(1f), - "Pending key and value field left edges should be aligned with nested WGroups." - ); - - Assert.That( - keyRect.width, - Is.EqualTo(valueRect.width).Within(1f), - "Pending key and value field widths should match with nested WGroups." - ); - } - - [UnityTest] - public IEnumerator PendingEntryKeyIndentIncreasesWithHigherIndentLevel() - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 400f, 450f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(int), - typeof(string), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - float keyXMinAtIndent0 = 0f; - float keyXMinAtIndent2 = 0f; - - yield return TestIMGUIExecutor.Run(() => - { - int previousIndent = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - keyXMinAtIndent0 = SerializableDictionaryPropertyDrawer - .LastPendingKeyFieldRect - .xMin; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - EditorGUI.indentLevel = 2; - drawer.OnGUI(controlRect, dictionaryProperty, label); - keyXMinAtIndent2 = SerializableDictionaryPropertyDrawer - .LastPendingKeyFieldRect - .xMin; - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - TestContext.WriteLine( - $"[PendingEntryKeyIndentIncreasesWithHigherIndentLevel] " - + $"keyXMinAtIndent0={keyXMinAtIndent0:F3}, keyXMinAtIndent2={keyXMinAtIndent2:F3}, " - + $"difference={keyXMinAtIndent2 - keyXMinAtIndent0:F3}" - ); - - Assert.Greater( - keyXMinAtIndent2, - keyXMinAtIndent0, - "Key field xMin should increase with higher indent level." - ); - - // Unity indentation and the drawer’s own offset each contribute about 15 pixels per level. - float expectedIndentDifference = 2 * 30f; - float actualDifference = keyXMinAtIndent2 - keyXMinAtIndent0; - Assert.That( - actualDifference, - Is.EqualTo(expectedIndentDifference).Within(5f), - $"Key field xMin difference should be approximately {expectedIndentDifference}px for indent level difference of 2 (includes both Unity indent and drawer indent)." - ); - } - - [UnityTest] - public IEnumerator PendingEntryKeyAndValueAlignedWithSortedDictionary() - { - TestSortedDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(TestSortedDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestSortedDictionaryHost), - nameof(TestSortedDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("SortedDictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(int), - typeof(string), - isSortedDictionary: true - ); - pending.isExpanded = true; - - const int IndentLevel = 2; - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - int previousIndent = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = IndentLevel; - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Draw sorted dictionary should capture pending key/value rects." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - float actualShift = keyRect.xMin - valueRect.xMin; - - TestContext.WriteLine( - $"[PendingEntryKeyAndValueAlignedWithSortedDictionary] " - + $"keyRect.xMin={keyRect.xMin:F3}, valueRect.xMin={valueRect.xMin:F3}, " - + $"IndentLevel={IndentLevel}, actualShift={actualShift:F3}" - ); - - Assert.That( - actualShift, - Is.EqualTo(0f).Within(0.01f), - "Key and Value must share an origin for a sorted dictionary (#284)." - ); - } - - [UnityTest] - public IEnumerator PendingEntryFoldoutValueRespectsGroupPadding() - { - ColorDataDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ColorDataDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ColorDataDictionaryHost), - nameof(ColorDataDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 420f, 480f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(string), - typeof(ColorData), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Baseline draw should capture pending field rects." - ); - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.LastPendingValueUsedFoldoutLabel, - "Baseline draw should report a foldout label for complex pending values." - ); - Assert.That( - SerializableDictionaryPropertyDrawer.LastPendingValueFoldoutOffset, - Is.EqualTo(SerializableDictionaryPropertyDrawer.PendingExpandableValueFoldoutGutter) - .Within(0.0001f), - "Baseline foldout gutter should match the configured pending-value gutter." - ); - - Rect baselineValue = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - const float LeftPadding = 16f; - const float RightPadding = 12f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - "Grouped draw should capture pending field rects." - ); - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.LastPendingValueUsedFoldoutLabel, - "Grouped draw should still report a foldout label for complex pending values." - ); - Assert.That( - SerializableDictionaryPropertyDrawer.LastPendingValueFoldoutOffset, - Is.EqualTo(SerializableDictionaryPropertyDrawer.PendingExpandableValueFoldoutGutter) - .Within(0.0001f), - "Grouped foldout gutter should match the configured pending-value gutter." - ); - - Rect groupedValue = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - TestContext.WriteLine( - $"[PendingEntryFoldoutValueRespectsGroupPadding] " - + $"baselineValue.xMin={baselineValue.xMin:F3}, groupedValue.xMin={groupedValue.xMin:F3}, " - + $"baselineValue.width={baselineValue.width:F3}, groupedValue.width={groupedValue.width:F3}, " - + $"LeftPadding={LeftPadding:F3}" - ); - - float expectedXShift = LeftPadding; - float actualXShift = groupedValue.xMin - baselineValue.xMin; - Assert.That( - actualXShift, - Is.EqualTo(expectedXShift).Within(1.0f), - $"Pending value foldout xMin shift should be approximately LeftPadding ({LeftPadding}). Actual: {actualXShift}" - ); - - Assert.Less( - groupedValue.width, - baselineValue.width + 1.0f, - "Pending value foldout width should be narrower with WGroup padding." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowsHonorGroupPadding() - { - _sharedHost.dictionary.Add(1, "One"); - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - ForcePopulateTestDictionarySerializedData(_sharedHost, dictionaryProperty); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 520f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Baseline draw should capture dictionary row layout." - ); - - Rect baselineRowRect = SerializableDictionaryPropertyDrawer.LastRowOriginalRect; - Rect baselineKeyRect = SerializableDictionaryPropertyDrawer.LastRowKeyRect; - Rect baselineValueRect = SerializableDictionaryPropertyDrawer.LastRowValueRect; - - const float LeftPadding = 24f; - const float RightPadding = 10f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - // WGroup supplies padding separately; pre-shifting the control rect would apply it twice. - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Grouped draw should capture dictionary row layout." - ); - - Rect groupedRowRect = SerializableDictionaryPropertyDrawer.LastRowOriginalRect; - Rect groupedKeyRect = SerializableDictionaryPropertyDrawer.LastRowKeyRect; - Rect groupedValueRect = SerializableDictionaryPropertyDrawer.LastRowValueRect; - - /* - Unity’s ReorderableList callback positions rows independently of WGroup padding, so its original - rect cannot prove an xMin shift. - */ - - TestContext.WriteLine( - $"[DictionaryRowsHonorGroupPadding] " - + $"baselineRowRect.xMin={baselineRowRect.xMin:F3}, groupedRowRect.xMin={groupedRowRect.xMin:F3}, " - + $"baselineRowRect.width={baselineRowRect.width:F3}, groupedRowRect.width={groupedRowRect.width:F3}, " - + $"LeftPadding={LeftPadding:F3}, RightPadding={RightPadding:F3}" - ); - - Assert.Greater( - groupedKeyRect.width, - 0f, - "Grouped key rect should have positive width." - ); - - Assert.Greater( - groupedValueRect.width, - 0f, - "Grouped value rect should have positive width." - ); - - /* - Different ReorderableList positions change virtual width by about 12 pixels and the column by - about 6.5; allow an 8-pixel tolerance. - */ - float baselineValueOffset = baselineValueRect.xMin - baselineRowRect.xMin; - float groupedValueOffset = groupedValueRect.xMin - groupedRowRect.xMin; - float offsetDelta = Mathf.Abs(groupedValueOffset - baselineValueOffset); - - float baselineVirtualWidth = baselineRowRect.width; - float groupedVirtualWidth = groupedRowRect.width + horizontalPadding; - float virtualWidthDelta = Mathf.Abs(groupedVirtualWidth - baselineVirtualWidth); - - TestContext.WriteLine( - $"[GroupPadding] baseline offset: {baselineValueOffset:F3}, grouped offset: {groupedValueOffset:F3}, delta: {offsetDelta:F3}" - ); - TestContext.WriteLine( - $"[GroupPadding] baselineVirtualWidth: {baselineVirtualWidth:F3}, groupedVirtualWidth: {groupedVirtualWidth:F3}, virtualWidthDelta: {virtualWidthDelta:F3}" - ); - - const float MaxExpectedValueOffsetDelta = 8.0f; - Assert.LessOrEqual( - offsetDelta, - MaxExpectedValueOffsetDelta, - $"Value field offset within the row should remain reasonably consistent when padding is applied (delta: {offsetDelta:F3}, virtualWidthDelta: {virtualWidthDelta:F3})." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsSymmetricSmall() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(12f, 12f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsAsymmetricMedium() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(24f, 10f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsAsymmetricLarge() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(40f, 20f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsMinimumPadding() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(6f, 6f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsLeftOnly() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(50f, 0f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsRightOnly() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(0f, 40f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsZeroPadding() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(0f, 0f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsVeryLargePadding() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(100f, 100f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsTinyPadding() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(0.5f, 0.5f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsExactlyWidthThreshold() - { - // Exactly at the 12.0 WidthTransitionThreshold, where grouped width matches baseline. - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(6f, 6f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsSlightlyAboveThreshold() - { - // Just above the 12.0 WidthTransitionThreshold, so grouped comes out narrower than baseline. - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(7f, 7f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsSlightlyBelowThreshold() - { - // Just below the 12.0 WidthTransitionThreshold, so grouped comes out wider than baseline. - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(5f, 5f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsAsymmetricTiny() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(1f, 0.5f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsLeftOnlyBelowThreshold() - { - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(4f, 0f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsNearMinFloor() - { - /* - Minimum content width is 284; padding near 82 places this 400-pixel rect just above that floor - after the 12-pixel threshold. - */ - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(40f, 40f); - } - - [UnityTest] - public IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsExtremelyLargePadding() - { - // Large enough that the grouped width clamps to minContentWidth. - yield return DictionaryRowLayoutConsistencyWithVariousPaddingsInternal(150f, 150f); - } - - [UnityTest] - public IEnumerator DictionaryRowComplexValueReservesGapAndWidth() - { - ComplexValueDictionaryHost host = CreateScriptableObject(); - host.dictionary.Add( - "Accent", - new ComplexValue { button = Color.cyan, text = Color.black } - ); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ComplexValueDictionaryHost.dictionary) - ); - ForcePopulateComplexDictionarySerializedData(host, dictionaryProperty); - dictionaryProperty.isExpanded = true; - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - Assert.Greater(valuesProperty.arraySize, 0, "Dictionary should contain test entries."); - SerializedProperty valueProperty = valuesProperty.GetArrayElementAtIndex(0); - valueProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ComplexValueDictionaryHost), - nameof(ComplexValueDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 640f, 520f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Expected dictionary row layout to be tracked." - ); - - float actualGap = - SerializableDictionaryPropertyDrawer.LastRowValueRect.xMin - - SerializableDictionaryPropertyDrawer.LastRowKeyRect.xMax; - /* - Expandable-value spacing combines the base gap, foldout boost, value-side field padding, and - foldout gutter. - */ - float expectedGap = - SerializableDictionaryPropertyDrawer.DictionaryRowKeyValueGap - + SerializableDictionaryPropertyDrawer.DictionaryRowFoldoutGapBoost - + SerializableDictionaryPropertyDrawer.DictionaryRowFieldPadding - + SerializableDictionaryPropertyDrawer.RowExpandableValueFoldoutGutter; - - Assert.That( - actualGap, - Is.EqualTo(expectedGap).Within(0.0001f), - "Complex value rows should reserve the configured key/value gap." - ); - - float minValueWidth = - SerializableDictionaryPropertyDrawer.DictionaryRowComplexValueMinWidth; - Assert.That( - SerializableDictionaryPropertyDrawer.LastRowValueRect.width, - Is.GreaterThanOrEqualTo(minValueWidth - 0.0001f), - "Complex value rows should preserve the minimum value column width." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowSimpleValueReservesCorrectGap() - { - _sharedHost.dictionary.Add(1, "TestValue"); - - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Expected dictionary row layout to be tracked for simple value type." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastRowKeyRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastRowValueRect; - - float actualGap = valueRect.xMin - keyRect.xMax; - /* - Simple values omit the foldout boost and gutter; value-side field padding still contributes to - the gap. - */ - float expectedGap = - SerializableDictionaryPropertyDrawer.DictionaryRowKeyValueGap - + SerializableDictionaryPropertyDrawer.DictionaryRowFieldPadding; - - TestContext.WriteLine( - $"[DictionaryRowSimpleValueReservesCorrectGap] " - + $"keyRect.xMax={keyRect.xMax:F3}, valueRect.xMin={valueRect.xMin:F3}, " - + $"actualGap={actualGap:F3}, expectedGap={expectedGap:F3}" - ); - - Assert.That( - actualGap, - Is.EqualTo(expectedGap).Within(0.0001f), - $"Simple value rows should reserve gap of {expectedGap}px (DictionaryRowKeyValueGap + DictionaryRowFieldPadding)." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowGapCalculationBreakdown() - { - ComplexValueDictionaryHost host = CreateScriptableObject(); - host.dictionary.Add( - "DiagnosticKey", - new ComplexValue { button = Color.cyan, text = Color.magenta } - ); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ComplexValueDictionaryHost.dictionary) - ); - ForcePopulateComplexDictionarySerializedData(host, dictionaryProperty); - dictionaryProperty.isExpanded = true; - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - Assert.Greater(valuesProperty.arraySize, 0, "Dictionary should contain test entry."); - SerializedProperty valueProperty = valuesProperty.GetArrayElementAtIndex(0); - valueProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ComplexValueDictionaryHost), - nameof(ComplexValueDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 640f, 520f); - GUIContent label = new("DiagnosticDictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Expected row rects to be tracked." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastRowKeyRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastRowValueRect; - - float baseGap = SerializableDictionaryPropertyDrawer.DictionaryRowKeyValueGap; - float foldoutBoost = SerializableDictionaryPropertyDrawer.DictionaryRowFoldoutGapBoost; - float fieldPadding = SerializableDictionaryPropertyDrawer.DictionaryRowFieldPadding; - float foldoutGutter = - SerializableDictionaryPropertyDrawer.RowExpandableValueFoldoutGutter; - float actualGap = valueRect.xMin - keyRect.xMax; - float expectedGap = baseGap + foldoutBoost + fieldPadding + foldoutGutter; - - TestContext.WriteLine( - "[DictionaryRowGapCalculationBreakdown] Gap component breakdown:" - ); - TestContext.WriteLine($" DictionaryRowKeyValueGap = {baseGap:F1}px"); - TestContext.WriteLine($" DictionaryRowFoldoutGapBoost = {foldoutBoost:F1}px"); - TestContext.WriteLine($" DictionaryRowFieldPadding = {fieldPadding:F1}px"); - TestContext.WriteLine($" RowExpandableValueFoldoutGutter = {foldoutGutter:F1}px"); - TestContext.WriteLine($" -----------------------------------"); - TestContext.WriteLine($" Expected total gap = {expectedGap:F1}px"); - TestContext.WriteLine($" Actual gap (measured) = {actualGap:F3}px"); - TestContext.WriteLine( - $" Difference = {Mathf.Abs(actualGap - expectedGap):F6}px" - ); - TestContext.WriteLine($" keyRect = {keyRect}"); - TestContext.WriteLine($" valueRect = {valueRect}"); - - Assert.That( - actualGap, - Is.EqualTo(expectedGap).Within(0.0001f), - $"Measured gap should match calculated expected gap of {expectedGap}px." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowFoldoutCollapseAdjustsHeight() - { - ComplexValueDictionaryHost host = CreateScriptableObject(); - host.dictionary.Add( - "Accent", - new ComplexValue { button = Color.blue, text = Color.white } - ); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ComplexValueDictionaryHost.dictionary) - ); - ForcePopulateComplexDictionarySerializedData(host, dictionaryProperty); - dictionaryProperty.isExpanded = true; - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - Assert.Greater(valuesProperty.arraySize, 0, "Dictionary should contain test entries."); - SerializedProperty valueProperty = valuesProperty.GetArrayElementAtIndex(0); - valueProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ComplexValueDictionaryHost), - nameof(ComplexValueDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 520f); - GUIContent label = new("Dictionary"); - string cacheKey = drawer.GetListKey(dictionaryProperty); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Baseline draw should capture dictionary row layout." - ); - float expandedHeight = SerializableDictionaryPropertyDrawer.LastRowValueRect.height; - - valueProperty.isExpanded = false; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - drawer.MarkListCacheDirty(cacheKey); - drawer.SetRowFoldoutStateForTests(cacheKey, 0, false); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - float collapsedHeight = SerializableDictionaryPropertyDrawer.LastRowValueRect.height; - TestContext.WriteLine( - $"[RowFoldout] expanded height: {expandedHeight:F3}, collapsed height: {collapsedHeight:F3}" - ); - Assert.Less( - collapsedHeight, - expandedHeight - 0.5f, - "Collapsing the value foldout should reduce the rendered height." - ); - - valueProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - drawer.MarkListCacheDirty(cacheKey); - drawer.SetRowFoldoutStateForTests(cacheKey, 0, true); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - float reopenedHeight = SerializableDictionaryPropertyDrawer.LastRowValueRect.height; - TestContext.WriteLine($"[RowFoldout] reopened height: {reopenedHeight:F3}"); - Assert.That( - reopenedHeight, - Is.EqualTo(expandedHeight).Within(0.5f), - "Re-expanding the value foldout should restore the original height." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowChildControlsStayInsideValueColumn() - { - ColorDataDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ColorDataDictionaryHost.dictionary) - ); - SerializedProperty keysProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Keys - ); - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - - SerializableDictionaryPropertyDrawer drawer = new(); - SerializableDictionaryPropertyDrawer.CommitResult commit = drawer.CommitEntry( - keysProperty, - valuesProperty, - typeof(string), - typeof(ColorData), - "Accent", - new ColorData - { - color1 = Color.red, - color2 = Color.green, - color3 = Color.blue, - color4 = Color.white, - otherColors = new[] { Color.yellow, Color.cyan, Color.magenta }, - }, - dictionaryProperty - ); - - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - SerializedProperty valueProperty = valuesProperty.GetArrayElementAtIndex(commit.index); - valueProperty.isExpanded = true; - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - AssignDictionaryFieldInfo( - drawer, - typeof(ColorDataDictionaryHost), - nameof(ColorDataDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 420f, 520f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Row layout tracking should record the most recent row." - ); - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowChildContentRect, - "Expected at least one child rect to be recorded for complex values." - ); - - Rect valueRect = SerializableDictionaryPropertyDrawer.LastRowValueRect; - Rect childRect = SerializableDictionaryPropertyDrawer.LastRowChildContentRect; - float foldoutOffset = SerializableDictionaryPropertyDrawer.LastRowValueFoldoutOffset; - bool usedFoldoutLabel = - SerializableDictionaryPropertyDrawer.LastRowValueUsedFoldoutLabel; - - TestContext.WriteLine( - $"[DictionaryRowChildControlsStayInsideValueColumn] " - + $"valueRect={{xMin={valueRect.xMin:F3}, xMax={valueRect.xMax:F3}, width={valueRect.width:F3}}}, " - + $"childRect={{xMin={childRect.xMin:F3}, xMax={childRect.xMax:F3}, width={childRect.width:F3}}}, " - + $"foldoutOffset={foldoutOffset:F3}, usedFoldoutLabel={usedFoldoutLabel}" - ); - - Assert.That( - childRect.xMin, - Is.GreaterThanOrEqualTo(valueRect.xMin - 0.0001f), - "Child controls should not render outside the value column on the left." - ); - Assert.That( - childRect.xMax, - Is.LessThanOrEqualTo(valueRect.xMax + 0.0001f), - "Child controls should stay within the value column bounds." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowChildControlsStayInsideValueColumnWithGroupPadding() - { - ColorDataDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ColorDataDictionaryHost.dictionary) - ); - SerializedProperty keysProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Keys - ); - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - - SerializableDictionaryPropertyDrawer drawer = new(); - SerializableDictionaryPropertyDrawer.CommitResult commit = drawer.CommitEntry( - keysProperty, - valuesProperty, - typeof(string), - typeof(ColorData), - "Accent", - new ColorData - { - color1 = Color.magenta, - color2 = Color.cyan, - color3 = Color.yellow, - color4 = Color.white, - otherColors = new[] { Color.red, Color.green }, - }, - dictionaryProperty - ); - - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - SerializedProperty valueProperty = valuesProperty.GetArrayElementAtIndex(commit.index); - valueProperty.isExpanded = true; - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - AssignDictionaryFieldInfo( - drawer, - typeof(ColorDataDictionaryHost), - nameof(ColorDataDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 420f, 520f); - GUIContent label = new("Dictionary"); - - const float LeftPadding = 22f; - const float RightPadding = 14f; - float horizontalPadding = LeftPadding + RightPadding; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Grouped draw should capture the most recent row layout." - ); - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowChildContentRect, - "Grouped draw should capture at least one child rect for complex values." - ); - - Rect valueRect = SerializableDictionaryPropertyDrawer.LastRowValueRect; - Rect childRect = SerializableDictionaryPropertyDrawer.LastRowChildContentRect; - float foldoutOffset = SerializableDictionaryPropertyDrawer.LastRowValueFoldoutOffset; - bool usedFoldoutLabel = - SerializableDictionaryPropertyDrawer.LastRowValueUsedFoldoutLabel; - - TestContext.WriteLine( - $"[DictionaryRowChildControlsStayInsideValueColumnWithGroupPadding] " - + $"valueRect={{xMin={valueRect.xMin:F3}, xMax={valueRect.xMax:F3}, width={valueRect.width:F3}}}, " - + $"childRect={{xMin={childRect.xMin:F3}, xMax={childRect.xMax:F3}, width={childRect.width:F3}}}, " - + $"foldoutOffset={foldoutOffset:F3}, usedFoldoutLabel={usedFoldoutLabel}, " - + $"leftPadding={LeftPadding:F3}, rightPadding={RightPadding:F3}" - ); - - Assert.That( - childRect.xMin, - Is.GreaterThanOrEqualTo(valueRect.xMin - 0.0001f), - "Child controls should remain within the left edge of the grouped value column." - ); - Assert.That( - childRect.xMax, - Is.LessThanOrEqualTo(valueRect.xMax + 0.0001f), - "Child controls should remain within the right edge of the grouped value column." - ); - } - - [UnityTest] - public IEnumerator DictionaryRowChildLabelWidthsAdaptToContent() - { - LabelStressDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(LabelStressDictionaryHost.dictionary) - ); - SerializedProperty keysProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Keys - ); - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - - SerializableDictionaryPropertyDrawer drawer = new(); - SerializableDictionaryPropertyDrawer.CommitResult commit = drawer.CommitEntry( - keysProperty, - valuesProperty, - typeof(string), - typeof(LabelStressValue), - "Entry", - new LabelStressValue - { - shortName = 1f, - ridiculouslyVerboseFieldNameRequiringSpace = 2f, - }, - dictionaryProperty - ); - - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - SerializedProperty valueProperty = valuesProperty.GetArrayElementAtIndex(commit.index); - valueProperty.isExpanded = true; - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - AssignDictionaryFieldInfo( - drawer, - typeof(LabelStressDictionaryHost), - nameof(LabelStressDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 420f, 480f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasRowChildLabelWidthData, - "Expected row child label width tracking to capture measurements." - ); - - float minWidth = SerializableDictionaryPropertyDrawer.LastRowChildMinLabelWidth; - float maxWidth = SerializableDictionaryPropertyDrawer.LastRowChildMaxLabelWidth; - - float widthGain = maxWidth - minWidth; - TestContext.WriteLine( - $"[ChildLabelWidth] min: {minWidth:F2}, max: {maxWidth:F2}, gain: {widthGain:F2}" - ); - Assert.That( - widthGain, - Is.GreaterThan(2f), - $"Long field names should reserve noticeably more width than short field names (observed gain: {widthGain:F2})." - ); - } - - [Test] - public void GetOrCreateListRebuildsAfterCommit() - { - ComplexValueDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ComplexValueDictionaryHost.dictionary) - ); - SerializedProperty keysProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Keys - ); - SerializedProperty valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - - SerializableDictionaryPropertyDrawer drawer = new(); - ReorderableList initialList = drawer.GetOrCreateList(dictionaryProperty); - Assert.IsTrue( - initialList != null, - "Initial call should create a ReorderableList instance." - ); - Assert.That(initialList.count, Is.EqualTo(0)); - - SerializableDictionaryPropertyDrawer.CommitResult result = drawer.CommitEntry( - keysProperty, - valuesProperty, - typeof(string), - typeof(ComplexValue), - "Inline", - new ComplexValue { button = Color.cyan, text = Color.black }, - dictionaryProperty - ); - Assert.IsTrue(result.added, "Expected CommitEntry to add a new entry."); - - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - dictionaryProperty = serializedObject.FindProperty( - nameof(ComplexValueDictionaryHost.dictionary) - ); - keysProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Keys - ); - valuesProperty = dictionaryProperty.FindPropertyRelative( - SerializableDictionarySerializedPropertyNames.Values - ); - - ReorderableList refreshedList = drawer.GetOrCreateList(dictionaryProperty); - Assert.IsTrue(refreshedList != null); - Assert.AreNotSame( - initialList, - refreshedList, - "ReorderableList cache should rebuild after committing new entries." - ); - Assert.That(refreshedList.count, Is.EqualTo(1)); - - float resolvedHeight = - refreshedList.elementHeightCallback?.Invoke(0) ?? refreshedList.elementHeight; - Assert.That( - resolvedHeight, - Is.GreaterThan(0f), - "Row height should resolve using up-to-date serialized properties." - ); - - SerializedProperty keyElement = keysProperty.GetArrayElementAtIndex(0); - SerializedProperty valueElement = valuesProperty.GetArrayElementAtIndex(0); - - Assert.That(keyElement.stringValue, Is.EqualTo("Inline")); - SerializedProperty buttonColorProperty = valueElement.FindPropertyRelative( - nameof(ComplexValue.button) - ); - Assert.IsTrue(buttonColorProperty != null); - Assert.That(buttonColorProperty.colorValue, Is.EqualTo(Color.cyan)); - } - - [UnityTest] - public IEnumerator HonorsGroupPaddingWithinGroups() - { - _sharedHost.dictionary.Add(1, "One"); - _sharedHost.dictionary.Add(2, "Two"); - - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - ForcePopulateTestDictionarySerializedData(_sharedHost, dictionaryProperty); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - Rect controlRect = new(0f, 0f, 360f, 600f); - GUIContent label = new("Dictionary"); - const int IndentDepth = 2; - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - dictionaryProperty.isExpanded = true; - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = IndentDepth; - try - { - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - Assert.IsTrue( - drawer.HasLastListRect, - $"Baseline draw should render the reorderable list. {BuildDictionaryDrawerDiagnostics(dictionaryProperty, drawer)}" - ); - - int snapshotIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = IndentDepth; - Rect expectedBaselineRect = EditorGUI.IndentedRect(controlRect); - EditorGUI.indentLevel = snapshotIndent; - - const float UnityListAlignmentOffset = -1.25f; - float expectedXMin = Mathf.Max( - 0f, - expectedBaselineRect.xMin + UnityListAlignmentOffset - ); - - Assert.That( - drawer.LastResolvedPosition.xMin, - Is.EqualTo(expectedXMin).Within(0.0001f), - $"Indentation should be reflected in the resolved content rectangle. " - + $"IndentedRect.xMin={expectedBaselineRect.xMin}, " - + $"AlignmentOffset={UnityListAlignmentOffset}, " - + $"ExpectedXMin={expectedXMin}, " - + $"ActualXMin={drawer.LastResolvedPosition.xMin}" - ); - - const float LeftPadding = 18f; - const float RightPadding = 12f; - const float HorizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - dictionaryProperty.isExpanded = true; - using ( - GroupGUIWidthUtility.PushContentPadding( - HorizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = IndentDepth; - try - { - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - } - }); - - Rect paddedRect = drawer.LastResolvedPosition; - Assert.That( - paddedRect.xMin, - Is.EqualTo(expectedBaselineRect.xMin + LeftPadding).Within(0.0001f), - "Group padding should shift content by the configured left margin." - ); - Assert.That( - paddedRect.width, - Is.EqualTo(Mathf.Max(0f, expectedBaselineRect.width - HorizontalPadding)) - .Within(0.0001f), - "Group padding should reduce the usable width." - ); - Assert.That( - drawer.LastListRect.xMin, - Is.EqualTo(paddedRect.xMin).Within(0.0001f), - $"List rect should align with the padded content area. {BuildDictionaryDrawerDiagnostics(dictionaryProperty, drawer)}" - ); - } - - [Test] - public void GetPropertyHeightIncreasesWhenPendingEntryIsExpanded() - { - using (new DictionaryTweenDisabledScope()) - { - bool tweenEnabled = SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( - isSortedDictionary: false - ); - Assert.IsFalse( - tweenEnabled, - "Tween should be disabled by DictionaryTweenDisabledScope for accurate height tests." - ); - - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); + _sharedSerializedObject.Update(); + SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( + nameof(TestDictionaryHost.dictionary) + ); + dictionaryProperty.isExpanded = true; + _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); SerializableDictionaryPropertyDrawer drawer = new(); AssignDictionaryFieldInfo( @@ -5424,7 +2848,7 @@ public void GetPropertyHeightDecreasesWhenPendingEntryIsCollapsed() { using (new DictionaryTweenDisabledScope()) { - bool tweenEnabled = SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ); Assert.IsFalse( @@ -5483,7 +2907,7 @@ public void GetPropertyHeightCacheInvalidatesWhenPendingExpandStateChanges() { using (new DictionaryTweenDisabledScope()) { - bool tweenEnabled = SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ); Assert.IsFalse( @@ -5595,7 +3019,7 @@ public void GetPropertyHeightDiffersBetweenExpandedAndCollapsedPendingForEmptyDi { using (new DictionaryTweenDisabledScope()) { - bool tweenEnabled = SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ); Assert.IsFalse( @@ -5656,7 +3080,7 @@ public void GetPropertyHeightPendingExpandAffectsHeightEvenWithDictionaryEntries { using (new DictionaryTweenDisabledScope()) { - bool tweenEnabled = SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ); Assert.IsFalse( @@ -5719,7 +3143,7 @@ public void GetPropertyHeightTogglingPendingMultipleTimesUpdatesHeightCorrectly( { using (new DictionaryTweenDisabledScope()) { - bool tweenEnabled = SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ); Assert.IsFalse( @@ -5791,7 +3215,7 @@ public void GetPropertyHeightSortedDictionaryPendingExpandBehavesCorrectly() { using (new SortedDictionaryTweenDisabledScope()) { - bool tweenEnabled = SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: true ); Assert.IsFalse( @@ -5852,7 +3276,7 @@ public void GetPropertyHeightComplexValueDictionaryPendingExpandUpdatesHeight() { using (new DictionaryTweenDisabledScope()) { - bool tweenEnabled = SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( isSortedDictionary: false ); Assert.IsFalse( @@ -7957,317 +5381,6 @@ private void ResetHostState() } } - private IEnumerator VerifyPendingEntryAlignmentAtIndentLevel(int indentLevel) - { - using (new DictionaryTweenDisabledScope()) - { - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(int), - typeof(string), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - int previousIndent = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = indentLevel; - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - $"Draw at indent level {indentLevel} should capture pending key/value rects." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - float actualShift = keyRect.xMin - valueRect.xMin; - - TestContext.WriteLine( - $"[PendingEntryKeyAndValueAlignedAtIndentLevel{indentLevel}] " - + $"keyRect.xMin={keyRect.xMin:F3}, valueRect.xMin={valueRect.xMin:F3}, " - + $"keyRect.width={keyRect.width:F3}, valueRect.width={valueRect.width:F3}, " - + $"actualShift={actualShift:F3}" - ); - - Assert.That( - actualShift, - Is.EqualTo(0f).Within(0.01f), - $"Key and Value are one column stacked twice, so they must share an origin at indent level {indentLevel} (#284)." - ); - - Assert.That( - valueRect.width - keyRect.width, - Is.EqualTo(0f).Within(0.01f), - $"Key and Value must share a width at indent level {indentLevel} (#284)." - ); - } - } - - private IEnumerator VerifyPendingEntryAlignmentWithComplexValueAtIndentLevel( - int indentLevel - ) - { - ColorDataDictionaryHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(ColorDataDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(ColorDataDictionaryHost), - nameof(ColorDataDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 420f, 480f); - GUIContent label = new("Dictionary"); - - SerializableDictionaryPropertyDrawer.PendingEntry pending = - drawer.GetOrCreatePendingEntry( - dictionaryProperty, - typeof(string), - typeof(ColorData), - isSortedDictionary: false - ); - pending.isExpanded = true; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - int previousIndent = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = indentLevel; - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastPendingFieldRects, - $"Draw with complex value at indent level {indentLevel} should capture pending key/value rects." - ); - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.LastPendingValueUsedFoldoutLabel, - $"Complex value should use foldout label at indent level {indentLevel}." - ); - - Rect keyRect = SerializableDictionaryPropertyDrawer.LastPendingKeyFieldRect; - Rect valueRect = SerializableDictionaryPropertyDrawer.LastPendingValueFieldRect; - - TestContext.WriteLine( - $"[PendingEntryKeyAndValueAlignedWithComplexFoldoutValue@Indent{indentLevel}] " - + $"keyRect.xMin={keyRect.xMin:F3}, valueRect.xMin={valueRect.xMin:F3}, " - + $"keyRect.width={keyRect.width:F3}, valueRect.width={valueRect.width:F3}, " - + $"foldoutOffset={SerializableDictionaryPropertyDrawer.LastPendingValueFoldoutOffset:F3}" - ); - - Assert.That( - keyRect.xMin, - Is.EqualTo(valueRect.xMin).Within(1f), - $"Pending key and value field left edges should be aligned with complex foldout value at indent level {indentLevel}." - ); - - Assert.That( - keyRect.width, - Is.EqualTo(valueRect.width).Within(1f), - $"Pending key and value field widths should match with complex foldout value at indent level {indentLevel}." - ); - } - - private IEnumerator DictionaryRowLayoutConsistencyWithVariousPaddingsInternal( - float leftPadding, - float rightPadding - ) - { - _sharedHost.dictionary.Add(1, "One"); - _sharedSerializedObject.Update(); - SerializedProperty dictionaryProperty = _sharedSerializedObject.FindProperty( - nameof(TestDictionaryHost.dictionary) - ); - ForcePopulateTestDictionarySerializedData(_sharedHost, dictionaryProperty); - dictionaryProperty.isExpanded = true; - _sharedSerializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - AssignDictionaryFieldInfo( - drawer, - typeof(TestDictionaryHost), - nameof(TestDictionaryHost.dictionary) - ); - - Rect controlRect = new(0f, 0f, 400f, 520f); - GUIContent label = new("Dictionary"); - - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Baseline draw should capture dictionary row layout." - ); - - Rect baselineRowRect = SerializableDictionaryPropertyDrawer.LastRowOriginalRect; - Rect baselineKeyRect = SerializableDictionaryPropertyDrawer.LastRowKeyRect; - Rect baselineValueRect = SerializableDictionaryPropertyDrawer.LastRowValueRect; - - float horizontalPadding = leftPadding + rightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - dictionaryProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - leftPadding, - rightPadding - ) - ) - { - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer.OnGUI(controlRect, dictionaryProperty, label); - } - }); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.HasLastRowRects, - "Grouped draw should capture dictionary row layout." - ); - - Rect groupedRowRect = SerializableDictionaryPropertyDrawer.LastRowOriginalRect; - Rect groupedKeyRect = SerializableDictionaryPropertyDrawer.LastRowKeyRect; - Rect groupedValueRect = SerializableDictionaryPropertyDrawer.LastRowValueRect; - - /* - ReorderableList row rects do not expose WGroup padding or virtual width, so assert the resulting - field geometry instead. - */ - - bool zeroPadding = horizontalPadding <= 0f; - int scopeDepthAfterGrouped = GroupGUIWidthUtility.CurrentScopeDepth; - - TestContext.WriteLine( - $"[DictionaryRowLayoutConsistencyWithVariousPaddings(left={leftPadding}, right={rightPadding})] " - + $"baselineRow={{xMin={baselineRowRect.xMin:F3}, width={baselineRowRect.width:F3}}}, " - + $"groupedRow={{xMin={groupedRowRect.xMin:F3}, width={groupedRowRect.width:F3}}}, " - + $"zeroPadding={zeroPadding}, scopeDepthAfterGrouped={scopeDepthAfterGrouped}" - ); - - Assert.Greater( - baselineKeyRect.width, - 0f, - "Baseline key rect should have positive width." - ); - Assert.Greater( - groupedKeyRect.width, - 0f, - "Grouped key rect should have positive width." - ); - - Assert.Greater( - baselineValueRect.width, - 0f, - "Baseline value rect should have positive width." - ); - Assert.Greater( - groupedValueRect.width, - 0f, - "Grouped value rect should have positive width." - ); - - float baselineValueOffset = baselineValueRect.xMin - baselineRowRect.xMin; - float groupedValueOffset = groupedValueRect.xMin - groupedRowRect.xMin; - float offsetDelta = Mathf.Abs(groupedValueOffset - baselineValueOffset); - - TestContext.WriteLine( - $"[DictionaryRowLayoutConsistencyWithVariousPaddings] " - + $"baselineValueOffset={baselineValueOffset:F3}, groupedValueOffset={groupedValueOffset:F3}, " - + $"offsetDelta={offsetDelta:F3}" - ); - - // Unity’s varying ReorderableList rects require tolerance when comparing proportional column offsets. - float scaleFactor = 100f < horizontalPadding ? 0.5f : 0.3f; - float scaledTolerance = 15.0f + (horizontalPadding * scaleFactor); - Assert.LessOrEqual( - offsetDelta, - scaledTolerance, - $"Value field offset delta ({offsetDelta:F3}) should be within scaled tolerance ({scaledTolerance:F3}) for padding {horizontalPadding:F0}." - ); - - float minKeyWidth = SerializableDictionaryPropertyDrawer.DictionaryRowKeyColumnMinWidth; - float minValueWidth = - SerializableDictionaryPropertyDrawer.DictionaryRowValueColumnMinWidth; - - float keyWidthWithPadding = - baselineKeyRect.width - + SerializableDictionaryPropertyDrawer.DictionaryRowFieldPadding; - Assert.GreaterOrEqual( - keyWidthWithPadding, - minKeyWidth * 0.5f, - $"Baseline key column should respect minimum width constraint (~{minKeyWidth:F0}px)." - ); - - float valueWidthWithPadding = - baselineValueRect.width - + SerializableDictionaryPropertyDrawer.DictionaryRowFieldPadding; - Assert.GreaterOrEqual( - valueWidthWithPadding, - minValueWidth * 0.5f, - $"Baseline value column should respect minimum width constraint (~{minValueWidth:F0}px)." - ); - } - private sealed class DictionaryTweenDisabledScope : IDisposable { private readonly bool originalValue; diff --git a/Tests/Editor/CustomDrawers/SerializableSetIndentationTests.cs b/Tests/Editor/CustomDrawers/SerializableSetIndentationTests.cs index 7d22dde9a..31bc575a4 100644 --- a/Tests/Editor/CustomDrawers/SerializableSetIndentationTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableSetIndentationTests.cs @@ -32,8 +32,8 @@ public sealed class SerializableSetIndentationTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableSetPropertyDrawerTestAccess.ResetLayoutTracking(); } [Test] @@ -46,7 +46,7 @@ public void ResolveContentRectNormalContextAppliesIndentation() { EditorGUI.indentLevel = 2; - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -79,9 +79,9 @@ public void ResolveContentRectNormalContextZeroIndentAlignsWithUnityListsClamped try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -117,9 +117,9 @@ public void ResolveContentRectNormalContextZeroIndentAlignsWithUnityListsWithPos try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -145,51 +145,6 @@ public void ResolveContentRectNormalContextZeroIndentAlignsWithUnityListsWithPos } } - [UnityTest] - public IEnumerator OnGUINormalContextRespectsIndentLevel() - { - IndentationTestSetHost host = CreateScriptableObject(); - host.set.Add(1); - host.set.Add(2); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IndentationTestSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 2; - try - { - serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, setProperty, label); - capturedRect = drawer.LastResolvedPosition; - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.Greater( - capturedRect.x, - controlRect.x, - "OnGUI should respect indentLevel in normal context and indent the content." - ); - } - [Test] public void NormalContextWithNestedIndentLevelStaysConsistent() { @@ -199,14 +154,14 @@ public void NormalContextWithNestedIndentLevelStaysConsistent() try { EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); - Rect rectAtLevel1 = SerializableSetPropertyDrawer.ResolveContentRectForTests( + GroupGUIWidthUtilityTestAccess.Reset(); + Rect rectAtLevel1 = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); EditorGUI.indentLevel = 3; - Rect rectAtLevel3 = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect rectAtLevel3 = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -243,9 +198,9 @@ public void NormalContextReducesWidthWithIndentation() try { EditorGUI.indentLevel = 3; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -318,8 +273,8 @@ public void DrawerDoesNotThrowWhenSetIsEmpty() "GetPropertyHeight should not throw for empty set." ); - GroupGUIWidthUtility.ResetForTests(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + GroupGUIWidthUtilityTestAccess.Reset(); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -358,14 +313,14 @@ public void DrawerHandlesVeryLargeIndentLevel() try { EditorGUI.indentLevel = 20; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Assert.DoesNotThrow( () => drawer.GetPropertyHeight(setProperty, label), "GetPropertyHeight should not throw for very large indentLevel." ); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -401,7 +356,7 @@ public void WGroupContextZeroIndentSkipsMinimumIndentWhenPaddingApplied() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -411,7 +366,7 @@ public void WGroupContextZeroIndentSkipsMinimumIndentWhenPaddingApplied() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -459,7 +414,7 @@ public void WGroupContextWithIndentLevelAppliesIndentWithoutDoubleOffset() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -469,7 +424,7 @@ public void WGroupContextWithIndentLevelAppliesIndentWithoutDoubleOffset() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -510,8 +465,8 @@ public void WGroupContextVsNoGroupContextIndentationDifference() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - Rect noGroupRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + GroupGUIWidthUtilityTestAccess.Reset(); + Rect noGroupRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -520,7 +475,7 @@ public void WGroupContextVsNoGroupContextIndentationDifference() const float GroupRightPadding = 8f; float horizontalPadding = GroupLeftPadding + GroupRightPadding; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( horizontalPadding, @@ -529,7 +484,7 @@ public void WGroupContextVsNoGroupContextIndentationDifference() ) ) { - Rect withGroupRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect withGroupRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -579,7 +534,7 @@ public void NestedWGroupContextAccumulatesPaddingCorrectly() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( OuterLeftPadding + OuterRightPadding, @@ -596,11 +551,10 @@ public void NestedWGroupContextAccumulatesPaddingCorrectly() ) ) { - Rect resolvedRect = - SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedTotalLeftPadding = OuterLeftPadding + InnerLeftPadding; float expectedTotalRightPadding = OuterRightPadding + InnerRightPadding; @@ -638,67 +592,6 @@ public void NestedWGroupContextAccumulatesPaddingCorrectly() } } - [UnityTest] - public IEnumerator OnGUIInWGroupContextSkipsMinimumIndentAtZeroLevel() - { - IndentationTestSetHost host = CreateScriptableObject(); - host.set.Add(1); - host.set.Add(2); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IndentationTestSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - const float GroupLeftPadding = 15f; - const float GroupRightPadding = 10f; - float horizontalPadding = GroupLeftPadding + GroupRightPadding; - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 0; - try - { - serializedObject.UpdateIfRequiredOrScript(); - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - GroupLeftPadding, - GroupRightPadding - ) - ) - { - drawer.OnGUI(controlRect, setProperty, label); - capturedRect = drawer.LastResolvedPosition; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - float expectedX = controlRect.x + GroupLeftPadding; - Assert.AreEqual( - expectedX, - capturedRect.x, - 0.01f, - "OnGUI in WGroup context at indentLevel 0 should use WGroup padding." - ); - } - [Test] public void SettingsContextSkipIndentationScenarioPreservesRectUnchanged() { @@ -713,7 +606,7 @@ public void SettingsContextSkipIndentationScenarioPreservesRectUnchanged() { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( horizontalPadding, @@ -722,7 +615,7 @@ public void SettingsContextSkipIndentationScenarioPreservesRectUnchanged() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -766,7 +659,7 @@ public void NormalContextWithGroupPaddingAppliesPaddingAndIndent() { EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( horizontalPadding, @@ -775,7 +668,7 @@ public void NormalContextWithGroupPaddingAppliesPaddingAndIndent() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -819,7 +712,7 @@ public void NormalContextZeroIndentWithGroupPaddingSkipsMinimumIndent() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( horizontalPadding, @@ -828,7 +721,7 @@ public void NormalContextZeroIndentWithGroupPaddingSkipsMinimumIndent() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -882,7 +775,7 @@ float minimumExpectedXShift try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect resolvedRect; if (0f < horizontalPadding) @@ -895,7 +788,7 @@ float minimumExpectedXShift ) ) { - resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -903,7 +796,7 @@ float minimumExpectedXShift } else { - resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -982,7 +875,7 @@ int indentLevel try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect resolvedRect; if (0f < horizontalPadding) @@ -995,7 +888,7 @@ int indentLevel ) ) { - resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1003,7 +896,7 @@ int indentLevel } else { - resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1048,11 +941,11 @@ public void WGroupPropertyContextAppliesAlignmentOffset() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1115,11 +1008,11 @@ public void WGroupPropertyContextIgnoresIndentLevel(int indentLevel) try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1171,7 +1064,7 @@ public void WGroupPropertyContextIgnoresPaddingValues() try { EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1183,11 +1076,10 @@ public void WGroupPropertyContextIgnoresPaddingValues() { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextIgnoresPaddingValues] " @@ -1232,11 +1124,11 @@ public void WGroupPropertyContextZeroPaddingZeroIndent() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1288,7 +1180,7 @@ public void WGroupPropertyContextWithMaxPaddingValues() try { EditorGUI.indentLevel = 5; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1300,11 +1192,10 @@ public void WGroupPropertyContextWithMaxPaddingValues() { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); const float WGroupAlignmentOffset = -4f; float expectedX = controlRect.x + WGroupAlignmentOffset; @@ -1355,11 +1246,11 @@ public void WGroupPropertyContextWithVeryHighIndentLevel() try { EditorGUI.indentLevel = 50; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1407,17 +1298,16 @@ public void WGroupPropertyContextNestedScopesApplyAlignmentOffset() try { EditorGUI.indentLevel = 3; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextNestedScopesApplyAlignmentOffset] " @@ -1462,16 +1352,16 @@ public void WGroupPropertyContextRestoredAfterDisposal() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect rectWithoutContext = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect rectWithoutContext = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect rectWithContext = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect rectWithContext = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1490,7 +1380,7 @@ public void WGroupPropertyContextRestoredAfterDisposal() ); } - Rect rectAfterContext = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect rectAfterContext = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1537,7 +1427,7 @@ public void PushContentPaddingOnlyAppliesPadding() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1547,7 +1437,7 @@ public void PushContentPaddingOnlyAppliesPadding() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1596,7 +1486,7 @@ public void PushContentPaddingNestedAccumulation() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1614,11 +1504,10 @@ public void PushContentPaddingNestedAccumulation() ) ) { - Rect resolvedRect = - SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float totalLeftPadding = OuterLeftPadding + InnerLeftPadding; float totalRightPadding = OuterRightPadding + InnerRightPadding; @@ -1667,7 +1556,7 @@ public void PushContentPaddingCombinedWithIndentLevel() try { EditorGUI.indentLevel = 2; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1677,7 +1566,7 @@ public void PushContentPaddingCombinedWithIndentLevel() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1719,7 +1608,7 @@ public void PushContentPaddingZeroPaddingDoesNotIncreaseScopeDepth() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); int initialScopeDepth = GroupGUIWidthUtility.CurrentScopeDepth; @@ -1763,7 +1652,7 @@ public void ContrastWGroupPropertyContextVsPushContentPaddingOnly() try { EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect rectWithPaddingOnly; using ( @@ -1774,13 +1663,13 @@ public void ContrastWGroupPropertyContextVsPushContentPaddingOnly() ) ) { - rectWithPaddingOnly = SerializableSetPropertyDrawer.ResolveContentRectForTests( + rectWithPaddingOnly = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); } - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect rectWithWGroupContext; using ( @@ -1793,11 +1682,10 @@ public void ContrastWGroupPropertyContextVsPushContentPaddingOnly() { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - rectWithWGroupContext = - SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + rectWithWGroupContext = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); } } @@ -1858,11 +1746,11 @@ public void WGroupPropertyContextWithRectAtOrigin() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -1915,17 +1803,16 @@ public void WGroupPropertyContextWithNarrowRect() try { EditorGUI.indentLevel = 3; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(40f, 20f, 20f)) { using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = - SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); TestContext.WriteLine( $"[WGroupPropertyContextWithNarrowRect] " @@ -1973,7 +1860,7 @@ public void TripleNestedPaddingScopesWithoutWGroupContext() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -1999,11 +1886,10 @@ public void TripleNestedPaddingScopesWithoutWGroupContext() ) ) { - Rect resolvedRect = - SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float totalLeft = Padding1Left + Padding2Left + Padding3Left; float totalRight = Padding1Right + Padding2Right + Padding3Right; @@ -2053,7 +1939,7 @@ public void AsymmetricPaddingWithoutWGroupContext() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -2063,7 +1949,7 @@ public void AsymmetricPaddingWithoutWGroupContext() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -2186,11 +2072,11 @@ int indentLevel try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -2241,11 +2127,11 @@ public void WGroupPropertyContextSmallWidthHandling(float smallWidth) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -2303,11 +2189,11 @@ public void WGroupPropertyContextLargeWidthHandling(float largeWidth) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -2363,11 +2249,11 @@ public void WGroupPropertyContextNegativeXBoundary() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -2414,11 +2300,11 @@ public void WGroupPropertyContextXEqualsOffset() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); diff --git a/Tests/Editor/CustomDrawers/SerializableSetPendingPaddingTests.cs b/Tests/Editor/CustomDrawers/SerializableSetPendingPaddingTests.cs index 5f728153b..f44110c0e 100644 --- a/Tests/Editor/CustomDrawers/SerializableSetPendingPaddingTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableSetPendingPaddingTests.cs @@ -31,8 +31,8 @@ public sealed class SerializableSetPendingPaddingTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableSetPropertyDrawerTestAccess.ResetLayoutTracking(); } [Test] @@ -103,37 +103,6 @@ public void ManualEntryFoldoutToggleOffsetDiffersForContexts() ); } - [UnityTest] - public IEnumerator OnGUINormalContextDrawsManualEntryWithFullPadding() - { - PaddingTestSetHost host = CreateScriptableObject(); - host.set.Add(1); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(PaddingTestSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - Rect capturedRect = default; - - yield return TestIMGUIExecutor.Run(() => - { - serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, setProperty, label); - capturedRect = drawer.LastResolvedPosition; - }); - - Assert.Greater(capturedRect.width, 0f, "Resolved position should have valid width."); - } - [Test] public void NullPropertyDoesNotCrashPaddingResolution() { @@ -273,7 +242,7 @@ public void ExpandedSetTallerThanCollapsed() try { UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - SerializableSetPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + SerializableSetPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); setProperty.isExpanded = true; float expandedHeight = drawer.GetPropertyHeight(setProperty, label); @@ -295,56 +264,6 @@ public void ExpandedSetTallerThanCollapsed() } } - [UnityTest] - public IEnumerator DrawerMaintainsConsistentPaddingAcrossMultipleRepaints() - { - PaddingTestSetHost host = CreateScriptableObject(); - host.set.Add(1); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(PaddingTestSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - Rect firstRect = default; - Rect secondRect = default; - - yield return TestIMGUIExecutor.Run(() => - { - serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, setProperty, label); - firstRect = drawer.LastResolvedPosition; - }); - - yield return TestIMGUIExecutor.Run(() => - { - serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, setProperty, label); - secondRect = drawer.LastResolvedPosition; - }); - - Assert.AreEqual( - firstRect.x, - secondRect.x, - 0.01f, - "Resolved x position should be consistent across repaints." - ); - Assert.AreEqual( - firstRect.width, - secondRect.width, - 0.01f, - "Resolved width should be consistent across repaints." - ); - } - [Test] public void SetWithManyElementsHandledCorrectly() { @@ -374,47 +293,6 @@ public void SetWithManyElementsHandledCorrectly() ); } - [Test] - public void VeryLargeIndentLevelHandledGracefully() - { - PaddingTestSetHost host = CreateScriptableObject(); - host.set.Add(42); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(PaddingTestSetHost.set) - ); - setProperty.isExpanded = true; - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 600f, 300f); - GUIContent label = new("Set"); - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 20; - - Assert.DoesNotThrow( - () => drawer.GetPropertyHeight(setProperty, label), - "GetPropertyHeight should not throw for very large indentLevel." - ); - - Rect resolvedRect = drawer.LastResolvedPosition; - Assert.GreaterOrEqual( - resolvedRect.width, - 0f, - "Width should not be negative even with very large indentLevel." - ); - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - [Test] public void ZeroIndentLevelAppliesMinimumPadding() { @@ -436,9 +314,9 @@ public void ZeroIndentLevelAppliesMinimumPadding() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( originalRect, skipIndentation: false ); @@ -482,16 +360,16 @@ public void HigherIndentLevelReducesWidth() int previousIndentLevel = EditorGUI.indentLevel; try { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); EditorGUI.indentLevel = 1; - Rect rectAtLevel1 = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect rectAtLevel1 = SerializableSetPropertyDrawer.ResolveContentRect( originalRect, skipIndentation: false ); EditorGUI.indentLevel = 4; - Rect rectAtLevel4 = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect rectAtLevel4 = SerializableSetPropertyDrawer.ResolveContentRect( originalRect, skipIndentation: false ); @@ -533,9 +411,9 @@ public void ResolveContentRectNeverReturnsNegativeXMin(float startX) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( originalRect, skipIndentation: false ); @@ -570,9 +448,9 @@ public void ResolveContentRectNonNegativeAtVariousIndentLevels(int indentLevel) try { EditorGUI.indentLevel = indentLevel; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( originalRect, skipIndentation: false ); @@ -628,7 +506,7 @@ int elementCount try { UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - SerializableSetPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + SerializableSetPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); setProperty.isExpanded = true; float expandedHeight = drawer.GetPropertyHeight(setProperty, label); @@ -669,9 +547,9 @@ public void ResolveContentRectWidthNonNegativeForVariousWidths(float startWidth) try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( originalRect, skipIndentation: false ); diff --git a/Tests/Editor/CustomDrawers/SerializableSetPropertyDrawerTests.cs b/Tests/Editor/CustomDrawers/SerializableSetPropertyDrawerTests.cs index f310f07ad..87213aa7d 100644 --- a/Tests/Editor/CustomDrawers/SerializableSetPropertyDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableSetPropertyDrawerTests.cs @@ -131,9 +131,9 @@ public override void BaseSetUp() { _sharedHost = CreateScriptableObject(); _sharedSerializedObject = new SerializedObject(_sharedHost); - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - SerializableSetPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableSetPropertyDrawerTestAccess.ResetLayoutTracking(); + SerializableSetPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); ResetHostState(); base.BaseSetUp(); } @@ -2812,633 +2812,6 @@ public void RemoveEntryShrinksSerializedArray() Assert.AreEqual(2, host.set.Count); } - [UnityTest] - public IEnumerator HonorsGroupPaddingWithinGroups() - { - HashSetHost host = CreateScriptableObject(); - host.set.Add(10); - host.set.Add(20); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty(nameof(HashSetHost.set)); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 360f, 400f); - GUIContent label = new("Set"); - const int IndentDepth = 1; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - setProperty.isExpanded = true; - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = IndentDepth; - try - { - drawer.OnGUI(controlRect, setProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - }); - - Assert.IsTrue( - drawer.HasItemsContainerRect, - "Baseline draw should capture the rendered container." - ); - - int snapshotIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = IndentDepth; - Rect expectedBaselineRect = EditorGUI.IndentedRect(controlRect); - EditorGUI.indentLevel = snapshotIndent; - - const float UnityListAlignmentOffset = -1.25f; - float expectedXMin = Mathf.Max( - 0f, - expectedBaselineRect.xMin + UnityListAlignmentOffset - ); - - Assert.That( - drawer.LastResolvedPosition.xMin, - Is.EqualTo(expectedXMin).Within(0.0001f), - $"Indentation should influence the resolved content rectangle. " - + $"IndentedRect.xMin={expectedBaselineRect.xMin}, " - + $"AlignmentOffset={UnityListAlignmentOffset}, " - + $"ExpectedXMin={expectedXMin}, " - + $"ActualXMin={drawer.LastResolvedPosition.xMin}" - ); - - const float LeftPadding = 16f; - const float RightPadding = 8f; - const float HorizontalPadding = LeftPadding + RightPadding; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - setProperty.isExpanded = true; - using ( - GroupGUIWidthUtility.PushContentPadding( - HorizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = IndentDepth; - try - { - drawer.OnGUI(controlRect, setProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - } - }); - - Rect paddedRect = drawer.LastResolvedPosition; - Assert.That( - paddedRect.xMin, - Is.EqualTo(expectedBaselineRect.xMin + LeftPadding).Within(0.0001f), - "Group padding should offset the rendered content." - ); - Assert.That( - paddedRect.width, - Is.EqualTo(Mathf.Max(0f, expectedBaselineRect.width - HorizontalPadding)) - .Within(0.0001f), - "Group padding should reduce usable width." - ); - Assert.IsTrue(drawer.HasItemsContainerRect, "Container rect should be recorded."); - Assert.That( - drawer.LastItemsContainerRect.xMin, - Is.EqualTo(paddedRect.xMin).Within(0.0001f), - "Container rect should align with the padded content." - ); - } - - [UnityTest] - public IEnumerator ManualEntryHeaderHonorsGroupPadding() - { - StringSetHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(StringSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - SerializableSetPropertyDrawer.PendingEntry pending = drawer.GetOrCreatePendingEntry( - setProperty, - setProperty.propertyPath, - typeof(string), - isSortedSet: false - ); - pending.isExpanded = true; - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Set"); - - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - bool baselineSetExpandedBeforeDraw = setProperty.isExpanded; - bool baselinePendingExpandedBeforeDraw = pending.isExpanded; - - int baselineIndentBefore = 0; - int baselineIndentAfter = 0; - Rect baselineResolvedPosition = default; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - baselineIndentBefore = EditorGUI.indentLevel; - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = 0; - try - { - drawer.OnGUI(controlRect, setProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - baselineIndentAfter = EditorGUI.indentLevel; - baselineResolvedPosition = drawer.LastResolvedPosition; - }); - - serializedObject.Update(); - bool baselineSetExpandedAfterDraw = setProperty.isExpanded; - TestContext.WriteLine( - $"ManualEntryHeader baseline foldout states -> set: {baselineSetExpandedBeforeDraw}->{baselineSetExpandedAfterDraw}, pending: {baselinePendingExpandedBeforeDraw}->{pending.isExpanded}" - ); - TestContext.WriteLine( - $"ManualEntryHeader baseline context -> indentLevel: {baselineIndentBefore}->{baselineIndentAfter}, " - + $"resolvedPosition: ({baselineResolvedPosition.x:F2}, {baselineResolvedPosition.y:F2}, {baselineResolvedPosition.width:F2}, {baselineResolvedPosition.height:F2})" - ); - - Assert.IsTrue( - SerializableSetPropertyDrawer.HasLastManualEntryHeaderRect, - "Baseline draw should capture the manual entry header layout." - ); - - Rect baselineHeader = SerializableSetPropertyDrawer.LastManualEntryHeaderRect; - - const float LeftPadding = 20f; - const float RightPadding = 12f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - bool groupedSetExpandedBeforeDraw = setProperty.isExpanded; - bool groupedPendingExpandedBeforeDraw = pending.isExpanded; - - int groupedIndentBefore = 0; - int groupedIndentAfter = 0; - Rect groupedResolvedPosition = default; - float groupedCurrentLeftPadding = 0f; - float groupedCurrentRightPadding = 0f; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - groupedCurrentLeftPadding = GroupGUIWidthUtility.CurrentLeftPadding; - groupedCurrentRightPadding = GroupGUIWidthUtility.CurrentRightPadding; - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - pending.isExpanded = true; - groupedIndentBefore = EditorGUI.indentLevel; - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = 0; - try - { - drawer.OnGUI(controlRect, setProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - groupedIndentAfter = EditorGUI.indentLevel; - groupedResolvedPosition = drawer.LastResolvedPosition; - } - }); - - serializedObject.Update(); - bool groupedSetExpandedAfterDraw = setProperty.isExpanded; - TestContext.WriteLine( - $"ManualEntryHeader grouped foldout states -> set: {groupedSetExpandedBeforeDraw}->{groupedSetExpandedAfterDraw}, pending: {groupedPendingExpandedBeforeDraw}->{pending.isExpanded}" - ); - TestContext.WriteLine( - $"ManualEntryHeader grouped context -> indentLevel: {groupedIndentBefore}->{groupedIndentAfter}, " - + $"resolvedPosition: ({groupedResolvedPosition.x:F2}, {groupedResolvedPosition.y:F2}, {groupedResolvedPosition.width:F2}, {groupedResolvedPosition.height:F2}), " - + $"currentPadding: left={groupedCurrentLeftPadding:F2}, right={groupedCurrentRightPadding:F2}" - ); - - Assert.IsTrue( - SerializableSetPropertyDrawer.HasLastManualEntryHeaderRect, - "Grouped draw should capture the manual entry header layout." - ); - - Rect groupedHeader = SerializableSetPropertyDrawer.LastManualEntryHeaderRect; - - float contentDeltaX = groupedResolvedPosition.x - baselineResolvedPosition.x; - float contentDeltaWidth = - baselineResolvedPosition.width - groupedResolvedPosition.width; - - TestContext.WriteLine( - $"ManualEntryHeader diagnostics -> baseline.xMin={baselineHeader.xMin:F2}, baseline.width={baselineHeader.width:F2}, " - + $"grouped.xMin={groupedHeader.xMin:F2}, grouped.width={groupedHeader.width:F2}, " - + $"LeftPadding={LeftPadding:F2}, RightPadding={RightPadding:F2}, " - + $"contentDeltaX={contentDeltaX:F2}, contentDeltaWidth={contentDeltaWidth:F2}, " - + $"expected.xMin={baselineHeader.xMin + contentDeltaX:F2}, " - + $"expected.width={Mathf.Max(0f, baselineHeader.width - contentDeltaWidth):F2}" - ); - - Assert.That( - groupedHeader.xMin, - Is.EqualTo(baselineHeader.xMin + contentDeltaX).Within(0.5f), - "Manual entry header should shift by the content rect's x delta." - ); - Assert.That( - groupedHeader.width, - Is.EqualTo(Mathf.Max(0f, baselineHeader.width - contentDeltaWidth)).Within(0.5f), - "Manual entry header width should shrink by the content rect's width delta." - ); - } - - [UnityTest] - public IEnumerator ManualEntryValueFieldHonorsGroupPadding() - { - StringSetHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(StringSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - SerializableSetPropertyDrawer.PendingEntry pending = drawer.GetOrCreatePendingEntry( - setProperty, - setProperty.propertyPath, - typeof(string), - isSortedSet: false - ); - pending.isExpanded = true; - - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Set"); - - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - bool baselineSetExpandedBeforeDraw = setProperty.isExpanded; - bool baselinePendingExpandedBeforeDraw = pending.isExpanded; - Rect baselineResolvedPosition = default; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - pending.isExpanded = true; - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = 0; - try - { - drawer.OnGUI(controlRect, setProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - baselineResolvedPosition = drawer.LastResolvedPosition; - }); - - serializedObject.Update(); - bool baselineSetExpandedAfterDraw = setProperty.isExpanded; - TestContext.WriteLine( - $"ManualEntryValue baseline foldout states -> set: {baselineSetExpandedBeforeDraw}->{baselineSetExpandedAfterDraw}, pending: {baselinePendingExpandedBeforeDraw}->{pending.isExpanded}" - ); - - Assert.IsTrue( - SerializableSetPropertyDrawer.HasLastManualEntryValueRect, - "Baseline draw should capture the manual entry value layout." - ); - - Rect baselineValue = SerializableSetPropertyDrawer.LastManualEntryValueRect; - - const float LeftPadding = 18f; - const float RightPadding = 14f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - bool groupedSetExpandedBeforeDraw = setProperty.isExpanded; - bool groupedPendingExpandedBeforeDraw = pending.isExpanded; - Rect groupedResolvedPosition = default; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - pending.isExpanded = true; - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = 0; - try - { - drawer.OnGUI(controlRect, setProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - groupedResolvedPosition = drawer.LastResolvedPosition; - } - }); - - serializedObject.Update(); - bool groupedSetExpandedAfterDraw = setProperty.isExpanded; - TestContext.WriteLine( - $"ManualEntryValue grouped foldout states -> set: {groupedSetExpandedBeforeDraw}->{groupedSetExpandedAfterDraw}, pending: {groupedPendingExpandedBeforeDraw}->{pending.isExpanded}" - ); - - Assert.IsTrue( - SerializableSetPropertyDrawer.HasLastManualEntryValueRect, - "Grouped draw should capture the manual entry value layout." - ); - - Rect groupedValue = SerializableSetPropertyDrawer.LastManualEntryValueRect; - - float contentDeltaX = groupedResolvedPosition.x - baselineResolvedPosition.x; - float contentDeltaWidth = - baselineResolvedPosition.width - groupedResolvedPosition.width; - - Assert.That( - groupedValue.xMin, - Is.EqualTo(baselineValue.xMin + contentDeltaX).Within(0.5f), - "Manual entry value field should shift by the content rect's x delta." - ); - Assert.That( - groupedValue.width, - Is.EqualTo(Mathf.Max(0f, baselineValue.width - contentDeltaWidth)).Within(0.5f), - "Manual entry value width should shrink by the content rect's width delta." - ); - } - - [UnityTest] - public IEnumerator ManualEntryFoldoutRespectsExplicitExpansionBeforeInitialization() - { - StringSetHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(StringSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - serializedObject.Update(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Set"); - - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - bool setExpandedBeforeDraw = setProperty.isExpanded; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, setProperty, label); - }); - - serializedObject.Update(); - bool setExpandedAfterDraw = setProperty.isExpanded; - TestContext.WriteLine( - $"ManualEntry explicit expansion state -> {setExpandedBeforeDraw}->{setExpandedAfterDraw}" - ); - - Assert.IsTrue( - setExpandedAfterDraw, - "Set foldout should remain expanded when explicitly set before the first draw." - ); - Assert.IsTrue( - SerializableSetPropertyDrawer.HasLastManualEntryHeaderRect, - "Manual entry header should render when the foldout remains expanded." - ); - } - - [UnityTest] - public IEnumerator ManualEntryAnimBoolSynchronizesWithExpandedState() - { - StringSetHost host = CreateScriptableObject(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(StringSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 360f, 420f); - GUIContent label = new("Set"); - - SerializableSetPropertyDrawer.PendingEntry pending = drawer.GetOrCreatePendingEntry( - setProperty, - setProperty.propertyPath, - typeof(string), - isSortedSet: false - ); - pending.isExpanded = false; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, setProperty, label); - }); - - bool rectsAfterCollapsed = SerializableSetPropertyDrawer.HasLastManualEntryValueRect; - TestContext.WriteLine( - $"[ManualEntryAnimBoolSynchronizesWithExpandedState] after collapsed draw: " - + $"hasValueRect={rectsAfterCollapsed}" - ); - - pending.isExpanded = true; - - // Assigning value rather than target skips the animation instead of starting it. - if (pending.foldoutAnim != null) - { - pending.foldoutAnim.value = pending.isExpanded; - } - - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - drawer.OnGUI(controlRect, setProperty, label); - }); - - bool rectsAfterExpanded = SerializableSetPropertyDrawer.HasLastManualEntryValueRect; - TestContext.WriteLine( - $"[ManualEntryAnimBoolSynchronizesWithExpandedState] after expanded draw: " - + $"hasValueRect={rectsAfterExpanded}, isExpanded={pending.isExpanded}" - ); - - Assert.IsTrue( - rectsAfterExpanded, - "Manual entry value rect should be captured after expansion when animation completes." - ); - } - - [UnityTest] - public IEnumerator RowContentHonorsGroupPadding() - { - ComplexSetHost host = CreateScriptableObject(); - ISerializableSetInspector inspector = host.set; - Array snapshot = new ComplexSetElement[1]; - snapshot.SetValue( - new ComplexSetElement - { - primary = Color.yellow, - nested = new NestedComplexElement - { - intensity = 2.5f, - offset = new Vector2(3f, 1f), - }, - }, - 0 - ); - inspector.SetSerializedItemsSnapshot(snapshot, preserveSerializedEntries: true); - inspector.SynchronizeSerializedState(); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(ComplexSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 420f, 520f); - GUIContent label = new("Set"); - - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - bool baselineSetExpandedBeforeDraw = setProperty.isExpanded; - Rect baselineResolvedPosition = default; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = 0; - try - { - drawer.OnGUI(controlRect, setProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - baselineResolvedPosition = drawer.LastResolvedPosition; - }); - - serializedObject.Update(); - bool baselineSetExpandedAfterDraw = setProperty.isExpanded; - TestContext.WriteLine( - $"RowContent baseline set foldout state -> {baselineSetExpandedBeforeDraw}->{baselineSetExpandedAfterDraw}" - ); - - Assert.IsTrue( - SerializableSetPropertyDrawer.HasLastRowContentRect, - "Baseline draw should capture the row content layout." - ); - - Rect baselineRow = SerializableSetPropertyDrawer.LastRowContentRect; - - const float LeftPadding = 24f; - const float RightPadding = 10f; - float horizontalPadding = LeftPadding + RightPadding; - - GroupGUIWidthUtility.ResetForTests(); - - bool groupedSetExpandedBeforeDraw = setProperty.isExpanded; - Rect groupedResolvedPosition = default; - - yield return TestIMGUIExecutor.Run(() => - { - setProperty.serializedObject.UpdateIfRequiredOrScript(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - LeftPadding, - RightPadding - ) - ) - { - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - int previousIndent = EditorGUI.indentLevel; - EditorGUI.indentLevel = 0; - try - { - drawer.OnGUI(controlRect, setProperty, label); - } - finally - { - EditorGUI.indentLevel = previousIndent; - } - groupedResolvedPosition = drawer.LastResolvedPosition; - } - }); - - serializedObject.Update(); - bool groupedSetExpandedAfterDraw = setProperty.isExpanded; - TestContext.WriteLine( - $"RowContent grouped set foldout state -> {groupedSetExpandedBeforeDraw}->{groupedSetExpandedAfterDraw}" - ); - - Assert.IsTrue( - SerializableSetPropertyDrawer.HasLastRowContentRect, - "Grouped draw should capture the row content layout." - ); - - Rect groupedRow = SerializableSetPropertyDrawer.LastRowContentRect; - - float contentDeltaX = groupedResolvedPosition.x - baselineResolvedPosition.x; - float contentDeltaWidth = - baselineResolvedPosition.width - groupedResolvedPosition.width; - - Assert.That( - groupedRow.xMin, - Is.EqualTo(baselineRow.xMin + contentDeltaX).Within(0.5f), - "Row content should shift by the content rect's x delta." - ); - Assert.That( - groupedRow.width, - Is.EqualTo(Mathf.Max(0f, baselineRow.width - contentDeltaWidth)).Within(0.5f), - "Row content width should shrink by the content rect's width delta." - ); - } - [UnityTest] public IEnumerator SetDrawerDoesNotResetUnappliedScalarChanges() { @@ -3554,7 +2927,7 @@ public void GetPropertyHeightIncreasesWhenPendingEntryIsExpanded() { using (new SetTweenDisabledScope()) { - bool tweenEnabled = SerializableSetPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout( isSortedSet: false ); Assert.IsFalse( @@ -3602,7 +2975,7 @@ public void GetPropertyHeightDecreasesWhenPendingEntryIsCollapsed() { using (new SetTweenDisabledScope()) { - bool tweenEnabled = SerializableSetPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout( isSortedSet: false ); Assert.IsFalse( @@ -3650,7 +3023,7 @@ public void GetPropertyHeightCacheInvalidatesWhenPendingExpandStateChanges() { using (new SetTweenDisabledScope()) { - bool tweenEnabled = SerializableSetPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout( isSortedSet: false ); Assert.IsFalse( @@ -3743,7 +3116,7 @@ public void GetPropertyHeightDiffersBetweenExpandedAndCollapsedPendingForEmptySe { using (new SetTweenDisabledScope()) { - bool tweenEnabled = SerializableSetPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout( isSortedSet: false ); Assert.IsFalse( @@ -3793,7 +3166,7 @@ public void GetPropertyHeightPendingExpandAffectsHeightEvenWithSetEntries() { using (new SetTweenDisabledScope()) { - bool tweenEnabled = SerializableSetPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout( isSortedSet: false ); Assert.IsFalse( @@ -3845,7 +3218,7 @@ public void GetPropertyHeightTogglingPendingMultipleTimesUpdatesHeightCorrectly( { using (new SetTweenDisabledScope()) { - bool tweenEnabled = SerializableSetPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout( isSortedSet: false ); Assert.IsFalse( @@ -3912,7 +3285,7 @@ public void GetPropertyHeightSortedSetPendingExpandBehavesCorrectly() { using (new SortedSetTweenDisabledScope()) { - bool tweenEnabled = SerializableSetPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout( isSortedSet: true ); Assert.IsFalse( @@ -3960,7 +3333,7 @@ public void GetPropertyHeightComplexSetPendingExpandUpdatesHeight() { using (new SetTweenDisabledScope()) { - bool tweenEnabled = SerializableSetPropertyDrawer.IsTweeningEnabledForTests( + bool tweenEnabled = SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout( isSortedSet: false ); Assert.IsFalse( diff --git a/Tests/Editor/CustomDrawers/StringInListDrawerTests.cs b/Tests/Editor/CustomDrawers/StringInListDrawerTests.cs index 10fd8ab18..79347b067 100644 --- a/Tests/Editor/CustomDrawers/StringInListDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/StringInListDrawerTests.cs @@ -119,30 +119,30 @@ public void SelectorWritesSelectedIndexToIntegerProperty() [Test] public void PopupChromeIncludesFooterPadding() { - float chrome = StringInListDrawer.TestHooks.CalculatePopupChromeHeight( + float chrome = StringInListDrawerTestAccess.CalculatePopupChromeHeight( includePagination: true ); float searchHeight = EditorGUIUtility.singleLineHeight; - float paginationHeight = StringInListDrawer.TestHooks.PaginationButtonHeight; + float paginationHeight = StringInListDrawerTestAccess.PaginationButtonHeight; float footerHeight = chrome - (searchHeight + paginationHeight); float expectedFooterHeight = EditorGUIUtility.standardVerticalSpacing - + StringInListDrawer.TestHooks.OptionFooterPadding; + + StringInListDrawerTestAccess.OptionFooterPadding; Assert.That(footerHeight, Is.EqualTo(expectedFooterHeight).Within(0.001f)); } [Test] public void PopupChromeAddsPaginationHeightWhenRequired() { - float withPagination = StringInListDrawer.TestHooks.CalculatePopupChromeHeight( + float withPagination = StringInListDrawerTestAccess.CalculatePopupChromeHeight( includePagination: true ); - float withoutPagination = StringInListDrawer.TestHooks.CalculatePopupChromeHeight( + float withoutPagination = StringInListDrawerTestAccess.CalculatePopupChromeHeight( includePagination: false ); float difference = withPagination - withoutPagination; float expectedDifference = - StringInListDrawer.TestHooks.PaginationButtonHeight + StringInListDrawerTestAccess.PaginationButtonHeight - EditorGUIUtility.standardVerticalSpacing; Assert.That(difference, Is.EqualTo(expectedDifference).Within(0.001f)); } @@ -151,12 +151,12 @@ public void PopupChromeAddsPaginationHeightWhenRequired() public void PopupTargetHeightAggregatesChromeAndRows() { const int pageSize = 10; - float chrome = StringInListDrawer.TestHooks.CalculatePopupChromeHeight( + float chrome = StringInListDrawerTestAccess.CalculatePopupChromeHeight( includePagination: false ); - float rowHeight = StringInListDrawer.TestHooks.GetOptionRowHeight(); + float rowHeight = StringInListDrawerTestAccess.GetOptionRowHeight(); float expected = chrome + (pageSize * rowHeight); - float actual = StringInListDrawer.TestHooks.CalculatePopupTargetHeight( + float actual = StringInListDrawerTestAccess.CalculatePopupTargetHeight( pageSize, includePagination: false ); @@ -166,12 +166,12 @@ public void PopupTargetHeightAggregatesChromeAndRows() [Test] public void PopupTargetHeightFitsSingleRowWithoutMinimumClamp() { - float chrome = StringInListDrawer.TestHooks.CalculatePopupChromeHeight( + float chrome = StringInListDrawerTestAccess.CalculatePopupChromeHeight( includePagination: false ); - float rowHeight = StringInListDrawer.TestHooks.GetOptionRowHeight(); + float rowHeight = StringInListDrawerTestAccess.GetOptionRowHeight(); float expected = chrome + rowHeight; - float actual = StringInListDrawer.TestHooks.CalculatePopupTargetHeight( + float actual = StringInListDrawerTestAccess.CalculatePopupTargetHeight( 1, includePagination: false ); @@ -181,30 +181,30 @@ public void PopupTargetHeightFitsSingleRowWithoutMinimumClamp() [Test] public void PopupTargetHeightScalesWithLargePageSizes() { - float twentyFiveRows = StringInListDrawer.TestHooks.CalculatePopupTargetHeight( + float twentyFiveRows = StringInListDrawerTestAccess.CalculatePopupTargetHeight( 25, includePagination: true ); - float fiftyRows = StringInListDrawer.TestHooks.CalculatePopupTargetHeight( + float fiftyRows = StringInListDrawerTestAccess.CalculatePopupTargetHeight( 50, includePagination: true ); - float rowHeight = StringInListDrawer.TestHooks.GetOptionRowHeight(); + float rowHeight = StringInListDrawerTestAccess.GetOptionRowHeight(); Assert.That(fiftyRows - twentyFiveRows, Is.EqualTo(25 * rowHeight).Within(0.001f)); } [Test] public void PopupTargetHeightTreatsNonPositivePageSizesAsSingleRow() { - float baseline = StringInListDrawer.TestHooks.CalculatePopupTargetHeight( + float baseline = StringInListDrawerTestAccess.CalculatePopupTargetHeight( 1, includePagination: false ); - float zero = StringInListDrawer.TestHooks.CalculatePopupTargetHeight( + float zero = StringInListDrawerTestAccess.CalculatePopupTargetHeight( 0, includePagination: false ); - float negative = StringInListDrawer.TestHooks.CalculatePopupTargetHeight( + float negative = StringInListDrawerTestAccess.CalculatePopupTargetHeight( -5, includePagination: false ); @@ -215,28 +215,28 @@ public void PopupTargetHeightTreatsNonPositivePageSizesAsSingleRow() [Test] public void OptionRowHeightMatchesControlPlusEffectiveMargin() { - float control = StringInListDrawer.TestHooks.GetOptionControlHeight(); - int marginVertical = StringInListDrawer.TestHooks.OptionButtonMarginVertical; + float control = StringInListDrawerTestAccess.GetOptionControlHeight(); + int marginVertical = StringInListDrawerTestAccess.OptionButtonMarginVertical; float expected = control + Mathf.Max(0f, marginVertical - EditorGUIUtility.standardVerticalSpacing); - float actual = StringInListDrawer.TestHooks.GetOptionRowHeight(); + float actual = StringInListDrawerTestAccess.GetOptionRowHeight(); Assert.That(actual, Is.EqualTo(expected).Within(0.001f)); } [Test] public void EmptySearchHeightLeavesRoomForHelpBox() { - float emptyHeight = StringInListDrawer.TestHooks.CalculateEmptySearchHeight(); + float emptyHeight = StringInListDrawerTestAccess.CalculateEmptySearchHeight(); GUIStyle helpStyle = EditorStyles.helpBox; int helpMargin = helpStyle.margin?.horizontal ?? 0; float helpWidth = - StringInListDrawer.TestHooks.PopupWidthValue - - StringInListDrawer.TestHooks.EmptySearchHorizontalPaddingValue + StringInListDrawerTestAccess.PopupWidthValue + - StringInListDrawerTestAccess.EmptySearchHorizontalPaddingValue - helpMargin; helpWidth = Mathf.Max(32f, helpWidth); float helpHeight = helpStyle.CalcHeight( - new GUIContent(StringInListDrawer.TestHooks.EmptyResultsMessageValue), + new GUIContent(StringInListDrawerTestAccess.EmptyResultsMessageValue), helpWidth ) + (helpStyle.margin?.vertical ?? 0); // The expected height includes spacing around the search row, empty-result help box, and footer. @@ -244,8 +244,8 @@ public void EmptySearchHeightLeavesRoomForHelpBox() EditorGUIUtility.singleLineHeight + (EditorGUIUtility.standardVerticalSpacing * 4f) + helpHeight - + StringInListDrawer.TestHooks.OptionFooterPadding - + StringInListDrawer.TestHooks.EmptySearchExtraPaddingValue; + + StringInListDrawerTestAccess.OptionFooterPadding + + StringInListDrawerTestAccess.EmptySearchExtraPaddingValue; Assert.That(emptyHeight, Is.EqualTo(expected).Within(0.001f)); } @@ -254,7 +254,7 @@ public void EmptySearchHeightPrefersMeasuredHelpBoxHeight() { const float measuredHelpHeight = 42f; float emptyHeight = - StringInListDrawer.TestHooks.CalculateEmptySearchHeightWithMeasurement( + StringInListDrawerTestAccess.CalculateEmptySearchHeightWithMeasurement( measuredHelpHeight ); @@ -262,8 +262,8 @@ public void EmptySearchHeightPrefersMeasuredHelpBoxHeight() EditorGUIUtility.singleLineHeight + (EditorGUIUtility.standardVerticalSpacing * 4f) + measuredHelpHeight - + StringInListDrawer.TestHooks.OptionFooterPadding - + StringInListDrawer.TestHooks.EmptySearchExtraPaddingValue; + + StringInListDrawerTestAccess.OptionFooterPadding + + StringInListDrawerTestAccess.EmptySearchExtraPaddingValue; Assert.That(emptyHeight, Is.EqualTo(expected).Within(0.001f)); } @@ -291,7 +291,7 @@ public void CalculateRowsOnPageDataDrivenScenarios( int expectedRows ) { - int rows = StringInListDrawer.TestHooks.CalculateRowsOnPage( + int rows = StringInListDrawerTestAccess.CalculateRowsOnPage( filteredCount: filteredCount, pageSize: pageSize, currentPage: currentPage diff --git a/Tests/Editor/CustomDrawers/Utils/InLineEditorSharedCacheTests.cs b/Tests/Editor/CustomDrawers/Utils/InLineEditorSharedCacheTests.cs index aaa260a44..64d3e0964 100644 --- a/Tests/Editor/CustomDrawers/Utils/InLineEditorSharedCacheTests.cs +++ b/Tests/Editor/CustomDrawers/Utils/InLineEditorSharedCacheTests.cs @@ -101,13 +101,13 @@ private static IEnumerable SpecialKeyCharacterCases() public override void BaseSetUp() { base.BaseSetUp(); - InLineEditorShared.ClearCachedStateForTesting(); + InLineEditorSharedTestAccess.ClearCachedState(); } [TearDown] public override void TearDown() { - InLineEditorShared.ClearCachedStateForTesting(); + InLineEditorSharedTestAccess.ClearCachedState(); base.TearDown(); } @@ -121,7 +121,7 @@ public void FoldoutStatesUnderLimitAcceptsAll(int numberOfEntries, int expectedC } Assert.That( - InLineEditorShared.GetFoldoutStateCacheCountForTesting(), + InLineEditorSharedTestAccess.GetFoldoutStateCacheCount(), Is.EqualTo(expectedCount) ); @@ -129,7 +129,7 @@ public void FoldoutStatesUnderLimitAcceptsAll(int numberOfEntries, int expectedC { bool expected = i % 2 == 0; Assert.That( - InLineEditorShared.GetFoldoutStateForTesting($"foldout{i}"), + InLineEditorSharedTestAccess.GetFoldoutState($"foldout{i}"), Is.EqualTo(expected), $"Foldout state for foldout{i} should be {expected}" ); @@ -142,40 +142,40 @@ public void FoldoutStatesSetAndGetReturnCorrectValue() InLineEditorShared.SetFoldoutState("expandedKey", true); InLineEditorShared.SetFoldoutState("collapsedKey", false); - Assert.That(InLineEditorShared.GetFoldoutStateForTesting("expandedKey"), Is.True); - Assert.That(InLineEditorShared.GetFoldoutStateForTesting("collapsedKey"), Is.False); + Assert.That(InLineEditorSharedTestAccess.GetFoldoutState("expandedKey"), Is.True); + Assert.That(InLineEditorSharedTestAccess.GetFoldoutState("collapsedKey"), Is.False); } [Test] public void FoldoutStatesUpdateExistingKeyCorrectly() { InLineEditorShared.SetFoldoutState("toggleKey", true); - Assert.That(InLineEditorShared.GetFoldoutStateForTesting("toggleKey"), Is.True); + Assert.That(InLineEditorSharedTestAccess.GetFoldoutState("toggleKey"), Is.True); InLineEditorShared.SetFoldoutState("toggleKey", false); - Assert.That(InLineEditorShared.GetFoldoutStateForTesting("toggleKey"), Is.False); + Assert.That(InLineEditorSharedTestAccess.GetFoldoutState("toggleKey"), Is.False); InLineEditorShared.SetFoldoutState("toggleKey", true); - Assert.That(InLineEditorShared.GetFoldoutStateForTesting("toggleKey"), Is.True); + Assert.That(InLineEditorSharedTestAccess.GetFoldoutState("toggleKey"), Is.True); } [Test] public void FoldoutStatesEmptyKeyIsIgnored() { - int countBefore = InLineEditorShared.GetFoldoutStateCacheCountForTesting(); + int countBefore = InLineEditorSharedTestAccess.GetFoldoutStateCacheCount(); InLineEditorShared.SetFoldoutState(string.Empty, true); InLineEditorShared.SetFoldoutState(null, true); - int countAfter = InLineEditorShared.GetFoldoutStateCacheCountForTesting(); + int countAfter = InLineEditorSharedTestAccess.GetFoldoutStateCacheCount(); Assert.That(countAfter, Is.EqualTo(countBefore)); } [Test] public void FoldoutStatesGetForEmptyKeyReturnsFalse() { - Assert.That(InLineEditorShared.GetFoldoutStateForTesting(string.Empty), Is.False); - Assert.That(InLineEditorShared.GetFoldoutStateForTesting(null), Is.False); + Assert.That(InLineEditorSharedTestAccess.GetFoldoutState(string.Empty), Is.False); + Assert.That(InLineEditorSharedTestAccess.GetFoldoutState(null), Is.False); } [Test] @@ -188,7 +188,7 @@ public void ScrollPositionsUnderLimitAcceptsAll(int numberOfEntries) } Assert.That( - InLineEditorShared.GetScrollPositionCacheCountForTesting(), + InLineEditorSharedTestAccess.GetScrollPositionCacheCount(), Is.EqualTo(numberOfEntries) ); @@ -225,12 +225,12 @@ public void ScrollPositionsUpdateExistingKeyCorrectly() [Test] public void ScrollPositionsEmptyKeyIsIgnored() { - int countBefore = InLineEditorShared.GetScrollPositionCacheCountForTesting(); + int countBefore = InLineEditorSharedTestAccess.GetScrollPositionCacheCount(); InLineEditorShared.SetScrollPosition(string.Empty, new Vector2(1, 2)); InLineEditorShared.SetScrollPosition(null, new Vector2(3, 4)); - int countAfter = InLineEditorShared.GetScrollPositionCacheCountForTesting(); + int countAfter = InLineEditorSharedTestAccess.GetScrollPositionCacheCount(); Assert.That(countAfter, Is.EqualTo(countBefore)); } @@ -260,7 +260,7 @@ public void EditorCacheGetOrCreateEditorReturnsValidEditor() Track(editor); Assert.That(editor != null, Is.True, "Editor should be created"); - Assert.That(InLineEditorShared.GetEditorCacheCountForTesting(), Is.EqualTo(1)); + Assert.That(InLineEditorSharedTestAccess.GetEditorCacheCount(), Is.EqualTo(1)); } [Test] @@ -273,7 +273,7 @@ public void EditorCacheGetOrCreateEditorReturnsCachedInstance() Editor secondEditor = InLineEditorShared.GetOrCreateEditor(target); Assert.That(ReferenceEquals(firstEditor, secondEditor), Is.True); - Assert.That(InLineEditorShared.GetEditorCacheCountForTesting(), Is.EqualTo(1)); + Assert.That(InLineEditorSharedTestAccess.GetEditorCacheCount(), Is.EqualTo(1)); } [Test] @@ -282,7 +282,7 @@ public void EditorCacheGetOrCreateEditorWithNullReturnsNull() Editor editor = InLineEditorShared.GetOrCreateEditor(null); Assert.That(editor == null, Is.True); - Assert.That(InLineEditorShared.GetEditorCacheCountForTesting(), Is.EqualTo(0)); + Assert.That(InLineEditorSharedTestAccess.GetEditorCacheCount(), Is.EqualTo(0)); } [Test] @@ -297,7 +297,7 @@ public void EditorCacheMultipleTargetsCreatesSeparateEditors() Track(editor2); Assert.That(ReferenceEquals(editor1, editor2), Is.False); - Assert.That(InLineEditorShared.GetEditorCacheCountForTesting(), Is.EqualTo(2)); + Assert.That(InLineEditorSharedTestAccess.GetEditorCacheCount(), Is.EqualTo(2)); } [Test] @@ -329,20 +329,20 @@ public void ClearCacheClearsAllCaches() InLineEditorShared.SetScrollPosition("testScroll", new Vector2(10, 20)); Assert.That( - InLineEditorShared.GetFoldoutStateCacheCountForTesting(), + InLineEditorSharedTestAccess.GetFoldoutStateCacheCount(), Is.GreaterThan(0) ); Assert.That( - InLineEditorShared.GetScrollPositionCacheCountForTesting(), + InLineEditorSharedTestAccess.GetScrollPositionCacheCount(), Is.GreaterThan(0) ); _trackedObjects.Remove(editor); - InLineEditorShared.ClearCachedStateForTesting(); + InLineEditorSharedTestAccess.ClearCachedState(); - Assert.That(InLineEditorShared.GetFoldoutStateCacheCountForTesting(), Is.EqualTo(0)); - Assert.That(InLineEditorShared.GetScrollPositionCacheCountForTesting(), Is.EqualTo(0)); - Assert.That(InLineEditorShared.GetEditorCacheCountForTesting(), Is.EqualTo(0)); + Assert.That(InLineEditorSharedTestAccess.GetFoldoutStateCacheCount(), Is.EqualTo(0)); + Assert.That(InLineEditorSharedTestAccess.GetScrollPositionCacheCount(), Is.EqualTo(0)); + Assert.That(InLineEditorSharedTestAccess.GetEditorCacheCount(), Is.EqualTo(0)); } [Test] @@ -404,7 +404,7 @@ public void FoldoutStatesHandleSpecialCharacterKeys(string key, bool value) { InLineEditorShared.SetFoldoutState(key, value); - bool result = InLineEditorShared.GetFoldoutStateForTesting(key); + bool result = InLineEditorSharedTestAccess.GetFoldoutState(key); Assert.That(result, Is.EqualTo(value)); } diff --git a/Tests/Editor/CustomDrawers/ValidateAssignmentPropertyDrawerTests.cs b/Tests/Editor/CustomDrawers/ValidateAssignmentPropertyDrawerTests.cs index 5ceebfaca..1ee8f2fff 100644 --- a/Tests/Editor/CustomDrawers/ValidateAssignmentPropertyDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/ValidateAssignmentPropertyDrawerTests.cs @@ -28,7 +28,7 @@ public sealed class ValidateAssignmentPropertyDrawerTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - ValidateAssignmentPropertyDrawer.ClearHeightCache(); + WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils.ValidationShared.ClearHeightCache(); } [TestCase(null, true, TestName = "ValidateAssignment.Message.Null.UsesDefault")] @@ -824,7 +824,7 @@ public void ClearHeightCacheClearsCache() "First GetPropertyHeight call should not throw" ); - ValidateAssignmentPropertyDrawer.ClearHeightCache(); + WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils.ValidationShared.ClearHeightCache(); float height2 = 0f; Assert.DoesNotThrow( diff --git a/Tests/Editor/CustomDrawers/WEnumToggleButtonsDrawerTests.cs b/Tests/Editor/CustomDrawers/WEnumToggleButtonsDrawerTests.cs index cc8dd8cc4..0c551db0d 100644 --- a/Tests/Editor/CustomDrawers/WEnumToggleButtonsDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/WEnumToggleButtonsDrawerTests.cs @@ -23,6 +23,46 @@ namespace WallstopStudios.UnityHelpers.Tests.CustomDrawers [NUnit.Framework.Category("Integration")] public sealed class WEnumToggleButtonsDrawerTests : CommonTestBase { + private static IEnumerable CaptionCases() + { + foreach (bool shared in new[] { false, true }) + { + string route = shared ? "Shared" : "Standard"; + yield return new TestCaseData(shared, null, "(Unnamed)").SetName( + $"Caption.{route}.Null" + ); + yield return new TestCaseData(shared, string.Empty, "(Unnamed)").SetName( + $"Caption.{route}.Empty" + ); + yield return new TestCaseData(shared, " ", "(Unnamed)").SetName( + $"Caption.{route}.Space" + ); + yield return new TestCaseData(shared, "\t\r\n", "(Unnamed)").SetName( + $"Caption.{route}.Control" + ); + yield return new TestCaseData(shared, "\u00a0\u2003", "(Unnamed)").SetName( + $"Caption.{route}.Unicode" + ); + yield return new TestCaseData(shared, " Visible ", " Visible ").SetName( + $"Caption.{route}.Padded" + ); + } + } + + private static IEnumerable LiteralStringCases() + { + yield return new TestCaseData(" ", "(Unnamed)").SetName("LiteralSelection.Space"); + yield return new TestCaseData("\t\r\n", "(Unnamed)").SetName( + "LiteralSelection.Control" + ); + yield return new TestCaseData("\u00a0\u2003", "(Unnamed)").SetName( + "LiteralSelection.Unicode" + ); + yield return new TestCaseData(" Visible ", " Visible ").SetName( + "LiteralSelection.Padded" + ); + } + // Negative enum members previously overflowed Convert.ToUInt64 and broke inspector drawing. private static IEnumerable SignedUnderlyingTypeCases() { @@ -440,6 +480,67 @@ public void IntDropDownOptionsRespectSelection() Assert.AreEqual(60, asset.intSelection); } + [TestCaseSource(nameof(CaptionCases))] + public void ToggleCaptionsUseReadableFallback(bool shared, string caption, string expected) + { + object value = new object(); + if (shared) + { + EnumShared.ToggleOption option = new(caption, value, 17UL, true); + Assert.That(option.Label, Is.EqualTo(expected)); + Assert.That(option.Value, Is.SameAs(value)); + Assert.That(option.FlagValue, Is.EqualTo(17UL)); + Assert.That(option.IsZeroFlag, Is.True); + } + else + { + ToggleOption option = new(caption, value, 17UL, true); + Assert.That(option.Label, Is.EqualTo(expected)); + Assert.That(option.Value, Is.SameAs(value)); + Assert.That(option.FlagValue, Is.EqualTo(17UL)); + Assert.That(option.IsZeroFlag, Is.True); + } + } + + [TestCaseSource(nameof(LiteralStringCases))] + public void StringInListSelectionAndSerializationPreserveLiteralWhitespace( + string value, + string expectedCaption + ) + { + ToggleTestAsset asset = CreateScriptableObject(); + using SerializedObject serializedObject = new(asset); + serializedObject.Update(); + using SerializedProperty property = serializedObject.FindProperty( + nameof(ToggleTestAsset.literalStateName) + ); + ToggleSet toggleSet = WEnumToggleButtonsUtility.CreateToggleSet( + property, + GetFieldInfo(nameof(ToggleTestAsset.literalStateName)) + ); + Assert.That(toggleSet.Source, Is.EqualTo(ToggleSource.Dropdown)); + ToggleOption option = GetOptionByValue(toggleSet, value); + Assert.That(option.Label, Is.EqualTo(expectedCaption)); + Assert.That(option.Value, Is.EqualTo(value)); + WEnumToggleButtonsUtility.ApplyOption(property, toggleSet, option, true); + serializedObject.ApplyModifiedProperties(); + Assert.That(asset.literalStateName, Is.EqualTo(value)); + serializedObject.Update(); + Assert.That(property.stringValue, Is.EqualTo(value)); + Assert.That( + WEnumToggleButtonsUtility.IsOptionActive(property, toggleSet, option), + Is.True + ); + ToggleOption different = new("Other", value + " ", 0UL, false); + Assert.That( + WEnumToggleButtonsUtility.IsOptionActive(property, toggleSet, different), + Is.False + ); + ToggleTestAsset restored = CreateScriptableObject(); + EditorJsonUtility.FromJsonOverwrite(EditorJsonUtility.ToJson(asset), restored); + Assert.That(restored.literalStateName, Is.EqualTo(value)); + } + [Test] public void StringInListOptionsRespectSelection() { diff --git a/Tests/Editor/CustomDrawers/WGroupSerializableCollectionIntegrationTests.cs b/Tests/Editor/CustomDrawers/WGroupSerializableCollectionIntegrationTests.cs index cdafc1513..97b019948 100644 --- a/Tests/Editor/CustomDrawers/WGroupSerializableCollectionIntegrationTests.cs +++ b/Tests/Editor/CustomDrawers/WGroupSerializableCollectionIntegrationTests.cs @@ -49,10 +49,10 @@ private static IEnumerable IndentationTestCases() public override void BaseSetUp() { base.BaseSetUp(); - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); + SerializableDictionaryPropertyDrawerTestAccess.ResetLayoutTracking(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); + SerializableSetPropertyDrawerTestAccess.ResetLayoutTracking(); _originalDictionaryTweenEnabled = UnityHelpersSettings.ShouldTweenSerializableDictionaryFoldouts(); @@ -79,7 +79,7 @@ public override void TearDown() ); UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(_originalWGroupTweenEnabled); - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); base.TearDown(); } @@ -98,7 +98,7 @@ public void DictionaryInsideWGroupHasCorrectIndentationAtZeroLevel() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( HorizontalPadding, @@ -107,11 +107,10 @@ public void DictionaryInsideWGroupHasCorrectIndentationAtZeroLevel() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedX = controlRect.x + SimulatedLeftPadding; Assert.AreEqual( @@ -146,7 +145,7 @@ public void SetInsideWGroupHasCorrectIndentationAtZeroLevel() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( HorizontalPadding, @@ -155,7 +154,7 @@ public void SetInsideWGroupHasCorrectIndentationAtZeroLevel() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -178,201 +177,195 @@ public void SetInsideWGroupHasCorrectIndentationAtZeroLevel() } } - [UnityTest] - public IEnumerator DictionaryFoldoutHasAlignmentOffsetWhenInsideWGroup() + [Test] + public void WGroupFoldoutAlignmentOffsetConstantHasExpectedValue() { - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) + Assert.AreEqual( + 2.5f, + SerializableDictionaryPropertyDrawer.WGroupFoldoutAlignmentOffset, + 0.01f, + "Dictionary WGroup foldout alignment offset should be 2.5f." ); - dictionaryProperty.isExpanded = false; - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; + Assert.AreEqual( + 2.5f, + SerializableSetPropertyDrawer.WGroupFoldoutAlignmentOffset, + 0.01f, + "Set WGroup foldout alignment offset should be 2.5f." + ); + } - Rect capturedFoldoutRect = default; - bool hasFoldoutRect = false; - int previousIndentLevel = EditorGUI.indentLevel; - string typeResolutionError = null; + [TestCase(0f, 0f, 0f, false, Description = "Zero padding - no WGroup context, no offset")] + [TestCase( + 0.001f, + 0f, + 0f, + true, + Description = "Tiny horizontal padding - creates WGroup context with offset" + )] + [TestCase( + 0f, + 0.001f, + 0f, + true, + Description = "Tiny left padding only - creates WGroup context with offset" + )] + [TestCase( + 0f, + 0f, + 0.001f, + true, + Description = "Tiny right padding only - creates WGroup context with offset" + )] + [TestCase(10f, 5f, 5f, true, Description = "Typical padding - creates WGroup context")] + [TestCase(24f, 12f, 12f, true, Description = "Larger padding - creates WGroup context")] + public void AlignmentOffsetAppliedBasedOnPaddingContext( + float horizontalPadding, + float leftPadding, + float rightPadding, + bool expectOffset + ) + { + GroupGUIWidthUtilityTestAccess.Reset(); - yield return TestIMGUIExecutor.Run(() => + using ( + GroupGUIWidthUtility.PushContentPadding( + horizontalPadding, + leftPadding, + rightPadding + ) + ) { - try - { - EditorGUI.indentLevel = 0; + int scopeDepth = GroupGUIWidthUtility.CurrentScopeDepth; + bool hasWGroupContext = 0 < scopeDepth; - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); + Assert.AreEqual( + expectOffset, + hasWGroupContext, + $"WGroup context (scope depth > 0) should be {expectOffset} with padding " + + $"(h={horizontalPadding}, l={leftPadding}, r={rightPadding}). " + + $"Actual scope depth: {scopeDepth}" + ); + } + } - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) - { - drawer.OnGUI(controlRect, dictionaryProperty, label); - - hasFoldoutRect = - SerializableDictionaryPropertyDrawer.HasLastMainFoldoutRect; - if (hasFoldoutRect) - { - capturedFoldoutRect = - SerializableDictionaryPropertyDrawer.LastMainFoldoutRect; - } - else - { - typeResolutionError = - $"OnGUI completed but HasLastMainFoldoutRect={hasFoldoutRect}. Property path: {dictionaryProperty.propertyPath}"; - } - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); + [Test] + public void DictionaryTweenSettingsRespectedWhenEnabled() + { + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); Assert.IsTrue( - hasFoldoutRect, - $"Main foldout rect should be tracked after OnGUI. {typeResolutionError ?? ""}" + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( + isSortedDictionary: false + ), + "Dictionary tweening should be enabled when setting is true." ); + } - float expectedX = - controlRect.x - + SimulatedLeftPadding - + SerializableDictionaryPropertyDrawer.WGroupFoldoutAlignmentOffset; + [Test] + public void DictionaryTweenSettingsRespectedWhenDisabled() + { + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); - Assert.AreEqual( - expectedX, - capturedFoldoutRect.x, - 0.1f, - $"Dictionary foldout inside WGroup should be shifted right by alignment offset. " - + $"Input: controlRect.x={controlRect.x}, leftPadding={SimulatedLeftPadding}, " - + $"alignmentOffset={SerializableDictionaryPropertyDrawer.WGroupFoldoutAlignmentOffset}, " - + $"Expected x={expectedX}, Actual x={capturedFoldoutRect.x}" + Assert.IsFalse( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( + isSortedDictionary: false + ), + "Dictionary tweening should be disabled when setting is false." ); + } - float expectedWidth = - controlRect.width - - horizontalPadding - - SerializableDictionaryPropertyDrawer.WGroupFoldoutAlignmentOffset; - Assert.AreEqual( - expectedWidth, - capturedFoldoutRect.width, - 0.1f, - $"Dictionary foldout width should be reduced by alignment offset. " - + $"Input: controlRect.width={controlRect.width}, horizontalPadding={horizontalPadding}, " - + $"alignmentOffset={SerializableDictionaryPropertyDrawer.WGroupFoldoutAlignmentOffset}, " - + $"Expected width={expectedWidth}, Actual width={capturedFoldoutRect.width}" + [Test] + public void SortedDictionaryTweenSettingsRespectedWhenEnabled() + { + UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(true); + + Assert.IsTrue( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( + isSortedDictionary: true + ), + "Sorted dictionary tweening should be enabled when setting is true." ); } - [UnityTest] - public IEnumerator SetFoldoutHasAlignmentOffsetWhenInsideWGroup() + [Test] + public void SortedDictionaryTweenSettingsRespectedWhenDisabled() { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(false); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) + Assert.IsFalse( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( + isSortedDictionary: true + ), + "Sorted dictionary tweening should be disabled when setting is false." ); - setProperty.isExpanded = false; + } - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); + [Test] + public void SetTweenSettingsRespectedWhenEnabled() + { + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; + Assert.IsTrue( + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false), + "Set tweening should be enabled when setting is true." + ); + } - Rect capturedFoldoutRect = default; - bool hasFoldoutRect = false; - int previousIndentLevel = EditorGUI.indentLevel; + [Test] + public void SetTweenSettingsRespectedWhenDisabled() + { + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - yield return TestIMGUIExecutor.Run(() => - { - try - { - EditorGUI.indentLevel = 0; + Assert.IsFalse( + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: false), + "Set tweening should be disabled when setting is false." + ); + } - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + [Test] + public void SortedSetTweenSettingsRespectedWhenEnabled() + { + UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) - { - drawer.OnGUI(controlRect, setProperty, label); + Assert.IsTrue( + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: true), + "Sorted set tweening should be enabled when setting is true." + ); + } - hasFoldoutRect = SerializableSetPropertyDrawer.HasLastMainFoldoutRect; - if (hasFoldoutRect) - { - capturedFoldoutRect = SerializableSetPropertyDrawer.LastMainFoldoutRect; - } - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); + [Test] + public void SortedSetTweenSettingsRespectedWhenDisabled() + { + UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(false); - Assert.IsTrue(hasFoldoutRect, "Main foldout rect should be tracked after OnGUI."); + Assert.IsFalse( + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(isSortedSet: true), + "Sorted set tweening should be disabled when setting is false." + ); + } - float expectedX = - controlRect.x - + SimulatedLeftPadding - + SerializableSetPropertyDrawer.WGroupFoldoutAlignmentOffset; + [Test] + public void WGroupTweenSettingsAreIndependentOfDictionaryTweenSettings() + { + UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(true); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); - Assert.AreEqual( - expectedX, - capturedFoldoutRect.x, - 0.1f, - $"Set foldout inside WGroup should be shifted right by alignment offset. " - + $"Input: controlRect.x={controlRect.x}, leftPadding={SimulatedLeftPadding}, " - + $"alignmentOffset={SerializableSetPropertyDrawer.WGroupFoldoutAlignmentOffset}, " - + $"Expected x={expectedX}, Actual x={capturedFoldoutRect.x}" + Assert.IsTrue( + UnityHelpersSettings.ShouldTweenWGroupFoldouts(), + "WGroup tweening should be enabled independently." ); - float expectedWidth = - controlRect.width - - horizontalPadding - - SerializableSetPropertyDrawer.WGroupFoldoutAlignmentOffset; - Assert.AreEqual( - expectedWidth, - capturedFoldoutRect.width, - 0.1f, - $"Set foldout width should be reduced by alignment offset. " - + $"Input: controlRect.width={controlRect.width}, horizontalPadding={horizontalPadding}, " - + $"alignmentOffset={SerializableSetPropertyDrawer.WGroupFoldoutAlignmentOffset}, " - + $"Expected width={expectedWidth}, Actual width={capturedFoldoutRect.width}" + Assert.IsFalse( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( + isSortedDictionary: false + ), + "Dictionary tweening should remain disabled." ); } - [UnityTest] - public IEnumerator DictionaryFoldoutHasNoOffsetWhenPaddingIsZero() + [Test] + public void WGroupContextDoesNotAffectDictionaryTweeningSetting() { IntegrationTestWGroupDictionaryHost host = CreateScriptableObject(); @@ -381,1268 +374,50 @@ public IEnumerator DictionaryFoldoutHasNoOffsetWhenPaddingIsZero() SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); + UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(false); + + bool dictionaryTweenEnabled = + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout( + isSortedDictionary: false + ); + + Assert.IsTrue( + dictionaryTweenEnabled, + "Dictionary tweening setting should not be affected by WGroup tween setting." ); - dictionaryProperty.isExpanded = false; + } - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); + [Test] + public void DictionaryWidthIsNotNegativeWithLargePadding() + { + Rect controlRect = new(0f, 0f, 100f, 300f); - Rect capturedFoldoutRect = default; - Rect capturedResolvedPosition = default; - bool hasFoldoutRect = false; - string typeResolutionError = null; - int previousIndentLevel = EditorGUI.indentLevel; - int capturedScopeDepth = -1; + const float LargePadding = 200f; - yield return TestIMGUIExecutor.Run(() => + int previousIndentLevel = EditorGUI.indentLevel; + try { - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - using (GroupGUIWidthUtility.PushContentPadding(0f, 0f, 0f)) - { - capturedScopeDepth = GroupGUIWidthUtility.CurrentScopeDepth; - drawer.OnGUI(controlRect, dictionaryProperty, label); - - hasFoldoutRect = - SerializableDictionaryPropertyDrawer.HasLastMainFoldoutRect; - if (hasFoldoutRect) - { - capturedFoldoutRect = - SerializableDictionaryPropertyDrawer.LastMainFoldoutRect; - capturedResolvedPosition = drawer.LastResolvedPosition; - } - else - { - typeResolutionError = - $"OnGUI completed but HasLastMainFoldoutRect={hasFoldoutRect}. Property path: {dictionaryProperty?.propertyPath}"; - } - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.IsTrue( - hasFoldoutRect, - $"Main foldout rect should be tracked after OnGUI. {typeResolutionError ?? ""}" - ); - - Assert.AreEqual( - 0, - capturedScopeDepth, - $"Zero padding scope should not increment scope depth (no visual WGroup context). Actual: {capturedScopeDepth}" - ); - - Assert.AreEqual( - capturedResolvedPosition.x, - capturedFoldoutRect.x, - 0.1f, - $"Dictionary foldout with zero padding should not have alignment offset. " - + $"Expected x={capturedResolvedPosition.x:F3}, Actual x={capturedFoldoutRect.x:F3}" - ); - - Assert.AreEqual( - capturedResolvedPosition.width, - capturedFoldoutRect.width, - 0.1f, - $"Dictionary foldout width should match resolved position width when padding is zero. " - + $"Expected width={capturedResolvedPosition.width:F3}, Actual width={capturedFoldoutRect.width:F3}" - ); - } - - [UnityTest] - public IEnumerator SetFoldoutHasNoOffsetWhenPaddingIsZero() - { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) - ); - setProperty.isExpanded = false; - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - Rect capturedFoldoutRect = default; - Rect capturedResolvedPosition = default; - bool hasFoldoutRect = false; - int previousIndentLevel = EditorGUI.indentLevel; - int capturedScopeDepth = -1; - - yield return TestIMGUIExecutor.Run(() => - { - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - - using (GroupGUIWidthUtility.PushContentPadding(0f, 0f, 0f)) - { - capturedScopeDepth = GroupGUIWidthUtility.CurrentScopeDepth; - drawer.OnGUI(controlRect, setProperty, label); - - hasFoldoutRect = SerializableSetPropertyDrawer.HasLastMainFoldoutRect; - if (hasFoldoutRect) - { - capturedFoldoutRect = SerializableSetPropertyDrawer.LastMainFoldoutRect; - capturedResolvedPosition = drawer.LastResolvedPosition; - } - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.IsTrue(hasFoldoutRect, "Main foldout rect should be tracked after OnGUI."); - - Assert.AreEqual( - 0, - capturedScopeDepth, - $"Zero padding scope should not increment scope depth (no visual WGroup context). Actual: {capturedScopeDepth}" - ); - - Assert.AreEqual( - capturedResolvedPosition.x, - capturedFoldoutRect.x, - 0.1f, - $"Set foldout with zero padding should not have alignment offset. " - + $"Expected x={capturedResolvedPosition.x:F3}, Actual x={capturedFoldoutRect.x:F3}" - ); - - Assert.AreEqual( - capturedResolvedPosition.width, - capturedFoldoutRect.width, - 0.1f, - $"Set foldout width should match resolved position width when padding is zero. " - + $"Expected width={capturedResolvedPosition.width:F3}, Actual width={capturedFoldoutRect.width:F3}" - ); - } - - [UnityTest] - public IEnumerator DictionaryFoldoutHasNoAlignmentOffsetWhenNotInsideWGroup() - { - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = false; - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); - - Rect capturedFoldoutRect = default; - Rect capturedResolvedPosition = default; - bool hasFoldoutRect = false; - string typeResolutionError = null; - - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - - drawer.OnGUI(controlRect, dictionaryProperty, label); - - hasFoldoutRect = SerializableDictionaryPropertyDrawer.HasLastMainFoldoutRect; - if (hasFoldoutRect) - { - capturedFoldoutRect = - SerializableDictionaryPropertyDrawer.LastMainFoldoutRect; - capturedResolvedPosition = drawer.LastResolvedPosition; - } - else - { - typeResolutionError = - $"OnGUI completed but HasLastMainFoldoutRect={hasFoldoutRect}. Property path: {dictionaryProperty?.propertyPath}"; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.IsTrue( - hasFoldoutRect, - $"Main foldout rect should be tracked after OnGUI. {typeResolutionError ?? ""}" - ); - - Assert.AreEqual( - capturedResolvedPosition.x, - capturedFoldoutRect.x, - 0.1f, - "Dictionary foldout outside WGroup should not have alignment offset." - ); - } - - [UnityTest] - public IEnumerator SetFoldoutHasNoAlignmentOffsetWhenNotInsideWGroup() - { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) - ); - setProperty.isExpanded = false; - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - Rect capturedFoldoutRect = default; - Rect capturedResolvedPosition = default; - bool hasFoldoutRect = false; - - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - - drawer.OnGUI(controlRect, setProperty, label); - - hasFoldoutRect = SerializableSetPropertyDrawer.HasLastMainFoldoutRect; - if (hasFoldoutRect) - { - capturedFoldoutRect = SerializableSetPropertyDrawer.LastMainFoldoutRect; - capturedResolvedPosition = drawer.LastResolvedPosition; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.IsTrue(hasFoldoutRect, "Main foldout rect should be tracked after OnGUI."); - - Assert.AreEqual( - capturedResolvedPosition.x, - capturedFoldoutRect.x, - 0.1f, - "Set foldout outside WGroup should not have alignment offset." - ); - } - - [Test] - public void WGroupFoldoutAlignmentOffsetConstantHasExpectedValue() - { - Assert.AreEqual( - 2.5f, - SerializableDictionaryPropertyDrawer.WGroupFoldoutAlignmentOffset, - 0.01f, - "Dictionary WGroup foldout alignment offset should be 2.5f." - ); - - Assert.AreEqual( - 2.5f, - SerializableSetPropertyDrawer.WGroupFoldoutAlignmentOffset, - 0.01f, - "Set WGroup foldout alignment offset should be 2.5f." - ); - } - - [TestCase(0f, 0f, 0f, false, Description = "Zero padding - no WGroup context, no offset")] - [TestCase( - 0.001f, - 0f, - 0f, - true, - Description = "Tiny horizontal padding - creates WGroup context with offset" - )] - [TestCase( - 0f, - 0.001f, - 0f, - true, - Description = "Tiny left padding only - creates WGroup context with offset" - )] - [TestCase( - 0f, - 0f, - 0.001f, - true, - Description = "Tiny right padding only - creates WGroup context with offset" - )] - [TestCase(10f, 5f, 5f, true, Description = "Typical padding - creates WGroup context")] - [TestCase(24f, 12f, 12f, true, Description = "Larger padding - creates WGroup context")] - public void AlignmentOffsetAppliedBasedOnPaddingContext( - float horizontalPadding, - float leftPadding, - float rightPadding, - bool expectOffset - ) - { - GroupGUIWidthUtility.ResetForTests(); - - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - leftPadding, - rightPadding - ) - ) - { - int scopeDepth = GroupGUIWidthUtility.CurrentScopeDepth; - bool hasWGroupContext = 0 < scopeDepth; - - Assert.AreEqual( - expectOffset, - hasWGroupContext, - $"WGroup context (scope depth > 0) should be {expectOffset} with padding " - + $"(h={horizontalPadding}, l={leftPadding}, r={rightPadding}). " - + $"Actual scope depth: {scopeDepth}" - ); - } - } - - [UnityTest] - public IEnumerator DictionaryFoldoutAlignmentOffsetConsistentAcrossDrawerInstances() - { - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = false; - - SerializableDictionaryPropertyDrawer drawer1 = new(); - SerializableDictionaryPropertyDrawer drawer2 = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); - - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; - - Rect capturedFoldoutRect1 = default; - Rect capturedFoldoutRect2 = default; - - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) - { - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer1.OnGUI(controlRect, dictionaryProperty, label); - capturedFoldoutRect1 = - SerializableDictionaryPropertyDrawer.LastMainFoldoutRect; - - SerializableDictionaryPropertyDrawer.ResetLayoutTrackingForTests(); - drawer2.OnGUI(controlRect, dictionaryProperty, label); - capturedFoldoutRect2 = - SerializableDictionaryPropertyDrawer.LastMainFoldoutRect; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.AreEqual( - capturedFoldoutRect1.x, - capturedFoldoutRect2.x, - 0.01f, - "Foldout x position should be consistent across drawer instances." - ); - - Assert.AreEqual( - capturedFoldoutRect1.width, - capturedFoldoutRect2.width, - 0.01f, - "Foldout width should be consistent across drawer instances." - ); - } - - [Test] - public void DictionaryTweenSettingsRespectedWhenEnabled() - { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( - isSortedDictionary: false - ), - "Dictionary tweening should be enabled when setting is true." - ); - } - - [Test] - public void DictionaryTweenSettingsRespectedWhenDisabled() - { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); - - Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( - isSortedDictionary: false - ), - "Dictionary tweening should be disabled when setting is false." - ); - } - - [Test] - public void SortedDictionaryTweenSettingsRespectedWhenEnabled() - { - UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(true); - - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( - isSortedDictionary: true - ), - "Sorted dictionary tweening should be enabled when setting is true." - ); - } - - [Test] - public void SortedDictionaryTweenSettingsRespectedWhenDisabled() - { - UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(false); - - Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( - isSortedDictionary: true - ), - "Sorted dictionary tweening should be disabled when setting is false." - ); - } - - [Test] - public void SetTweenSettingsRespectedWhenEnabled() - { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - - Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false), - "Set tweening should be enabled when setting is true." - ); - } - - [Test] - public void SetTweenSettingsRespectedWhenDisabled() - { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - - Assert.IsFalse( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: false), - "Set tweening should be disabled when setting is false." - ); - } - - [Test] - public void SortedSetTweenSettingsRespectedWhenEnabled() - { - UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); - - Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: true), - "Sorted set tweening should be enabled when setting is true." - ); - } - - [Test] - public void SortedSetTweenSettingsRespectedWhenDisabled() - { - UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(false); - - Assert.IsFalse( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(isSortedSet: true), - "Sorted set tweening should be disabled when setting is false." - ); - } - - [Test] - public void WGroupTweenSettingsAreIndependentOfDictionaryTweenSettings() - { - UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(true); - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); - - Assert.IsTrue( - UnityHelpersSettings.ShouldTweenWGroupFoldouts(), - "WGroup tweening should be enabled independently." - ); - - Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( - isSortedDictionary: false - ), - "Dictionary tweening should remain disabled." - ); - } - - [Test] - public void WGroupContextDoesNotAffectDictionaryTweeningSetting() - { - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); - UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(false); - - bool dictionaryTweenEnabled = - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests( - isSortedDictionary: false - ); - - Assert.IsTrue( - dictionaryTweenEnabled, - "Dictionary tweening setting should not be affected by WGroup tween setting." - ); - } - - [UnityTest] - public IEnumerator DictionaryInWGroupRendersWithCorrectPaddingDuringOnGUI() - { - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; - host.dictionary["key2"] = 200; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Dictionary"); - - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 0; - try - { - serializedObject.UpdateIfRequiredOrScript(); - GroupGUIWidthUtility.ResetForTests(); - - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) - { - drawer.OnGUI(controlRect, dictionaryProperty, label); - capturedRect = drawer.LastResolvedPosition; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - float expectedX = controlRect.x + SimulatedLeftPadding; - Assert.AreEqual( - expectedX, - capturedRect.x, - 0.1f, - $"Dictionary OnGUI in WGroup context should apply WGroup padding correctly. " - + $"Input: controlRect.x={controlRect.x}, leftPadding={SimulatedLeftPadding}, " - + $"Expected x={expectedX}, Actual x={capturedRect.x}" - ); - - float expectedWidth = controlRect.width - horizontalPadding; - Assert.AreEqual( - expectedWidth, - capturedRect.width, - 0.1f, - $"Dictionary OnGUI in WGroup context should have width reduced by WGroup padding. " - + $"Input: controlRect.width={controlRect.width}, horizontalPadding={horizontalPadding}, " - + $"Expected width={expectedWidth}, Actual width={capturedRect.width}" - ); - } - - [UnityTest] - public IEnumerator SetInWGroupRendersWithCorrectPaddingDuringOnGUI() - { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(1); - host.set.Add(2); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) - ); - setProperty.isExpanded = true; - serializedObject.ApplyModifiedPropertiesWithoutUndo(); - - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; - - Rect capturedRect = default; - int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => - { - EditorGUI.indentLevel = 0; - try - { - serializedObject.UpdateIfRequiredOrScript(); - GroupGUIWidthUtility.ResetForTests(); - - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) - { - drawer.OnGUI(controlRect, setProperty, label); - capturedRect = drawer.LastResolvedPosition; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - float expectedX = controlRect.x + SimulatedLeftPadding; - Assert.AreEqual( - expectedX, - capturedRect.x, - 0.1f, - $"Set OnGUI in WGroup context should apply WGroup padding correctly. " - + $"Input: controlRect.x={controlRect.x}, leftPadding={SimulatedLeftPadding}, " - + $"Expected x={expectedX}, Actual x={capturedRect.x}" - ); - - float expectedWidth = controlRect.width - horizontalPadding; - Assert.AreEqual( - expectedWidth, - capturedRect.width, - 0.1f, - $"Set OnGUI in WGroup context should have width reduced by WGroup padding. " - + $"Input: controlRect.width={controlRect.width}, horizontalPadding={horizontalPadding}, " - + $"Expected width={expectedWidth}, Actual width={capturedRect.width}" - ); - } - - [Test] - public void DictionaryWidthIsNotNegativeWithLargePadding() - { - Rect controlRect = new(0f, 0f, 100f, 300f); - - const float LargePadding = 200f; - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - LargePadding, - LargePadding / 2f, - LargePadding / 2f - ) - ) - { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); - - Assert.GreaterOrEqual( - resolvedRect.width, - 0f, - "Width should not be negative even with padding larger than control rect." - ); - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void SetWidthIsNotNegativeWithLargePadding() - { - Rect controlRect = new(0f, 0f, 100f, 300f); - - const float LargePadding = 200f; - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - LargePadding, - LargePadding / 2f, - LargePadding / 2f - ) - ) - { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); - - Assert.GreaterOrEqual( - resolvedRect.width, - 0f, - "Width should not be negative even with padding larger than control rect." - ); - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void PaddingScopeIsProperlyCleanedUp() - { - GroupGUIWidthUtility.ResetForTests(); - - Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); - Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentRightPadding, 0.001f); - - using (GroupGUIWidthUtility.PushContentPadding(20f, 10f, 10f)) - { - Assert.AreEqual(10f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); - Assert.AreEqual(10f, GroupGUIWidthUtility.CurrentRightPadding, 0.001f); - } - - Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); - Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentRightPadding, 0.001f); - } - - [Test] - public void NestedPaddingScopesAccumulateCorrectly() - { - GroupGUIWidthUtility.ResetForTests(); - - using (GroupGUIWidthUtility.PushContentPadding(20f, 10f, 10f)) - { - Assert.AreEqual(10f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); - - using (GroupGUIWidthUtility.PushContentPadding(16f, 8f, 8f)) - { - Assert.AreEqual(18f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); - Assert.AreEqual(18f, GroupGUIWidthUtility.CurrentRightPadding, 0.001f); - } - - Assert.AreEqual(10f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); - } - - Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); - } - - [Test] - public void DictionaryIndentationConsistentAcrossMultipleDrawCalls() - { - Rect controlRect = new(0f, 0f, 400f, 300f); - - const float LeftPadding = 12f; - const float RightPadding = 12f; - - int previousIndentLevel = EditorGUI.indentLevel; - List capturedXPositions = new(); - - try - { - EditorGUI.indentLevel = 0; - - for (int i = 0; i < 5; ++i) - { - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - LeftPadding + RightPadding, - LeftPadding, - RightPadding - ) - ) - { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); - capturedXPositions.Add(resolvedRect.x); - } - } - - float firstX = capturedXPositions[0]; - foreach (float x in capturedXPositions) - { - Assert.AreEqual( - firstX, - x, - 0.01f, - "Indentation should be consistent across multiple draw calls." - ); - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void DictionaryInSettingsContextWithWGroupPaddingAppliesPadding() - { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - if (paletteProp == null) - { - Assert.Ignore("WButtonCustomColors property not found in settings."); - return; - } - - paletteProp.isExpanded = true; - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 300f); - - const float WGroupLeftPadding = 10f; - const float WGroupRightPadding = 10f; - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - WGroupLeftPadding + WGroupRightPadding, - WGroupLeftPadding, - WGroupRightPadding - ) - ) - { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); - - Assert.AreEqual( - controlRect.x + WGroupLeftPadding, - resolvedRect.x, - 0.01f, - "Settings context with WGroup padding should apply WGroup padding to x position." - ); - - float expectedWidth = - controlRect.width - WGroupLeftPadding - WGroupRightPadding; - Assert.AreEqual( - expectedWidth, - resolvedRect.width, - 0.01f, - "Settings context with WGroup padding should reduce width by total padding." - ); - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void DictionaryInSettingsContextWithoutWGroupPaddingHasUnchangedPosition() - { - Rect controlRect = new(0f, 0f, 400f, 300f); - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); - - Assert.AreEqual( - controlRect.x, - resolvedRect.x, - 0.01f, - "Settings context without WGroup padding should have unchanged x position." - ); - - Assert.AreEqual( - controlRect.width, - resolvedRect.width, - 0.01f, - "Settings context without WGroup padding should have unchanged width." - ); - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void SetInSettingsContextWithWGroupPaddingAppliesPadding() - { - Rect controlRect = new(0f, 0f, 400f, 300f); - - const float WGroupLeftPadding = 10f; - const float WGroupRightPadding = 10f; - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - WGroupLeftPadding + WGroupRightPadding, - WGroupLeftPadding, - WGroupRightPadding - ) - ) - { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); - - Assert.AreEqual( - controlRect.x + WGroupLeftPadding, - resolvedRect.x, - 0.01f, - "Set in WGroup context should apply WGroup padding to x position." - ); - - float expectedWidth = - controlRect.width - WGroupLeftPadding - WGroupRightPadding; - Assert.AreEqual( - expectedWidth, - resolvedRect.width, - 0.01f, - "Set in WGroup context should reduce width by total padding." - ); - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void SetWithoutWGroupPaddingAlignsWithUnityListsClampedAtZero() - { - // At controlRect.x = 0 the -1.25f offset would go negative, so it is clamped to 0. - Rect controlRect = new(0f, 0f, 400f, 300f); - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); - - float rawExpectedX = controlRect.x - 1.25f; - float expectedX = 0f; - Assert.AreEqual( - expectedX, - resolvedRect.x, - 0.01f, - $"Set without WGroup padding and indent=0 should align with Unity's default list rendering but clamp to 0. " - + $"Raw expected x={rawExpectedX}, Clamped expected x={expectedX}, Actual x={resolvedRect.x}" - ); - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void SetWithoutWGroupPaddingAlignsWithUnityListsWithPositiveX() - { - Rect controlRect = new(10f, 0f, 400f, 300f); - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); - - float expectedX = controlRect.x - 1.25f; - Assert.AreEqual( - expectedX, - resolvedRect.x, - 0.01f, - $"Set without WGroup padding and indent=0 should align with Unity's default list rendering (-1.25f alignment offset). " - + $"Expected x={expectedX}, Actual x={resolvedRect.x}" - ); - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void DictionaryWithNonZeroIndentLevelAppliesUnityIndentation() - { - Rect controlRect = new(0f, 0f, 400f, 300f); - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 2; - - GroupGUIWidthUtility.ResetForTests(); - - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); - - // The exact indentation is Unity's own, so only "greater than zero" is asserted. - Assert.Greater( - resolvedRect.x, - controlRect.x, - "Dictionary with indent level > 0 should have indentation applied." - ); - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void NestedWGroupsAccumulatePaddingCorrectly() - { - Rect controlRect = new(0f, 0f, 400f, 300f); - - const float OuterLeftPadding = 10f; - const float OuterRightPadding = 10f; - const float InnerLeftPadding = 8f; - const float InnerRightPadding = 8f; - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); + EditorGUI.indentLevel = 0; + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( - OuterLeftPadding + OuterRightPadding, - OuterLeftPadding, - OuterRightPadding - ) - ) - { - using ( - GroupGUIWidthUtility.PushContentPadding( - InnerLeftPadding + InnerRightPadding, - InnerLeftPadding, - InnerRightPadding - ) - ) - { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); - - float expectedX = controlRect.x + OuterLeftPadding + InnerLeftPadding; - Assert.AreEqual( - expectedX, - resolvedRect.x, - 0.01f, - $"Nested WGroups should accumulate left padding. " - + $"Input: controlRect.x={controlRect.x}, outer={OuterLeftPadding}, inner={InnerLeftPadding}, " - + $"Expected x={expectedX}, Actual x={resolvedRect.x}, " - + $"ScopeDepth={GroupGUIWidthUtility.CurrentScopeDepth}, " - + $"CurrentLeftPadding={GroupGUIWidthUtility.CurrentLeftPadding}" - ); - - float expectedWidth = - controlRect.width - - OuterLeftPadding - - OuterRightPadding - - InnerLeftPadding - - InnerRightPadding; - Assert.AreEqual( - expectedWidth, - resolvedRect.width, - 0.01f, - $"Nested WGroups should accumulate total padding reduction on width. " - + $"Input: controlRect.width={controlRect.width}, " - + $"outer=(left={OuterLeftPadding}, right={OuterRightPadding}), " - + $"inner=(left={InnerLeftPadding}, right={InnerRightPadding}), " - + $"Expected width={expectedWidth}, Actual width={resolvedRect.width}" - ); - } - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } - - [Test] - public void DictionaryInSettingsContextWithNonZeroIndentLevelAppliesOnlyWGroupPadding() - { - Rect controlRect = new(0f, 0f, 400f, 300f); - - const float WGroupLeftPadding = 10f; - const float WGroupRightPadding = 10f; - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - // A parent context would set this, though skipIndentation mode ignores it. - EditorGUI.indentLevel = 1; - - GroupGUIWidthUtility.ResetForTests(); - using ( - GroupGUIWidthUtility.PushContentPadding( - WGroupLeftPadding + WGroupRightPadding, - WGroupLeftPadding, - WGroupRightPadding + LargePadding, + LargePadding / 2f, + LargePadding / 2f ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: true - ); - - Assert.AreEqual( - controlRect.x + WGroupLeftPadding, - resolvedRect.x, - 0.01f, - "Settings context with non-zero indent should still only apply WGroup padding." + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false ); - float expectedWidth = - controlRect.width - WGroupLeftPadding - WGroupRightPadding; - Assert.AreEqual( - expectedWidth, + Assert.GreaterOrEqual( resolvedRect.width, - 0.01f, - "Settings context with non-zero indent should reduce width by WGroup padding only." + 0f, + "Width should not be negative even with padding larger than control rect." ); } } @@ -1653,46 +428,35 @@ public void DictionaryInSettingsContextWithNonZeroIndentLevelAppliesOnlyWGroupPa } [Test] - public void SetInSettingsContextWithNonZeroIndentLevelAppliesOnlyWGroupPadding() + public void SetWidthIsNotNegativeWithLargePadding() { - Rect controlRect = new(0f, 0f, 400f, 300f); + Rect controlRect = new(0f, 0f, 100f, 300f); - const float WGroupLeftPadding = 10f; - const float WGroupRightPadding = 10f; + const float LargePadding = 200f; int previousIndentLevel = EditorGUI.indentLevel; try { - EditorGUI.indentLevel = 1; + EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( - WGroupLeftPadding + WGroupRightPadding, - WGroupLeftPadding, - WGroupRightPadding + LargePadding, + LargePadding / 2f, + LargePadding / 2f ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, - skipIndentation: true - ); - - Assert.AreEqual( - controlRect.x + WGroupLeftPadding, - resolvedRect.x, - 0.01f, - "Settings context Set with non-zero indent should only apply WGroup padding." + skipIndentation: false ); - float expectedWidth = - controlRect.width - WGroupLeftPadding - WGroupRightPadding; - Assert.AreEqual( - expectedWidth, + Assert.GreaterOrEqual( resolvedRect.width, - 0.01f, - "Settings context Set with non-zero indent should reduce width by WGroup padding only." + 0f, + "Width should not be negative even with padding larger than control rect." ); } } @@ -1702,150 +466,154 @@ public void SetInSettingsContextWithNonZeroIndentLevelAppliesOnlyWGroupPadding() } } - [UnityTest] - public IEnumerator DictionaryInSettingsContextRestoresOriginalIndentLevelAfterDraw() + [Test] + public void PaddingScopeIsProperlyCleanedUp() { - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); + GroupGUIWidthUtilityTestAccess.Reset(); - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); + Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); + Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentRightPadding, 0.001f); - if (paletteProp == null) + using (GroupGUIWidthUtility.PushContentPadding(20f, 10f, 10f)) { - Assert.Ignore("WButtonCustomColors property not found in settings."); - yield break; + Assert.AreEqual(10f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); + Assert.AreEqual(10f, GroupGUIWidthUtility.CurrentRightPadding, 0.001f); } - paletteProp.isExpanded = false; - - SerializableDictionaryPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 50f); - GUIContent label = new("Palette"); + Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); + Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentRightPadding, 0.001f); + } - int previousIndentLevel = EditorGUI.indentLevel; - int capturedIndentAfter = -1; + [Test] + public void NestedPaddingScopesAccumulateCorrectly() + { + GroupGUIWidthUtilityTestAccess.Reset(); - yield return TestIMGUIExecutor.Run(() => + using (GroupGUIWidthUtility.PushContentPadding(20f, 10f, 10f)) { - try - { - EditorGUI.indentLevel = 2; - - GroupGUIWidthUtility.ResetForTests(); - - drawer.OnGUI(controlRect, paletteProp, label); + Assert.AreEqual(10f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); - capturedIndentAfter = EditorGUI.indentLevel; - } - finally + using (GroupGUIWidthUtility.PushContentPadding(16f, 8f, 8f)) { - EditorGUI.indentLevel = previousIndentLevel; + Assert.AreEqual(18f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); + Assert.AreEqual(18f, GroupGUIWidthUtility.CurrentRightPadding, 0.001f); } - }); - Assert.AreEqual( - 2, - capturedIndentAfter, - "Settings context dictionary drawer should restore original indent level after OnGUI." - ); + Assert.AreEqual(10f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); + } + + Assert.AreEqual(0f, GroupGUIWidthUtility.CurrentLeftPadding, 0.001f); } - [UnityTest] - public IEnumerator SetInSettingsContextRestoresOriginalIndentLevelAfterDraw() + [Test] + public void DictionaryIndentationConsistentAcrossMultipleDrawCalls() { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) - ); - setProperty.isExpanded = false; + Rect controlRect = new(0f, 0f, 400f, 300f); - SerializableSetPropertyDrawer drawer = new(); - Rect controlRect = new(0f, 0f, 400f, 50f); - GUIContent label = new("Set"); + const float LeftPadding = 12f; + const float RightPadding = 12f; int previousIndentLevel = EditorGUI.indentLevel; - int capturedIndentAfter = -1; + List capturedXPositions = new(); - yield return TestIMGUIExecutor.Run(() => + try { - try - { - EditorGUI.indentLevel = 2; - - GroupGUIWidthUtility.ResetForTests(); - - drawer.OnGUI(controlRect, setProperty, label); + EditorGUI.indentLevel = 0; - capturedIndentAfter = EditorGUI.indentLevel; - } - finally + for (int i = 0; i < 5; ++i) { - EditorGUI.indentLevel = previousIndentLevel; + GroupGUIWidthUtilityTestAccess.Reset(); + using ( + GroupGUIWidthUtility.PushContentPadding( + LeftPadding + RightPadding, + LeftPadding, + RightPadding + ) + ) + { + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); + capturedXPositions.Add(resolvedRect.x); + } } - }); - Assert.AreEqual( - 2, - capturedIndentAfter, - "Set drawer should restore original indent level after OnGUI." - ); + float firstX = capturedXPositions[0]; + foreach (float x in capturedXPositions) + { + Assert.AreEqual( + firstX, + x, + 0.01f, + "Indentation should be consistent across multiple draw calls." + ); + } + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } [Test] - public void DictionaryInWGroupCreatesAnimBoolWhenTweenEnabled() + public void DictionaryInSettingsContextWithWGroupPaddingAppliesPadding() { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); + UnityHelpersSettings settings = UnityHelpersSettings.instance; + SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); + serializedSettings.Update(); - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; + SerializedProperty paletteProp = serializedSettings.FindProperty( + UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors + ); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + if (paletteProp == null) + { + Assert.Ignore("WButtonCustomColors property not found in settings."); + return; + } - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; + paletteProp.isExpanded = true; SerializableDictionaryPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupDictionaryHost), - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - GUIContent label = new("Dictionary"); + Rect controlRect = new(0f, 0f, 400f, 300f); + + const float WGroupLeftPadding = 10f; + const float WGroupRightPadding = 10f; int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + GroupGUIWidthUtilityTestAccess.Reset(); + using ( + GroupGUIWidthUtility.PushContentPadding( + WGroupLeftPadding + WGroupRightPadding, + WGroupLeftPadding, + WGroupRightPadding + ) + ) { - drawer.GetPropertyHeight(dictionaryProperty, label); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); - bool found = drawer.TryGetPendingAnimationStateForTests( - dictionaryProperty, - out bool isExpanded, - out float animProgress, - out bool hasAnimBool + Assert.AreEqual( + controlRect.x + WGroupLeftPadding, + resolvedRect.x, + 0.01f, + "Settings context with WGroup padding should apply WGroup padding to x position." ); - Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); - Assert.IsTrue( - hasAnimBool, - "AnimBool should be created when dictionary tweening is enabled." + float expectedWidth = + controlRect.width - WGroupLeftPadding - WGroupRightPadding; + Assert.AreEqual( + expectedWidth, + resolvedRect.width, + 0.01f, + "Settings context with WGroup padding should reduce width by total padding." ); } } @@ -1856,51 +624,83 @@ out bool hasAnimBool } [Test] - public void DictionaryInWGroupNoAnimBoolWhenTweenDisabled() + public void DictionaryInSettingsContextWithoutWGroupPaddingHasUnchangedPosition() { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); + Rect controlRect = new(0f, 0f, 400f, 300f); - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; + int previousIndentLevel = EditorGUI.indentLevel; + try + { + EditorGUI.indentLevel = 0; - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + GroupGUIWidthUtilityTestAccess.Reset(); - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); - SerializableDictionaryPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupDictionaryHost), - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - GUIContent label = new("Dictionary"); + Assert.AreEqual( + controlRect.x, + resolvedRect.x, + 0.01f, + "Settings context without WGroup padding should have unchanged x position." + ); + + Assert.AreEqual( + controlRect.width, + resolvedRect.width, + 0.01f, + "Settings context without WGroup padding should have unchanged width." + ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } + } + + [Test] + public void SetInSettingsContextWithWGroupPaddingAppliesPadding() + { + Rect controlRect = new(0f, 0f, 400f, 300f); + + const float WGroupLeftPadding = 10f; + const float WGroupRightPadding = 10f; int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + GroupGUIWidthUtilityTestAccess.Reset(); + using ( + GroupGUIWidthUtility.PushContentPadding( + WGroupLeftPadding + WGroupRightPadding, + WGroupLeftPadding, + WGroupRightPadding + ) + ) { - drawer.GetPropertyHeight(dictionaryProperty, label); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); - bool found = drawer.TryGetPendingAnimationStateForTests( - dictionaryProperty, - out bool isExpanded, - out float animProgress, - out bool hasAnimBool + Assert.AreEqual( + controlRect.x + WGroupLeftPadding, + resolvedRect.x, + 0.01f, + "Set in WGroup context should apply WGroup padding to x position." ); - Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); - Assert.IsFalse( - hasAnimBool, - "AnimBool should NOT be created when dictionary tweening is disabled." + float expectedWidth = + controlRect.width - WGroupLeftPadding - WGroupRightPadding; + Assert.AreEqual( + expectedWidth, + resolvedRect.width, + 0.01f, + "Set in WGroup context should reduce width by total padding." ); } } @@ -1911,53 +711,64 @@ out bool hasAnimBool } [Test] - public void SetInWGroupCreatesAnimBoolWhenTweenEnabled() + public void SetWithoutWGroupPaddingAlignsWithUnityListsClampedAtZero() { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); + // At controlRect.x = 0 the -1.25f offset would go negative, so it is clamped to 0. + Rect controlRect = new(0f, 0f, 400f, 300f); - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); + int previousIndentLevel = EditorGUI.indentLevel; + try + { + EditorGUI.indentLevel = 0; - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + GroupGUIWidthUtilityTestAccess.Reset(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) - ); - setProperty.isExpanded = true; + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); - SerializableSetPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) - ); - GUIContent label = new("Set"); + float rawExpectedX = controlRect.x - 1.25f; + float expectedX = 0f; + Assert.AreEqual( + expectedX, + resolvedRect.x, + 0.01f, + $"Set without WGroup padding and indent=0 should align with Unity's default list rendering but clamp to 0. " + + $"Raw expected x={rawExpectedX}, Clamped expected x={expectedX}, Actual x={resolvedRect.x}" + ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } + } + + [Test] + public void SetWithoutWGroupPaddingAlignsWithUnityListsWithPositiveX() + { + Rect controlRect = new(10f, 0f, 400f, 300f); int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) - { - drawer.GetPropertyHeight(setProperty, label); + GroupGUIWidthUtilityTestAccess.Reset(); - bool found = drawer.TryGetPendingAnimationStateForTests( - setProperty, - out bool isExpanded, - out float animProgress, - out bool hasAnimBool - ); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); - Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); - Assert.IsTrue( - hasAnimBool, - "AnimBool should be created when set tweening is enabled." - ); - } + float expectedX = controlRect.x - 1.25f; + Assert.AreEqual( + expectedX, + resolvedRect.x, + 0.01f, + $"Set without WGroup padding and indent=0 should align with Unity's default list rendering (-1.25f alignment offset). " + + $"Expected x={expectedX}, Actual x={resolvedRect.x}" + ); } finally { @@ -1966,52 +777,102 @@ out bool hasAnimBool } [Test] - public void SetInWGroupNoAnimBoolWhenTweenDisabled() + public void DictionaryWithNonZeroIndentLevelAppliesUnityIndentation() { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); + Rect controlRect = new(0f, 0f, 400f, 300f); - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); + int previousIndentLevel = EditorGUI.indentLevel; + try + { + EditorGUI.indentLevel = 2; - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + GroupGUIWidthUtilityTestAccess.Reset(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) - ); - setProperty.isExpanded = true; + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); + + // The exact indentation is Unity's own, so only "greater than zero" is asserted. + Assert.Greater( + resolvedRect.x, + controlRect.x, + "Dictionary with indent level > 0 should have indentation applied." + ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } + } - SerializableSetPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) - ); - GUIContent label = new("Set"); + [Test] + public void NestedWGroupsAccumulatePaddingCorrectly() + { + Rect controlRect = new(0f, 0f, 400f, 300f); + + const float OuterLeftPadding = 10f; + const float OuterRightPadding = 10f; + const float InnerLeftPadding = 8f; + const float InnerRightPadding = 8f; int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + GroupGUIWidthUtilityTestAccess.Reset(); + + using ( + GroupGUIWidthUtility.PushContentPadding( + OuterLeftPadding + OuterRightPadding, + OuterLeftPadding, + OuterRightPadding + ) + ) { - drawer.GetPropertyHeight(setProperty, label); + using ( + GroupGUIWidthUtility.PushContentPadding( + InnerLeftPadding + InnerRightPadding, + InnerLeftPadding, + InnerRightPadding + ) + ) + { + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); - bool found = drawer.TryGetPendingAnimationStateForTests( - setProperty, - out bool isExpanded, - out float animProgress, - out bool hasAnimBool - ); + float expectedX = controlRect.x + OuterLeftPadding + InnerLeftPadding; + Assert.AreEqual( + expectedX, + resolvedRect.x, + 0.01f, + $"Nested WGroups should accumulate left padding. " + + $"Input: controlRect.x={controlRect.x}, outer={OuterLeftPadding}, inner={InnerLeftPadding}, " + + $"Expected x={expectedX}, Actual x={resolvedRect.x}, " + + $"ScopeDepth={GroupGUIWidthUtility.CurrentScopeDepth}, " + + $"CurrentLeftPadding={GroupGUIWidthUtility.CurrentLeftPadding}" + ); - Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); - Assert.IsFalse( - hasAnimBool, - "AnimBool should NOT be created when set tweening is disabled." - ); + float expectedWidth = + controlRect.width + - OuterLeftPadding + - OuterRightPadding + - InnerLeftPadding + - InnerRightPadding; + Assert.AreEqual( + expectedWidth, + resolvedRect.width, + 0.01f, + $"Nested WGroups should accumulate total padding reduction on width. " + + $"Input: controlRect.width={controlRect.width}, " + + $"outer=(left={OuterLeftPadding}, right={OuterRightPadding}), " + + $"inner=(left={InnerLeftPadding}, right={InnerRightPadding}), " + + $"Expected width={expectedWidth}, Actual width={resolvedRect.width}" + ); + } } } finally @@ -2021,49 +882,48 @@ out bool hasAnimBool } [Test] - public void DictionaryFoldoutProgressStartsAtZeroWhenCollapsed() + public void DictionaryInSettingsContextWithNonZeroIndentLevelAppliesOnlyWGroupPadding() { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); - - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; + Rect controlRect = new(0f, 0f, 400f, 300f); - SerializableDictionaryPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupDictionaryHost), - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - GUIContent label = new("Dictionary"); + const float WGroupLeftPadding = 10f; + const float WGroupRightPadding = 10f; int previousIndentLevel = EditorGUI.indentLevel; try { - EditorGUI.indentLevel = 0; + // A parent context would set this, though skipIndentation mode ignores it. + EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + GroupGUIWidthUtilityTestAccess.Reset(); + using ( + GroupGUIWidthUtility.PushContentPadding( + WGroupLeftPadding + WGroupRightPadding, + WGroupLeftPadding, + WGroupRightPadding + ) + ) { - drawer.GetPropertyHeight(dictionaryProperty, label); - - drawer.SetPendingExpandedStateForTests(dictionaryProperty, false); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); - float progress = drawer.GetPendingFoldoutProgressFromInstance( - dictionaryProperty + Assert.AreEqual( + controlRect.x + WGroupLeftPadding, + resolvedRect.x, + 0.01f, + "Settings context with non-zero indent should still only apply WGroup padding." ); - // An AnimBool starts at its target, so progress is 0 or on its way there. - Assert.GreaterOrEqual(progress, 0f, "Progress should be >= 0."); - Assert.LessOrEqual(progress, 1f, "Progress should be <= 1."); + float expectedWidth = + controlRect.width - WGroupLeftPadding - WGroupRightPadding; + Assert.AreEqual( + expectedWidth, + resolvedRect.width, + 0.01f, + "Settings context with non-zero indent should reduce width by WGroup padding only." + ); } } finally @@ -2073,46 +933,47 @@ public void DictionaryFoldoutProgressStartsAtZeroWhenCollapsed() } [Test] - public void SetFoldoutProgressStartsAtZeroWhenCollapsed() + public void SetInSettingsContextWithNonZeroIndentLevelAppliesOnlyWGroupPadding() { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); - - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) - ); - setProperty.isExpanded = true; + Rect controlRect = new(0f, 0f, 400f, 300f); - SerializableSetPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) - ); - GUIContent label = new("Set"); + const float WGroupLeftPadding = 10f; + const float WGroupRightPadding = 10f; int previousIndentLevel = EditorGUI.indentLevel; try { - EditorGUI.indentLevel = 0; + EditorGUI.indentLevel = 1; - GroupGUIWidthUtility.ResetForTests(); - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + GroupGUIWidthUtilityTestAccess.Reset(); + using ( + GroupGUIWidthUtility.PushContentPadding( + WGroupLeftPadding + WGroupRightPadding, + WGroupLeftPadding, + WGroupRightPadding + ) + ) { - drawer.GetPropertyHeight(setProperty, label); - - drawer.SetPendingExpandedStateForTests(setProperty, false); + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: true + ); - float progress = drawer.GetPendingFoldoutProgressFromInstance(setProperty); + Assert.AreEqual( + controlRect.x + WGroupLeftPadding, + resolvedRect.x, + 0.01f, + "Settings context Set with non-zero indent should only apply WGroup padding." + ); - Assert.GreaterOrEqual(progress, 0f, "Progress should be >= 0."); - Assert.LessOrEqual(progress, 1f, "Progress should be <= 1."); + float expectedWidth = + controlRect.width - WGroupLeftPadding - WGroupRightPadding; + Assert.AreEqual( + expectedWidth, + resolvedRect.width, + 0.01f, + "Settings context Set with non-zero indent should reduce width by WGroup padding only." + ); } } finally @@ -2121,75 +982,60 @@ public void SetFoldoutProgressStartsAtZeroWhenCollapsed() } } - [Test] - public void DictionaryFoldoutProgressIsImmediateWhenTweenDisabled() + [UnityTest] + public IEnumerator DictionaryInSettingsContextRestoresOriginalIndentLevelAfterDraw() { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); + UnityHelpersSettings settings = UnityHelpersSettings.instance; + SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); + serializedSettings.Update(); - IntegrationTestWGroupDictionaryHost host = - CreateScriptableObject(); - host.dictionary["key1"] = 100; + SerializedProperty paletteProp = serializedSettings.FindProperty( + UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors + ); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + if (paletteProp == null) + { + Assert.Ignore("WButtonCustomColors property not found in settings."); + yield break; + } - SerializedProperty dictionaryProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - dictionaryProperty.isExpanded = true; + paletteProp.isExpanded = false; SerializableDictionaryPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupDictionaryHost), - nameof(IntegrationTestWGroupDictionaryHost.dictionary) - ); - GUIContent label = new("Dictionary"); + Rect controlRect = new(0f, 0f, 400f, 50f); + GUIContent label = new("Palette"); int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; + int capturedIndentAfter = -1; - GroupGUIWidthUtility.ResetForTests(); - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + yield return TestIMGUIExecutor.Run(() => + { + try { - drawer.GetPropertyHeight(dictionaryProperty, label); + EditorGUI.indentLevel = 2; - drawer.SetPendingExpandedStateForTests(dictionaryProperty, true); - float expandedProgress = drawer.GetPendingFoldoutProgressFromInstance( - dictionaryProperty - ); - Assert.AreEqual( - 1f, - expandedProgress, - 0.001f, - "When tween disabled and expanded, progress should immediately be 1." - ); + GroupGUIWidthUtilityTestAccess.Reset(); - drawer.SetPendingExpandedStateForTests(dictionaryProperty, false); - float collapsedProgress = drawer.GetPendingFoldoutProgressFromInstance( - dictionaryProperty - ); - Assert.AreEqual( - 0f, - collapsedProgress, - 0.001f, - "When tween disabled and collapsed, progress should immediately be 0." - ); + drawer.OnGUI(controlRect, paletteProp, label); + + capturedIndentAfter = EditorGUI.indentLevel; } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } + }); + + Assert.AreEqual( + 2, + capturedIndentAfter, + "Settings context dictionary drawer should restore original indent level after OnGUI." + ); } - [Test] - public void SetFoldoutProgressIsImmediateWhenTweenDisabled() + [UnityTest] + public IEnumerator SetInSettingsContextRestoresOriginalIndentLevelAfterDraw() { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - IntegrationTestWGroupSetHost host = CreateScriptableObject(); host.set.Add(42); @@ -2200,58 +1046,45 @@ public void SetFoldoutProgressIsImmediateWhenTweenDisabled() SerializedProperty setProperty = serializedObject.FindProperty( nameof(IntegrationTestWGroupSetHost.set) ); - setProperty.isExpanded = true; + setProperty.isExpanded = false; SerializableSetPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) - ); + Rect controlRect = new(0f, 0f, 400f, 50f); GUIContent label = new("Set"); int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; + int capturedIndentAfter = -1; - GroupGUIWidthUtility.ResetForTests(); - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + yield return TestIMGUIExecutor.Run(() => + { + try { - drawer.GetPropertyHeight(setProperty, label); + EditorGUI.indentLevel = 2; - drawer.SetPendingExpandedStateForTests(setProperty, true); - float expandedProgress = drawer.GetPendingFoldoutProgressFromInstance( - setProperty - ); - Assert.AreEqual( - 1f, - expandedProgress, - 0.001f, - "When tween disabled and expanded, progress should immediately be 1." - ); + GroupGUIWidthUtilityTestAccess.Reset(); - drawer.SetPendingExpandedStateForTests(setProperty, false); - float collapsedProgress = drawer.GetPendingFoldoutProgressFromInstance( - setProperty - ); - Assert.AreEqual( - 0f, - collapsedProgress, - 0.001f, - "When tween disabled and collapsed, progress should immediately be 0." - ); + drawer.OnGUI(controlRect, setProperty, label); + + capturedIndentAfter = EditorGUI.indentLevel; } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } + }); + + Assert.AreEqual( + 2, + capturedIndentAfter, + "Set drawer should restore original indent level after OnGUI." + ); } [Test] - public void DictionaryTweenSettingsRespectedInWGroupContext() + public void DictionaryInWGroupCreatesAnimBoolWhenTweenEnabled() { + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); + IntegrationTestWGroupDictionaryHost host = CreateScriptableObject(); host.dictionary["key1"] = 100; @@ -2277,35 +1110,22 @@ public void DictionaryTweenSettingsRespectedInWGroupContext() { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); drawer.GetPropertyHeight(dictionaryProperty, label); - bool found1 = drawer.TryGetPendingAnimationStateForTests( + bool found = drawer.TryGetPendingAnimationState( dictionaryProperty, - out _, - out _, - out bool hasAnimBool1 - ); - Assert.IsTrue( - found1 && hasAnimBool1, - "Should have AnimBool when tween enabled." + out bool isExpanded, + out float animProgress, + out bool hasAnimBool ); - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); - drawer.GetPropertyHeight(dictionaryProperty, label); - - bool found2 = drawer.TryGetPendingAnimationStateForTests( - dictionaryProperty, - out _, - out _, - out bool hasAnimBool2 - ); + Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); Assert.IsTrue( - found2 && !hasAnimBool2, - "Should NOT have AnimBool when tween disabled." + hasAnimBool, + "AnimBool should be created when dictionary tweening is enabled." ); } } @@ -2316,62 +1136,51 @@ out bool hasAnimBool2 } [Test] - public void SetTweenSettingsRespectedInWGroupContext() + public void DictionaryInWGroupNoAnimBoolWhenTweenDisabled() { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); + + IntegrationTestWGroupDictionaryHost host = + CreateScriptableObject(); + host.dictionary["key1"] = 100; SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) + SerializedProperty dictionaryProperty = serializedObject.FindProperty( + nameof(IntegrationTestWGroupDictionaryHost.dictionary) ); - setProperty.isExpanded = true; + dictionaryProperty.isExpanded = true; - SerializableSetPropertyDrawer drawer = new(); + SerializableDictionaryPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) + typeof(IntegrationTestWGroupDictionaryHost), + nameof(IntegrationTestWGroupDictionaryHost.dictionary) ); - GUIContent label = new("Set"); + GUIContent label = new("Dictionary"); int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - drawer.GetPropertyHeight(setProperty, label); + drawer.GetPropertyHeight(dictionaryProperty, label); - bool found1 = drawer.TryGetPendingAnimationStateForTests( - setProperty, - out _, - out _, - out bool hasAnimBool1 - ); - Assert.IsTrue( - found1 && hasAnimBool1, - "Should have AnimBool when tween enabled." + bool found = drawer.TryGetPendingAnimationState( + dictionaryProperty, + out bool isExpanded, + out float animProgress, + out bool hasAnimBool ); - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - drawer.GetPropertyHeight(setProperty, label); - - bool found2 = drawer.TryGetPendingAnimationStateForTests( - setProperty, - out _, - out _, - out bool hasAnimBool2 - ); - Assert.IsTrue( - found2 && !hasAnimBool2, - "Should NOT have AnimBool when tween disabled." + Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); + Assert.IsFalse( + hasAnimBool, + "AnimBool should NOT be created when dictionary tweening is disabled." ); } } @@ -2382,117 +1191,53 @@ out bool hasAnimBool2 } [Test] - public void DictionaryInSettingsContextCreatesAnimBoolWhenTweenEnabled() + public void SetInWGroupCreatesAnimBoolWhenTweenEnabled() { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); - - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); - - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - - if (paletteProp == null) - { - Assert.Ignore("WButtonCustomColors property not found in settings."); - return; - } - - paletteProp.isExpanded = true; - - SerializableDictionaryPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(UnityHelpersSettings), - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors - ); - GUIContent label = new("Palette"); - - int previousIndentLevel = EditorGUI.indentLevel; - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - - drawer.GetPropertyHeight(paletteProp, label); - - bool found = drawer.TryGetPendingAnimationStateForTests( - paletteProp, - out bool isExpanded, - out float animProgress, - out bool hasAnimBool - ); - - Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); - Assert.IsTrue( - hasAnimBool, - "AnimBool should be created when dictionary tweening is enabled in Settings context." - ); - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - } + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - [Test] - public void DictionaryInSettingsContextFoldoutProgressIsImmediateWhenTweenDisabled() - { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); + IntegrationTestWGroupSetHost host = + CreateScriptableObject(); + host.set.Add(42); - UnityHelpersSettings settings = UnityHelpersSettings.instance; - SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); - serializedSettings.Update(); + SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); + serializedObject.Update(); - SerializedProperty paletteProp = serializedSettings.FindProperty( - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors + SerializedProperty setProperty = serializedObject.FindProperty( + nameof(IntegrationTestWGroupSetHost.set) ); + setProperty.isExpanded = true; - if (paletteProp == null) - { - Assert.Ignore("WButtonCustomColors property not found in settings."); - return; - } - - paletteProp.isExpanded = true; - - SerializableDictionaryPropertyDrawer drawer = new(); + SerializableSetPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(UnityHelpersSettings), - UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors + typeof(IntegrationTestWGroupSetHost), + nameof(IntegrationTestWGroupSetHost.set) ); - GUIContent label = new("Palette"); + GUIContent label = new("Set"); int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - - drawer.GetPropertyHeight(paletteProp, label); - - drawer.SetPendingExpandedStateForTests(paletteProp, true); - float expandedProgress = drawer.GetPendingFoldoutProgressFromInstance(paletteProp); - Assert.AreEqual( - 1f, - expandedProgress, - 0.001f, - "Settings context: when tween disabled and expanded, progress should immediately be 1." - ); + GroupGUIWidthUtilityTestAccess.Reset(); + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + { + drawer.GetPropertyHeight(setProperty, label); - drawer.SetPendingExpandedStateForTests(paletteProp, false); - float collapsedProgress = drawer.GetPendingFoldoutProgressFromInstance(paletteProp); - Assert.AreEqual( - 0f, - collapsedProgress, - 0.001f, - "Settings context: when tween disabled and collapsed, progress should immediately be 0." - ); + bool found = drawer.TryGetPendingAnimationState( + setProperty, + out bool isExpanded, + out float animProgress, + out bool hasAnimBool + ); + + Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); + Assert.IsTrue( + hasAnimBool, + "AnimBool should be created when set tweening is enabled." + ); + } } finally { @@ -2501,79 +1246,52 @@ public void DictionaryInSettingsContextFoldoutProgressIsImmediateWhenTweenDisabl } [Test] - public void NestedWGroupDictionaryAnimationIsIndependent() + public void SetInWGroupNoAnimBoolWhenTweenDisabled() { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - IntegrationTestMultiWGroupHost host = - CreateScriptableObject(); - host.nestedDictionary["innerKey"] = 50; - host.nestedSet.Add(99); + IntegrationTestWGroupSetHost host = + CreateScriptableObject(); + host.set.Add(42); SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); - SerializedProperty dictProperty = serializedObject.FindProperty( - nameof(IntegrationTestMultiWGroupHost.nestedDictionary) - ); SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestMultiWGroupHost.nestedSet) + nameof(IntegrationTestWGroupSetHost.set) ); - dictProperty.isExpanded = true; setProperty.isExpanded = true; - SerializableDictionaryPropertyDrawer dictDrawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - dictDrawer, - typeof(IntegrationTestMultiWGroupHost), - nameof(IntegrationTestMultiWGroupHost.nestedDictionary) - ); - SerializableSetPropertyDrawer setDrawer = new(); + SerializableSetPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( - setDrawer, - typeof(IntegrationTestMultiWGroupHost), - nameof(IntegrationTestMultiWGroupHost.nestedSet) + drawer, + typeof(IntegrationTestWGroupSetHost), + nameof(IntegrationTestWGroupSetHost.set) ); - GUIContent dictLabel = new("Dict"); - GUIContent setLabel = new("Set"); + GUIContent label = new("Set"); int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) - { - dictDrawer.GetPropertyHeight(dictProperty, dictLabel); - setDrawer.GetPropertyHeight(setProperty, setLabel); + drawer.GetPropertyHeight(setProperty, label); - bool dictFound = dictDrawer.TryGetPendingAnimationStateForTests( - dictProperty, - out _, - out _, - out bool dictHasAnim - ); - bool setFound = setDrawer.TryGetPendingAnimationStateForTests( - setProperty, - out _, - out _, - out bool setHasAnim - ); + bool found = drawer.TryGetPendingAnimationState( + setProperty, + out bool isExpanded, + out float animProgress, + out bool hasAnimBool + ); - Assert.IsTrue( - dictFound && dictHasAnim, - "Nested dictionary should have AnimBool in nested WGroup." - ); - Assert.IsTrue( - setFound && setHasAnim, - "Nested set should have AnimBool in nested WGroup." - ); - } + Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); + Assert.IsFalse( + hasAnimBool, + "AnimBool should NOT be created when set tweening is disabled." + ); } } finally @@ -2583,90 +1301,49 @@ out bool setHasAnim } [Test] - public void WGroupTweenAndCollectionTweenAreIndependent() + public void DictionaryFoldoutProgressStartsAtZeroWhenCollapsed() { - UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(true); - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - - Assert.IsTrue( - UnityHelpersSettings.ShouldTweenWGroupFoldouts(), - "WGroup tween should be enabled." - ); - Assert.IsFalse( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests(false), - "Dictionary tween should be disabled." - ); - Assert.IsFalse( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(false), - "Set tween should be disabled." - ); - - UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(false); UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - - Assert.IsFalse( - UnityHelpersSettings.ShouldTweenWGroupFoldouts(), - "WGroup tween should be disabled." - ); - Assert.IsTrue( - SerializableDictionaryPropertyDrawer.IsTweeningEnabledForTests(false), - "Dictionary tween should be enabled." - ); - Assert.IsTrue( - SerializableSetPropertyDrawer.IsTweeningEnabledForTests(false), - "Set tween should be enabled." - ); - } - - [Test] - public void SortedDictionaryInWGroupCreatesAnimBoolWhenTweenEnabled() - { - UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(true); - IntegrationTestWGroupSortedDictionaryHost host = - CreateScriptableObject(); - host.sortedDictionary["key1"] = 100; + IntegrationTestWGroupDictionaryHost host = + CreateScriptableObject(); + host.dictionary["key1"] = 100; SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); - SerializedProperty dictProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSortedDictionaryHost.sortedDictionary) + SerializedProperty dictionaryProperty = serializedObject.FindProperty( + nameof(IntegrationTestWGroupDictionaryHost.dictionary) ); - dictProperty.isExpanded = true; + dictionaryProperty.isExpanded = true; SerializableDictionaryPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(IntegrationTestWGroupSortedDictionaryHost), - nameof(IntegrationTestWGroupSortedDictionaryHost.sortedDictionary) + typeof(IntegrationTestWGroupDictionaryHost), + nameof(IntegrationTestWGroupDictionaryHost.dictionary) ); - GUIContent label = new("SortedDict"); + GUIContent label = new("Dictionary"); int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - drawer.GetPropertyHeight(dictProperty, label); + drawer.GetPropertyHeight(dictionaryProperty, label); - bool found = drawer.TryGetPendingAnimationStateForTests( - dictProperty, - out _, - out _, - out bool hasAnimBool - ); + drawer.SetPendingExpandedState(dictionaryProperty, false); - Assert.IsTrue(found, "Pending entry should exist."); - Assert.IsTrue( - hasAnimBool, - "AnimBool should be created when sorted dictionary tweening is enabled." + float progress = drawer.GetPendingFoldoutProgressFromInstance( + dictionaryProperty ); + + // An AnimBool starts at its target, so progress is 0 or on its way there. + Assert.GreaterOrEqual(progress, 0f, "Progress should be >= 0."); + Assert.LessOrEqual(progress, 1f, "Progress should be <= 1."); } } finally @@ -2676,52 +1353,46 @@ out bool hasAnimBool } [Test] - public void SortedSetInWGroupCreatesAnimBoolWhenTweenEnabled() + public void SetFoldoutProgressStartsAtZeroWhenCollapsed() { - UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - IntegrationTestWGroupSortedSetHost host = - CreateScriptableObject(); - host.sortedSet.Add(42); + IntegrationTestWGroupSetHost host = + CreateScriptableObject(); + host.set.Add(42); SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSortedSetHost.sortedSet) + nameof(IntegrationTestWGroupSetHost.set) ); setProperty.isExpanded = true; SerializableSetPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(IntegrationTestWGroupSortedSetHost), - nameof(IntegrationTestWGroupSortedSetHost.sortedSet) + typeof(IntegrationTestWGroupSetHost), + nameof(IntegrationTestWGroupSetHost.set) ); - GUIContent label = new("SortedSet"); + GUIContent label = new("Set"); int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { drawer.GetPropertyHeight(setProperty, label); - bool found = drawer.TryGetPendingAnimationStateForTests( - setProperty, - out _, - out _, - out bool hasAnimBool - ); + drawer.SetPendingExpandedState(setProperty, false); - Assert.IsTrue(found, "Pending entry should exist."); - Assert.IsTrue( - hasAnimBool, - "AnimBool should be created when sorted set tweening is enabled." - ); + float progress = drawer.GetPendingFoldoutProgressFromInstance(setProperty); + + Assert.GreaterOrEqual(progress, 0f, "Progress should be >= 0."); + Assert.LessOrEqual(progress, 1f, "Progress should be <= 1."); } } finally @@ -2731,14 +1402,10 @@ out bool hasAnimBool } [Test] - [TestCase( - true, - true, - TestName = "DictionaryAnimBoolCreatedWhenTweenEnabledThenClearedWhenDisabled" - )] - [TestCase(false, true, TestName = "DictionaryAnimBoolNotCreatedWhenTweenAlwaysDisabled")] - public void DictionaryAnimBoolLifecycleWithTweenToggle(bool enableFirst, bool disableAfter) + public void DictionaryFoldoutProgressIsImmediateWhenTweenDisabled() { + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); + IntegrationTestWGroupDictionaryHost host = CreateScriptableObject(); host.dictionary["key1"] = 100; @@ -2763,56 +1430,33 @@ public void DictionaryAnimBoolLifecycleWithTweenToggle(bool enableFirst, bool di try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(enableFirst); drawer.GetPropertyHeight(dictionaryProperty, label); - bool foundInitial = drawer.TryGetPendingAnimationStateForTests( - dictionaryProperty, - out bool isExpandedInitial, - out float animProgressInitial, - out bool hasAnimBoolInitial + drawer.SetPendingExpandedState(dictionaryProperty, true); + float expandedProgress = drawer.GetPendingFoldoutProgressFromInstance( + dictionaryProperty ); - - string initialDiagnostic = - $"[Initial] found={foundInitial}, expanded={isExpandedInitial}, progress={animProgressInitial:F3}, hasAnimBool={hasAnimBoolInitial}"; - UnityEngine.Debug.Log(initialDiagnostic); - - Assert.IsTrue(foundInitial, "Initial call should create pending entry."); Assert.AreEqual( - enableFirst, - hasAnimBoolInitial, - $"AnimBool should {(enableFirst ? "exist" : "not exist")} when tween is {(enableFirst ? "enabled" : "disabled")}. Diagnostic: {initialDiagnostic}" + 1f, + expandedProgress, + 0.001f, + "When tween disabled and expanded, progress should immediately be 1." ); - if (disableAfter && enableFirst) - { - UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); - drawer.GetPropertyHeight(dictionaryProperty, label); - - bool foundAfter = drawer.TryGetPendingAnimationStateForTests( - dictionaryProperty, - out bool isExpandedAfter, - out float animProgressAfter, - out bool hasAnimBoolAfter - ); - - string afterDiagnostic = - $"[After Disable] found={foundAfter}, expanded={isExpandedAfter}, progress={animProgressAfter:F3}, hasAnimBool={hasAnimBoolAfter}"; - UnityEngine.Debug.Log(afterDiagnostic); - - Assert.IsTrue( - foundAfter, - "Pending entry should still exist after disabling tween." - ); - Assert.IsFalse( - hasAnimBoolAfter, - $"AnimBool should be cleaned up when tween is disabled. Diagnostic: {afterDiagnostic}" - ); - } + drawer.SetPendingExpandedState(dictionaryProperty, false); + float collapsedProgress = drawer.GetPendingFoldoutProgressFromInstance( + dictionaryProperty + ); + Assert.AreEqual( + 0f, + collapsedProgress, + 0.001f, + "When tween disabled and collapsed, progress should immediately be 0." + ); } } finally @@ -2822,14 +1466,10 @@ out bool hasAnimBoolAfter } [Test] - [TestCase( - true, - true, - TestName = "SetAnimBoolCreatedWhenTweenEnabledThenClearedWhenDisabled" - )] - [TestCase(false, true, TestName = "SetAnimBoolNotCreatedWhenTweenAlwaysDisabled")] - public void SetAnimBoolLifecycleWithTweenToggle(bool enableFirst, bool disableAfter) + public void SetFoldoutProgressIsImmediateWhenTweenDisabled() { + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); + IntegrationTestWGroupSetHost host = CreateScriptableObject(); host.set.Add(42); @@ -2854,56 +1494,33 @@ public void SetAnimBoolLifecycleWithTweenToggle(bool enableFirst, bool disableAf try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(enableFirst); drawer.GetPropertyHeight(setProperty, label); - bool foundInitial = drawer.TryGetPendingAnimationStateForTests( - setProperty, - out bool isExpandedInitial, - out float animProgressInitial, - out bool hasAnimBoolInitial + drawer.SetPendingExpandedState(setProperty, true); + float expandedProgress = drawer.GetPendingFoldoutProgressFromInstance( + setProperty ); - - string initialDiagnostic = - $"[Initial] found={foundInitial}, expanded={isExpandedInitial}, progress={animProgressInitial:F3}, hasAnimBool={hasAnimBoolInitial}"; - UnityEngine.Debug.Log(initialDiagnostic); - - Assert.IsTrue(foundInitial, "Initial call should create pending entry."); Assert.AreEqual( - enableFirst, - hasAnimBoolInitial, - $"AnimBool should {(enableFirst ? "exist" : "not exist")} when tween is {(enableFirst ? "enabled" : "disabled")}. Diagnostic: {initialDiagnostic}" + 1f, + expandedProgress, + 0.001f, + "When tween disabled and expanded, progress should immediately be 1." ); - if (disableAfter && enableFirst) - { - UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - drawer.GetPropertyHeight(setProperty, label); - - bool foundAfter = drawer.TryGetPendingAnimationStateForTests( - setProperty, - out bool isExpandedAfter, - out float animProgressAfter, - out bool hasAnimBoolAfter - ); - - string afterDiagnostic = - $"[After Disable] found={foundAfter}, expanded={isExpandedAfter}, progress={animProgressAfter:F3}, hasAnimBool={hasAnimBoolAfter}"; - UnityEngine.Debug.Log(afterDiagnostic); - - Assert.IsTrue( - foundAfter, - "Pending entry should still exist after disabling tween." - ); - Assert.IsFalse( - hasAnimBoolAfter, - $"AnimBool should be cleaned up when tween is disabled. Diagnostic: {afterDiagnostic}" - ); - } + drawer.SetPendingExpandedState(setProperty, false); + float collapsedProgress = drawer.GetPendingFoldoutProgressFromInstance( + setProperty + ); + Assert.AreEqual( + 0f, + collapsedProgress, + 0.001f, + "When tween disabled and collapsed, progress should immediately be 0." + ); } } finally @@ -2913,128 +1530,63 @@ out bool hasAnimBoolAfter } [Test] - [TestCase(true, TestName = "SortedDictionaryAnimBoolCleanedUpWhenTweenDisabled")] - [TestCase(false, TestName = "SortedSetAnimBoolCleanedUpWhenTweenDisabled")] - public void SortedCollectionAnimBoolLifecycle(bool isSortedDictionary) + public void DictionaryTweenSettingsRespectedInWGroupContext() { - int previousIndentLevel = EditorGUI.indentLevel; + IntegrationTestWGroupDictionaryHost host = + CreateScriptableObject(); + host.dictionary["key1"] = 100; + + SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); + serializedObject.Update(); + + SerializedProperty dictionaryProperty = serializedObject.FindProperty( + nameof(IntegrationTestWGroupDictionaryHost.dictionary) + ); + dictionaryProperty.isExpanded = true; + + SerializableDictionaryPropertyDrawer drawer = new(); + PropertyDrawerTestHelper.AssignFieldInfo( + drawer, + typeof(IntegrationTestWGroupDictionaryHost), + nameof(IntegrationTestWGroupDictionaryHost.dictionary) + ); + GUIContent label = new("Dictionary"); + int previousIndentLevel = EditorGUI.indentLevel; try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - if (isSortedDictionary) + GroupGUIWidthUtilityTestAccess.Reset(); + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - IntegrationTestWGroupSortedDictionaryHost host = - CreateScriptableObject(); - host.sortedDictionary["key1"] = 100; - - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); + drawer.GetPropertyHeight(dictionaryProperty, label); - SerializedProperty sortedDictProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSortedDictionaryHost.sortedDictionary) + bool found1 = drawer.TryGetPendingAnimationState( + dictionaryProperty, + out _, + out _, + out bool hasAnimBool1 ); - sortedDictProperty.isExpanded = true; - - SerializableDictionaryPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupSortedDictionaryHost), - nameof(IntegrationTestWGroupSortedDictionaryHost.sortedDictionary) + Assert.IsTrue( + found1 && hasAnimBool1, + "Should have AnimBool when tween enabled." ); - GUIContent label = new("SortedDictionary"); - - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) - { - UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled( - true - ); - drawer.GetPropertyHeight(sortedDictProperty, label); - - bool found1 = drawer.TryGetPendingAnimationStateForTests( - sortedDictProperty, - out _, - out _, - out bool hasAnimBool1 - ); - Assert.IsTrue( - found1 && hasAnimBool1, - "Sorted dictionary should have AnimBool when tween enabled." - ); - - UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled( - false - ); - drawer.GetPropertyHeight(sortedDictProperty, label); - - bool found2 = drawer.TryGetPendingAnimationStateForTests( - sortedDictProperty, - out _, - out _, - out bool hasAnimBool2 - ); - Assert.IsTrue(found2, "Pending entry should still exist."); - Assert.IsFalse( - hasAnimBool2, - "Sorted dictionary AnimBool should be cleaned up when tween disabled." - ); - } - } - else - { - IntegrationTestWGroupSortedSetHost host = - CreateScriptableObject(); - host.sortedSet.Add(42); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); + drawer.GetPropertyHeight(dictionaryProperty, label); - SerializedProperty sortedSetProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSortedSetHost.sortedSet) + bool found2 = drawer.TryGetPendingAnimationState( + dictionaryProperty, + out _, + out _, + out bool hasAnimBool2 ); - sortedSetProperty.isExpanded = true; - - SerializableSetPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupSortedSetHost), - nameof(IntegrationTestWGroupSortedSetHost.sortedSet) + Assert.IsTrue( + found2 && !hasAnimBool2, + "Should NOT have AnimBool when tween disabled." ); - GUIContent label = new("SortedSet"); - - using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) - { - UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); - drawer.GetPropertyHeight(sortedSetProperty, label); - - bool found1 = drawer.TryGetPendingAnimationStateForTests( - sortedSetProperty, - out _, - out _, - out bool hasAnimBool1 - ); - Assert.IsTrue( - found1 && hasAnimBool1, - "Sorted set should have AnimBool when tween enabled." - ); - - UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(false); - drawer.GetPropertyHeight(sortedSetProperty, label); - - bool found2 = drawer.TryGetPendingAnimationStateForTests( - sortedSetProperty, - out _, - out _, - out bool hasAnimBool2 - ); - Assert.IsTrue(found2, "Pending entry should still exist."); - Assert.IsFalse( - hasAnimBool2, - "Sorted set AnimBool should be cleaned up when tween disabled." - ); - } } } finally @@ -3043,12 +1595,12 @@ out bool hasAnimBool2 } } - [UnityTest] - public IEnumerator SetFooterRangeLabelAccountsForWGroupPadding() + [Test] + public void SetTweenSettingsRespectedInWGroupContext() { IntegrationTestWGroupSetHost host = CreateScriptableObject(); - host.set.Add(1); + host.set.Add(42); SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); @@ -3064,604 +1616,503 @@ public IEnumerator SetFooterRangeLabelAccountsForWGroupPadding() typeof(IntegrationTestWGroupSetHost), nameof(IntegrationTestWGroupSetHost.set) ); - Rect controlRect = new(0f, 0f, 400f, 300f); GUIContent label = new("Set"); - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; - - float capturedLeftPadding = 0f; - float capturedRightPadding = 0f; - bool rangeLabelWasDrawn = false; int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => + try { - try + EditorGUI.indentLevel = 0; + + GroupGUIWidthUtilityTestAccess.Reset(); + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - EditorGUI.indentLevel = 0; + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); + drawer.GetPropertyHeight(setProperty, label); - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + bool found1 = drawer.TryGetPendingAnimationState( + setProperty, + out _, + out _, + out bool hasAnimBool1 + ); + Assert.IsTrue( + found1 && hasAnimBool1, + "Should have AnimBool when tween enabled." + ); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) - { - drawer.OnGUI(controlRect, setProperty, label); - - capturedLeftPadding = - SerializableSetPropertyDrawer.LastFooterWGroupLeftPadding; - capturedRightPadding = - SerializableSetPropertyDrawer.LastFooterWGroupRightPadding; - rangeLabelWasDrawn = - SerializableSetPropertyDrawer.LastFooterRangeLabelWasDrawn; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); + drawer.GetPropertyHeight(setProperty, label); - Assert.AreEqual( - SimulatedLeftPadding, - capturedLeftPadding, - 0.1f, - "Footer should capture WGroup left padding." - ); - Assert.AreEqual( - SimulatedRightPadding, - capturedRightPadding, - 0.1f, - "Footer should capture WGroup right padding." - ); - Assert.IsTrue( - rangeLabelWasDrawn, - "Range label should be drawn when sufficient space is available." - ); + bool found2 = drawer.TryGetPendingAnimationState( + setProperty, + out _, + out _, + out bool hasAnimBool2 + ); + Assert.IsTrue( + found2 && !hasAnimBool2, + "Should NOT have AnimBool when tween disabled." + ); + } + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } - [UnityTest] - public IEnumerator SetFooterRangeLabelDrawnWithNoPadding() + [Test] + public void DictionaryInSettingsContextCreatesAnimBoolWhenTweenEnabled() { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(42); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + UnityHelpersSettings settings = UnityHelpersSettings.instance; + SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); + serializedSettings.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) + SerializedProperty paletteProp = serializedSettings.FindProperty( + UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors ); - setProperty.isExpanded = true; - SerializableSetPropertyDrawer drawer = new(); + if (paletteProp == null) + { + Assert.Ignore("WButtonCustomColors property not found in settings."); + return; + } + + paletteProp.isExpanded = true; + + SerializableDictionaryPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) + typeof(UnityHelpersSettings), + UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors ); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); + GUIContent label = new("Palette"); - float capturedAvailableWidth = 0f; - bool rangeLabelWasDrawn = false; int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => + try { - try - { - EditorGUI.indentLevel = 0; + EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - drawer.OnGUI(controlRect, setProperty, label); + drawer.GetPropertyHeight(paletteProp, label); - capturedAvailableWidth = SerializableSetPropertyDrawer.LastFooterAvailableWidth; - rangeLabelWasDrawn = SerializableSetPropertyDrawer.LastFooterRangeLabelWasDrawn; - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); + bool found = drawer.TryGetPendingAnimationState( + paletteProp, + out bool isExpanded, + out float animProgress, + out bool hasAnimBool + ); - Assert.Greater( - capturedAvailableWidth, - 0f, - "Available width should be positive with no WGroup padding." - ); - Assert.IsTrue( - rangeLabelWasDrawn, - "Range label should be drawn with no WGroup padding and sufficient space." - ); + Assert.IsTrue(found, "Pending entry should exist after GetPropertyHeight."); + Assert.IsTrue( + hasAnimBool, + "AnimBool should be created when dictionary tweening is enabled in Settings context." + ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } - [UnityTest] - public IEnumerator SetFooterRangeLabelHiddenWhenInsufficientSpace() + [Test] + public void DictionaryInSettingsContextFoldoutProgressIsImmediateWhenTweenDisabled() { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(1); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + UnityHelpersSettings settings = UnityHelpersSettings.instance; + SerializedObject serializedSettings = TrackDisposable(new SerializedObject(settings)); + serializedSettings.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) + SerializedProperty paletteProp = serializedSettings.FindProperty( + UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors ); - setProperty.isExpanded = true; - SerializableSetPropertyDrawer drawer = new(); + if (paletteProp == null) + { + Assert.Ignore("WButtonCustomColors property not found in settings."); + return; + } + + paletteProp.isExpanded = true; + + SerializableDictionaryPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) + typeof(UnityHelpersSettings), + UnityHelpersSettings.SerializedPropertyNames.WButtonCustomColors ); + GUIContent label = new("Palette"); - Rect controlRect = new(0f, 0f, 150f, 300f); - GUIContent label = new("Set"); - - const float SimulatedLeftPadding = 40f; - const float SimulatedRightPadding = 40f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; - - float capturedAvailableWidth = 0f; - float capturedRangeWidth = 0f; - bool rangeLabelWasDrawn = false; int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => + try { - try - { - EditorGUI.indentLevel = 0; + EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) - { - drawer.OnGUI(controlRect, setProperty, label); + drawer.GetPropertyHeight(paletteProp, label); - capturedAvailableWidth = - SerializableSetPropertyDrawer.LastFooterAvailableWidth; - capturedRangeWidth = SerializableSetPropertyDrawer.LastFooterRangeWidth; - rangeLabelWasDrawn = - SerializableSetPropertyDrawer.LastFooterRangeLabelWasDrawn; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); + drawer.SetPendingExpandedState(paletteProp, true); + float expandedProgress = drawer.GetPendingFoldoutProgressFromInstance(paletteProp); + Assert.AreEqual( + 1f, + expandedProgress, + 0.001f, + "Settings context: when tween disabled and expanded, progress should immediately be 1." + ); - Assert.IsFalse( - rangeLabelWasDrawn, - "Range label should NOT be drawn when WGroup padding leaves insufficient space." - ); - Assert.Greater( - capturedRangeWidth, - capturedAvailableWidth, - "Range width should exceed available width when label is hidden." - ); + drawer.SetPendingExpandedState(paletteProp, false); + float collapsedProgress = drawer.GetPendingFoldoutProgressFromInstance(paletteProp); + Assert.AreEqual( + 0f, + collapsedProgress, + 0.001f, + "Settings context: when tween disabled and collapsed, progress should immediately be 0." + ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } - [UnityTest] - public IEnumerator SetFooterPaginationWidthReducedByWGroupPadding() + [Test] + public void NestedWGroupDictionaryAnimationIsIndependent() { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(1); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); + + IntegrationTestMultiWGroupHost host = + CreateScriptableObject(); + host.nestedDictionary["innerKey"] = 50; + host.nestedSet.Add(99); SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); + SerializedProperty dictProperty = serializedObject.FindProperty( + nameof(IntegrationTestMultiWGroupHost.nestedDictionary) + ); SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) + nameof(IntegrationTestMultiWGroupHost.nestedSet) ); + dictProperty.isExpanded = true; setProperty.isExpanded = true; - SerializableSetPropertyDrawer drawer = new(); + SerializableDictionaryPropertyDrawer dictDrawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) + dictDrawer, + typeof(IntegrationTestMultiWGroupHost), + nameof(IntegrationTestMultiWGroupHost.nestedDictionary) ); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - const float SimulatedLeftPadding = 20f; - const float SimulatedRightPadding = 20f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; + SerializableSetPropertyDrawer setDrawer = new(); + PropertyDrawerTestHelper.AssignFieldInfo( + setDrawer, + typeof(IntegrationTestMultiWGroupHost), + nameof(IntegrationTestMultiWGroupHost.nestedSet) + ); + GUIContent dictLabel = new("Dict"); + GUIContent setLabel = new("Set"); - float availableWidthWithPadding = 0f; - float availableWidthWithoutPadding = 0f; int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => + try { - try - { - EditorGUI.indentLevel = 0; + EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + { + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - drawer.OnGUI(controlRect, setProperty, label); - availableWidthWithPadding = - SerializableSetPropertyDrawer.LastFooterAvailableWidth; + dictDrawer.GetPropertyHeight(dictProperty, dictLabel); + setDrawer.GetPropertyHeight(setProperty, setLabel); + + bool dictFound = dictDrawer.TryGetPendingAnimationState( + dictProperty, + out _, + out _, + out bool dictHasAnim + ); + bool setFound = setDrawer.TryGetPendingAnimationState( + setProperty, + out _, + out _, + out bool setHasAnim + ); + + Assert.IsTrue( + dictFound && dictHasAnim, + "Nested dictionary should have AnimBool in nested WGroup." + ); + Assert.IsTrue( + setFound && setHasAnim, + "Nested set should have AnimBool in nested WGroup." + ); } } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - yield return TestIMGUIExecutor.Run(() => + } + finally { - try - { - EditorGUI.indentLevel = 0; + EditorGUI.indentLevel = previousIndentLevel; + } + } - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + [Test] + public void WGroupTweenAndCollectionTweenAreIndependent() + { + UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(true); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); - drawer.OnGUI(controlRect, setProperty, label); - availableWidthWithoutPadding = - SerializableSetPropertyDrawer.LastFooterAvailableWidth; - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); + Assert.IsTrue( + UnityHelpersSettings.ShouldTweenWGroupFoldouts(), + "WGroup tween should be enabled." + ); + Assert.IsFalse( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout(false), + "Dictionary tween should be disabled." + ); + Assert.IsFalse( + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(false), + "Set tween should be disabled." + ); - /* - At x=0 the alignment offset is clamped, so only the full horizontal WGroup padding changes - available width. - */ - float expectedDifference = horizontalPadding; - float actualDifference = availableWidthWithoutPadding - availableWidthWithPadding; + UnityHelpersSettings.SetWGroupFoldoutTweenEnabled(false); + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(true); + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(true); - Assert.AreEqual( - expectedDifference, - actualDifference, - 2f, - $"Available width should be reduced by WGroup horizontal padding. " - + $"Without padding: {availableWidthWithoutPadding}, " - + $"With padding: {availableWidthWithPadding}, " - + $"Difference: {actualDifference}, " - + $"Expected: {expectedDifference} (horizontalPadding={horizontalPadding})" + Assert.IsFalse( + UnityHelpersSettings.ShouldTweenWGroupFoldouts(), + "WGroup tween should be disabled." + ); + Assert.IsTrue( + SerializableDictionaryPropertyDrawer.ShouldTweenPendingFoldout(false), + "Dictionary tween should be enabled." + ); + Assert.IsTrue( + SerializableSetPropertyDrawer.ShouldTweenManualEntryFoldout(false), + "Set tween should be enabled." ); } - [UnityTest] - public IEnumerator SetFooterRangeLabelPositionRespectsWGroupLeftPadding() + [Test] + public void SortedDictionaryInWGroupCreatesAnimBoolWhenTweenEnabled() { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); - host.set.Add(1); + UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled(true); + + IntegrationTestWGroupSortedDictionaryHost host = + CreateScriptableObject(); + host.sortedDictionary["key1"] = 100; SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) + SerializedProperty dictProperty = serializedObject.FindProperty( + nameof(IntegrationTestWGroupSortedDictionaryHost.sortedDictionary) ); - setProperty.isExpanded = true; + dictProperty.isExpanded = true; - SerializableSetPropertyDrawer drawer = new(); + SerializableDictionaryPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) + typeof(IntegrationTestWGroupSortedDictionaryHost), + nameof(IntegrationTestWGroupSortedDictionaryHost.sortedDictionary) ); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); - - const float SimulatedLeftPadding = 15f; - const float SimulatedRightPadding = 15f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; + GUIContent label = new("SortedDict"); - Rect rangeLabelRectWithPadding = default; - Rect rangeLabelRectWithoutPadding = default; - bool drawnWithPadding = false; - bool drawnWithoutPadding = false; int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => + try { - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); - - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) - { - drawer.OnGUI(controlRect, setProperty, label); - drawnWithPadding = - SerializableSetPropertyDrawer.LastFooterRangeLabelWasDrawn; - if (drawnWithPadding) - { - rangeLabelRectWithPadding = - SerializableSetPropertyDrawer.LastFooterRangeLabelRect; - } - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); + EditorGUI.indentLevel = 0; - yield return TestIMGUIExecutor.Run(() => - { - try + GroupGUIWidthUtilityTestAccess.Reset(); + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - EditorGUI.indentLevel = 0; + drawer.GetPropertyHeight(dictProperty, label); - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + bool found = drawer.TryGetPendingAnimationState( + dictProperty, + out _, + out _, + out bool hasAnimBool + ); - drawer.OnGUI(controlRect, setProperty, label); - drawnWithoutPadding = - SerializableSetPropertyDrawer.LastFooterRangeLabelWasDrawn; - if (drawnWithoutPadding) - { - rangeLabelRectWithoutPadding = - SerializableSetPropertyDrawer.LastFooterRangeLabelRect; - } - } - finally - { - EditorGUI.indentLevel = previousIndentLevel; + Assert.IsTrue(found, "Pending entry should exist."); + Assert.IsTrue( + hasAnimBool, + "AnimBool should be created when sorted dictionary tweening is enabled." + ); } - }); - - Assert.IsTrue( - drawnWithPadding, - "Range label should be drawn with WGroup padding in sufficient space." - ); - Assert.IsTrue( - drawnWithoutPadding, - "Range label should be drawn without WGroup padding." - ); - - float xDifference = rangeLabelRectWithPadding.x - rangeLabelRectWithoutPadding.x; - - // At x=0 the ungrouped alignment offset clamps away, isolating the left-padding difference. - float expectedDifference = SimulatedLeftPadding; - - Assert.AreEqual( - expectedDifference, - xDifference, - 1f, - $"Range label X position should be shifted right by WGroup left padding. " - + $"(Alignment offset is clamped when starting rect x=0) " - + $"With padding X: {rangeLabelRectWithPadding.x}, " - + $"Without padding X: {rangeLabelRectWithoutPadding.x}, " - + $"Difference: {xDifference}, " - + $"Expected: {expectedDifference} (padding={SimulatedLeftPadding})" - ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } - [UnityTest] - public IEnumerator SetFooterInNestedWGroupsAccumulatesPadding() + [Test] + public void SortedSetInWGroupCreatesAnimBoolWhenTweenEnabled() { - IntegrationTestMultiWGroupHost host = - CreateScriptableObject(); - host.nestedSet.Add(1); + UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); + + IntegrationTestWGroupSortedSetHost host = + CreateScriptableObject(); + host.sortedSet.Add(42); SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestMultiWGroupHost.nestedSet) + nameof(IntegrationTestWGroupSortedSetHost.sortedSet) ); setProperty.isExpanded = true; SerializableSetPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(IntegrationTestMultiWGroupHost), - nameof(IntegrationTestMultiWGroupHost.nestedSet) + typeof(IntegrationTestWGroupSortedSetHost), + nameof(IntegrationTestWGroupSortedSetHost.sortedSet) ); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("NestedSet"); - - const float OuterLeftPadding = 10f; - const float OuterRightPadding = 10f; - const float InnerLeftPadding = 8f; - const float InnerRightPadding = 8f; + GUIContent label = new("SortedSet"); - float capturedLeftPadding = 0f; - float capturedRightPadding = 0f; int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => + try { - try - { - EditorGUI.indentLevel = 0; - - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + EditorGUI.indentLevel = 0; - using ( - GroupGUIWidthUtility.PushContentPadding( - OuterLeftPadding + OuterRightPadding, - OuterLeftPadding, - OuterRightPadding - ) - ) - { - using ( - GroupGUIWidthUtility.PushContentPadding( - InnerLeftPadding + InnerRightPadding, - InnerLeftPadding, - InnerRightPadding - ) - ) - { - drawer.OnGUI(controlRect, setProperty, label); - - capturedLeftPadding = - SerializableSetPropertyDrawer.LastFooterWGroupLeftPadding; - capturedRightPadding = - SerializableSetPropertyDrawer.LastFooterWGroupRightPadding; - } - } - } - finally + GroupGUIWidthUtilityTestAccess.Reset(); + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - EditorGUI.indentLevel = previousIndentLevel; - } - }); + drawer.GetPropertyHeight(setProperty, label); - float expectedTotalLeftPadding = OuterLeftPadding + InnerLeftPadding; - float expectedTotalRightPadding = OuterRightPadding + InnerRightPadding; + bool found = drawer.TryGetPendingAnimationState( + setProperty, + out _, + out _, + out bool hasAnimBool + ); - Assert.AreEqual( - expectedTotalLeftPadding, - capturedLeftPadding, - 0.1f, - $"Nested WGroups should accumulate left padding. " - + $"Expected: {expectedTotalLeftPadding}, Actual: {capturedLeftPadding}" - ); - Assert.AreEqual( - expectedTotalRightPadding, - capturedRightPadding, - 0.1f, - $"Nested WGroups should accumulate right padding. " - + $"Expected: {expectedTotalRightPadding}, Actual: {capturedRightPadding}" - ); + Assert.IsTrue(found, "Pending entry should exist."); + Assert.IsTrue( + hasAnimBool, + "AnimBool should be created when sorted set tweening is enabled." + ); + } + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } - [UnityTest] - public IEnumerator SortedSetFooterRangeLabelAccountsForWGroupPadding() + [Test] + [TestCase( + true, + true, + TestName = "DictionaryAnimBoolCreatedWhenTweenEnabledThenClearedWhenDisabled" + )] + [TestCase(false, true, TestName = "DictionaryAnimBoolNotCreatedWhenTweenAlwaysDisabled")] + public void DictionaryAnimBoolLifecycleWithTweenToggle(bool enableFirst, bool disableAfter) { - IntegrationTestWGroupSortedSetHost host = - CreateScriptableObject(); - host.sortedSet.Add(1); + IntegrationTestWGroupDictionaryHost host = + CreateScriptableObject(); + host.dictionary["key1"] = 100; SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSortedSetHost.sortedSet) + SerializedProperty dictionaryProperty = serializedObject.FindProperty( + nameof(IntegrationTestWGroupDictionaryHost.dictionary) ); - setProperty.isExpanded = true; + dictionaryProperty.isExpanded = true; - SerializableSetPropertyDrawer drawer = new(); + SerializableDictionaryPropertyDrawer drawer = new(); PropertyDrawerTestHelper.AssignFieldInfo( drawer, - typeof(IntegrationTestWGroupSortedSetHost), - nameof(IntegrationTestWGroupSortedSetHost.sortedSet) + typeof(IntegrationTestWGroupDictionaryHost), + nameof(IntegrationTestWGroupDictionaryHost.dictionary) ); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("SortedSet"); - - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; + GUIContent label = new("Dictionary"); - float capturedLeftPadding = 0f; - float capturedRightPadding = 0f; - bool rangeLabelWasDrawn = false; int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => + try { - try + EditorGUI.indentLevel = 0; + GroupGUIWidthUtilityTestAccess.Reset(); + + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - EditorGUI.indentLevel = 0; + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(enableFirst); + drawer.GetPropertyHeight(dictionaryProperty, label); - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + bool foundInitial = drawer.TryGetPendingAnimationState( + dictionaryProperty, + out bool isExpandedInitial, + out float animProgressInitial, + out bool hasAnimBoolInitial + ); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) + string initialDiagnostic = + $"[Initial] found={foundInitial}, expanded={isExpandedInitial}, progress={animProgressInitial:F3}, hasAnimBool={hasAnimBoolInitial}"; + UnityEngine.Debug.Log(initialDiagnostic); + + Assert.IsTrue(foundInitial, "Initial call should create pending entry."); + Assert.AreEqual( + enableFirst, + hasAnimBoolInitial, + $"AnimBool should {(enableFirst ? "exist" : "not exist")} when tween is {(enableFirst ? "enabled" : "disabled")}. Diagnostic: {initialDiagnostic}" + ); + + if (disableAfter && enableFirst) { - drawer.OnGUI(controlRect, setProperty, label); - - capturedLeftPadding = - SerializableSetPropertyDrawer.LastFooterWGroupLeftPadding; - capturedRightPadding = - SerializableSetPropertyDrawer.LastFooterWGroupRightPadding; - rangeLabelWasDrawn = - SerializableSetPropertyDrawer.LastFooterRangeLabelWasDrawn; + UnityHelpersSettings.SetSerializableDictionaryFoldoutTweenEnabled(false); + drawer.GetPropertyHeight(dictionaryProperty, label); + + bool foundAfter = drawer.TryGetPendingAnimationState( + dictionaryProperty, + out bool isExpandedAfter, + out float animProgressAfter, + out bool hasAnimBoolAfter + ); + + string afterDiagnostic = + $"[After Disable] found={foundAfter}, expanded={isExpandedAfter}, progress={animProgressAfter:F3}, hasAnimBool={hasAnimBoolAfter}"; + UnityEngine.Debug.Log(afterDiagnostic); + + Assert.IsTrue( + foundAfter, + "Pending entry should still exist after disabling tween." + ); + Assert.IsFalse( + hasAnimBoolAfter, + $"AnimBool should be cleaned up when tween is disabled. Diagnostic: {afterDiagnostic}" + ); } } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.AreEqual( - SimulatedLeftPadding, - capturedLeftPadding, - 0.1f, - "SortedSet footer should capture WGroup left padding." - ); - Assert.AreEqual( - SimulatedRightPadding, - capturedRightPadding, - 0.1f, - "SortedSet footer should capture WGroup right padding." - ); - Assert.IsTrue( - rangeLabelWasDrawn, - "SortedSet range label should be drawn when sufficient space is available." - ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } - [UnityTest] - public IEnumerator SetFooterRangeLabelDrawnInsideWGroup() + [Test] + [TestCase( + true, + true, + TestName = "SetAnimBoolCreatedWhenTweenEnabledThenClearedWhenDisabled" + )] + [TestCase(false, true, TestName = "SetAnimBoolNotCreatedWhenTweenAlwaysDisabled")] + public void SetAnimBoolLifecycleWithTweenToggle(bool enableFirst, bool disableAfter) { IntegrationTestWGroupSetHost host = CreateScriptableObject(); - // Add one item so that DrawFooterControls is called (empty sets use DrawEmptySetDrawer instead) - host.set.Add(1); + host.set.Add(42); SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); serializedObject.Update(); @@ -3677,138 +2128,199 @@ public IEnumerator SetFooterRangeLabelDrawnInsideWGroup() typeof(IntegrationTestWGroupSetHost), nameof(IntegrationTestWGroupSetHost.set) ); - Rect controlRect = new(0f, 0f, 400f, 300f); GUIContent label = new("Set"); - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; - - bool rangeLabelWasDrawn = false; - float capturedLeftPadding = 0f; int previousIndentLevel = EditorGUI.indentLevel; - - yield return TestIMGUIExecutor.Run(() => + try { - try + EditorGUI.indentLevel = 0; + GroupGUIWidthUtilityTestAccess.Reset(); + + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - EditorGUI.indentLevel = 0; + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(enableFirst); + drawer.GetPropertyHeight(setProperty, label); + + bool foundInitial = drawer.TryGetPendingAnimationState( + setProperty, + out bool isExpandedInitial, + out float animProgressInitial, + out bool hasAnimBoolInitial + ); - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + string initialDiagnostic = + $"[Initial] found={foundInitial}, expanded={isExpandedInitial}, progress={animProgressInitial:F3}, hasAnimBool={hasAnimBoolInitial}"; + UnityEngine.Debug.Log(initialDiagnostic); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) + Assert.IsTrue(foundInitial, "Initial call should create pending entry."); + Assert.AreEqual( + enableFirst, + hasAnimBoolInitial, + $"AnimBool should {(enableFirst ? "exist" : "not exist")} when tween is {(enableFirst ? "enabled" : "disabled")}. Diagnostic: {initialDiagnostic}" + ); + + if (disableAfter && enableFirst) { - drawer.OnGUI(controlRect, setProperty, label); + UnityHelpersSettings.SetSerializableSetFoldoutTweenEnabled(false); + drawer.GetPropertyHeight(setProperty, label); + + bool foundAfter = drawer.TryGetPendingAnimationState( + setProperty, + out bool isExpandedAfter, + out float animProgressAfter, + out bool hasAnimBoolAfter + ); - rangeLabelWasDrawn = - SerializableSetPropertyDrawer.LastFooterRangeLabelWasDrawn; - capturedLeftPadding = - SerializableSetPropertyDrawer.LastFooterWGroupLeftPadding; + string afterDiagnostic = + $"[After Disable] found={foundAfter}, expanded={isExpandedAfter}, progress={animProgressAfter:F3}, hasAnimBool={hasAnimBoolAfter}"; + UnityEngine.Debug.Log(afterDiagnostic); + + Assert.IsTrue( + foundAfter, + "Pending entry should still exist after disabling tween." + ); + Assert.IsFalse( + hasAnimBoolAfter, + $"AnimBool should be cleaned up when tween is disabled. Diagnostic: {afterDiagnostic}" + ); } } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.IsTrue( - rangeLabelWasDrawn, - "Range label should be drawn inside WGroup when space is sufficient." - ); - Assert.AreEqual( - SimulatedLeftPadding, - capturedLeftPadding, - 0.1f, - "Footer should capture WGroup padding." - ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } - [UnityTest] - public IEnumerator SetFooterWithMultipleItemsDrawsCorrectlyInsideWGroup() + [Test] + [TestCase(true, TestName = "SortedDictionaryAnimBoolCleanedUpWhenTweenDisabled")] + [TestCase(false, TestName = "SortedSetAnimBoolCleanedUpWhenTweenDisabled")] + public void SortedCollectionAnimBoolLifecycle(bool isSortedDictionary) { - IntegrationTestWGroupSetHost host = - CreateScriptableObject(); + int previousIndentLevel = EditorGUI.indentLevel; - for (int i = 0; i < 20; ++i) + try { - host.set.Add(i); - } + EditorGUI.indentLevel = 0; + GroupGUIWidthUtilityTestAccess.Reset(); - SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); - serializedObject.Update(); + if (isSortedDictionary) + { + IntegrationTestWGroupSortedDictionaryHost host = + CreateScriptableObject(); + host.sortedDictionary["key1"] = 100; - SerializedProperty setProperty = serializedObject.FindProperty( - nameof(IntegrationTestWGroupSetHost.set) - ); - setProperty.isExpanded = true; + SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); + serializedObject.Update(); - SerializableSetPropertyDrawer drawer = new(); - PropertyDrawerTestHelper.AssignFieldInfo( - drawer, - typeof(IntegrationTestWGroupSetHost), - nameof(IntegrationTestWGroupSetHost.set) - ); - Rect controlRect = new(0f, 0f, 400f, 300f); - GUIContent label = new("Set"); + SerializedProperty sortedDictProperty = serializedObject.FindProperty( + nameof(IntegrationTestWGroupSortedDictionaryHost.sortedDictionary) + ); + sortedDictProperty.isExpanded = true; - const float SimulatedLeftPadding = 12f; - const float SimulatedRightPadding = 12f; - float horizontalPadding = SimulatedLeftPadding + SimulatedRightPadding; + SerializableDictionaryPropertyDrawer drawer = new(); + PropertyDrawerTestHelper.AssignFieldInfo( + drawer, + typeof(IntegrationTestWGroupSortedDictionaryHost), + nameof(IntegrationTestWGroupSortedDictionaryHost.sortedDictionary) + ); + GUIContent label = new("SortedDictionary"); - bool rangeLabelWasDrawn = false; - float capturedAvailableWidth = 0f; - float capturedRangeWidth = 0f; - int previousIndentLevel = EditorGUI.indentLevel; + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) + { + UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled( + true + ); + drawer.GetPropertyHeight(sortedDictProperty, label); - yield return TestIMGUIExecutor.Run(() => - { - try + bool found1 = drawer.TryGetPendingAnimationState( + sortedDictProperty, + out _, + out _, + out bool hasAnimBool1 + ); + Assert.IsTrue( + found1 && hasAnimBool1, + "Sorted dictionary should have AnimBool when tween enabled." + ); + + UnityHelpersSettings.SetSerializableSortedDictionaryFoldoutTweenEnabled( + false + ); + drawer.GetPropertyHeight(sortedDictProperty, label); + + bool found2 = drawer.TryGetPendingAnimationState( + sortedDictProperty, + out _, + out _, + out bool hasAnimBool2 + ); + Assert.IsTrue(found2, "Pending entry should still exist."); + Assert.IsFalse( + hasAnimBool2, + "Sorted dictionary AnimBool should be cleaned up when tween disabled." + ); + } + } + else { - EditorGUI.indentLevel = 0; + IntegrationTestWGroupSortedSetHost host = + CreateScriptableObject(); + host.sortedSet.Add(42); - GroupGUIWidthUtility.ResetForTests(); - SerializableSetPropertyDrawer.ResetLayoutTrackingForTests(); + SerializedObject serializedObject = TrackDisposable(new SerializedObject(host)); + serializedObject.Update(); - using ( - GroupGUIWidthUtility.PushContentPadding( - horizontalPadding, - SimulatedLeftPadding, - SimulatedRightPadding - ) - ) + SerializedProperty sortedSetProperty = serializedObject.FindProperty( + nameof(IntegrationTestWGroupSortedSetHost.sortedSet) + ); + sortedSetProperty.isExpanded = true; + + SerializableSetPropertyDrawer drawer = new(); + PropertyDrawerTestHelper.AssignFieldInfo( + drawer, + typeof(IntegrationTestWGroupSortedSetHost), + nameof(IntegrationTestWGroupSortedSetHost.sortedSet) + ); + GUIContent label = new("SortedSet"); + + using (GroupGUIWidthUtility.PushContentPadding(24f, 12f, 12f)) { - drawer.OnGUI(controlRect, setProperty, label); + UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(true); + drawer.GetPropertyHeight(sortedSetProperty, label); + + bool found1 = drawer.TryGetPendingAnimationState( + sortedSetProperty, + out _, + out _, + out bool hasAnimBool1 + ); + Assert.IsTrue( + found1 && hasAnimBool1, + "Sorted set should have AnimBool when tween enabled." + ); + + UnityHelpersSettings.SetSerializableSortedSetFoldoutTweenEnabled(false); + drawer.GetPropertyHeight(sortedSetProperty, label); - rangeLabelWasDrawn = - SerializableSetPropertyDrawer.LastFooterRangeLabelWasDrawn; - capturedAvailableWidth = - SerializableSetPropertyDrawer.LastFooterAvailableWidth; - capturedRangeWidth = SerializableSetPropertyDrawer.LastFooterRangeWidth; + bool found2 = drawer.TryGetPendingAnimationState( + sortedSetProperty, + out _, + out _, + out bool hasAnimBool2 + ); + Assert.IsTrue(found2, "Pending entry should still exist."); + Assert.IsFalse( + hasAnimBool2, + "Sorted set AnimBool should be cleaned up when tween disabled." + ); } } - finally - { - EditorGUI.indentLevel = previousIndentLevel; - } - }); - - Assert.IsTrue( - rangeLabelWasDrawn, - "Range label with multiple items should be drawn inside WGroup." - ); - Assert.GreaterOrEqual( - capturedAvailableWidth, - capturedRangeWidth, - "Available width should be >= range width when label is drawn." - ); + } + finally + { + EditorGUI.indentLevel = previousIndentLevel; + } } [Test] @@ -3920,7 +2432,7 @@ float rightPadding try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -3930,11 +2442,10 @@ float rightPadding ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedX = controlRect.x + leftPadding; float expectedWidth = controlRect.width - horizontalPadding; @@ -3978,7 +2489,7 @@ float rightPadding try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -3988,7 +2499,7 @@ float rightPadding ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -4033,9 +2544,9 @@ public void DictionaryOutsideWGroupAppliesUnityListAlignmentOffsetClampedAtZero( try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -4075,9 +2586,9 @@ public void DictionaryOutsideWGroupAppliesUnityListAlignmentOffsetWithPositiveX( try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -4116,9 +2627,9 @@ public void SetOutsideWGroupAppliesUnityListAlignmentOffsetClampedAtZero() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -4158,9 +2669,9 @@ public void SetOutsideWGroupAppliesUnityListAlignmentOffsetWithPositiveX() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); @@ -4200,7 +2711,7 @@ public void DictionaryInsideWGroupDoesNotApplyUnityListAlignmentOffset() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -4210,11 +2721,10 @@ public void DictionaryInsideWGroupDoesNotApplyUnityListAlignmentOffset() ) ) { - Rect resolvedRect = - SerializableDictionaryPropertyDrawer.ResolveContentRectForTests( - controlRect, - skipIndentation: false - ); + Rect resolvedRect = SerializableDictionaryPropertyDrawer.ResolveContentRect( + controlRect, + skipIndentation: false + ); float expectedX = controlRect.x + SimulatedLeftPadding; @@ -4252,7 +2762,7 @@ public void SetInsideWGroupDoesNotApplyUnityListAlignmentOffset() try { EditorGUI.indentLevel = 0; - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using ( GroupGUIWidthUtility.PushContentPadding( @@ -4262,7 +2772,7 @@ public void SetInsideWGroupDoesNotApplyUnityListAlignmentOffset() ) ) { - Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRectForTests( + Rect resolvedRect = SerializableSetPropertyDrawer.ResolveContentRect( controlRect, skipIndentation: false ); diff --git a/Tests/Editor/CustomDrawers/WInLineEditorDrawerTests.cs b/Tests/Editor/CustomDrawers/WInLineEditorDrawerTests.cs index b67d18883..eea5ef789 100644 --- a/Tests/Editor/CustomDrawers/WInLineEditorDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/WInLineEditorDrawerTests.cs @@ -39,7 +39,7 @@ SerializedObject serializedHost ) PrepareInlineEditorTestContext(bool propertyExpanded, bool? setInlineExpanded) where THost : ScriptableObject { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); THost host = Track(ScriptableObject.CreateInstance()); host.hideFlags = HideFlags.HideAndDontSave; @@ -77,7 +77,7 @@ SerializedObject serializedHost property.isExpanded = propertyExpanded; if (setInlineExpanded.HasValue) { - WInLineEditorDrawer.SetInlineFoldoutStateForTesting( + WInLineEditorDrawerTestAccess.SetInlineFoldoutState( property, setInlineExpanded.Value ); @@ -166,14 +166,14 @@ SerializedObject serializedHost bool showHeader, bool showBody, float displayHeight - ) details = WInLineEditorDrawer.GetHeightCalculationDetailsForTesting( + ) details = WInLineEditorDrawerTestAccess.GetHeightCalculationDetails( property, inlineAttribute, target, 500f ); - string diagnostics = WInLineEditorDrawer.GetExtensiveDiagnosticsForTesting( + string diagnostics = WInLineEditorDrawerTestAccess.GetExtensiveDiagnostics( property, inlineAttribute, target, @@ -193,8 +193,8 @@ public override void BaseSetUp() _originalTweenEnabled = settings.InlineEditorFoldoutTweenEnabled; _originalTweenSpeed = settings.InlineEditorFoldoutSpeed; - WInLineEditorDrawer.ClearCachedStateForTesting(); - WInLineEditorDrawer.ClearAnimationCacheForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); + WInLineEditorDrawerTestAccess.ClearAnimationCache(); } [TearDown] @@ -204,8 +204,8 @@ public override void TearDown() settings.InlineEditorFoldoutTweenEnabled = _originalTweenEnabled; settings.InlineEditorFoldoutSpeed = _originalTweenSpeed; - WInLineEditorDrawer.ClearCachedStateForTesting(); - WInLineEditorDrawer.ClearAnimationCacheForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); + WInLineEditorDrawerTestAccess.ClearAnimationCache(); base.TearDown(); } @@ -562,30 +562,32 @@ public void InlineInspectorOmitsScriptField() [Test] public void PingButtonsDisabledWhenProjectBrowserHidden() { + bool originalVisibility = ProjectBrowserVisibilityUtility._cachedVisibility; InlineEditorTarget target = CreateHiddenInstance(); try { - ProjectBrowserVisibilityUtility.SetProjectBrowserVisibilityForTesting(false); + ProjectBrowserVisibilityUtility._cachedVisibility = false; Assert.That(WInLineEditorDrawer.ShouldShowPingButton(target), Is.False); } finally { - ProjectBrowserVisibilityUtility.SetProjectBrowserVisibilityForTesting(null); + ProjectBrowserVisibilityUtility._cachedVisibility = originalVisibility; } } [Test] public void PingButtonsEnabledWhenProjectBrowserVisible() { + bool originalVisibility = ProjectBrowserVisibilityUtility._cachedVisibility; InlineEditorTarget target = CreateHiddenInstance(); try { - ProjectBrowserVisibilityUtility.SetProjectBrowserVisibilityForTesting(true); + ProjectBrowserVisibilityUtility._cachedVisibility = true; Assert.That(WInLineEditorDrawer.ShouldShowPingButton(target), Is.True); } finally { - ProjectBrowserVisibilityUtility.SetProjectBrowserVisibilityForTesting(null); + ProjectBrowserVisibilityUtility._cachedVisibility = originalVisibility; } } @@ -594,7 +596,7 @@ public void SimplePropertiesAreDetectedCorrectly() { SimpleInlineEditorTarget target = CreateHiddenInstance(); using SerializedObject serializedObject = new(target); - bool hasOnlySimple = WInLineEditorDrawer.HasOnlySimplePropertiesForTesting( + bool hasOnlySimple = WInLineEditorDrawer.SerializedObjectHasOnlySimpleProperties( serializedObject ); Assert.That( @@ -609,7 +611,7 @@ public void ArrayPropertiesAreDetectedAsComplex() { ArrayInlineEditorTarget target = CreateHiddenInstance(); using SerializedObject serializedObject = new(target); - bool hasOnlySimple = WInLineEditorDrawer.HasOnlySimplePropertiesForTesting( + bool hasOnlySimple = WInLineEditorDrawer.SerializedObjectHasOnlySimpleProperties( serializedObject ); Assert.That( @@ -664,7 +666,7 @@ public void SimplePropertyDetectionDataDriven(Type targetType, bool expectedSimp target.hideFlags = HideFlags.HideAndDontSave; using SerializedObject serializedObject = new(target); - bool hasOnlySimple = WInLineEditorDrawer.HasOnlySimplePropertiesForTesting( + bool hasOnlySimple = WInLineEditorDrawer.SerializedObjectHasOnlySimpleProperties( serializedObject ); Assert.That( @@ -747,7 +749,7 @@ public void HorizontalScrollbarDecisionLogic( bool expectedNeedsScroll ) { - bool needsScroll = WInLineEditorDrawer.RequiresHorizontalScrollbarForTesting( + bool needsScroll = WInLineEditorDrawerTestAccess.RequiresHorizontalScrollbar( enableScrolling, minInspectorWidth, hasExplicitMinInspectorWidth, @@ -769,12 +771,14 @@ public void SimpleTargetsDoNotTriggerHorizontalScrollbars() SimpleInlineEditorTarget target = CreateHiddenInstance(); using SerializedObject serializedObject = new(target); - bool isSimple = WInLineEditorDrawer.HasOnlySimplePropertiesForTesting(serializedObject); + bool isSimple = WInLineEditorDrawer.SerializedObjectHasOnlySimpleProperties( + serializedObject + ); if (isSimple) { WInLineEditorAttribute inlineAttribute = new(); - bool usesScrollbar = WInLineEditorDrawer.UsesHorizontalScrollbarForTesting( + bool usesScrollbar = WInLineEditorDrawerTestAccess.UsesHorizontalScrollbar( target, inlineAttribute, availableWidth: 360f @@ -788,7 +792,7 @@ public void SimpleTargetsDoNotTriggerHorizontalScrollbars() else { // Detection can fail from editor integration, so check the logic with known-good inputs. - bool wouldNeedScroll = WInLineEditorDrawer.RequiresHorizontalScrollbarForTesting( + bool wouldNeedScroll = WInLineEditorDrawerTestAccess.RequiresHorizontalScrollbar( enableScrolling: true, minInspectorWidth: 520f, hasExplicitMinInspectorWidth: false, @@ -813,12 +817,14 @@ public void ComplexTargetsStillTriggerHorizontalScrollbars() ArrayInlineEditorTarget target = CreateHiddenInstance(); using SerializedObject serializedObject = new(target); - bool isSimple = WInLineEditorDrawer.HasOnlySimplePropertiesForTesting(serializedObject); + bool isSimple = WInLineEditorDrawer.SerializedObjectHasOnlySimpleProperties( + serializedObject + ); Assert.That(isSimple, Is.False, "Array target should be detected as complex"); WInLineEditorAttribute inlineAttribute = new(); - bool usesScrollbar = WInLineEditorDrawer.UsesHorizontalScrollbarForTesting( + bool usesScrollbar = WInLineEditorDrawerTestAccess.UsesHorizontalScrollbar( target, inlineAttribute, availableWidth: 360f @@ -833,7 +839,7 @@ public void ComplexTargetsStillTriggerHorizontalScrollbars() [Test] public void ExplicitMinWidthOverridesSimpleTargetHeuristic() { - bool needsScroll = WInLineEditorDrawer.RequiresHorizontalScrollbarForTesting( + bool needsScroll = WInLineEditorDrawerTestAccess.RequiresHorizontalScrollbar( enableScrolling: true, minInspectorWidth: 720f, hasExplicitMinInspectorWidth: true, @@ -867,7 +873,7 @@ public void CustomEditorsRespectMeasuredInlineHeight() public void InlineInspectorContentRectAppliesPadding() { Rect outer = new(10f, 20f, 200f, 100f); - Rect content = WInLineEditorDrawer.GetInlineContentRectForTesting(outer); + Rect content = WInLineEditorDrawer.GetInlineContentRect(outer); Assert.That(content.x, Is.EqualTo(outer.x + 2f)); Assert.That(content.y, Is.EqualTo(outer.y + 2f)); Assert.That(content.width, Is.EqualTo(outer.width - 4f)); @@ -878,7 +884,7 @@ public void InlineInspectorContentRectAppliesPadding() public void InlineInspectorContentRectClampsHeightToZero() { Rect outer = new(0f, 0f, 4f, 3f); - Rect content = WInLineEditorDrawer.GetInlineContentRectForTesting(outer); + Rect content = WInLineEditorDrawer.GetInlineContentRect(outer); Assert.That(content.height, Is.EqualTo(0f)); } @@ -889,7 +895,7 @@ public void InlineInspectorContentRectClampsHeightToZero() [Test] public void HorizontalScrollbarCalculationHandlesOutsideGUIContext() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); Assert.DoesNotThrow( () => @@ -919,7 +925,7 @@ float expectedHeight ) { Rect outer = new(outerX, outerY, outerWidth, outerHeight); - Rect content = WInLineEditorDrawer.GetInlineContentRectForTesting(outer); + Rect content = WInLineEditorDrawer.GetInlineContentRect(outer); Assert.That(content.x, Is.EqualTo(expectedX).Within(0.01f), "Content X mismatch"); Assert.That(content.y, Is.EqualTo(expectedY).Within(0.01f), "Content Y mismatch"); Assert.That( @@ -937,7 +943,7 @@ float expectedHeight [Test] public void NullTargetReturnsBaseHeight() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); InlineEditorHost host = CreateHiddenInstance(); using SerializedObject serializedHost = new(host); @@ -981,7 +987,7 @@ public void NullTargetReturnsBaseHeight() [Test] public void HeightDoesNotDoubleFromRecursion() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); float collapsedHeight = MeasurePropertyHeight( propertyExpanded: false, @@ -1034,7 +1040,7 @@ public void HeightDoesNotDoubleFromRecursion() )] public void ExplicitModeInitialFoldoutState(WInLineEditorMode mode, bool expectExpanded) { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); ExplicitModeTestHost host = CreateHiddenInstance(); InlineEditorTarget target = CreateHiddenInstance(); @@ -1077,7 +1083,7 @@ public void ExplicitModeInitialFoldoutState(WInLineEditorMode mode, bool expectE bool showHeader, bool showBody, float displayHeight - ) details = WInLineEditorDrawer.GetHeightCalculationDetailsForTesting( + ) details = WInLineEditorDrawerTestAccess.GetHeightCalculationDetails( property, inlineAttribute, target, @@ -1096,7 +1102,7 @@ public void SerializedInspectorIsUsedByDefault() { // The serialized inspector path is what avoids the 50% width defect. Assert.That( - WInLineEditorDrawer.ForceSerializedInspectorForTesting, + WInLineEditorDrawerTestAccess.ForceSerializedInspector, Is.True, "ForceSerializedInspector should be true by default to avoid width issues" ); @@ -1108,7 +1114,7 @@ public void LabelWidthIsCalculatedCorrectly() const float availableWidth = 400f; const float expectedLabelWidth = 160f; - float calculatedLabelWidth = WInLineEditorDrawer.CalculateLabelWidthForTesting( + float calculatedLabelWidth = WInLineEditorDrawerTestAccess.CalculateLabelWidth( availableWidth ); @@ -1125,7 +1131,7 @@ public void LabelWidthIsCalculatedCorrectly() [TestCase(100f, 40f, TestName = "LabelWidth.100px.Returns40")] public void LabelWidthCalculationDataDriven(float availableWidth, float expectedLabelWidth) { - float calculatedLabelWidth = WInLineEditorDrawer.CalculateLabelWidthForTesting( + float calculatedLabelWidth = WInLineEditorDrawerTestAccess.CalculateLabelWidth( availableWidth ); @@ -1152,7 +1158,7 @@ public void NarrowWidthTriggersHorizontalScrollDataDriven( bool expectedNeedsScroll ) { - bool needsScroll = WInLineEditorDrawer.RequiresHorizontalScrollbarForTesting( + bool needsScroll = WInLineEditorDrawerTestAccess.RequiresHorizontalScrollbar( enableScrolling: true, minInspectorWidth: 520f, hasExplicitMinInspectorWidth: false, @@ -1179,11 +1185,13 @@ public void VeryNarrowWidthTriggersScrollForSimpleLayouts() SimpleInlineEditorTarget target = CreateHiddenInstance(); using SerializedObject serializedObject = new(target); - bool isSimple = WInLineEditorDrawer.HasOnlySimplePropertiesForTesting(serializedObject); + bool isSimple = WInLineEditorDrawer.SerializedObjectHasOnlySimpleProperties( + serializedObject + ); Assert.That(isSimple, Is.True, "SimpleInlineEditorTarget should be detected as simple"); WInLineEditorAttribute inlineAttribute = new(); - bool usesScrollbar = WInLineEditorDrawer.UsesHorizontalScrollbarForTesting( + bool usesScrollbar = WInLineEditorDrawerTestAccess.UsesHorizontalScrollbar( target, inlineAttribute, availableWidth: 150f @@ -1201,11 +1209,13 @@ public void SimpleLayoutAtNormalWidthDoesNotTriggerScroll() { SimpleInlineEditorTarget target = CreateHiddenInstance(); using SerializedObject serializedObject = new(target); - bool isSimple = WInLineEditorDrawer.HasOnlySimplePropertiesForTesting(serializedObject); + bool isSimple = WInLineEditorDrawer.SerializedObjectHasOnlySimpleProperties( + serializedObject + ); Assert.That(isSimple, Is.True, "SimpleInlineEditorTarget should be detected as simple"); WInLineEditorAttribute inlineAttribute = new(); - bool usesScrollbar = WInLineEditorDrawer.UsesHorizontalScrollbarForTesting( + bool usesScrollbar = WInLineEditorDrawerTestAccess.UsesHorizontalScrollbar( target, inlineAttribute, availableWidth: 360f @@ -1236,7 +1246,7 @@ public void EffectiveWidthBoundaryTests(float availableWidth, bool expectedNeeds + $"manual calc says needsScroll={expectedBasedOnThreshold}, but expectedNeedsScroll={expectedNeedsScroll}" ); - bool needsScroll = WInLineEditorDrawer.RequiresHorizontalScrollbarForTesting( + bool needsScroll = WInLineEditorDrawerTestAccess.RequiresHorizontalScrollbar( enableScrolling: true, minInspectorWidth: 520f, hasExplicitMinInspectorWidth: false, @@ -1262,7 +1272,7 @@ public void MinimumUsableWidthConstantMatchesProduction() float thresholdAvailableWidth = ExpectedMinimumUsableWidth + (ExpectedContentPadding * 2f); - bool needsScrollAtThreshold = WInLineEditorDrawer.RequiresHorizontalScrollbarForTesting( + bool needsScrollAtThreshold = WInLineEditorDrawerTestAccess.RequiresHorizontalScrollbar( enableScrolling: true, minInspectorWidth: 520f, hasExplicitMinInspectorWidth: false, @@ -1279,7 +1289,7 @@ public void MinimumUsableWidthConstantMatchesProduction() ); bool needsScrollBelowThreshold = - WInLineEditorDrawer.RequiresHorizontalScrollbarForTesting( + WInLineEditorDrawerTestAccess.RequiresHorizontalScrollbar( enableScrolling: true, minInspectorWidth: 520f, hasExplicitMinInspectorWidth: false, @@ -1298,7 +1308,7 @@ public void MinimumUsableWidthConstantMatchesProduction() [Test] public void CompactModeShowsObjectPickerInsteadOfFullObjectField() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); CompactInlineEditorHost host = CreateHiddenInstance(); InlineEditorTarget target = CreateHiddenInstance(); @@ -1413,7 +1423,7 @@ float displayHeight [Test] public void CompactModeWithNullTargetReturnsBaseHeight() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); CompactInlineEditorHost host = CreateHiddenInstance(); using SerializedObject serializedHost = new(host); @@ -1469,7 +1479,7 @@ public void CompactModeRespectsInitialFoldoutState( bool expectExpanded ) { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); CompactModeTestHost host = CreateHiddenInstance(); InlineEditorTarget target = CreateHiddenInstance(); @@ -1512,7 +1522,7 @@ bool expectExpanded bool showHeader, bool showBody, float displayHeight - ) details = WInLineEditorDrawer.GetHeightCalculationDetailsForTesting( + ) details = WInLineEditorDrawerTestAccess.GetHeightCalculationDetails( property, inlineAttribute, target, @@ -1529,7 +1539,7 @@ float displayHeight [Test] public void CompactModeWithCustomHeightRespectsHeight() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); CompactCustomHeightHost host = CreateHiddenInstance(); InlineEditorTarget target = CreateHiddenInstance(); @@ -1660,7 +1670,7 @@ float displayHeight bool showHeader, bool showBody, float displayHeight - ) details = WInLineEditorDrawer.GetHeightCalculationDetailsForTesting( + ) details = WInLineEditorDrawerTestAccess.GetHeightCalculationDetails( property, inlineAttribute, target, @@ -1721,7 +1731,7 @@ float displayHeight [Test] public void CompactModeWithPreviewShowsPreview() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); CompactCustomHeightHost host = CreateHiddenInstance(); InlineEditorTarget target = CreateHiddenInstance(); @@ -1765,7 +1775,7 @@ public void CompactModeWithPreviewShowsPreview() [Test] public void CompactModeNoScrollRespectsScrollSetting() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); CompactCustomHeightHost host = CreateHiddenInstance(); InlineEditorTarget target = CreateHiddenInstance(); @@ -1798,7 +1808,7 @@ public void CompactModeNoScrollRespectsScrollSetting() "DrawObjectField should be false for compact mode" ); - bool usesScrollbar = WInLineEditorDrawer.UsesHorizontalScrollbarForTesting( + bool usesScrollbar = WInLineEditorDrawerTestAccess.UsesHorizontalScrollbar( target, inlineAttribute, availableWidth: 200f @@ -1814,7 +1824,7 @@ public void CompactModeNoScrollRespectsScrollSetting() [TestCase(true, false, TestName = "CompactFoldoutToggle.ExpandedToCollapsed")] public void CompactModeFoldoutToggleChangesHeight(bool initialState, bool finalState) { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); float initialHeight = MeasurePropertyHeight( propertyExpanded: false, @@ -1875,7 +1885,7 @@ public void CompactModeDrawObjectFieldAttributeIsCorrect() [Test] public void CompactAlwaysExpandedWithHeaderShowsHeaderAndBody() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); CompactModeTestHost host = CreateHiddenInstance(); InlineEditorTarget target = CreateHiddenInstance(); @@ -1905,7 +1915,7 @@ public void CompactAlwaysExpandedWithHeaderShowsHeaderAndBody() bool showHeader, bool showBody, float displayHeight - ) details = WInLineEditorDrawer.GetHeightCalculationDetailsForTesting( + ) details = WInLineEditorDrawerTestAccess.GetHeightCalculationDetails( property, inlineAttribute, target, @@ -1923,7 +1933,7 @@ float displayHeight [Test] public void BaseHeightIsConsistentAcrossDrawerCalls() { - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearCachedState(); InlineEditorHost host = CreateHiddenInstance(); InlineEditorTarget target = CreateHiddenInstance(); @@ -1945,7 +1955,7 @@ public void BaseHeightIsConsistentAcrossDrawerCalls() WInLineEditorAttribute inlineAttribute = (WInLineEditorAttribute) Attribute.GetCustomAttribute(targetField, typeof(WInLineEditorAttribute)); - WInLineEditorDrawer.SetInlineFoldoutStateForTesting(property, false); + WInLineEditorDrawerTestAccess.SetInlineFoldoutState(property, false); GUIContent label = new("Target"); WInLineEditorDrawer drawer = new(); @@ -1967,15 +1977,12 @@ public void FoldoutAnimationCacheCreatesNewAnimBoolForUnseenKey() const string foldoutKey = "TestKey_NewAnimation"; Assert.That( - WInLineEditorDrawer.GetAnimationCacheCountForTesting(), + WInLineEditorDrawerTestAccess.GetAnimationCacheCount(), Is.EqualTo(0), "Animation cache should be empty at start." ); - AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( - foldoutKey, - expanded: true - ); + AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: true); Assert.That( anim, @@ -1983,12 +1990,12 @@ public void FoldoutAnimationCacheCreatesNewAnimBoolForUnseenKey() "GetOrCreateFoldoutAnim should return a non-null AnimBool." ); Assert.That( - WInLineEditorDrawer.GetAnimationCacheCountForTesting(), + WInLineEditorDrawerTestAccess.GetAnimationCacheCount(), Is.EqualTo(1), "Animation cache should contain one entry after creation." ); Assert.That( - WInLineEditorDrawer.HasAnimationCacheEntryForTesting(foldoutKey), + WInLineEditorDrawerTestAccess.HasAnimationCacheEntry(foldoutKey), Is.True, "Animation cache should have an entry for the specified key." ); @@ -1999,11 +2006,8 @@ public void FoldoutAnimationCacheReturnsSameAnimBoolForSameKey() { const string foldoutKey = "TestKey_SameInstance"; - AnimBool first = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( - foldoutKey, - expanded: true - ); - AnimBool second = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( + AnimBool first = WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: true); + AnimBool second = WInLineEditorDrawer.GetOrCreateFoldoutAnim( foldoutKey, expanded: true ); @@ -2014,7 +2018,7 @@ public void FoldoutAnimationCacheReturnsSameAnimBoolForSameKey() "GetOrCreateFoldoutAnim should return the same AnimBool instance for the same key." ); Assert.That( - WInLineEditorDrawer.GetAnimationCacheCountForTesting(), + WInLineEditorDrawerTestAccess.GetAnimationCacheCount(), Is.EqualTo(1), "Animation cache should still contain only one entry." ); @@ -2026,11 +2030,11 @@ public void FoldoutAnimationCacheReturnsDifferentAnimBoolsForDifferentKeys() const string foldoutKey1 = "TestKey_First"; const string foldoutKey2 = "TestKey_Second"; - AnimBool first = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( + AnimBool first = WInLineEditorDrawer.GetOrCreateFoldoutAnim( foldoutKey1, expanded: true ); - AnimBool second = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( + AnimBool second = WInLineEditorDrawer.GetOrCreateFoldoutAnim( foldoutKey2, expanded: true ); @@ -2041,7 +2045,7 @@ public void FoldoutAnimationCacheReturnsDifferentAnimBoolsForDifferentKeys() "GetOrCreateFoldoutAnim should return different AnimBool instances for different keys." ); Assert.That( - WInLineEditorDrawer.GetAnimationCacheCountForTesting(), + WInLineEditorDrawerTestAccess.GetAnimationCacheCount(), Is.EqualTo(2), "Animation cache should contain two entries." ); @@ -2052,16 +2056,13 @@ public void AnimBoolTargetUpdatesOnExpandedChange() { const string foldoutKey = "TestKey_TargetUpdate"; - AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( - foldoutKey, - expanded: true - ); + AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: true); Assert.IsTrue(anim.target, "Initial target should be true when expanded is true."); - WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting(foldoutKey, expanded: false); + WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: false); Assert.IsFalse(anim.target, "Target should update to false when expanded changes."); - WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting(foldoutKey, expanded: true); + WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: true); Assert.IsTrue(anim.target, "Target should update back to true."); } @@ -2072,14 +2073,11 @@ public void AnimBoolSpeedReflectsCurrentSettings() const string foldoutKey = "TestKey_SpeedCheck"; settings.InlineEditorFoldoutSpeed = 8f; - AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( - foldoutKey, - expanded: true - ); + AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: true); Assert.That(anim.speed, Is.EqualTo(8f), "AnimBool speed should match settings value."); settings.InlineEditorFoldoutSpeed = 4f; - WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting(foldoutKey, expanded: true); + WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: true); Assert.That( anim.speed, Is.EqualTo(4f), @@ -2095,7 +2093,7 @@ public void GetFadeProgressReturnsImmediateValueWhenTweenDisabled() const string foldoutKey = "TestKey_ImmediateProgress"; - float expandedProgress = WInLineEditorDrawer.GetFadeProgressForTesting( + float expandedProgress = WInLineEditorDrawer.GetFadeProgress( foldoutKey, expanded: true ); @@ -2105,7 +2103,7 @@ public void GetFadeProgressReturnsImmediateValueWhenTweenDisabled() "When tweening disabled, expanded=true should return 1f immediately." ); - float collapsedProgress = WInLineEditorDrawer.GetFadeProgressForTesting( + float collapsedProgress = WInLineEditorDrawer.GetFadeProgress( foldoutKey, expanded: false ); @@ -2125,18 +2123,12 @@ public void GetFadeProgressReturnsAnimatedValueWhenTweenEnabled() const string foldoutKey = "TestKey_AnimatedProgress"; - AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( - foldoutKey, - expanded: false - ); + AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: false); anim.value = false; anim.target = true; - float progress = WInLineEditorDrawer.GetFadeProgressForTesting( - foldoutKey, - expanded: true - ); + float progress = WInLineEditorDrawer.GetFadeProgress(foldoutKey, expanded: true); Assert.That( progress, @@ -2154,18 +2146,15 @@ public void GetFadeProgressCreatesAnimBoolWhenTweenEnabled() const string foldoutKey = "TestKey_CreateOnProgress"; Assert.That( - WInLineEditorDrawer.HasAnimationCacheEntryForTesting(foldoutKey), + WInLineEditorDrawerTestAccess.HasAnimationCacheEntry(foldoutKey), Is.False, "Animation cache should not have entry before GetFadeProgress call." ); - float progress = WInLineEditorDrawer.GetFadeProgressForTesting( - foldoutKey, - expanded: true - ); + float progress = WInLineEditorDrawer.GetFadeProgress(foldoutKey, expanded: true); Assert.That( - WInLineEditorDrawer.HasAnimationCacheEntryForTesting(foldoutKey), + WInLineEditorDrawerTestAccess.HasAnimationCacheEntry(foldoutKey), Is.True, "GetFadeProgress should create AnimBool entry when tweening is enabled." ); @@ -2181,18 +2170,15 @@ public void GetFadeProgressDoesNotCreateAnimBoolWhenTweenDisabled() const string foldoutKey = "TestKey_NoCreateOnDisabled"; Assert.That( - WInLineEditorDrawer.HasAnimationCacheEntryForTesting(foldoutKey), + WInLineEditorDrawerTestAccess.HasAnimationCacheEntry(foldoutKey), Is.False, "Animation cache should not have entry before GetFadeProgress call." ); - float progress = WInLineEditorDrawer.GetFadeProgressForTesting( - foldoutKey, - expanded: true - ); + float progress = WInLineEditorDrawer.GetFadeProgress(foldoutKey, expanded: true); Assert.That( - WInLineEditorDrawer.HasAnimationCacheEntryForTesting(foldoutKey), + WInLineEditorDrawerTestAccess.HasAnimationCacheEntry(foldoutKey), Is.False, "GetFadeProgress should not create AnimBool when tweening is disabled." ); @@ -2209,34 +2195,34 @@ public void ClearAnimationCacheRemovesAllEntries() const string foldoutKey1 = "TestKey_ClearCache1"; const string foldoutKey2 = "TestKey_ClearCache2"; - AnimBool anim1Before = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( + AnimBool anim1Before = WInLineEditorDrawer.GetOrCreateFoldoutAnim( foldoutKey1, expanded: true ); - AnimBool anim2Before = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( + AnimBool anim2Before = WInLineEditorDrawer.GetOrCreateFoldoutAnim( foldoutKey2, expanded: false ); Assert.That( - WInLineEditorDrawer.GetAnimationCacheCountForTesting(), + WInLineEditorDrawerTestAccess.GetAnimationCacheCount(), Is.EqualTo(2), "Should have 2 cache entries before clearing." ); - WInLineEditorDrawer.ClearAnimationCacheForTesting(); + WInLineEditorDrawerTestAccess.ClearAnimationCache(); Assert.That( - WInLineEditorDrawer.GetAnimationCacheCountForTesting(), + WInLineEditorDrawerTestAccess.GetAnimationCacheCount(), Is.EqualTo(0), "Animation cache should be empty after clearing." ); - AnimBool anim1After = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( + AnimBool anim1After = WInLineEditorDrawer.GetOrCreateFoldoutAnim( foldoutKey1, expanded: true ); - AnimBool anim2After = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( + AnimBool anim2After = WInLineEditorDrawer.GetOrCreateFoldoutAnim( foldoutKey2, expanded: false ); @@ -2256,12 +2242,12 @@ public void ClearAnimationCacheRemovesAllEntries() [Test] public void ClearAnimationCacheCanBeCalledMultipleTimes() { - WInLineEditorDrawer.ClearAnimationCacheForTesting(); - WInLineEditorDrawer.ClearAnimationCacheForTesting(); - WInLineEditorDrawer.ClearAnimationCacheForTesting(); + WInLineEditorDrawerTestAccess.ClearAnimationCache(); + WInLineEditorDrawerTestAccess.ClearAnimationCache(); + WInLineEditorDrawerTestAccess.ClearAnimationCache(); Assert.That( - WInLineEditorDrawer.GetAnimationCacheCountForTesting(), + WInLineEditorDrawerTestAccess.GetAnimationCacheCount(), Is.EqualTo(0), "Clearing an already empty cache should not cause errors." ); @@ -2277,10 +2263,7 @@ public void AnimBoolSpeedReflectsVariousSpeedSettings(float expectedSpeed) settings.InlineEditorFoldoutSpeed = expectedSpeed; string foldoutKey = $"TestKey_Speed_{expectedSpeed}"; - AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnimForTesting( - foldoutKey, - expanded: true - ); + AnimBool anim = WInLineEditorDrawer.GetOrCreateFoldoutAnim(foldoutKey, expanded: true); Assert.That( anim.speed, @@ -2333,8 +2316,8 @@ public void HeightCalculationReturnsFullHeightWhenTweenDisabled() settings.InlineEditorFoldoutTweenEnabled = true; - WInLineEditorDrawer.ClearAnimationCacheForTesting(); - WInLineEditorDrawer.ClearCachedStateForTesting(); + WInLineEditorDrawerTestAccess.ClearAnimationCache(); + WInLineEditorDrawerTestAccess.ClearCachedState(); float expandedHeightWithTween = MeasurePropertyHeight( propertyExpanded: false, @@ -2356,14 +2339,8 @@ public void GetFadeProgressReturnsConsistentResultsForSameState() const string foldoutKey = "TestKey_ConsistentProgress"; - float progress1 = WInLineEditorDrawer.GetFadeProgressForTesting( - foldoutKey, - expanded: true - ); - float progress2 = WInLineEditorDrawer.GetFadeProgressForTesting( - foldoutKey, - expanded: true - ); + float progress1 = WInLineEditorDrawer.GetFadeProgress(foldoutKey, expanded: true); + float progress2 = WInLineEditorDrawer.GetFadeProgress(foldoutKey, expanded: true); // Progress values should be equal or nearly equal (animation may have progressed slightly) Assert.That( diff --git a/Tests/Editor/CustomDrawers/WNotNullPropertyDrawerTests.cs b/Tests/Editor/CustomDrawers/WNotNullPropertyDrawerTests.cs index 503351a13..b7f616cb6 100644 --- a/Tests/Editor/CustomDrawers/WNotNullPropertyDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/WNotNullPropertyDrawerTests.cs @@ -28,7 +28,7 @@ public sealed class WNotNullPropertyDrawerTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - WNotNullPropertyDrawer.ClearHeightCache(); + WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils.ValidationShared.ClearHeightCache(); } [TestCase(null, true, TestName = "WNotNull.Message.Null.UsesDefault")] @@ -720,7 +720,7 @@ public void ClearHeightCacheClearsCache() "First GetPropertyHeight call should not throw" ); - WNotNullPropertyDrawer.ClearHeightCache(); + WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils.ValidationShared.ClearHeightCache(); float height2 = 0f; Assert.DoesNotThrow( diff --git a/Tests/Editor/CustomDrawers/WValueDropDownConvertibleOptionTests.cs b/Tests/Editor/CustomDrawers/WValueDropDownConvertibleOptionTests.cs index 0bd04febf..d9f87fa1c 100644 --- a/Tests/Editor/CustomDrawers/WValueDropDownConvertibleOptionTests.cs +++ b/Tests/Editor/CustomDrawers/WValueDropDownConvertibleOptionTests.cs @@ -55,7 +55,7 @@ string propertyPath object[] options = attribute.GetOptions(asset); Assert.IsTrue(options != null, $"{propertyPath} produced no options."); - return WValueDropDownDrawer.TestHooks.ResolveSelectedIndex( + return WValueDropDownDrawerTestAccess.ResolveSelectedIndex( property, attribute.ValueType, options @@ -248,7 +248,7 @@ object option ) { Assert.IsTrue( - WValueDropDownDrawer.TestHooks.MatchesAuthoredOption(serializedValue, option), + WValueDropDownDrawerTestAccess.MatchesAuthoredOption(serializedValue, option), $"{serializedValue.GetType().Name} holding {serializedValue} must match the authored " + $"{option.GetType().Name} {option}" ); @@ -262,7 +262,7 @@ object option ) { Assert.IsFalse( - WValueDropDownDrawer.TestHooks.MatchesAuthoredOption(serializedValue, option), + WValueDropDownDrawerTestAccess.MatchesAuthoredOption(serializedValue, option), $"{serializedValue.GetType().Name} holding {serializedValue} must not match the " + $"authored {option.GetType().Name} {option}" ); diff --git a/Tests/Editor/CustomDrawers/WValueDropDownDisplayLabelCacheTests.cs b/Tests/Editor/CustomDrawers/WValueDropDownDisplayLabelCacheTests.cs index 3984c8a36..e52bd80f8 100644 --- a/Tests/Editor/CustomDrawers/WValueDropDownDisplayLabelCacheTests.cs +++ b/Tests/Editor/CustomDrawers/WValueDropDownDisplayLabelCacheTests.cs @@ -31,30 +31,30 @@ private static string PropertyPath(int index) [SetUp] public void SetUp() { - WValueDropDownDrawer.TestHooks.ClearCaches(); + WValueDropDownDrawerTestAccess.ClearCaches(); } [TearDown] public void TearDown() { - WValueDropDownDrawer.TestHooks.ClearCaches(); + WValueDropDownDrawerTestAccess.ClearCaches(); } [Test] public void TheDisplayLabelCacheStopsAtItsBound() { - int bound = WValueDropDownDrawer.TestHooks.MaxDisplayLabelsCacheCount; + int bound = WValueDropDownDrawerTestAccess.MaxDisplayLabelsCacheCount; Assert.Greater(bound, 0, "the bound must be positive for this to measure anything"); for (int index = 0; index < bound * 2; ++index) { - _ = WValueDropDownDrawer.TestHooks.GetOrCreateDisplayLabels( + _ = WValueDropDownDrawerTestAccess.GetOrCreateDisplayLabels( PropertyPath(index), new object[] { index } ); } - Assert.AreEqual(bound, WValueDropDownDrawer.TestHooks.DisplayLabelsCacheCount); + Assert.AreEqual(bound, WValueDropDownDrawerTestAccess.DisplayLabelsCacheCount); } /// @@ -66,16 +66,16 @@ public void TheDisplayLabelCacheStopsAtItsBound() public void AnEvictedPathStillAnswersWithItsOwnLabels() { object[] options = { "alpha", "beta" }; - string[] before = WValueDropDownDrawer.TestHooks.GetOrCreateDisplayLabels( + string[] before = WValueDropDownDrawerTestAccess.GetOrCreateDisplayLabels( PropertyPath(-1), options ); Assert.IsNotEmpty(before, "the probe must have produced labels to compare against"); - int bound = WValueDropDownDrawer.TestHooks.MaxDisplayLabelsCacheCount; + int bound = WValueDropDownDrawerTestAccess.MaxDisplayLabelsCacheCount; for (int index = 0; index < bound + 1; ++index) { - _ = WValueDropDownDrawer.TestHooks.GetOrCreateDisplayLabels( + _ = WValueDropDownDrawerTestAccess.GetOrCreateDisplayLabels( PropertyPath(index), new object[] { index } ); @@ -83,11 +83,11 @@ public void AnEvictedPathStillAnswersWithItsOwnLabels() Assert.AreEqual( bound, - WValueDropDownDrawer.TestHooks.DisplayLabelsCacheCount, + WValueDropDownDrawerTestAccess.DisplayLabelsCacheCount, "the churn never reached the bound, so nothing was evicted" ); - string[] after = WValueDropDownDrawer.TestHooks.GetOrCreateDisplayLabels( + string[] after = WValueDropDownDrawerTestAccess.GetOrCreateDisplayLabels( PropertyPath(-1), options ); diff --git a/Tests/Editor/CustomDrawers/WValueDropDownDrawerTests.cs b/Tests/Editor/CustomDrawers/WValueDropDownDrawerTests.cs index 500b900d8..5ac213cf9 100644 --- a/Tests/Editor/CustomDrawers/WValueDropDownDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/WValueDropDownDrawerTests.cs @@ -261,30 +261,30 @@ public void StaticMethodProviderReturnsValues() [Test] public void PopupChromeIncludesFooterPadding() { - float chrome = WValueDropDownDrawer.TestHooks.CalculatePopupChromeHeight( + float chrome = WValueDropDownDrawerTestAccess.CalculatePopupChromeHeight( includePagination: true ); float searchHeight = EditorGUIUtility.singleLineHeight; - float paginationHeight = WValueDropDownDrawer.TestHooks.PaginationButtonHeight; + float paginationHeight = WValueDropDownDrawerTestAccess.PaginationButtonHeight; float footerHeight = chrome - (searchHeight + paginationHeight); float expectedFooterHeight = EditorGUIUtility.standardVerticalSpacing - + WValueDropDownDrawer.TestHooks.OptionFooterPadding; + + WValueDropDownDrawerTestAccess.OptionFooterPadding; Assert.That(footerHeight, Is.EqualTo(expectedFooterHeight).Within(0.001f)); } [Test] public void PopupChromeAddsPaginationHeightWhenRequired() { - float withPagination = WValueDropDownDrawer.TestHooks.CalculatePopupChromeHeight( + float withPagination = WValueDropDownDrawerTestAccess.CalculatePopupChromeHeight( includePagination: true ); - float withoutPagination = WValueDropDownDrawer.TestHooks.CalculatePopupChromeHeight( + float withoutPagination = WValueDropDownDrawerTestAccess.CalculatePopupChromeHeight( includePagination: false ); float difference = withPagination - withoutPagination; float expectedDifference = - WValueDropDownDrawer.TestHooks.PaginationButtonHeight + WValueDropDownDrawerTestAccess.PaginationButtonHeight - EditorGUIUtility.standardVerticalSpacing; Assert.That(difference, Is.EqualTo(expectedDifference).Within(0.001f)); } @@ -293,12 +293,12 @@ public void PopupChromeAddsPaginationHeightWhenRequired() public void PopupTargetHeightAggregatesChromeAndRows() { const int pageSize = 10; - float chrome = WValueDropDownDrawer.TestHooks.CalculatePopupChromeHeight( + float chrome = WValueDropDownDrawerTestAccess.CalculatePopupChromeHeight( includePagination: false ); - float rowHeight = WValueDropDownDrawer.TestHooks.GetOptionRowHeight(); + float rowHeight = WValueDropDownDrawerTestAccess.GetOptionRowHeight(); float expected = chrome + (pageSize * rowHeight); - float actual = WValueDropDownDrawer.TestHooks.CalculatePopupTargetHeight( + float actual = WValueDropDownDrawerTestAccess.CalculatePopupTargetHeight( pageSize, includePagination: false ); @@ -308,12 +308,12 @@ public void PopupTargetHeightAggregatesChromeAndRows() [Test] public void PopupTargetHeightFitsSingleRowWithoutMinimumClamp() { - float chrome = WValueDropDownDrawer.TestHooks.CalculatePopupChromeHeight( + float chrome = WValueDropDownDrawerTestAccess.CalculatePopupChromeHeight( includePagination: false ); - float rowHeight = WValueDropDownDrawer.TestHooks.GetOptionRowHeight(); + float rowHeight = WValueDropDownDrawerTestAccess.GetOptionRowHeight(); float expected = chrome + rowHeight; - float actual = WValueDropDownDrawer.TestHooks.CalculatePopupTargetHeight( + float actual = WValueDropDownDrawerTestAccess.CalculatePopupTargetHeight( 1, includePagination: false ); @@ -323,30 +323,30 @@ public void PopupTargetHeightFitsSingleRowWithoutMinimumClamp() [Test] public void PopupTargetHeightScalesWithLargePageSizes() { - float twentyFiveRows = WValueDropDownDrawer.TestHooks.CalculatePopupTargetHeight( + float twentyFiveRows = WValueDropDownDrawerTestAccess.CalculatePopupTargetHeight( 25, includePagination: true ); - float fiftyRows = WValueDropDownDrawer.TestHooks.CalculatePopupTargetHeight( + float fiftyRows = WValueDropDownDrawerTestAccess.CalculatePopupTargetHeight( 50, includePagination: true ); - float rowHeight = WValueDropDownDrawer.TestHooks.GetOptionRowHeight(); + float rowHeight = WValueDropDownDrawerTestAccess.GetOptionRowHeight(); Assert.That(fiftyRows - twentyFiveRows, Is.EqualTo(25 * rowHeight).Within(0.001f)); } [Test] public void PopupTargetHeightTreatsNonPositivePageSizesAsSingleRow() { - float baseline = WValueDropDownDrawer.TestHooks.CalculatePopupTargetHeight( + float baseline = WValueDropDownDrawerTestAccess.CalculatePopupTargetHeight( 1, includePagination: false ); - float zero = WValueDropDownDrawer.TestHooks.CalculatePopupTargetHeight( + float zero = WValueDropDownDrawerTestAccess.CalculatePopupTargetHeight( 0, includePagination: false ); - float negative = WValueDropDownDrawer.TestHooks.CalculatePopupTargetHeight( + float negative = WValueDropDownDrawerTestAccess.CalculatePopupTargetHeight( -5, includePagination: false ); @@ -357,36 +357,36 @@ public void PopupTargetHeightTreatsNonPositivePageSizesAsSingleRow() [Test] public void OptionRowHeightMatchesControlPlusEffectiveMargin() { - float control = WValueDropDownDrawer.TestHooks.GetOptionControlHeight(); - int marginVertical = WValueDropDownDrawer.TestHooks.OptionButtonMarginVertical; + float control = WValueDropDownDrawerTestAccess.GetOptionControlHeight(); + int marginVertical = WValueDropDownDrawerTestAccess.OptionButtonMarginVertical; float expected = control + Mathf.Max(0f, marginVertical - EditorGUIUtility.standardVerticalSpacing); - float actual = WValueDropDownDrawer.TestHooks.GetOptionRowHeight(); + float actual = WValueDropDownDrawerTestAccess.GetOptionRowHeight(); Assert.That(actual, Is.EqualTo(expected).Within(0.001f)); } [Test] public void EmptySearchHeightLeavesRoomForHelpBox() { - float emptyHeight = WValueDropDownDrawer.TestHooks.CalculateEmptySearchHeight(); + float emptyHeight = WValueDropDownDrawerTestAccess.CalculateEmptySearchHeight(); GUIStyle helpStyle = EditorStyles.helpBox; int helpMargin = helpStyle.margin?.horizontal ?? 0; float helpWidth = - WValueDropDownDrawer.TestHooks.PopupWidthValue - - WValueDropDownDrawer.TestHooks.EmptySearchHorizontalPaddingValue + WValueDropDownDrawerTestAccess.PopupWidthValue + - WValueDropDownDrawerTestAccess.EmptySearchHorizontalPaddingValue - helpMargin; helpWidth = Mathf.Max(32f, helpWidth); float helpHeight = helpStyle.CalcHeight( - new GUIContent(WValueDropDownDrawer.TestHooks.EmptyResultsMessageValue), + new GUIContent(WValueDropDownDrawerTestAccess.EmptyResultsMessageValue), helpWidth ) + (helpStyle.margin?.vertical ?? 0); float expected = EditorGUIUtility.singleLineHeight + (EditorGUIUtility.standardVerticalSpacing * 4f) + helpHeight - + WValueDropDownDrawer.TestHooks.OptionFooterPadding - + WValueDropDownDrawer.TestHooks.EmptySearchExtraPaddingValue; + + WValueDropDownDrawerTestAccess.OptionFooterPadding + + WValueDropDownDrawerTestAccess.EmptySearchExtraPaddingValue; Assert.That(emptyHeight, Is.EqualTo(expected).Within(0.001f)); } @@ -395,7 +395,7 @@ public void EmptySearchHeightPrefersMeasuredHelpBoxHeight() { const float measuredHelpHeight = 42f; float emptyHeight = - WValueDropDownDrawer.TestHooks.CalculateEmptySearchHeightWithMeasurement( + WValueDropDownDrawerTestAccess.CalculateEmptySearchHeightWithMeasurement( measuredHelpHeight ); @@ -403,8 +403,8 @@ public void EmptySearchHeightPrefersMeasuredHelpBoxHeight() EditorGUIUtility.singleLineHeight + (EditorGUIUtility.standardVerticalSpacing * 4f) + measuredHelpHeight - + WValueDropDownDrawer.TestHooks.OptionFooterPadding - + WValueDropDownDrawer.TestHooks.EmptySearchExtraPaddingValue; + + WValueDropDownDrawerTestAccess.OptionFooterPadding + + WValueDropDownDrawerTestAccess.EmptySearchExtraPaddingValue; Assert.That(emptyHeight, Is.EqualTo(expected).Within(0.001f)); } @@ -432,7 +432,7 @@ public void CalculateRowsOnPageDataDrivenScenarios( int expectedRows ) { - int rows = WValueDropDownDrawer.TestHooks.CalculateRowsOnPage( + int rows = WValueDropDownDrawerTestAccess.CalculateRowsOnPage( filteredCount: filteredCount, pageSize: pageSize, currentPage: currentPage @@ -608,7 +608,7 @@ public void ResolveSelectedIndexReturnsNegativeOneForUnmatchedValue() ); Assert.IsTrue(property != null, "Failed to locate selection property."); - int index = WValueDropDownDrawer.TestHooks.ResolveSelectedIndex( + int index = WValueDropDownDrawerTestAccess.ResolveSelectedIndex( property, typeof(int), new object[] { 10, 20, 30 } @@ -630,7 +630,7 @@ public void ResolveSelectedIndexReturnsCorrectIndexForMatchedValue() ); Assert.IsTrue(property != null, "Failed to locate selection property."); - int index = WValueDropDownDrawer.TestHooks.ResolveSelectedIndex( + int index = WValueDropDownDrawerTestAccess.ResolveSelectedIndex( property, typeof(int), new object[] { 10, 20, 30 } @@ -701,12 +701,12 @@ bool useSharedFormatter string label = useSharedFormatter ? DropDownShared.FormatOption(selected) - : WValueDropDownDrawer.TestHooks.FormatOptionCached(selected); + : WValueDropDownDrawerTestAccess.FormatOptionCached(selected); Assert.IsTrue(asset.selectedObject == selected); Assert.IsTrue(property.objectReferenceValue == selected); Assert.AreEqual( 1, - WValueDropDownDrawer.TestHooks.ResolveSelectedIndex( + WValueDropDownDrawerTestAccess.ResolveSelectedIndex( property, typeof(UnityEngine.Object), options @@ -754,13 +754,13 @@ bool useSharedFormatter string label = useSharedFormatter ? DropDownShared.FormatOption(option) - : WValueDropDownDrawer.TestHooks.FormatOptionCached(option); + : WValueDropDownDrawerTestAccess.FormatOptionCached(option); Assert.AreEqual(option, label); Assert.AreEqual(option, property.stringValue); Assert.AreEqual(option, asset.selection); Assert.AreEqual( 1, - WValueDropDownDrawer.TestHooks.ResolveSelectedIndex( + WValueDropDownDrawerTestAccess.ResolveSelectedIndex( property, typeof(string), new object[] { "Different", option } @@ -772,7 +772,7 @@ bool useSharedFormatter public void FormatOptionCachedReturnsTypeNameForEmptyToString() { EmptyToStringHelper instance = new(); - string result = WValueDropDownDrawer.TestHooks.FormatOptionCached(instance); + string result = WValueDropDownDrawerTestAccess.FormatOptionCached(instance); Assert.That(result, Is.Not.Null.And.Not.Empty); Assert.That(result, Does.Contain(nameof(EmptyToStringHelper))); } @@ -781,14 +781,14 @@ public void FormatOptionCachedReturnsTypeNameForEmptyToString() public void FormatOptionCachedReturnsNonEmptyForNullToString() { NullToStringHelper instance = new(); - string result = WValueDropDownDrawer.TestHooks.FormatOptionCached(instance); + string result = WValueDropDownDrawerTestAccess.FormatOptionCached(instance); Assert.That(result, Is.Not.Null.And.Not.Empty); } [Test] public void FormatOptionCachedHandlesNullOption() { - string result = WValueDropDownDrawer.TestHooks.FormatOptionCached(null); + string result = WValueDropDownDrawerTestAccess.FormatOptionCached(null); Assert.That(result, Is.EqualTo("(null)")); } @@ -796,7 +796,7 @@ public void FormatOptionCachedHandlesNullOption() [TestCaseSource(nameof(FormatOptionCachedNeverReturnsEmptyStringData))] public void FormatOptionCachedNeverReturnsEmptyString(object input) { - string result = WValueDropDownDrawer.TestHooks.FormatOptionCached(input); + string result = WValueDropDownDrawerTestAccess.FormatOptionCached(input); Assert.That(result, Is.Not.Null.And.Not.Empty); } @@ -804,7 +804,7 @@ public void FormatOptionCachedNeverReturnsEmptyString(object input) public void BuildDisplayLabelsProducesNoEmptyStrings() { object[] options = new object[] { 42, null }; - string[] labels = WValueDropDownDrawer.TestHooks.BuildDisplayLabelsUncached(options); + string[] labels = WValueDropDownDrawerTestAccess.BuildDisplayLabelsUncached(options); Assert.That(labels.Length, Is.EqualTo(options.Length)); foreach (string label in labels) { @@ -822,7 +822,7 @@ public void BuildDisplayLabelsHandlesEmptyToStringWithFallback() 42, null, }; - string[] labels = WValueDropDownDrawer.TestHooks.BuildDisplayLabelsUncached(options); + string[] labels = WValueDropDownDrawerTestAccess.BuildDisplayLabelsUncached(options); Assert.That(labels.Length, Is.EqualTo(options.Length)); foreach (string label in labels) { @@ -844,7 +844,7 @@ public void SelectorWithUnmatchedValueDefaultsToFirstOption() ); Assert.IsTrue(property != null, "Failed to locate string selection property."); - int rawIndex = WValueDropDownDrawer.TestHooks.ResolveSelectedIndex( + int rawIndex = WValueDropDownDrawerTestAccess.ResolveSelectedIndex( property, typeof(string), new object[] { "Alpha", "Beta", "Gamma" } diff --git a/Tests/Editor/EditorProgressCleanupTests.cs b/Tests/Editor/EditorProgressCleanupTests.cs deleted file mode 100644 index 73d5eaa3f..000000000 --- a/Tests/Editor/EditorProgressCleanupTests.cs +++ /dev/null @@ -1,260 +0,0 @@ -// MIT License - Copyright (c) 2026 wallstop -// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE - -namespace WallstopStudios.UnityHelpers.Tests -{ -#if UNITY_EDITOR - using System; - using System.Collections.Generic; - using System.IO; - using NUnit.Framework; - using UnityEditor; - using UnityEngine; - using WallstopStudios.UnityHelpers.Editor; - using WallstopStudios.UnityHelpers.Editor.Sprites; - using WallstopStudios.UnityHelpers.Editor.Utils; - using WallstopStudios.UnityHelpers.Tests.Core; - - [TestFixture] - [Category("Slow")] - [Category("Integration")] - public sealed class EditorProgressCleanupTests : CommonTestBase - { - private const string Root = "Assets/Temp/EditorProgressCleanupTests"; - - [TestCase(typeof(SpriteSettingsApplierWindow), true)] - [TestCase(typeof(SpriteSettingsApplierWindow), false)] - [TestCase(typeof(TextureSettingsApplierWindow), true)] - [TestCase(typeof(TextureSettingsApplierWindow), false)] - [TestCase(typeof(ScriptableSpriteAtlasEditor), false)] - [TestCase(typeof(ScriptableSpriteAtlasEditor), true)] - [TestCase(typeof(PrefabChecker), false)] - [TestCase(typeof(TextureResizerWizard), false)] - [TestCase(typeof(SpriteSheetExtractor), false)] - public void ProcessingExceptionClearsProgress(Type windowType, bool calculateStats) - { - Action operation = PrepareOperation(windowType, calculateStats); - InvalidOperationException expected = new("Injected processing failure"); - int shown = 0; - int cleared = 0; - Func previousProgress = EditorUi.ProgressForTesting; - Action previousClear = EditorUi.ProgressClearedForTesting; - try - { - EditorUi.ProgressForTesting = () => - { - ++shown; - throw expected; - }; - EditorUi.ProgressClearedForTesting = () => ++cleared; - - if (windowType == typeof(SpriteSheetExtractor)) - { - ExpectError( - LogType.Error, - "Progress callback failed: Injected processing failure" - ); - Assert.DoesNotThrow(() => operation()); - } - else if (windowType == typeof(TextureResizerWizard)) - { - ExpectError( - LogType.Error, - "Texture resizing failed.*Injected processing failure" - ); - Assert.DoesNotThrow(() => operation()); - } - else if (windowType == typeof(ScriptableSpriteAtlasEditor) && calculateStats) - { - ExpectError( - LogType.Error, - "Failed to set source sprite import settings.*Injected processing failure" - ); - Assert.DoesNotThrow(() => operation()); - } - else - { - InvalidOperationException actual = Assert.Throws( - () => - operation() - ); - Assert.AreSame( - expected, - actual, - "Cleanup must preserve the processing exception." - ); - } - Assert.AreEqual(1, shown, "The operation must reach a progress display."); - Assert.AreEqual( - 1, - cleared, - "A failed operation must clear its progress exactly once." - ); - } - finally - { - EditorUi.ProgressForTesting = previousProgress; - EditorUi.ProgressClearedForTesting = previousClear; - } - } - - [TestCase(typeof(SpriteSettingsApplierWindow))] - [TestCase(typeof(TextureSettingsApplierWindow))] - [TestCase(typeof(PrefabChecker))] - [TestCase(typeof(TextureResizerWizard))] - [TestCase(typeof(SpriteSheetExtractor))] - public void CancellationClearsProgress(Type windowType) - { - Action operation = PrepareOperation(windowType, calculateStats: false); - int shown = 0; - int cleared = 0; - Func previousProgress = EditorUi.ProgressForTesting; - Action previousClear = EditorUi.ProgressClearedForTesting; - try - { - EditorUi.ProgressForTesting = () => - { - ++shown; - return true; - }; - EditorUi.ProgressClearedForTesting = () => ++cleared; - - Assert.DoesNotThrow(() => operation()); - - Assert.AreEqual( - 1, - shown, - "Cancellation must stop before another asset is processed." - ); - Assert.AreEqual(1, cleared, "Cancellation must clear its progress exactly once."); - } - finally - { - EditorUi.ProgressForTesting = previousProgress; - EditorUi.ProgressClearedForTesting = previousClear; - } - } - - private Action PrepareOperation(Type windowType, bool calculateStats) - { - EnsureFolder(Root); - if (windowType == typeof(PrefabChecker)) - { - string prefabPath = Root + "/subject.prefab"; - TrackAssetPath(prefabPath); - GameObject source = Track(new GameObject("ProgressCleanupSubject")); - PrefabUtility.SaveAsPrefabAsset(source, prefabPath); - PrefabChecker window = Track(ScriptableObject.CreateInstance()); - window._assetPaths.Clear(); - window._assetPaths.Add(Root); - return window.RunChecksImproved; - } - - if (windowType == typeof(ScriptableSpriteAtlasEditor) && !calculateStats) - { - string configPath = Root + "/subject.asset"; - TrackAssetPath(configPath); - ScriptableSpriteAtlas config = Track( - ScriptableObject.CreateInstance() - ); - AssetDatabase.CreateAsset(config, configPath); - ScriptableSpriteAtlasEditor window = Track( - ScriptableObject.CreateInstance() - ); - window.LoadAtlasConfigs(); - return window.GenerateAllAtlases; - } - - string texturePath = Root + "/subject.png"; - TrackAssetPath(texturePath); - Texture2D sourceTexture = Track(new Texture2D(2, 2)); - File.WriteAllBytes(texturePath, sourceTexture.EncodeToPNG()); - AssetDatabase.ImportAsset(texturePath, ImportAssetOptions.ForceSynchronousImport); - TextureImporter importer = AssetImporter.GetAtPath(texturePath) as TextureImporter; - Assert.IsTrue(importer != null, "The fixture texture must have an importer."); - importer.textureType = TextureImporterType.Sprite; - importer.spriteImportMode = SpriteImportMode.Single; - importer.SaveAndReimport(); - - if (windowType == typeof(SpriteSheetExtractor)) - { - string extractedPath = Root + "/subject_000.png"; - TrackAssetPath(extractedPath); - Assert.IsTrue(AssetDatabase.CopyAsset(texturePath, extractedPath)); - AssetDatabase.ImportAsset(extractedPath, ImportAssetOptions.ForceSynchronousImport); - Sprite original = AssetDatabase.LoadAssetAtPath(texturePath); - Sprite extracted = AssetDatabase.LoadAssetAtPath(extractedPath); - Assert.IsTrue(original != null, "The original sprite must be imported."); - Assert.IsTrue(extracted != null, "The replacement sprite must be imported."); - string prefabPath = Root + "/subject.prefab"; - TrackAssetPath(prefabPath); - GameObject source = Track(new GameObject("ProgressCleanupSubject")); - PrefabUtility.SaveAsPrefabAsset(source, prefabPath); - - SpriteSheetExtractor window = Track( - ScriptableObject.CreateInstance() - ); - window._outputDirectory = AssetDatabase.LoadAssetAtPath(Root); - window._discoveredSheets = new List - { - new() - { - _assetPath = texturePath, - _isSelected = true, - _sprites = new List - { - new() { _originalName = original.name, _isSelected = true }, - }, - }, - }; - return window.ReplaceSpriteReferences; - } - - if (windowType == typeof(ScriptableSpriteAtlasEditor)) - { - Sprite sprite = AssetDatabase.LoadAssetAtPath(texturePath); - Assert.IsTrue(sprite != null, "The fixture must contain an imported sprite."); - ScriptableSpriteAtlas config = Track( - ScriptableObject.CreateInstance() - ); - config.spritesToPack.Add(sprite); - ScriptableSpriteAtlasEditor window = Track( - ScriptableObject.CreateInstance() - ); - return () => window.ForceUncompressedSourceSprites(config); - } - - if (windowType == typeof(SpriteSettingsApplierWindow)) - { - Sprite sprite = AssetDatabase.LoadAssetAtPath(texturePath); - Assert.IsTrue(sprite != null, "The fixture must contain an imported sprite."); - SpriteSettingsApplierWindow window = Track( - ScriptableObject.CreateInstance() - ); - window.sprites.Add(sprite); - window.SerializedStateForTesting.Update(); - return calculateStats ? window.CalculateStats : window.ApplySettings; - } - - Texture2D texture = AssetDatabase.LoadAssetAtPath(texturePath); - Assert.IsTrue(texture != null, "The fixture must contain an imported texture."); - if (windowType == typeof(TextureResizerWizard)) - { - TextureResizerWizard window = Track( - ScriptableObject.CreateInstance() - ); - window.textures.Add(texture); - return window.OnWizardCreate; - } - - Assert.AreEqual(typeof(TextureSettingsApplierWindow), windowType); - TextureSettingsApplierWindow textureWindow = Track( - ScriptableObject.CreateInstance() - ); - textureWindow.textures.Add(texture); - textureWindow.requireChangesBeforeApply = false; - return calculateStats ? textureWindow.CalculateStats : textureWindow.ApplySettings; - } - } -#endif -} diff --git a/Tests/Editor/EditorProgressSuppressionTests.cs b/Tests/Editor/EditorProgressSuppressionTests.cs new file mode 100644 index 000000000..3192c754c --- /dev/null +++ b/Tests/Editor/EditorProgressSuppressionTests.cs @@ -0,0 +1,33 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE +namespace WallstopStudios.UnityHelpers.Tests +{ +#if UNITY_EDITOR + using NUnit.Framework; + using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Tests.Core; + + [TestFixture] + public sealed class EditorProgressSuppressionTests : CommonTestBase + { + [TestCase(0f)] + [TestCase(0.5f)] + [TestCase(1f)] + public void SuppressedProgressDoesNotRequestCancellation(float progress) + { + bool previousSuppress = EditorUi.Suppress; + try + { + EditorUi.Suppress = true; + Assert.DoesNotThrow(() => EditorUi.ShowProgress("Test", "Suppressed", progress)); + Assert.IsFalse(EditorUi.CancelableProgress("Test", "Suppressed", progress)); + Assert.DoesNotThrow(EditorUi.ClearProgress); + } + finally + { + EditorUi.Suppress = previousSuppress; + } + } + } +#endif +} diff --git a/Tests/Editor/EditorProgressCleanupTests.cs.meta b/Tests/Editor/EditorProgressSuppressionTests.cs.meta similarity index 100% rename from Tests/Editor/EditorProgressCleanupTests.cs.meta rename to Tests/Editor/EditorProgressSuppressionTests.cs.meta diff --git a/Tests/Editor/EditorWindowNativeResourceTests.cs b/Tests/Editor/EditorWindowNativeResourceTests.cs index 2c33ec862..aebceedad 100644 --- a/Tests/Editor/EditorWindowNativeResourceTests.cs +++ b/Tests/Editor/EditorWindowNativeResourceTests.cs @@ -104,12 +104,12 @@ public void FitTextureWindowReleasesStateAcrossReopens() FitTextureSizeWindow window = Track( ScriptableObject.CreateInstance() ); - SerializedObject bound = window.SerializedStateForTesting; + SerializedObject bound = window._serializedObject; Assert.IsTrue(bound != null); Assert.AreNotSame(previous, bound); bound.Update(); Object.DestroyImmediate(window); // UNH-SUPPRESS: teardown is the subject - Assert.IsTrue(window.SerializedStateForTesting == null); + Assert.IsTrue(window._serializedObject == null); Assert.Catch(() => bound.Update()); previous = bound; } diff --git a/Tests/Editor/EditorWindowSerializedStateTests.cs b/Tests/Editor/EditorWindowSerializedStateTests.cs index a455ad2cf..769589c55 100644 --- a/Tests/Editor/EditorWindowSerializedStateTests.cs +++ b/Tests/Editor/EditorWindowSerializedStateTests.cs @@ -35,32 +35,24 @@ public sealed class EditorWindowSerializedStateTests : CommonTestBase [Test] public void EveryWindowReleasesItsSerializedObjectOnTeardown() { - AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting - ); + AssertSerializedStateLifecycle(window => window._serializedObject); AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting - ); - AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting + window._serializedObject ); + AssertSerializedStateLifecycle(window => window._so); AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting - ); - AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting + window._serializedObject ); + AssertSerializedStateLifecycle(window => window._serializedObject); AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting - ); - AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting + window._serializedObject ); + AssertSerializedStateLifecycle(window => window._serializedObject); AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting + window._serializedObject ); AssertSerializedStateLifecycle(window => - window.SerializedStateForTesting + window._serializedObject ); } diff --git a/Tests/Editor/Helper/ReflectionHelpersTypedEditorTests.cs b/Tests/Editor/Helper/ReflectionHelpersTypedEditorTests.cs index 2d29fef9d..3008f7f24 100644 --- a/Tests/Editor/Helper/ReflectionHelpersTypedEditorTests.cs +++ b/Tests/Editor/Helper/ReflectionHelpersTypedEditorTests.cs @@ -8,6 +8,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Helper using System.Reflection; using NUnit.Framework; using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Tests.Core; public sealed class TypedHelperEditorTarget { @@ -26,12 +27,7 @@ public void EditorTypedFieldSetterFallbacksWhenCapabilitiesDisabled() FieldInfo field = typeof(TypedHelperEditorTarget).GetField( nameof(TypedHelperEditorTarget.Field) ); - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { FieldSetter setter = ReflectionHelpers.GetFieldSetter< TypedHelperEditorTarget, @@ -49,12 +45,7 @@ public void EditorTypedPropertySetterFallbacksWhenCapabilitiesDisabled() PropertyInfo property = typeof(TypedHelperEditorTarget).GetProperty( nameof(TypedHelperEditorTarget.Property) ); - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { Action setter = ReflectionHelpers.GetPropertySetter< TypedHelperEditorTarget, @@ -73,12 +64,7 @@ public void EditorTypedPropertyGetterFallbacksWhenCapabilitiesDisabled() nameof(TypedHelperEditorTarget.Property) ); TypedHelperEditorTarget instance = new() { Property = 56 }; - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { Func getter = ReflectionHelpers.GetPropertyGetter< TypedHelperEditorTarget, diff --git a/Tests/Editor/Integrations/VContainer/VContainerRelationalEntryPointTests.cs b/Tests/Editor/Integrations/VContainer/VContainerRelationalEntryPointTests.cs index 30643077a..52d6b0d48 100644 --- a/Tests/Editor/Integrations/VContainer/VContainerRelationalEntryPointTests.cs +++ b/Tests/Editor/Integrations/VContainer/VContainerRelationalEntryPointTests.cs @@ -52,7 +52,9 @@ public IEnumerator EntryPointAssignsSiblingOnActiveScene() new[] { relationalMetadata }, System.Array.Empty() ); - cache.ForceRebuildForTests(); + global::WallstopStudios.UnityHelpers.Tests.Core.AttributeMetadataCacheTestUtilities.Rebuild( + cache + ); yield return null; #endif diff --git a/Tests/Editor/Integrations/VContainer/VContainerRelationalHelpersTests.cs b/Tests/Editor/Integrations/VContainer/VContainerRelationalHelpersTests.cs index a8409050b..ab2fb4d5c 100644 --- a/Tests/Editor/Integrations/VContainer/VContainerRelationalHelpersTests.cs +++ b/Tests/Editor/Integrations/VContainer/VContainerRelationalHelpersTests.cs @@ -126,7 +126,9 @@ public void AssignerRecognizesBaseTypeMetadataForDerivedComponents() new(typeof(BaseWithSibling).AssemblyQualifiedName, fields), }; cache._relationalTypeMetadata = relational; - cache.ForceRebuildForTests(); + global::WallstopStudios.UnityHelpers.Tests.Core.AttributeMetadataCacheTestUtilities.Rebuild( + cache + ); ContainerBuilder builder = new(); builder.RegisterInstance(cache).AsSelf(); diff --git a/Tests/Editor/Integrations/Zenject/ZenjectRelationalInitializerTests.cs b/Tests/Editor/Integrations/Zenject/ZenjectRelationalInitializerTests.cs index cd5897c6e..180cfc41c 100644 --- a/Tests/Editor/Integrations/Zenject/ZenjectRelationalInitializerTests.cs +++ b/Tests/Editor/Integrations/Zenject/ZenjectRelationalInitializerTests.cs @@ -51,7 +51,9 @@ public IEnumerator InitializerAssignsSiblingOnActiveScene() new[] { relationalMetadata }, System.Array.Empty() ); - cache.ForceRebuildForTests(); + global::WallstopStudios.UnityHelpers.Tests.Core.AttributeMetadataCacheTestUtilities.Rebuild( + cache + ); yield return null; #endif diff --git a/Tests/Editor/PersistentDirectorySettingsMigrationTests.cs b/Tests/Editor/PersistentDirectorySettingsMigrationTests.cs index f17753642..cb71621e4 100644 --- a/Tests/Editor/PersistentDirectorySettingsMigrationTests.cs +++ b/Tests/Editor/PersistentDirectorySettingsMigrationTests.cs @@ -69,9 +69,9 @@ private static IEnumerable RootCleanupInvocationCounts() private static void SaveAndRefreshFixtureAssets() { AssetDatabase.SaveAssets(); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); AssetDatabaseBatchHelper.RefreshIfNotBatching(); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); } private static void EnsureFolderExists(string folderPath) @@ -415,7 +415,7 @@ public override IEnumerator UnityTearDown() } _createdFolders.Clear(); EditorUi.Suppress = _previousEditorUiSuppress; - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); yield return null; } diff --git a/Tests/Editor/Settings/ColorKeyChangeNotifierTests.cs b/Tests/Editor/Settings/ColorKeyChangeNotifierTests.cs index c6f3bdc0d..f40b24ef2 100644 --- a/Tests/Editor/Settings/ColorKeyChangeNotifierTests.cs +++ b/Tests/Editor/Settings/ColorKeyChangeNotifierTests.cs @@ -28,6 +28,21 @@ public sealed class ColorKeyChangeNotifierTests private int _wbuttonEventCount; private int _wenumEventCount; + /// + /// Clears all cached state. Useful for tests or domain reload scenarios. + /// + private static void ClearCache() + { + ColorKeyChangeNotifier.PreviousWButtonButtonColors.Clear(); + ColorKeyChangeNotifier.PreviousWButtonTextColors.Clear(); + ColorKeyChangeNotifier.PreviousWEnumSelectedBackgrounds.Clear(); + ColorKeyChangeNotifier.PreviousWEnumSelectedTexts.Clear(); + ColorKeyChangeNotifier.PreviousWEnumInactiveBackgrounds.Clear(); + ColorKeyChangeNotifier.PreviousWEnumInactiveTexts.Clear(); + ColorKeyChangeNotifier._changedWButtonKeys = null; + ColorKeyChangeNotifier._changedWEnumKeys = null; + } + [SetUp] public void SetUp() { @@ -39,7 +54,7 @@ public void SetUp() _wbuttonEventCount = 0; _wenumEventCount = 0; - ColorKeyChangeNotifier.ClearCache(); + ClearCache(); ColorKeyChangeNotifier.OnWButtonColorKeysChanged += OnWButtonColorKeysChanged; ColorKeyChangeNotifier.OnWEnumToggleButtonsColorKeysChanged += OnWEnumToggleButtonsColorKeysChanged; @@ -51,7 +66,7 @@ public void TearDown() ColorKeyChangeNotifier.OnWButtonColorKeysChanged -= OnWButtonColorKeysChanged; ColorKeyChangeNotifier.OnWEnumToggleButtonsColorKeysChanged -= OnWEnumToggleButtonsColorKeysChanged; - ColorKeyChangeNotifier.ClearCache(); + ClearCache(); _serializedSettings?.Dispose(); _serializedSettings = null; @@ -235,7 +250,7 @@ public void WEnumToggleButtonsColorModificationTriggersEventWithCorrectKey() public void ClearCacheResetsState() { ColorKeyChangeNotifier.CaptureCurrentState(_serializedSettings); - ColorKeyChangeNotifier.ClearCache(); + ClearCache(); Assert.DoesNotThrow(() => ColorKeyChangeNotifier.CaptureCurrentState(_serializedSettings) diff --git a/Tests/Editor/Settings/SettingsSerializedObjectCacheTests.cs b/Tests/Editor/Settings/SettingsSerializedObjectCacheTests.cs index 8636ab9a3..ecc404dbc 100644 --- a/Tests/Editor/Settings/SettingsSerializedObjectCacheTests.cs +++ b/Tests/Editor/Settings/SettingsSerializedObjectCacheTests.cs @@ -48,8 +48,8 @@ public void SetUp() _originalSortedDictionaryTweenEnabled = UnityHelpersSettings.ShouldTweenSerializableSortedDictionaryFoldouts(); - UnityHelpersSettings.ClearCachedSerializedObjectForTests(); - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + UnityHelpersSettingsTestAccess.ClearCachedSerializedObject(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); } [TearDown] @@ -62,8 +62,8 @@ public void TearDown() _originalSortedDictionaryTweenEnabled ); - UnityHelpersSettings.ClearCachedSerializedObjectForTests(); - SerializableDictionaryPropertyDrawer.ClearMainFoldoutAnimCacheForTests(); + UnityHelpersSettingsTestAccess.ClearCachedSerializedObject(); + SerializableDictionaryPropertyDrawerTestAccess.ClearMainFoldoutAnimCache(); } [Test] @@ -179,7 +179,7 @@ public void ClearCachedSerializedObjectForcesNewObjectCreation() SerializedObject firstAccess = GetCachedSerializedObject(settings); int firstHashCode = firstAccess.GetHashCode(); - UnityHelpersSettings.ClearCachedSerializedObjectForTests(); + UnityHelpersSettingsTestAccess.ClearCachedSerializedObject(); SerializedObject secondAccess = GetCachedSerializedObject(settings); int secondHashCode = secondAccess.GetHashCode(); @@ -194,7 +194,7 @@ public void ClearCachedSerializedObjectForcesNewObjectCreation() [Test] public void CachedSerializedObjectHandlesNullGracefully() { - UnityHelpersSettings.ClearCachedSerializedObjectForTests(); + UnityHelpersSettingsTestAccess.ClearCachedSerializedObject(); // Note: using is safe here since result is expected to be null SerializedObject result = GetCachedSerializedObjectWithNull(); @@ -423,7 +423,7 @@ public void CachedSerializedObjectWorksWithUpdateCycles() public void SettingsProviderPatternSimulation() { UnityHelpersSettings settings = UnityHelpersSettings.instance; - UnityHelpersSettings.ClearCachedSerializedObjectForTests(); + UnityHelpersSettingsTestAccess.ClearCachedSerializedObject(); SerializedObject cached = GetCachedSerializedObject(settings); SerializedProperty wButtonColors = cached.FindProperty( @@ -564,7 +564,7 @@ public void CacheInvalidationRecreatesNewObject() "Before invalidation, cache should return same reference." ); - UnityHelpersSettings.ClearCachedSerializedObjectForTests(); + UnityHelpersSettingsTestAccess.ClearCachedSerializedObject(); SerializedObject afterInvalidation = GetCachedSerializedObject(settings); Assert.IsTrue( @@ -808,7 +808,7 @@ public void CacheHandlesTargetObjectChangeGracefully() "Target object should have a valid instance ID." ); - UnityHelpersSettings.ClearCachedSerializedObjectForTests(); + UnityHelpersSettingsTestAccess.ClearCachedSerializedObject(); UnityHelpersSettings sameSettings = UnityHelpersSettings.instance; diff --git a/Tests/Editor/Settings/UnityHelpersSettingsTests.cs b/Tests/Editor/Settings/UnityHelpersSettingsTests.cs index 5d393b884..e81389639 100644 --- a/Tests/Editor/Settings/UnityHelpersSettingsTests.cs +++ b/Tests/Editor/Settings/UnityHelpersSettingsTests.cs @@ -1,11 +1,16 @@ // MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE +// UNH-SUPPRESS UNH003: this fixture preserves an existing project asset, or owns one shared +// resource asset until OneTimeTearDown. Per-test tracked-object destruction would delete the +// resource before the remaining controls run. + #if UNITY_EDITOR namespace WallstopStudios.UnityHelpers.Tests.Settings { using System; using System.Collections.Generic; + using System.IO; using System.Linq; using NUnit.Framework; using UnityEditor; @@ -15,6 +20,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Settings using WallstopStudios.UnityHelpers.Editor.Extensions; using WallstopStudios.UnityHelpers.Editor.Settings; using WallstopStudios.UnityHelpers.Editor.Utils.WButton; + using WallstopStudios.UnityHelpers.Editor.Validation; using WallstopStudios.UnityHelpers.Settings; using WallstopStudios.UnityHelpers.Utils; @@ -56,6 +62,13 @@ SerializedProperty dictionaryProperty return (keys, values); } + private readonly List _ownedBufferSettingsFolders = new(); + private UnityHelpersBufferSettingsAsset _bufferSettingsAsset; + private byte[] _originalBufferSettingsBytes; + private string _originalBufferSettingsJson; + private bool _originalBufferSettingsDirty; + private bool _ownsBufferSettingsAsset; + private static void AssertColorsApproximately( Color expected, Color actual, @@ -380,6 +393,131 @@ private static string GetAvailableRelativeProperties(SerializedProperty property : "(no visible properties found)"; } + [OneTimeSetUp] + public void ProvisionBufferSettingsAsset() + { + string assetPath = UnityHelpersBufferSettingsAsset.AssetPath; + string filePath = AuthoredAssetPaths.ToFileSystemPath(assetPath); + _bufferSettingsAsset = AssetDatabase.LoadAssetAtPath( + assetPath + ); + if (_bufferSettingsAsset != null) + { + _originalBufferSettingsBytes = File.ReadAllBytes(filePath); + _originalBufferSettingsJson = EditorJsonUtility.ToJson(_bufferSettingsAsset); + _originalBufferSettingsDirty = EditorUtility.IsDirty(_bufferSettingsAsset); + } + else + { + Assert.IsFalse( + File.Exists(filePath), + "Preserve an existing unreadable settings asset." + ); + string folder = Path.GetDirectoryName(assetPath).Replace('\\', '/'); + List missingFolders = new(); + for (string candidate = folder; !AssetDatabase.IsValidFolder(candidate); ) + { + missingFolders.Add(candidate); + candidate = Path.GetDirectoryName(candidate).Replace('\\', '/'); + } + for (int index = missingFolders.Count - 1; 0 <= index; --index) + { + string candidate = missingFolders[index]; + string parent = Path.GetDirectoryName(candidate).Replace('\\', '/'); + string name = Path.GetFileName(candidate); + Assert.IsFalse(string.IsNullOrEmpty(AssetDatabase.CreateFolder(parent, name))); + _ownedBufferSettingsFolders.Add(candidate); + } + + _bufferSettingsAsset = + ScriptableObject.CreateInstance(); // UNH-SUPPRESS UNH002: persisted fixture asset, deleted in OneTimeTearDown only when owned. + _bufferSettingsAsset.SyncFromRuntime(); + _ownsBufferSettingsAsset = true; + AssetDatabase.CreateAsset(_bufferSettingsAsset, assetPath); + } + + AssetDatabase.ImportAsset( + assetPath, + ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport + ); + Assert.IsTrue( + Resources.Load( + UnityHelpersBufferSettingsAsset.ResourcePath + ) != null, + "The fixture's real buffer settings resource must load before its controls run." + ); + } + + [OneTimeTearDown] + public void RestoreBufferSettingsAsset() + { + string assetPath = UnityHelpersBufferSettingsAsset.AssetPath; + try + { + if (_ownsBufferSettingsAsset) + { + AssetDatabase.DeleteAsset(assetPath); + if (_bufferSettingsAsset != null) + { + UnityEngine.Object.DestroyImmediate(_bufferSettingsAsset); // UNH-SUPPRESS UNH001: release an owned instance if asset publication failed. + } + } + else if (_originalBufferSettingsBytes != null) + { + File.WriteAllBytes( + AuthoredAssetPaths.ToFileSystemPath(assetPath), + _originalBufferSettingsBytes + ); + AssetDatabase.ImportAsset( + assetPath, + ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport + ); + _bufferSettingsAsset = + AssetDatabase.LoadAssetAtPath(assetPath); + Assert.IsTrue( + _bufferSettingsAsset != null, + "The original settings asset must reload." + ); + if (_bufferSettingsAsset != null) + { + EditorJsonUtility.FromJsonOverwrite( + _originalBufferSettingsJson, + _bufferSettingsAsset + ); + if (_originalBufferSettingsDirty) + { + EditorUtility.SetDirty(_bufferSettingsAsset); + } + else + { + EditorUtility.ClearDirty(_bufferSettingsAsset); + } + } + } + } + finally + { + for (int index = _ownedBufferSettingsFolders.Count - 1; 0 <= index; --index) + { + string folder = _ownedBufferSettingsFolders[index]; + string filePath = AuthoredAssetPaths.ToFileSystemPath(folder); + if ( + Directory.Exists(filePath) + && Directory.GetFileSystemEntries(filePath).Length == 0 + ) + { + AssetDatabase.DeleteAsset(folder); + } + } + _ownedBufferSettingsFolders.Clear(); + _bufferSettingsAsset = null; + _originalBufferSettingsBytes = null; + _originalBufferSettingsJson = null; + _originalBufferSettingsDirty = false; + _ownsBufferSettingsAsset = false; + } + } + [Test] public void OnEnableNormalizesWithoutSavingDuringLoad() { @@ -1303,7 +1441,7 @@ public void WGroupAutoIncludeConfigurationClampsToBounds() try { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.Finite, 1000 ); @@ -1322,7 +1460,7 @@ public void WGroupAutoIncludeConfigurationClampsToBounds() } finally { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( original.Mode, original.RowCount ); @@ -2185,8 +2323,9 @@ public void SerializedPropertyNamesMapsToActualField( string expectedFieldDescription ) { - string actualFieldName = - UnityHelpersSettings.SerializedPropertyNames.GetPropertyNameValue(constantName); + string actualFieldName = UnityHelpersSettingsTestAccess.GetPropertyNameValue( + constantName + ); Assert.IsTrue( actualFieldName != null, $"SerializedPropertyNames.{constantName} should have a non-null value." @@ -2244,9 +2383,7 @@ string expectedFieldDescription [TestCase(nameof(UnityHelpersSettings.SerializedPropertyNames.InlineEditorFoldoutSpeed))] public void SerializedPropertyNamesResolvesToSerializedProperty(string constantName) { - string fieldName = UnityHelpersSettings.SerializedPropertyNames.GetPropertyNameValue( - constantName - ); + string fieldName = UnityHelpersSettingsTestAccess.GetPropertyNameValue(constantName); UnityHelpersSettings settings = UnityHelpersSettings.instance; using SerializedObject serialized = new(settings); @@ -2315,9 +2452,7 @@ public void TweenFieldsHaveCorrectSerializedPropertyType( Type expectedFieldType ) { - string fieldName = UnityHelpersSettings.SerializedPropertyNames.GetPropertyNameValue( - constantName - ); + string fieldName = UnityHelpersSettingsTestAccess.GetPropertyNameValue(constantName); UnityHelpersSettings settings = UnityHelpersSettings.instance; using SerializedObject serialized = new(settings); @@ -2505,14 +2640,10 @@ public void ApplyBufferSettingsSavesAssetWithCorrectValues() UnityHelpersBufferSettingsAsset asset = Resources.Load( UnityHelpersBufferSettingsAsset.ResourcePath ); - if (asset == null) - { - Assert.Inconclusive( - "UnityHelpersBufferSettingsAsset not found in Resources. " - + "Run the settings UI to create the asset first." - ); - return; - } + Assert.IsTrue( + asset != null, + "The fixture must retain its provisioned buffer settings resource." + ); float originalQuantization = asset.QuantizationStepSeconds; int originalMaxEntries = asset.MaxDistinctEntries; @@ -2624,14 +2755,10 @@ public void BufferSettingsAssetAppliesValuesToRuntime() UnityHelpersBufferSettingsAsset asset = Resources.Load( UnityHelpersBufferSettingsAsset.ResourcePath ); - if (asset == null) - { - Assert.Inconclusive( - "UnityHelpersBufferSettingsAsset not found in Resources. " - + "Run the settings UI to create the asset first." - ); - return; - } + Assert.IsTrue( + asset != null, + "The fixture must retain its provisioned buffer settings resource." + ); float originalRuntimeQuantization = Buffers.WaitInstructionQuantizationStepSeconds; int originalRuntimeMaxEntries = Buffers.WaitInstructionMaxDistinctEntries; @@ -2714,14 +2841,10 @@ public void BufferSettingsAssetSyncsFromRuntime() UnityHelpersBufferSettingsAsset asset = Resources.Load( UnityHelpersBufferSettingsAsset.ResourcePath ); - if (asset == null) - { - Assert.Inconclusive( - "UnityHelpersBufferSettingsAsset not found in Resources. " - + "Run the settings UI to create the asset first." - ); - return; - } + Assert.IsTrue( + asset != null, + "The fixture must retain its provisioned buffer settings resource." + ); float originalRuntimeQuantization = Buffers.WaitInstructionQuantizationStepSeconds; int originalRuntimeMaxEntries = Buffers.WaitInstructionMaxDistinctEntries; @@ -2804,14 +2927,10 @@ public void BufferSettingsQuantizationSanitizesEdgeCases(float input, float expe UnityHelpersBufferSettingsAsset asset = Resources.Load( UnityHelpersBufferSettingsAsset.ResourcePath ); - if (asset == null) - { - Assert.Inconclusive( - "UnityHelpersBufferSettingsAsset not found in Resources. " - + "Run the settings UI to create the asset first." - ); - return; - } + Assert.IsTrue( + asset != null, + "The fixture must retain its provisioned buffer settings resource." + ); float originalQuantization = asset.QuantizationStepSeconds; @@ -2862,14 +2981,10 @@ int expectedOutput UnityHelpersBufferSettingsAsset asset = Resources.Load( UnityHelpersBufferSettingsAsset.ResourcePath ); - if (asset == null) - { - Assert.Inconclusive( - "UnityHelpersBufferSettingsAsset not found in Resources. " - + "Run the settings UI to create the asset first." - ); - return; - } + Assert.IsTrue( + asset != null, + "The fixture must retain its provisioned buffer settings resource." + ); int originalMaxEntries = asset.MaxDistinctEntries; @@ -2909,14 +3024,10 @@ public void BufferSettingsBootstrapAppliesSettingsWhenApplyOnLoadIsTrue() UnityHelpersBufferSettingsAsset asset = Resources.Load( UnityHelpersBufferSettingsAsset.ResourcePath ); - if (asset == null) - { - Assert.Inconclusive( - "UnityHelpersBufferSettingsAsset not found in Resources. " - + "Run the settings UI to create the asset first." - ); - return; - } + Assert.IsTrue( + asset != null, + "The fixture must retain its provisioned buffer settings resource." + ); float originalRuntimeQuantization = Buffers.WaitInstructionQuantizationStepSeconds; int originalRuntimeMaxEntries = Buffers.WaitInstructionMaxDistinctEntries; @@ -3019,14 +3130,10 @@ public void BufferSettingsBootstrapSkipsApplicationWhenApplyOnLoadIsFalse() UnityHelpersBufferSettingsAsset asset = Resources.Load( UnityHelpersBufferSettingsAsset.ResourcePath ); - if (asset == null) - { - Assert.Inconclusive( - "UnityHelpersBufferSettingsAsset not found in Resources. " - + "Run the settings UI to create the asset first." - ); - return; - } + Assert.IsTrue( + asset != null, + "The fixture must retain its provisioned buffer settings resource." + ); float originalRuntimeQuantization = Buffers.WaitInstructionQuantizationStepSeconds; int originalRuntimeMaxEntries = Buffers.WaitInstructionMaxDistinctEntries; @@ -3177,14 +3284,10 @@ public void BufferSettingsPropertyNameConstantsMatchFields() UnityHelpersBufferSettingsAsset asset = Resources.Load( UnityHelpersBufferSettingsAsset.ResourcePath ); - if (asset == null) - { - Assert.Inconclusive( - "UnityHelpersBufferSettingsAsset not found in Resources. " - + "Run the settings UI to create the asset first." - ); - return; - } + Assert.IsTrue( + asset != null, + "The fixture must retain its provisioned buffer settings resource." + ); using SerializedObject serialized = new(asset); diff --git a/Tests/Editor/Sprites/Animation/SpriteSheetAnimationAPITests.cs b/Tests/Editor/Sprites/Animation/SpriteSheetAnimationAPITests.cs index 3956026ab..2e5e0cf48 100644 --- a/Tests/Editor/Sprites/Animation/SpriteSheetAnimationAPITests.cs +++ b/Tests/Editor/Sprites/Animation/SpriteSheetAnimationAPITests.cs @@ -469,37 +469,6 @@ out string error ); } - [Test] - public void SaveFailureReportsCreatedAssetPath() - { - Sprite sprite = LoadExistingSprite(); - RestorableGlobal saveAssets = new( - () => SpriteSheetAnimationAPI.SaveAssetsAction, - action => SpriteSheetAnimationAPI.SaveAssetsAction = action - ); - using (saveAssets.Borrow(() => throw new IOException("save failed"))) - { - Assert.IsFalse( - SpriteSheetAnimationAPI.TryCreate( - Root, - "Partial", - new[] { sprite }, - 12f, - null, - false, - 0f, - false, - out string assetPath, - out string error - ) - ); - Assert.IsNotEmpty(assetPath); - TrackAssetPath(assetPath); - StringAssert.Contains("save failed", error); - Assert.IsTrue(AssetDatabase.LoadAssetAtPath(assetPath) != null); - } - } - [Test] public void SanitizesPortableInvalidFileNameCharacters() { diff --git a/Tests/Editor/Sprites/Cropper/SpriteCropperAdditionalTests.cs b/Tests/Editor/Sprites/Cropper/SpriteCropperAdditionalTests.cs index d307510a6..d6d56dee9 100644 --- a/Tests/Editor/Sprites/Cropper/SpriteCropperAdditionalTests.cs +++ b/Tests/Editor/Sprites/Cropper/SpriteCropperAdditionalTests.cs @@ -51,7 +51,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } diff --git a/Tests/Editor/Sprites/Cropper/SpriteCropperTests.cs b/Tests/Editor/Sprites/Cropper/SpriteCropperTests.cs index dfc27e920..d519d27ce 100644 --- a/Tests/Editor/Sprites/Cropper/SpriteCropperTests.cs +++ b/Tests/Editor/Sprites/Cropper/SpriteCropperTests.cs @@ -49,7 +49,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } diff --git a/Tests/Editor/Sprites/PivotAdjuster/SpritePivotAdjusterAdditionalTests.cs b/Tests/Editor/Sprites/PivotAdjuster/SpritePivotAdjusterAdditionalTests.cs index ab65ed7ce..3c1463c2d 100644 --- a/Tests/Editor/Sprites/PivotAdjuster/SpritePivotAdjusterAdditionalTests.cs +++ b/Tests/Editor/Sprites/PivotAdjuster/SpritePivotAdjusterAdditionalTests.cs @@ -50,7 +50,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); SpritePivotAdjuster.SuppressUserPrompts = false; } diff --git a/Tests/Editor/Sprites/PivotAdjuster/SpritePivotAdjusterTests.cs b/Tests/Editor/Sprites/PivotAdjuster/SpritePivotAdjusterTests.cs index 88a17bbf0..0af5dfe0f 100644 --- a/Tests/Editor/Sprites/PivotAdjuster/SpritePivotAdjusterTests.cs +++ b/Tests/Editor/Sprites/PivotAdjuster/SpritePivotAdjusterTests.cs @@ -49,7 +49,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } diff --git a/Tests/Editor/Sprites/SpriteSheetExtractor/SpriteSheetExtractionAPITests.cs b/Tests/Editor/Sprites/SpriteSheetExtractor/SpriteSheetExtractionAPITests.cs index 693f03ff4..e7203d266 100644 --- a/Tests/Editor/Sprites/SpriteSheetExtractor/SpriteSheetExtractionAPITests.cs +++ b/Tests/Editor/Sprites/SpriteSheetExtractor/SpriteSheetExtractionAPITests.cs @@ -358,33 +358,31 @@ public void FailedPublishRemovesStagedFileAndRestoresReadability() } [Test] - public void PublishedOutputIsImportedWhenStagedCleanupWarns() + public void PublishedOutputSurvivesAnActualStagingCleanupFailure() { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + string root = Path.Combine( + Application.temporaryCachePath, + Guid.NewGuid().ToString("N") + ); + Directory.CreateDirectory(root); + try { - Assert.Ignore("Windows moves the staged file and has no post-publish cleanup."); + string outputPath = Path.Combine(root, "output.txt"); + File.WriteAllText(outputPath, "published"); + string stagedDirectory = Path.Combine(root, "staged"); + Directory.CreateDirectory(stagedDirectory); + Exception warning = ExclusiveFilePublisher.RemoveStagedFileAfterPublication( + stagedDirectory, + outputPath + ); + Assert.IsInstanceOf(warning); + StringAssert.Contains("Published", warning.Message); + Assert.AreEqual("published", File.ReadAllText(outputPath)); + Assert.IsTrue(Directory.Exists(stagedDirectory)); } - - int initialFileCount = CountNonMetaFiles(); - RestorableGlobal> deleteStagedFile = new( - () => ExclusiveFilePublisher.DeleteStagedFile, - action => ExclusiveFilePublisher.DeleteStagedFile = action - ); - using ( - deleteStagedFile.Borrow(_ => - throw new IOException("Simulated staged cleanup failure.") - ) - ) + finally { - SpriteSheetExtractionResult result = SpriteSheetExtractionAPI.Extract(Requests()); - - Assert.That(result.ExtractedCount, Is.EqualTo(1)); - Assert.That(result.SkippedCount, Is.Zero); - Assert.That(result.Errors, Has.Count.EqualTo(1)); - StringAssert.Contains("Published", result.Errors[0]); - Assert.That(File.Exists(ToFullPath(Output)), Is.True); - Assert.That(AssetDatabase.LoadAssetAtPath(Output), Is.Not.Null); - Assert.That(CountNonMetaFiles(), Is.EqualTo(initialFileCount + 1)); + Directory.Delete(root, true); } } diff --git a/Tests/Editor/Sprites/TextureSettings/SpriteSettingsProfilePersistenceTests.cs b/Tests/Editor/Sprites/TextureSettings/SpriteSettingsProfilePersistenceTests.cs index d65aff0b9..46c74a1a9 100644 --- a/Tests/Editor/Sprites/TextureSettings/SpriteSettingsProfilePersistenceTests.cs +++ b/Tests/Editor/Sprites/TextureSettings/SpriteSettingsProfilePersistenceTests.cs @@ -322,14 +322,13 @@ out error } [Test] - public void FailedOverwriteRestoresOriginalWhenDestinationDisappears() + public void MissingDestinationRecoveryRestoresOriginalBytes() { - List first = new() { new SpriteSettings { name = "First" } }; - List second = new() { new SpriteSettings { name = "Second" } }; + List profiles = new() { new SpriteSettings { name = "First" } }; Assert.IsTrue( SpriteSettingsApplierAPI.TrySaveProfiles( ProfilePath, - first, + profiles, false, out string error ), @@ -340,193 +339,17 @@ out string error ProfilePath ); byte[] originalBytes = File.ReadAllBytes(fullPath); - bool failedOnce = false; - RestorableGlobal> swap = new( - () => DurableFile.BeforeStagedSwapForTests, - action => DurableFile.BeforeStagedSwapForTests = action - ); - using ( - swap.Borrow(_ => - { - if (failedOnce) - { - return; - } - failedOnce = true; - File.Delete(fullPath); - throw new IOException("forced replacement failure"); - }) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles(ProfilePath, second, true, out error) - ); - } - StringAssert.Contains("forced replacement failure", error); - Assert.IsTrue(File.Exists(fullPath)); - CollectionAssert.AreEqual(originalBytes, File.ReadAllBytes(fullPath)); + File.Delete(fullPath); Assert.IsTrue( - SpriteSettingsApplierAPI.TryLoadProfiles( - ProfilePath, - out List loaded, - out error + SpriteSettingsApplierAPI.TryRestoreAbsentProfileBytes( + fullPath, + originalBytes, + out Exception restoreError ), - error + restoreError?.Message ); - Assert.AreEqual("First", loaded[0].name); - } - - [Test] - public void FailedNewSavePreservesStagedAssetWithoutCallingDelete() - { - RestorableGlobal> load = new( - () => SpriteSettingsApplierAPI.LoadProfileAssetAction, - action => SpriteSettingsApplierAPI.LoadProfileAssetAction = action - ); - RestorableGlobal> delete = new( - () => SpriteSettingsApplierAPI.DeleteProfileAssetAction, - action => SpriteSettingsApplierAPI.DeleteProfileAssetAction = action - ); - int deleteCalls = 0; - using (load.Borrow(_ => null)) - using ( - delete.Borrow(_ => - { - ++deleteCalls; - return true; - }) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings() }, - false, - out string error - ) - ); - StringAssert.Contains("Could not create profiles asset", error); - StringAssert.Contains("inspect it before removal", error); - Assert.AreEqual(1, StagedAssetFiles().Length); - Assert.IsTrue(AssetDatabase.LoadMainAssetAtPath(ProfilePath) == null); - } - Assert.AreEqual(0, deleteCalls); - } - - [Test] - public void AFailedSavePreservesStagedBytesWithoutAnOwnershipSnapshot() - { - byte[] laterBytes = { 8, 7, 6, 5 }; - RestorableGlobal save = new( - () => SpriteSettingsApplierAPI.SaveProfileAssetsAction, - action => SpriteSettingsApplierAPI.SaveProfileAssetsAction = action - ); - using ( - save.Borrow(() => - { - AssetDatabase.SaveAssets(); - File.WriteAllBytes(StagedAssetFiles()[0], laterBytes); - throw new IOException("forced post-save failure"); - }) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings() }, - false, - out string error - ) - ); - StringAssert.Contains("forced post-save failure", error); - StringAssert.Contains("inspect it before removal", error); - } - string[] staged = StagedAssetFiles(); - Assert.AreEqual(1, staged.Length); - CollectionAssert.AreEqual(laterBytes, File.ReadAllBytes(staged[0])); - Assert.IsTrue(AssetDatabase.LoadMainAssetAtPath(ProfilePath) == null); - } - - [Test] - public void FailedTypedReloadPreservesStagedAssets() - { - RestorableGlobal> load = new( - () => SpriteSettingsApplierAPI.LoadProfileAssetAction, - action => SpriteSettingsApplierAPI.LoadProfileAssetAction = action - ); - List profiles = new() { new SpriteSettings { name = "Original" } }; - using (load.Borrow(_ => null)) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - profiles, - false, - out string error - ) - ); - StringAssert.Contains("Could not create profiles asset", error); - } - Assert.IsTrue(AssetDatabase.LoadMainAssetAtPath(ProfilePath) == null); - - Assert.IsTrue( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - profiles, - false, - out string saveError - ), - saveError - ); - using (load.Borrow(_ => null)) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - profiles, - true, - out string error - ) - ); - StringAssert.Contains("Could not create profiles asset", error); - } - Assert.AreEqual(2, StagedAssetFiles().Length); - Assert.IsTrue( - SpriteSettingsApplierAPI.TryLoadProfiles( - ProfilePath, - out List loaded, - out string loadError - ), - loadError - ); - Assert.AreEqual("Original", loaded[0].name); - } - - [Test] - public void FailedStagingCleanupPreservesExistingAssetAndReportsPath() - { - List first = new() { new SpriteSettings { name = "First" } }; - Assert.IsTrue( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - first, - false, - out string error - ), - error - ); - RestorableGlobal> delete = new( - () => SpriteSettingsApplierAPI.DeleteProfileAssetAction, - action => SpriteSettingsApplierAPI.DeleteProfileAssetAction = action - ); - using (delete.Borrow(_ => false)) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles(ProfilePath, first, true, out error) - ); - StringAssert.Contains("Profiles.staging.", error); - Assert.AreEqual(1, StagedAssetFiles().Length); - } + CollectionAssert.AreEqual(originalBytes, File.ReadAllBytes(fullPath)); + AssetDatabase.ImportAsset(ProfilePath, ImportAssetOptions.ForceSynchronousImport); Assert.IsTrue( SpriteSettingsApplierAPI.TryLoadProfiles( ProfilePath, @@ -539,434 +362,188 @@ out error } [Test] - public void ANewSavePreservesALateCollisionAtTheDestination() + public void RecoveryPreservesAnExistingDestination() { - RestorableGlobal> move = new( - () => SpriteSettingsApplierAPI.MoveProfileAssetAction, - action => SpriteSettingsApplierAPI.MoveProfileAssetAction = action - ); - using ( - move.Borrow( - (stagingPath, destinationPath) => - { - Texture2D collision = new(1, 1); - AssetDatabase.CreateAsset(collision, destinationPath); - return AssetDatabase.MoveAsset(stagingPath, destinationPath); - } + string fullPath = Path.Combine( + Path.GetDirectoryName(Application.dataPath), + Root, + "collision.bytes" + ); + byte[] laterBytes = { 6, 7, 8, 9 }; + File.WriteAllBytes(fullPath, laterBytes); + Assert.IsFalse( + SpriteSettingsApplierAPI.TryRestoreAbsentProfileBytes( + fullPath, + new byte[] { 1, 2, 3 }, + out Exception error ) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings() }, - false, - out string error - ) - ); - StringAssert.Contains("Could not move profiles asset", error); - } - Assert.IsTrue(AssetDatabase.LoadMainAssetAtPath(ProfilePath) is Texture2D); - Assert.AreEqual(1, StagedAssetFiles().Length); + ); + Assert.IsTrue(error != null); + CollectionAssert.AreEqual(laterBytes, File.ReadAllBytes(fullPath)); } [Test] - public void AnOverwriteRejectsAChangedSnapshotWithoutRestoringIt() + public void AnOverwriteRejectsAChangedSnapshot() { - List first = new() { new SpriteSettings { name = "First" } }; - Assert.IsTrue( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - first, - false, - out string error - ), - error - ); string fullPath = Path.Combine( Path.GetDirectoryName(Application.dataPath), - ProfilePath + Root, + "snapshot.bytes" ); - byte[] originalBytes = File.ReadAllBytes(fullPath); - byte[] changedBytes = { 1, 2, 3, 4 }; - RestorableGlobal save = new( - () => SpriteSettingsApplierAPI.SaveProfileAssetsAction, - action => SpriteSettingsApplierAPI.SaveProfileAssetsAction = action - ); - using ( - save.Borrow(() => - { - AssetDatabase.SaveAssets(); - File.WriteAllBytes(fullPath, changedBytes); - }) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles(ProfilePath, first, true, out error) - ); - } - StringAssert.Contains("changed since it was read", error); - CollectionAssert.AreEqual(changedBytes, File.ReadAllBytes(fullPath)); - File.WriteAllBytes(fullPath, originalBytes); - AssetDatabase.ImportAsset(ProfilePath, ImportAssetOptions.ForceSynchronousImport); - Assert.AreEqual(0, StagedAssetFiles().Length); + byte[] originalBytes = { 1, 2, 3 }; + byte[] laterBytes = { 6, 7, 8, 9 }; + File.WriteAllBytes(fullPath, laterBytes); + Assert.IsFalse( + DurableFile.TryCompareThenReplaceBytes( + fullPath, + originalBytes, + new byte[] { 4, 5 }, + out Exception error + ) + ); + Assert.IsInstanceOf(error); + CollectionAssert.AreEqual(laterBytes, File.ReadAllBytes(fullPath)); } [Test] - public void AFailedPostSwapLoadPreservesALaterWriterInsteadOfRollingItBack() + public void APostSwapRollbackPreservesALaterWriter() { - List first = new() { new SpriteSettings { name = "First" } }; - Assert.IsTrue( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - first, - false, - out string error - ), - error - ); string fullPath = Path.Combine( Path.GetDirectoryName(Application.dataPath), - ProfilePath + Root, + "rollback.bytes" ); - byte[] originalBytes = File.ReadAllBytes(fullPath); - byte[] laterBytes = { 5, 6, 7, 8 }; - RestorableGlobal> load = new( - () => SpriteSettingsApplierAPI.LoadProfileAssetAction, - action => SpriteSettingsApplierAPI.LoadProfileAssetAction = action - ); - using ( - load.Borrow(path => - { - if (string.Equals(path, ProfilePath, StringComparison.Ordinal)) - { - File.WriteAllBytes(fullPath, laterBytes); - return null; - } - return AssetDatabase.LoadAssetAtPath(path); - }) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles(ProfilePath, first, true, out error) - ); - } - StringAssert.Contains("Could not restore the prior asset", error); - CollectionAssert.AreEqual(laterBytes, File.ReadAllBytes(fullPath)); + byte[] originalBytes = { 1, 2, 3 }; + byte[] committedBytes = { 4, 5 }; + byte[] laterBytes = { 6, 7, 8, 9 }; File.WriteAllBytes(fullPath, originalBytes); - AssetDatabase.ImportAsset(ProfilePath, ImportAssetOptions.ForceSynchronousImport); - Assert.AreEqual(0, StagedAssetFiles().Length); - } - - [Test] - public void FailedOverwriteDoesNotDeleteAReusedStagingPath() - { - List first = new() { new SpriteSettings { name = "First" } }; Assert.IsTrue( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - first, - false, - out string error + DurableFile.TryCompareThenReplaceBytes( + fullPath, + originalBytes, + committedBytes, + out Exception error ), - error - ); - string stagingPath = null; - RestorableGlobal> load = new( - () => SpriteSettingsApplierAPI.LoadProfileAssetAction, - action => SpriteSettingsApplierAPI.LoadProfileAssetAction = action - ); - RestorableGlobal> compare = new( - () => DurableFile.BeforeCompareReadForTests, - action => DurableFile.BeforeCompareReadForTests = action - ); - using ( - load.Borrow(path => - { - if (!string.Equals(path, ProfilePath, StringComparison.Ordinal)) - { - stagingPath = path; - } - return AssetDatabase.LoadAssetAtPath(path); - }) - ) - using ( - compare.Borrow(_ => - { - Assert.IsTrue(stagingPath != null); - Texture2D replacement = new(1, 1); - AssetDatabase.CreateAsset(replacement, stagingPath); - throw new IOException("forced compare failure"); - }) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles(ProfilePath, first, true, out error) - ); - } - StringAssert.Contains("forced compare failure", error); - Assert.IsTrue(AssetDatabase.LoadMainAssetAtPath(stagingPath) is Texture2D); - Assert.AreEqual( - "First", - AssetDatabase - .LoadAssetAtPath(ProfilePath) - .profiles[0] - .name + error?.Message ); - } - - [Test] - public void AFailedCreateAfterWritingPreservesItsUnverifiedStagedAsset() - { - RestorableGlobal> create = new( - () => SpriteSettingsApplierAPI.CreateProfileAssetAction, - action => SpriteSettingsApplierAPI.CreateProfileAssetAction = action - ); - using ( - create.Borrow( - (asset, path) => - { - AssetDatabase.CreateAsset(asset, path); - throw new IOException("forced post-create failure"); - } + File.WriteAllBytes(fullPath, laterBytes); + Assert.IsFalse( + DurableFile.TryCompareThenReplaceBytes( + fullPath, + committedBytes, + originalBytes, + out error ) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings() }, - false, - out string error - ) - ); - StringAssert.Contains("forced post-create failure", error); - StringAssert.Contains("inspect it before removal", error); - } - Assert.IsTrue(AssetDatabase.LoadMainAssetAtPath(ProfilePath) == null); - Assert.AreEqual(1, StagedAssetFiles().Length); + ); + CollectionAssert.AreEqual(laterBytes, File.ReadAllBytes(fullPath)); } [Test] - public void AFailedImportAfterMoveIdentifiesTheCommittedDestination() + public void ExclusivePublicationPreservesALateDestinationCollision() { - RestorableGlobal> import = new( - () => SpriteSettingsApplierAPI.ImportProfileAssetAction, - action => SpriteSettingsApplierAPI.ImportProfileAssetAction = action + string fullPath = Path.Combine( + Path.GetDirectoryName(Application.dataPath), + Root, + "publish.bytes" ); - using (import.Borrow((_, _) => throw new IOException("forced import failure"))) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings() }, - false, - out string error - ) - ); - StringAssert.Contains("forced import failure", error); - StringAssert.Contains("Inspect the destination", error); - StringAssert.Contains(ProfilePath, error); - } - Assert.IsTrue( - AssetDatabase.LoadMainAssetAtPath(ProfilePath) is SpriteSettingsProfileCollection + string stagedPath = fullPath + ".staged"; + byte[] stagedBytes = { 1, 2, 3 }; + byte[] laterBytes = { 6, 7, 8, 9 }; + File.WriteAllBytes(stagedPath, stagedBytes); + File.WriteAllBytes(fullPath, laterBytes); + Assert.IsFalse( + DurableFile.TryPublishStagedFileWithoutOverwrite( + stagedPath, + fullPath, + out bool leavesStaged + ) ); - Assert.AreEqual(0, StagedAssetFiles().Length); + Assert.IsTrue(leavesStaged); + CollectionAssert.AreEqual(laterBytes, File.ReadAllBytes(fullPath)); + CollectionAssert.AreEqual(stagedBytes, File.ReadAllBytes(stagedPath)); } [Test] - public void AnOverwriteDoesNotReportSuccessAfterALaterValidWrite() + public void OwnershipRefusalPreservesAReusedStagingPath() { - Assert.IsTrue( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings { name = "First" } }, - false, - out string error - ), - error - ); - string laterPath = Root + "/Later.asset"; - SpriteSettingsProfileCollection later = Track( - ScriptableObject.CreateInstance() - ); - later.profiles = new List { new SpriteSettings { name = "Later" } }; - AssetDatabase.CreateAsset(later, laterPath); - AssetDatabase.SaveAssets(); - byte[] laterBytes = File.ReadAllBytes( - Path.Combine(Path.GetDirectoryName(Application.dataPath), laterPath) - ); - AssetDatabase.DeleteAsset(laterPath); string fullPath = Path.Combine( Path.GetDirectoryName(Application.dataPath), - ProfilePath + Root, + "reuse.bytes" + ); + string stagedPath = fullPath + ".tmp"; + byte[] originalBytes = { 1, 2, 3 }; + byte[] stagedBytes = { 6, 7, 8, 9 }; + File.WriteAllBytes(fullPath, originalBytes); + Assert.IsTrue( + DurableFile.TryOpenStagingOwnership( + stagedPath, + out FileStream ownership, + out Exception error + ), + error?.Message ); - RestorableGlobal> load = new( - () => SpriteSettingsApplierAPI.LoadProfileAssetAction, - action => SpriteSettingsApplierAPI.LoadProfileAssetAction = action - ); - using ( - load.Borrow(path => - { - if (string.Equals(path, ProfilePath, StringComparison.Ordinal)) - { - File.WriteAllBytes(fullPath, laterBytes); - AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); - } - return AssetDatabase.LoadAssetAtPath(path); - }) - ) + using (ownership) { + File.WriteAllBytes(stagedPath, stagedBytes); Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings { name = "Second" } }, - true, + DurableFile.TryCompareThenReplaceBytes( + fullPath, + originalBytes, + new byte[] { 4, 5 }, out error ) ); + CollectionAssert.AreEqual(originalBytes, File.ReadAllBytes(fullPath)); + CollectionAssert.AreEqual(stagedBytes, File.ReadAllBytes(stagedPath)); } - StringAssert.Contains("could not be verified", error); - StringAssert.Contains("Could not restore the prior asset", error); - SpriteSettingsProfileCollection persisted = - AssetDatabase.LoadAssetAtPath(ProfilePath); - Assert.AreEqual("Later", persisted.profiles[0].name); - Assert.AreEqual(0, StagedAssetFiles().Length); } - [Test] - public void AMoveThatReportsAnErrorAfterCompletionStillProducesAValidAsset() + [TestCase("Expected", true)] + [TestCase("Later", false)] + public void CommittedProfilesMustMatchExpectedContent(string committedName, bool expected) { - string failureError = null; - RestorableGlobal> move = new( - () => SpriteSettingsApplierAPI.MoveProfileAssetAction, - action => SpriteSettingsApplierAPI.MoveProfileAssetAction = action - ); - using ( - move.Borrow( - (stagingPath, destinationPath) => - { - string moveError = AssetDatabase.MoveAsset(stagingPath, destinationPath); - Assert.IsEmpty(moveError); - throw new IOException("forced post-move failure"); - } - ) - ) - { - Assert.IsTrue( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings() }, - false, - out failureError - ) - ); - Assert.IsTrue(failureError == null); - } - Assert.IsTrue( - AssetDatabase.LoadMainAssetAtPath(ProfilePath) is SpriteSettingsProfileCollection, - failureError - ); - Assert.AreEqual(0, StagedAssetFiles().Length); - } - - [Test] - public void ANewSaveNeverRemovesBytesWrittenAfterItsMove() - { - string laterPath = Root + "/Later.asset"; - SpriteSettingsProfileCollection later = Track( + SpriteSettingsProfileCollection committed = Track( ScriptableObject.CreateInstance() ); - later.profiles = new List { new SpriteSettings { name = "Later" } }; - AssetDatabase.CreateAsset(later, laterPath); - AssetDatabase.SaveAssets(); - byte[] laterBytes = File.ReadAllBytes( - Path.Combine(Path.GetDirectoryName(Application.dataPath), laterPath) + committed.profiles = new() { new SpriteSettings { name = committedName } }; + string expectedJson = + WallstopStudios.UnityHelpers.Core.Serialization.Serializer.JsonStringify( + new List { new() { name = "Expected" } } + ); + Assert.AreEqual( + expected, + SpriteSettingsApplierAPI.ProfilesMatchExpected(committed, 1, expectedJson) ); - AssetDatabase.DeleteAsset(laterPath); - string fullPath = Path.Combine( - Path.GetDirectoryName(Application.dataPath), - ProfilePath + Assert.IsFalse( + SpriteSettingsApplierAPI.ProfilesMatchExpected(committed, 2, expectedJson) ); - RestorableGlobal> move = new( - () => SpriteSettingsApplierAPI.MoveProfileAssetAction, - action => SpriteSettingsApplierAPI.MoveProfileAssetAction = action - ); - using ( - move.Borrow( - (stagingPath, destinationPath) => - { - string moveError = AssetDatabase.MoveAsset(stagingPath, destinationPath); - Assert.IsEmpty(moveError); - File.WriteAllBytes(fullPath, laterBytes); - return moveError; - } - ) - ) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles( - ProfilePath, - new List { new SpriteSettings() }, - false, - out string error - ) - ); - StringAssert.Contains("inspect the destination", error); - } - SpriteSettingsProfileCollection persisted = - AssetDatabase.LoadAssetAtPath(ProfilePath); - Assert.IsTrue(persisted != null); - Assert.AreEqual("Later", persisted.profiles[0].name); - Assert.AreEqual(0, StagedAssetFiles().Length); + Assert.IsFalse(SpriteSettingsApplierAPI.ProfilesMatchExpected(null, 1, expectedJson)); } [Test] - public void AnAbsentDestinationRecoveryPreservesALateCollision() + public void SuccessfulStagingCleanupRemovesOnlyTheStagedAsset() { - List first = new() { new SpriteSettings { name = "First" } }; Assert.IsTrue( SpriteSettingsApplierAPI.TrySaveProfiles( ProfilePath, - first, + new List { new() { name = "Original" } }, false, out string error ), error ); - string fullPath = Path.Combine( - Path.GetDirectoryName(Application.dataPath), - ProfilePath + string stagedPath = Root + "/owned.staging.asset"; + SpriteSettingsProfileCollection staged = Track( + ScriptableObject.CreateInstance() ); - byte[] originalBytes = File.ReadAllBytes(fullPath); - byte[] laterBytes = { 6, 7, 8, 9 }; - RestorableGlobal> swap = new( - () => DurableFile.BeforeStagedSwapForTests, - action => DurableFile.BeforeStagedSwapForTests = action - ); - RestorableGlobal> restore = new( - () => SpriteSettingsApplierAPI.BeforeAbsentRestoreMoveForTests, - action => SpriteSettingsApplierAPI.BeforeAbsentRestoreMoveForTests = action - ); - using ( - swap.Borrow(_ => - { - File.Delete(fullPath); - throw new IOException("forced replacement failure"); - }) - ) - using (restore.Borrow(_ => File.WriteAllBytes(fullPath, laterBytes))) - { - Assert.IsFalse( - SpriteSettingsApplierAPI.TrySaveProfiles(ProfilePath, first, true, out error) - ); - } - StringAssert.Contains("Could not restore the prior asset", error); - StringAssert.Contains("Inspect restore staging file at", error); - CollectionAssert.AreEqual(laterBytes, File.ReadAllBytes(fullPath)); - File.WriteAllBytes(fullPath, originalBytes); - AssetDatabase.ImportAsset(ProfilePath, ImportAssetOptions.ForceSynchronousImport); - Assert.AreEqual(0, StagedAssetFiles().Length); + AssetDatabase.CreateAsset(staged, stagedPath); + Assert.IsTrue( + SpriteSettingsApplierAPI.TryDeleteProfileAsset(stagedPath, out error), + error + ); + Assert.IsTrue(AssetDatabase.LoadMainAssetAtPath(stagedPath) == null); + Assert.IsTrue(AssetDatabase.LoadMainAssetAtPath(ProfilePath) != null); } [Test] diff --git a/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierAPITests.cs b/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierAPITests.cs index f54983eb0..29973b55c 100644 --- a/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierAPITests.cs +++ b/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierAPITests.cs @@ -49,7 +49,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } diff --git a/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierWizardAdditionalTests.cs b/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierWizardAdditionalTests.cs index 6b85c3104..e100bc69a 100644 --- a/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierWizardAdditionalTests.cs +++ b/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierWizardAdditionalTests.cs @@ -64,7 +64,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } diff --git a/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierWizardTests.cs b/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierWizardTests.cs index c9f1aa6aa..bf86307cf 100644 --- a/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierWizardTests.cs +++ b/Tests/Editor/Sprites/TextureSettings/TextureSettingsApplierWizardTests.cs @@ -48,7 +48,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } diff --git a/Tests/Editor/Sprites/TextureTools/TextureResizerWizardTests.cs b/Tests/Editor/Sprites/TextureTools/TextureResizerWizardTests.cs index e87795edc..0757007e7 100644 --- a/Tests/Editor/Sprites/TextureTools/TextureResizerWizardTests.cs +++ b/Tests/Editor/Sprites/TextureTools/TextureResizerWizardTests.cs @@ -56,7 +56,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } diff --git a/Tests/Editor/Tags/AttributeMetadataCacheGeneratorTests.cs b/Tests/Editor/Tags/AttributeMetadataCacheGeneratorTests.cs index de8fc7f50..f843adab4 100644 --- a/Tests/Editor/Tags/AttributeMetadataCacheGeneratorTests.cs +++ b/Tests/Editor/Tags/AttributeMetadataCacheGeneratorTests.cs @@ -36,7 +36,6 @@ public sealed class AttributeMetadataCacheGeneratorTests : CommonTestBase private bool _assetExistedBefore; private bool _assetBackedUp; private bool _setUpComplete; - private bool _suppressionCaptured; private bool _cacheFolderExistedBefore; private bool _cacheFolderRemovedForTest; private bool _cacheParentFolderExistedBefore; @@ -46,7 +45,6 @@ public sealed class AttributeMetadataCacheGeneratorTests : CommonTestBase private byte[] _assetMetaBackup; private byte[] _cacheFolderMetaBackup; private string _setUpTestId; - private bool _previousAllowAssetCreationDuringSuppression; private static void ImportAssetIfExists(string assetPath) { @@ -74,18 +72,12 @@ private static void ImportAssetIfExists(string assetPath) private static IEnumerable SuppressionFlagTestCases() { - yield return new TestCaseData(true, false) + yield return new TestCaseData(true) .Returns(null) - .SetName("Suppression.Enabled.AllowFalse"); - yield return new TestCaseData(true, true) + .SetName("Suppression.ExplicitlyEnabled"); + yield return new TestCaseData(false) .Returns(null) - .SetName("Suppression.Enabled.AllowTrue"); - yield return new TestCaseData(false, false) - .Returns(null) - .SetName("Suppression.Disabled.AllowFalse"); - yield return new TestCaseData(false, true) - .Returns(null) - .SetName("Suppression.Disabled.AllowTrue"); + .SetName("Suppression.AutomaticPolicy"); } private static bool FileExistsOnDisk(string assetPath) @@ -147,14 +139,13 @@ public IEnumerator SetUp() { _setUpTestId = TestContext.CurrentContext.Test.ID; _setUpComplete = false; - _suppressionCaptured = false; _assetBackedUp = false; _assetBackup = null; _assetMetaBackup = null; _cacheFolderMetaBackup = null; _cacheFolderRemovedForTest = false; - ScriptableObjectSingletonMetadataUtility.ResetAssetEditingDepthForTesting(); + ScriptableObjectSingletonMetadataUtilityTestAccess.ResetAssetEditingDepth(); ScriptableObjectSingleton.ClearInstance(); _assetExistedBefore = @@ -183,11 +174,6 @@ public IEnumerator SetUp() _assetBackedUp = true; } - _previousAllowAssetCreationDuringSuppression = - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression; - _suppressionCaptured = true; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; - AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; _setUpComplete = true; @@ -211,11 +197,6 @@ public override IEnumerator UnityTearDown() yield break; } - if (_suppressionCaptured) - { - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = - _previousAllowAssetCreationDuringSuppression; - } ScriptableObjectSingleton.ClearInstance(); if (!_setUpComplete) @@ -269,14 +250,14 @@ public override IEnumerator UnityTearDown() } [UnityTest] - public IEnumerator GenerateCacheCreatesAssetWhenMissing() + public IEnumerator GenerateCacheContentsCreatesAssetWhenMissing() { bool folderExistedBefore = AssetDatabase.IsValidFolder(CacheFolder); TestContext.WriteLine( $"Initial state: AssetExistedBefore={_assetExistedBefore}, " + $"FolderExistedBefore={folderExistedBefore}, " + $"EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); if (_assetExistedBefore) @@ -298,7 +279,7 @@ public IEnumerator GenerateCacheCreatesAssetWhenMissing() + $"Path: '{CacheAssetPath}', FolderExists: {AssetDatabase.IsValidFolder(CacheFolder)}" ); - AttributeMetadataCacheGenerator.GenerateCache(); + AttributeMetadataCacheGenerator.GenerateCacheContents(); yield return null; AssetDatabase.SaveAssets(); @@ -321,19 +302,19 @@ public IEnumerator GenerateCacheCreatesAssetWhenMissing() $"GenerateCache should create the cache asset when missing. " + $"Path: '{CacheAssetPath}', FolderExists={folderExists}, " + $"FileExistsOnDisk={fileExistsOnDisk}, EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); } [UnityTest] - public IEnumerator GenerateCacheCreatesFolderHierarchyWhenMissing() + public IEnumerator GenerateCacheContentsCreatesFolderHierarchyWhenMissing() { bool folderExistedBefore = AssetDatabase.IsValidFolder(CacheFolder); TestContext.WriteLine( $"Initial state: AssetExistedBefore={_assetExistedBefore}, " + $"FolderExistedBefore={folderExistedBefore}, " + $"EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); if (_assetExistedBefore) @@ -378,7 +359,7 @@ public IEnumerator GenerateCacheCreatesFolderHierarchyWhenMissing() AssetDatabase.SaveAssets(); yield return null; - AttributeMetadataCacheGenerator.GenerateCache(); + AttributeMetadataCacheGenerator.GenerateCacheContents(); yield return null; AssetDatabase.SaveAssets(); @@ -400,7 +381,7 @@ public IEnumerator GenerateCacheCreatesFolderHierarchyWhenMissing() AssetDatabase.IsValidFolder(CacheFolder), $"GenerateCache should create the folder hierarchy. " + $"FileExistsOnDisk={fileExistsOnDisk}, EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); Assert.IsTrue( @@ -408,22 +389,22 @@ public IEnumerator GenerateCacheCreatesFolderHierarchyWhenMissing() $"GenerateCache should create the cache asset. " + $"Path: '{CacheAssetPath}', FolderExists: {AssetDatabase.IsValidFolder(CacheFolder)}, " + $"FileExistsOnDisk={fileExistsOnDisk}, EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); } [UnityTest] - public IEnumerator GenerateCacheDoesNotThrowWhenAssetAlreadyExists() + public IEnumerator GenerateCacheContentsDoesNotThrowWhenAssetAlreadyExists() { bool folderExistedBefore = AssetDatabase.IsValidFolder(CacheFolder); TestContext.WriteLine( $"Initial state: AssetExistedBefore={_assetExistedBefore}, " + $"FolderExistedBefore={folderExistedBefore}, " + $"EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); - AttributeMetadataCacheGenerator.GenerateCache(); + AttributeMetadataCacheGenerator.GenerateCacheContents(); yield return null; AssetDatabase.SaveAssets(); @@ -446,11 +427,11 @@ public IEnumerator GenerateCacheDoesNotThrowWhenAssetAlreadyExists() $"First GenerateCache should create the asset. " + $"FolderExists={AssetDatabase.IsValidFolder(CacheFolder)}, " + $"FileExistsOnDisk={firstFileExistsOnDisk}, EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); Assert.DoesNotThrow( - () => AttributeMetadataCacheGenerator.GenerateCache(), + () => AttributeMetadataCacheGenerator.GenerateCacheContents(), "GenerateCache should not throw when cache already exists" ); yield return null; @@ -476,18 +457,18 @@ public IEnumerator GenerateCacheDoesNotThrowWhenAssetAlreadyExists() } [UnityTest] - public IEnumerator GenerateCacheIsIdempotent() + public IEnumerator GenerateCacheContentsIsIdempotent() { TestContext.WriteLine( $"Initial state: AssetExistedBefore={_assetExistedBefore}, " + $"FolderExists={AssetDatabase.IsValidFolder(CacheFolder)}, " + $"EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); for (int i = 0; i < 3; ++i) { - AttributeMetadataCacheGenerator.GenerateCache(); + AttributeMetadataCacheGenerator.GenerateCacheContents(); yield return null; AssetDatabase.SaveAssets(); @@ -509,13 +490,13 @@ public IEnumerator GenerateCacheIsIdempotent() $"Cache should exist after iteration {i + 1}. " + $"Path: '{CacheAssetPath}', FolderExists={folderExists}, " + $"FileExistsOnDisk={fileExistsOnDisk}, EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); } } [UnityTest] - public IEnumerator GenerateCachePreservesWallstopStudiosFolder() + public IEnumerator GenerateCacheContentsPreservesWallstopStudiosFolder() { bool folderExistedBefore = AssetDatabase.IsValidFolder(CacheFolder); bool cacheExistedBefore = @@ -524,10 +505,10 @@ public IEnumerator GenerateCachePreservesWallstopStudiosFolder() $"Initial state: AssetExistedBefore={_assetExistedBefore}, " + $"CacheExistedBefore={cacheExistedBefore}, FolderExistedBefore={folderExistedBefore}, " + $"EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); - AttributeMetadataCacheGenerator.GenerateCache(); + AttributeMetadataCacheGenerator.GenerateCacheContents(); yield return null; AssetDatabase.SaveAssets(); @@ -548,12 +529,12 @@ public IEnumerator GenerateCachePreservesWallstopStudiosFolder() AssetDatabase.IsValidFolder(CacheFolder), $"Wallstop Studios folder should be preserved after cache generation. " + $"FileExistsOnDisk={fileExistsOnDisk}, EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); } [UnityTest] - public IEnumerator GenerateCacheCreatesAtCorrectPathWhenStaleInstanceExists() + public IEnumerator GenerateCacheContentsCreatesAtCorrectPathWhenStaleInstanceExists() { const string decoyPath = "Assets/Temp/DecoyCache.asset"; @@ -562,7 +543,7 @@ public IEnumerator GenerateCacheCreatesAtCorrectPathWhenStaleInstanceExists() $"Initial state: AssetExistedBefore={_assetExistedBefore}, " + $"FolderExistedBefore={folderExistedBefore}, " + $"EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); if (!AssetDatabase.IsValidFolder("Assets/Temp")) @@ -604,7 +585,7 @@ public IEnumerator GenerateCacheCreatesAtCorrectPathWhenStaleInstanceExists() ScriptableObjectSingleton.ClearInstance(); yield return null; - AttributeMetadataCacheGenerator.GenerateCache(); + AttributeMetadataCacheGenerator.GenerateCacheContents(); yield return null; AssetDatabase.SaveAssets(); @@ -628,7 +609,7 @@ public IEnumerator GenerateCacheCreatesAtCorrectPathWhenStaleInstanceExists() + $"ExpectedPath: '{CacheAssetPath}', DecoyPath: '{decoyPath}', " + $"FolderExists={folderExists}, FileExistsOnDisk={fileExistsOnDisk}, " + $"EditorUiSuppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" + + "OperationPhase=GenerateCacheContents" ); string cachePath = AssetDatabase.GetAssetPath(cache); @@ -677,107 +658,39 @@ public void CachePathsMatchScriptableSingletonPathAttribute() [UnityTest] [TestCaseSource(nameof(SuppressionFlagTestCases))] - public IEnumerator GenerateCacheRespectsSuppressionFlags( - bool suppressEditorUi, - bool allowDuringSuppression - ) + public IEnumerator GenerateCacheRespectsEffectiveSuppression(bool suppressEditorUi) { - TestContext.WriteLine( - $"Test parameters: SuppressEditorUi={suppressEditorUi}, " - + $"AllowDuringSuppression={allowDuringSuppression}" - ); - - bool cacheExistedAtStart = - AssetDatabase.LoadAssetAtPath(CacheAssetPath) != null; - bool folderExistedAtStart = AssetDatabase.IsValidFolder(CacheFolder); - TestContext.WriteLine( - $"Initial state: CacheExistedAtStart={cacheExistedAtStart}, " - + $"FolderExistedAtStart={folderExistedAtStart}, " - + $"AssetExistedBefore={_assetExistedBefore}" - ); - if (AssetDatabase.LoadAssetAtPath(CacheAssetPath) != null) { AssetDatabase.DeleteAsset(CacheAssetPath); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; } - - AttributeMetadataCache cacheCheck = - AssetDatabase.LoadAssetAtPath(CacheAssetPath); - bool folderExistsBeforeTest = AssetDatabase.IsValidFolder(CacheFolder); - TestContext.WriteLine( - $"After cleanup: CacheExists={cacheCheck != null}, FolderExists={folderExistsBeforeTest}" - ); - Assert.IsTrue( - cacheCheck == null, - $"Setup: Cache asset should not exist before test. Path: '{CacheAssetPath}', " - + $"Exists: {cacheCheck != null}, AssetExistedBefore: {_assetExistedBefore}, " - + $"FolderExists: {folderExistsBeforeTest}" + AssetDatabase.LoadAssetAtPath(CacheAssetPath) == null ); - bool originalSuppress = EditorUi.Suppress; - bool originalAllow = - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression; - TestContext.WriteLine( - $"Original flag values: EditorUi.Suppress={originalSuppress}, " - + $"AllowAssetCreationDuringSuppression={originalAllow}" - ); - try { EditorUi.Suppress = suppressEditorUi; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = - allowDuringSuppression; - ScriptableObjectSingleton.ClearInstance(); - - // Automatic UI suppression also depends on batch mode, CI, and the test runner. bool effectiveSuppress = EditorUi.Suppress; - bool expectCacheCreated = !effectiveSuppress || allowDuringSuppression; - - TestContext.WriteLine( - $"Flags set for test: EditorUi.Suppress={EditorUi.Suppress}, " - + $"EffectiveSuppress={effectiveSuppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}, " - + $"ExpectCacheCreated={expectCacheCreated}" - ); - + ScriptableObjectSingleton.ClearInstance(); AttributeMetadataCacheGenerator.GenerateCache(); yield return null; - AssetDatabase.SaveAssets(); - AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); ImportAssetIfExists(CacheAssetPath); yield return null; - - bool folderExistsAfterGenerate = AssetDatabase.IsValidFolder(CacheFolder); - AttributeMetadataCache cache = - AssetDatabase.LoadAssetAtPath(CacheAssetPath); - bool cacheExists = cache != null; - TestContext.WriteLine( - $"After GenerateCache: CacheExists={cacheExists}, " - + $"FolderExists={folderExistsAfterGenerate}, " - + $"ExpectedCacheCreated={expectCacheCreated}" - ); - + bool cacheExists = + AssetDatabase.LoadAssetAtPath(CacheAssetPath) != null; Assert.AreEqual( - expectCacheCreated, + !effectiveSuppress, cacheExists, - $"GenerateCache with Suppress={suppressEditorUi}, AllowDuringSuppression={allowDuringSuppression} " - + $"should {(expectCacheCreated ? "" : "NOT ")}create cache. " - + $"Path: '{CacheAssetPath}', FolderExists: {folderExistsAfterGenerate}" + $"Automatic generation must respect actual suppression: requested={suppressEditorUi}, effective={effectiveSuppress}." ); } finally { EditorUi.Suppress = originalSuppress; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = - originalAllow; - TestContext.WriteLine( - $"Flags restored: EditorUi.Suppress={EditorUi.Suppress}, " - + $"AllowAssetCreationDuringSuppression={ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression}" - ); } } } diff --git a/Tests/Editor/TestAccess.meta b/Tests/Editor/TestAccess.meta new file mode 100644 index 000000000..67c5613c7 --- /dev/null +++ b/Tests/Editor/TestAccess.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ed83d4c82245d98fd1af1805aac5c55c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/AssetPostprocessorDeferralTestAccess.cs b/Tests/Editor/TestAccess/AssetPostprocessorDeferralTestAccess.cs new file mode 100644 index 000000000..b189f0642 --- /dev/null +++ b/Tests/Editor/TestAccess/AssetPostprocessorDeferralTestAccess.cs @@ -0,0 +1,121 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.AssetProcessors +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.Threading; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Editor.Settings; + using static WallstopStudios.UnityHelpers.Editor.AssetProcessors.AssetPostprocessorDeferral; + + /// Provides test-side setup and inspection for AssetPostprocessorDeferral. + internal static class AssetPostprocessorDeferralTestAccess + { + private const int FlushIterationCap = 32; + + /// + /// Test-only snapshot of the pending-drain count. Used by regression + /// tests that verify cap/drain behavior without pulling in the full + /// reflection machinery. + /// + internal static int PendingDrainCount => PendingDrains.Count; + + /// + /// Synchronously drains any pending actions, iterating until the queue is + /// stable so a drain that reentrantly calls does + /// not leave items in the queue for the next test's setup to inherit. + /// Intended for tests to avoid yielding an editor frame. + /// + /// Bounded by iterations to prevent a + /// buggy handler that re-schedules itself from hanging the test run; if + /// the cap is hit, the method returns with drains still pending, logs a + /// warning, and those drains fire on the next editor tick (potentially + /// polluting the next test — the warning is the caller's signal to + /// investigate). + /// + /// Note on dormant delayCalls: when a drain appends to + /// during its execution, + /// re-arms an + /// subscription for the next editor tick. This loop then drains that + /// queue synchronously in the next iteration, so the delayCall (when it + /// eventually fires) observes an empty queue and returns as a harmless + /// no-op. Within a single reentrant iteration, at most ONE dormant + /// delayCall is registered: both and + /// gate on _scheduled and will not + /// double-register. Across a full flush cycle, however, the top of each + /// iteration clears _scheduled = false, so up to + /// dormant DrainScheduled + /// callbacks can accumulate on + /// — each one a harmless no-op when it fires. Editor-tick telemetry may + /// therefore show between zero and + /// no-op DrainScheduled invocations per flush cycle (zero when + /// no reentrant appends happened, one per iteration that had them). + /// + internal static void Flush() + { + if (_draining) + { + // Reentrant flush cannot drain its own active queue; warn without aborting the outer batch. + Debug.LogWarning( + "Flush called reentrantly during drain — flush is a no-op; " + + "ensure tests don't call Flush from a handler callback." + ); + return; + } + + for (int iteration = 0; iteration < FlushIterationCap; ++iteration) + { + // Clear scheduling state before callbacks can reenter and enqueue another batch. + _scheduled = false; + DrainPending(); + + if (PendingDrains.Count == 0) + { + _scheduled = false; + return; + } + // Take synchronous ownership of the reentrant batch instead of racing its scheduled tick. + } + + Debug.LogWarning( + "Flush hit the iteration cap (" + + FlushIterationCap + + ") with " + + PendingDrains.Count + + " drain(s) still pending. A drain handler is likely re-scheduling itself. " + + "Remaining drains will fire on the next editor tick, which may pollute the next test." + ); + } + + /// + /// Test-only reset hook. Wipes and the + /// scheduling flags, mirroring . Tests + /// that deliberately exercise edge cases (e.g. hitting + /// ) may leave drains queued; calling + /// this from a TearDown guarantees the next test starts with a + /// quiescent deferral. + /// + /// Caveat — dormant subscriptions + /// are NOT purged by this reset. Each call to or + /// 's fallback appends + /// to Unity's multicast delayCall, and Unity does not expose a + /// safe way to dequeue a specific subscription mid-flight. Those + /// subscriptions remain pending and fire on subsequent editor ticks — + /// but because early-returns on an empty + /// , each dormant fire is a harmless no-op. + /// Consequence: do NOT treat + /// as a proxy for "no delayCall callback is pending". It only reflects + /// the drain queue; the delayCall multicast may still hold stale + /// subscriptions that will quietly no-op when they fire. + /// + internal static void Reset() + { + ResetForDomainReload(); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/AssetPostprocessorDeferralTestAccess.cs.meta b/Tests/Editor/TestAccess/AssetPostprocessorDeferralTestAccess.cs.meta new file mode 100644 index 000000000..01569fe37 --- /dev/null +++ b/Tests/Editor/TestAccess/AssetPostprocessorDeferralTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b7b90370a6fef044f0d294551e1f4dd5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/DropDownSharedTestAccess.cs b/Tests/Editor/TestAccess/DropDownSharedTestAccess.cs new file mode 100644 index 000000000..a5c757c42 --- /dev/null +++ b/Tests/Editor/TestAccess/DropDownSharedTestAccess.cs @@ -0,0 +1,63 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Core.Helper; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils.DropDownShared; + + /// Provides test-side setup and inspection for DropDownShared. + internal static class DropDownSharedTestAccess + { + /// + /// Gets the number of formatted option labels currently retained, for testing. + /// + public static int FormattedOptionCacheCount => FormattedOptionCache.Count; + + /// + /// Gets the option button margin vertical value for testing. + /// + public static int OptionButtonMarginVertical + { + get + { + EnsureStylesInitialized(); + return s_optionButton.margin?.vertical ?? 0; + } + } + + /// + /// Gets the option footer padding for testing. + /// + public static float OptionFooterPadding => OptionBottomPadding; + + /// + /// Gets the popup width value for testing. + /// + public static float PopupWidthValue => PopupWidth; + + /// + /// Gets the empty search horizontal padding value for testing. + /// + public static float EmptySearchHorizontalPaddingValue => EmptySearchHorizontalPadding; + + /// + /// Gets the empty results message value for testing. + /// + public static string EmptyResultsMessageValue => EmptyResultsMessage; + + /// + /// Gets the empty search extra padding value for testing. + /// + public static float EmptySearchExtraPaddingValue => EmptySearchExtraPadding; + } +#endif +} diff --git a/Tests/Editor/TestAccess/DropDownSharedTestAccess.cs.meta b/Tests/Editor/TestAccess/DropDownSharedTestAccess.cs.meta new file mode 100644 index 000000000..fd36eeb87 --- /dev/null +++ b/Tests/Editor/TestAccess/DropDownSharedTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 45057767fc84e82243f24019560a8cc5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/GroupGUIWidthUtilityTestAccess.cs b/Tests/Editor/TestAccess/GroupGUIWidthUtilityTestAccess.cs new file mode 100644 index 000000000..134ef9bf4 --- /dev/null +++ b/Tests/Editor/TestAccess/GroupGUIWidthUtilityTestAccess.cs @@ -0,0 +1,27 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Utils +{ +#if UNITY_EDITOR + using System; + using UnityEditor; + using UnityEngine; + using static WallstopStudios.UnityHelpers.Editor.Utils.GroupGUIWidthUtility; + + /// Provides test-side setup and inspection for GroupGUIWidthUtility. + internal static class GroupGUIWidthUtilityTestAccess + { + [System.Diagnostics.Conditional("UNITY_EDITOR")] + internal static void Reset() + { + _totalPadding = 0f; + _totalLeftPadding = 0f; + _totalRightPadding = 0f; + _scopeDepth = 0; + _isInsideWGroupPropertyDraw = false; + _currentThemeState = null; + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/GroupGUIWidthUtilityTestAccess.cs.meta b/Tests/Editor/TestAccess/GroupGUIWidthUtilityTestAccess.cs.meta new file mode 100644 index 000000000..3368537ed --- /dev/null +++ b/Tests/Editor/TestAccess/GroupGUIWidthUtilityTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ee193b552f842cbcb3101ba1ac2f590 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/InLineEditorSharedTestAccess.cs b/Tests/Editor/TestAccess/InLineEditorSharedTestAccess.cs new file mode 100644 index 000000000..14cb0bb6e --- /dev/null +++ b/Tests/Editor/TestAccess/InLineEditorSharedTestAccess.cs @@ -0,0 +1,87 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Editor.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Editor.Utils; + using static WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils.InLineEditorShared; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for InLineEditorShared. + internal static class InLineEditorSharedTestAccess + { + /// + /// Clears all cached state. Primarily for testing purposes. + /// + internal static void ClearCachedState() + { + ClearCache(); + } + + /// + /// Test hook to set the foldout state for a given key. + /// + /// The foldout key. + /// Whether the foldout should be expanded. + internal static void SetFoldoutState(string key, bool expanded) + { + if (!string.IsNullOrEmpty(key)) + { + FoldoutStates[key] = expanded; + } + } + + /// + /// Test hook to get the foldout state for a given key. + /// + /// The foldout key. + /// True if expanded; false otherwise. + internal static bool GetFoldoutState(string key) + { + if (string.IsNullOrEmpty(key)) + { + return false; + } + + return FoldoutStates.TryGetValue(key, out bool value) && value; + } + + /// + /// Test hook to get the number of cached editors. + /// + /// The number of cached editors. + internal static int GetEditorCacheCount() + { + return EditorCache.Count; + } + + /// + /// Test hook to get the number of cached foldout states. + /// + /// The number of cached foldout states. + internal static int GetFoldoutStateCacheCount() + { + return FoldoutStates.Count; + } + + /// + /// Test hook to get the number of cached scroll positions. + /// + /// The number of cached scroll positions. + internal static int GetScrollPositionCacheCount() + { + return ScrollPositions.Count; + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/InLineEditorSharedTestAccess.cs.meta b/Tests/Editor/TestAccess/InLineEditorSharedTestAccess.cs.meta new file mode 100644 index 000000000..01fe48bb8 --- /dev/null +++ b/Tests/Editor/TestAccess/InLineEditorSharedTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee9eab5d8f82c199c59928f725c312aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/LlmArtifactCleanerTestAccess.cs b/Tests/Editor/TestAccess/LlmArtifactCleanerTestAccess.cs new file mode 100644 index 000000000..e5388a31d --- /dev/null +++ b/Tests/Editor/TestAccess/LlmArtifactCleanerTestAccess.cs @@ -0,0 +1,33 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.AssetProcessors +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using UnityEditor; + using static WallstopStudios.UnityHelpers.Editor.AssetProcessors.LlmArtifactCleaner; + + /// Provides test-side setup and inspection for LlmArtifactCleaner. + internal static class LlmArtifactCleanerTestAccess + { + internal static int PendingDeletionCount + { + get { return PendingDeletions.Count; } + } + + internal static void Reset() + { + PendingDeletions.Clear(); + _isDeleting = false; + } + + internal static void DeleteBlockedAssets(string[] assetPaths) + { + EnqueueBlockedAssets(assetPaths); + DrainPendingDeletions(); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/LlmArtifactCleanerTestAccess.cs.meta b/Tests/Editor/TestAccess/LlmArtifactCleanerTestAccess.cs.meta new file mode 100644 index 000000000..996cdcdb3 --- /dev/null +++ b/Tests/Editor/TestAccess/LlmArtifactCleanerTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c0f0af41e19e1e6e89f2ca6e75f1723d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/ScriptableObjectSingletonCreatorTestAccess.cs b/Tests/Editor/TestAccess/ScriptableObjectSingletonCreatorTestAccess.cs new file mode 100644 index 000000000..11c288240 --- /dev/null +++ b/Tests/Editor/TestAccess/ScriptableObjectSingletonCreatorTestAccess.cs @@ -0,0 +1,109 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Utils +{ +#if UNITY_EDITOR + using System; + using System.Collections; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Threading; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Core.Helper; + using static WallstopStudios.UnityHelpers.Editor.Utils.ScriptableObjectSingletonCreator; + using Debug = UnityEngine.Debug; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for ScriptableObjectSingletonCreator. + internal static class ScriptableObjectSingletonCreatorTestAccess + { + internal static bool IncludeTestAssemblies; + internal static bool DisableAutomaticRetries; + internal static Func TypeFilter; + internal static bool IgnoreExclusionAttribute; + internal static bool AllowAssetCreationDuringSuppression; + + internal static void EnsureSingletonAssets() + { + if (ScriptableObjectSingletonCreator.IsRunningInsideAssetImportWorkerProcess()) + { + return; + } + if (!AllowAssetCreationDuringSuppression && EditorUi.Suppress) + { + ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + return; + } + List candidates = new(); + foreach ( + Type candidate in ReflectionHelpers.GetTypesDerivedFrom( + typeof(UnityHelpers.Utils.ScriptableObjectSingleton<>), + includeAbstract: false + ) + ) + { + if ( + !candidate.IsGenericType + && (IncludeTestAssemblies || !TestAssemblyHelper.IsTestType(candidate)) + && (TypeFilter == null || TypeFilter(candidate)) + && ( + IgnoreExclusionAttribute + || !ReflectionHelpers.TryGetAttributeSafe( + candidate, + out _, + inherit: false + ) + ) + ) + { + candidates.Add(candidate); + } + } + ScriptableObjectSingletonCreator.EnsureSingletonAssets(candidates); + if (DisableAutomaticRetries) + { + CancelScheduledEnsureInvocation(); + } + } + + internal static void ResetAssetImportWorkerDetectionState() + { + _assetImportWorkerEnvCachedValue = null; + _defaultAssetImportWorkerDetector = null; + _mainThreadConfirmed = false; + _mainThreadConfirmationPending = false; + _capturedMainThreadId = 0; + } + + internal static void ResetRetryState() + { + _retryAttempts = 0; + _consecutiveZeroProgressRetries = 0; + CancelScheduledEnsureInvocation(); + } + + internal static void ResetInitialEnsureState() + { + UnityHelpers.Utils.ScriptableObjectSingletonInitState.InitialEnsureCompleted = false; + } + + /// + /// Resets state for testing. Cleanup of AssetDatabase batch state is now handled + /// by the unified . + /// + internal static void ResetAssetEditingScopeDepth() + { + _isEnsuring = false; + + CancelScheduledEnsureInvocation(); + _retryAttempts = 0; + _consecutiveZeroProgressRetries = 0; + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/ScriptableObjectSingletonCreatorTestAccess.cs.meta b/Tests/Editor/TestAccess/ScriptableObjectSingletonCreatorTestAccess.cs.meta new file mode 100644 index 000000000..1e2cc14c9 --- /dev/null +++ b/Tests/Editor/TestAccess/ScriptableObjectSingletonCreatorTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d2534726dfd8be321153356ccd6a681 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/ScriptableObjectSingletonMetadataUtilityTestAccess.cs b/Tests/Editor/TestAccess/ScriptableObjectSingletonMetadataUtilityTestAccess.cs new file mode 100644 index 000000000..4b96c13f6 --- /dev/null +++ b/Tests/Editor/TestAccess/ScriptableObjectSingletonMetadataUtilityTestAccess.cs @@ -0,0 +1,34 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Utils +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.IO; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.Utils.ScriptableObjectSingletonMetadataUtility; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for ScriptableObjectSingletonMetadataUtility. + internal static class ScriptableObjectSingletonMetadataUtilityTestAccess + { + /// + /// Resets legacy state for testing. AssetDatabase batch cleanup is now handled + /// by the unified . + /// + /// + /// This method is kept for backward compatibility with test cleanup code. + /// The actual AssetDatabase state cleanup is handled by AssetDatabaseBatchHelper.ResetBatchDepth(). + /// + internal static void ResetAssetEditingDepth() + { + // CommonTestBase owns batch cleanup; this compatibility entry point remains inert. + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/ScriptableObjectSingletonMetadataUtilityTestAccess.cs.meta b/Tests/Editor/TestAccess/ScriptableObjectSingletonMetadataUtilityTestAccess.cs.meta new file mode 100644 index 000000000..b65744cca --- /dev/null +++ b/Tests/Editor/TestAccess/ScriptableObjectSingletonMetadataUtilityTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb4b6533abe19f18f6a5637ca0c635a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/SerializableDictionaryPropertyDrawerTestAccess.cs b/Tests/Editor/TestAccess/SerializableDictionaryPropertyDrawerTestAccess.cs new file mode 100644 index 000000000..7009dbb88 --- /dev/null +++ b/Tests/Editor/TestAccess/SerializableDictionaryPropertyDrawerTestAccess.cs @@ -0,0 +1,263 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers +{ +#if UNITY_EDITOR + using System; + using System.Collections; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Diagnostics; + using System.Globalization; + using System.Reflection; + using System.Runtime.CompilerServices; + using System.Runtime.Serialization; + using System.Text; + using UnityEditor; + using UnityEditor.AnimatedValues; + using UnityEditorInternal; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Core.DataStructure.Adapters; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.CustomDrawers.SerializableDictionaryPropertyDrawer; + using Debug = UnityEngine.Debug; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for SerializableDictionaryPropertyDrawer. + internal static class SerializableDictionaryPropertyDrawerTestAccess + { + /// + /// Gets the frame number when the child height changed signal was last set. + /// For testing purposes only. + /// + internal static int GetChildHeightChangedFrame() + { + return _childHeightChangedFrame; + } + + /// + /// Resets the child height changed frame to -1 for testing purposes. + /// + internal static void ResetChildHeightChangedFrame() + { + _childHeightChangedFrame = -1; + } + + internal static void ResetLayoutTracking() { } + + /// + /// Returns the resolved pending section padding value for test diagnostics. + /// + internal static float GetPendingSectionPadding() + { + return PendingSectionPadding; + } + + internal static bool HasDroppedValuesArray(SerializedProperty dictionaryProperty) + { + return SerializableDictionaryPropertyDrawer.HasDroppedValuesArray( + new CachedPropertyPair + { + keysProperty = dictionaryProperty.FindPropertyRelative( + SerializableDictionarySerializedPropertyNames.Keys + ), + valuesProperty = ResolveValuesArray(dictionaryProperty), + }, + dictionaryProperty + ); + } + + /// + /// Returns the expected static foldout progress without animation. + /// This static method cannot access instance animation state. + /// Use for actual animation testing. + /// + internal static float GetPendingFoldoutProgress( + SerializedProperty property, + bool expanded, + bool isSorted + ) + { + return SerializableDictionaryPropertyDrawer.GetPendingFoldoutProgress( + new PendingEntry { isExpanded = expanded, isSorted = isSorted }, + property?.propertyPath + ); + } + + /// + /// Clears the main foldout animation cache. Used for testing purposes. + /// + internal static void ClearMainFoldoutAnimCache() + { + MainFoldoutAnimations.Clear(); + } + + /// + /// Returns true if a main foldout AnimBool exists for the given property path and target object. + /// + internal static bool HasMainFoldoutAnimBool( + SerializedObject serializedObject, + string propertyPath + ) + { + MainFoldoutCacheKey cacheKey = + SerializableDictionaryPropertyDrawer.GetMainFoldoutCacheKey( + serializedObject, + propertyPath + ); + return MainFoldoutAnimations.ContainsKey(cacheKey); + } + + /// + /// Gets the main foldout cache key for testing purposes. + /// Returns the string representation of the struct key. + /// + internal static string GetMainFoldoutCacheKey( + SerializedObject serializedObject, + string propertyPath + ) + { + return SerializableDictionaryPropertyDrawer + .GetMainFoldoutCacheKey(serializedObject, propertyPath) + .ToString(); + } + + internal static void SetRowFoldoutState( + this SerializableDictionaryPropertyDrawer owner, + string cacheKey, + int globalIndex, + bool isExpanded + ) + { + SerializableDictionaryPropertyDrawer.RowFoldoutKey key = BuildRowFoldoutStateKey( + cacheKey, + globalIndex + ); + if (!key.IsValid) + { + return; + } + + owner._rowValueFoldoutStates[key] = isExpanded; + } + + /// + /// Gets the pending entry's expanded state and animation information for testing. + /// + /// The serialized property for the dictionary. + /// Output: whether the pending section is logically expanded. + /// Output: the current animation progress (0 to 1). + /// Output: whether an AnimBool is active for this entry. + /// True if a pending entry was found, false otherwise. + internal static bool TryGetPendingAnimationState( + this SerializableDictionaryPropertyDrawer owner, + SerializedProperty property, + out bool isExpanded, + out float animProgress, + out bool hasAnimBool + ) + { + if (property == null) + { + isExpanded = false; + animProgress = 0f; + hasAnimBool = false; + return false; + } + + string cacheKey = owner.GetListKey(property); + if ( + !owner._pendingEntries.TryGetValue( + cacheKey, + out SerializableDictionaryPropertyDrawer.PendingEntry pending + ) + || pending == null + ) + { + isExpanded = false; + animProgress = 0f; + hasAnimBool = false; + return false; + } + + isExpanded = pending.isExpanded; + hasAnimBool = pending.foldoutAnim != null; + animProgress = SerializableDictionaryPropertyDrawer.GetPendingFoldoutProgress(pending); + return true; + } + + /// + /// Sets the pending entry's expanded state for testing purposes. + /// This properly triggers animation state updates. + /// + internal static void SetPendingExpandedState( + this SerializableDictionaryPropertyDrawer owner, + SerializedProperty property, + bool expanded + ) + { + if (property == null) + { + return; + } + + string cacheKey = owner.GetListKey(property); + if ( + !owner._pendingEntries.TryGetValue( + cacheKey, + out SerializableDictionaryPropertyDrawer.PendingEntry pending + ) + || pending == null + ) + { + return; + } + + pending.isExpanded = expanded; + AnimBool anim = EnsurePendingFoldoutAnim(pending, property.propertyPath); + if (anim != null) + { + anim.target = expanded; + } + } + + /// + /// Gets the actual animated foldout progress from the drawer instance's pending entry. + /// Use this for testing that animations are actually progressing over time. + /// + /// The serialized property for the dictionary. + /// + /// The current animation progress (0 to 1), or -1 if no pending entry exists for this property. + /// When tweening is disabled, returns 0 or 1 immediately based on expanded state. + /// + internal static float GetPendingFoldoutProgressFromInstance( + this SerializableDictionaryPropertyDrawer owner, + SerializedProperty property + ) + { + if (property == null) + { + return -1f; + } + + string cacheKey = owner.GetListKey(property); + if ( + !owner._pendingEntries.TryGetValue(cacheKey, out PendingEntry pending) + || pending == null + ) + { + return -1f; + } + + return SerializableDictionaryPropertyDrawer.GetPendingFoldoutProgress(pending); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/SerializableDictionaryPropertyDrawerTestAccess.cs.meta b/Tests/Editor/TestAccess/SerializableDictionaryPropertyDrawerTestAccess.cs.meta new file mode 100644 index 000000000..acf3071fb --- /dev/null +++ b/Tests/Editor/TestAccess/SerializableDictionaryPropertyDrawerTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a65b635e667607cb05094c9c59b1d913 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/SerializableSetPropertyDrawerTestAccess.cs b/Tests/Editor/TestAccess/SerializableSetPropertyDrawerTestAccess.cs new file mode 100644 index 000000000..baab79ec0 --- /dev/null +++ b/Tests/Editor/TestAccess/SerializableSetPropertyDrawerTestAccess.cs @@ -0,0 +1,224 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers +{ +#if UNITY_EDITOR + using System; + using System.Collections; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Diagnostics; + using System.Globalization; + using System.Reflection; + using System.Runtime.CompilerServices; + using System.Runtime.Serialization; + using System.Text; + using UnityEditor; + using UnityEditor.AnimatedValues; + using UnityEditorInternal; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Core.DataStructure.Adapters; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.CustomDrawers.SerializableSetPropertyDrawer; + using Debug = UnityEngine.Debug; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for SerializableSetPropertyDrawer. + internal static class SerializableSetPropertyDrawerTestAccess + { + internal static void ResetLayoutTracking() + { + MainFoldoutAnimations.Clear(); + } + + /// + /// Gets the frame number when the child height changed signal was last set. + /// For testing purposes only. + /// + internal static int GetChildHeightChangedFrame() + { + return _childHeightChangedFrame; + } + + /// + /// Resets the child height changed frame to -1 for testing purposes. + /// + internal static void ResetChildHeightChangedFrame() + { + _childHeightChangedFrame = -1; + } + + /// + /// Returns the expected static foldout progress without animation. + /// This static method cannot access instance animation state. + /// Use for actual animation testing. + /// + internal static float GetPendingFoldoutProgress( + SerializedProperty property, + bool expanded, + bool isSorted + ) + { + return SerializableSetPropertyDrawer.GetPendingFoldoutProgress( + new PendingEntry { isExpanded = expanded, isSorted = isSorted }, + property?.propertyPath + ); + } + + /// + /// Clears the main foldout animation cache. Used for testing purposes. + /// + internal static void ClearMainFoldoutAnimCache() + { + MainFoldoutAnimations.Clear(); + } + + /// + /// Returns true if a main foldout AnimBool exists for the given property path and target object. + /// + internal static bool HasMainFoldoutAnimBool( + SerializedObject serializedObject, + string propertyPath + ) + { + MainFoldoutCacheKey cacheKey = SerializableSetPropertyDrawer.GetMainFoldoutCacheKey( + serializedObject, + propertyPath + ); + return MainFoldoutAnimations.ContainsKey(cacheKey); + } + + /// + /// Gets the main foldout cache key for testing purposes. + /// Returns the string representation of the struct key. + /// + internal static string GetMainFoldoutCacheKey( + SerializedObject serializedObject, + string propertyPath + ) + { + return SerializableSetPropertyDrawer + .GetMainFoldoutCacheKey(serializedObject, propertyPath) + .ToString(); + } + + /// + /// Gets the pending entry's expanded state and animation information for testing. + /// + /// The serialized property for the set. + /// Output: whether the pending section is logically expanded. + /// Output: the current animation progress (0 to 1). + /// Output: whether an AnimBool is active for this entry. + /// True if a pending entry was found, false otherwise. + internal static bool TryGetPendingAnimationState( + this SerializableSetPropertyDrawer owner, + SerializedProperty property, + out bool isExpanded, + out float animProgress, + out bool hasAnimBool + ) + { + if (property == null) + { + isExpanded = false; + animProgress = 0f; + hasAnimBool = false; + return false; + } + + string cacheKey = owner.GetPropertyCacheKey(property); + if ( + !owner._pendingEntries.TryGetValue( + cacheKey, + out SerializableSetPropertyDrawer.PendingEntry pending + ) + || pending == null + ) + { + isExpanded = false; + animProgress = 0f; + hasAnimBool = false; + return false; + } + + isExpanded = pending.isExpanded; + hasAnimBool = pending.foldoutAnim != null; + animProgress = SerializableSetPropertyDrawer.GetPendingFoldoutProgress(pending); + return true; + } + + /// + /// Sets the pending entry's expanded state for testing purposes. + /// This properly triggers animation state updates. + /// + internal static void SetPendingExpandedState( + this SerializableSetPropertyDrawer owner, + SerializedProperty property, + bool expanded + ) + { + if (property == null) + { + return; + } + + string cacheKey = owner.GetPropertyCacheKey(property); + if ( + !owner._pendingEntries.TryGetValue( + cacheKey, + out SerializableSetPropertyDrawer.PendingEntry pending + ) + || pending == null + ) + { + return; + } + + pending.isExpanded = expanded; + AnimBool anim = EnsureManualEntryFoldoutAnim(pending, property.propertyPath); + if (anim != null) + { + anim.target = expanded; + } + } + + /// + /// Gets the actual animated foldout progress from the drawer instance's pending entry. + /// Use this for testing that animations are actually progressing over time. + /// + /// The serialized property for the set. + /// + /// The current animation progress (0 to 1), or -1 if no pending entry exists for this property. + /// When tweening is disabled, returns 0 or 1 immediately based on expanded state. + /// + internal static float GetPendingFoldoutProgressFromInstance( + this SerializableSetPropertyDrawer owner, + SerializedProperty property + ) + { + if (property == null) + { + return -1f; + } + + string cacheKey = owner.GetPropertyCacheKey(property); + if ( + !owner._pendingEntries.TryGetValue(cacheKey, out PendingEntry pending) + || pending == null + ) + { + return -1f; + } + + return SerializableSetPropertyDrawer.GetPendingFoldoutProgress(pending); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/SerializableSetPropertyDrawerTestAccess.cs.meta b/Tests/Editor/TestAccess/SerializableSetPropertyDrawerTestAccess.cs.meta new file mode 100644 index 000000000..90b6b942f --- /dev/null +++ b/Tests/Editor/TestAccess/SerializableSetPropertyDrawerTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7bb5b727194a45bf121bdaaa64e6eae2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/SingletonCandidateList.cs b/Tests/Editor/TestAccess/SingletonCandidateList.cs new file mode 100644 index 000000000..ac04b0b4d --- /dev/null +++ b/Tests/Editor/TestAccess/SingletonCandidateList.cs @@ -0,0 +1,48 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Utils +{ + using System; + using System.Collections; + using System.Collections.Generic; + + /// Exercises candidate collection failures and reentry during real enumeration. + internal sealed class SingletonCandidateList : IReadOnlyList + { + public int Count + { + get + { + ++CountReadCount; + _onCountRead(); + return 0; + } + } + + public Type this[int index] => throw new ArgumentOutOfRangeException(nameof(index)); + + internal int CountReadCount { get; private set; } + + private readonly Action _onCountRead; + + internal SingletonCandidateList(Action onCountRead) + { + _onCountRead = onCountRead; + } + + public IEnumerator GetEnumerator() + { + int count = Count; + for (int index = 0; index < count; ++index) + { + yield return this[index]; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/Tests/Editor/TestAccess/SingletonCandidateList.cs.meta b/Tests/Editor/TestAccess/SingletonCandidateList.cs.meta new file mode 100644 index 000000000..7c9a3c13f --- /dev/null +++ b/Tests/Editor/TestAccess/SingletonCandidateList.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 993535df255c9671779d4b434ca03bc8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/SpriteLabelProcessorTestAccess.cs b/Tests/Editor/TestAccess/SpriteLabelProcessorTestAccess.cs new file mode 100644 index 000000000..59fae45fd --- /dev/null +++ b/Tests/Editor/TestAccess/SpriteLabelProcessorTestAccess.cs @@ -0,0 +1,52 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.AssetProcessors +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using UnityEditor; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.AssetProcessors.SpriteLabelProcessor; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for SpriteLabelProcessor. + internal static class SpriteLabelProcessorTestAccess + { + internal static int PendingImportedPathCount + { + get { return PendingImportedPaths.Count; } + } + + internal static string[] SnapshotPendingImportedPaths() + { + string[] snapshot = new string[PendingImportedPaths.Count]; + PendingImportedPaths.CopyTo(snapshot); + return snapshot; + } + + internal static void EnqueueImportedPaths(string[] importedAssets) + { + if (importedAssets == null || importedAssets.Length == 0) + { + return; + } + + foreach (string path in importedAssets) + { + if (IsCandidatePath(path)) + { + PendingImportedPaths.Add(path); + } + } + } + + internal static void Reset() + { + PendingImportedPaths.Clear(); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/SpriteLabelProcessorTestAccess.cs.meta b/Tests/Editor/TestAccess/SpriteLabelProcessorTestAccess.cs.meta new file mode 100644 index 000000000..b0e78cef0 --- /dev/null +++ b/Tests/Editor/TestAccess/SpriteLabelProcessorTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df7be9f912433dad1c2c2b4979f5a25f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/StringInListDrawerTestAccess.cs b/Tests/Editor/TestAccess/StringInListDrawerTestAccess.cs new file mode 100644 index 000000000..4d01cf1d2 --- /dev/null +++ b/Tests/Editor/TestAccess/StringInListDrawerTestAccess.cs @@ -0,0 +1,103 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using UnityEditor; + using UnityEditor.UIElements; + using UnityEngine; + using UnityEngine.UIElements; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.DataStructure.Adapters; + using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Base; + using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.CustomDrawers.StringInListDrawer; + + /// Provides test-side setup and inspection for StringInListDrawer. + internal static class StringInListDrawerTestAccess + { + /// Provides test-side access to OptionButtonMarginVertical. + public static int OptionButtonMarginVertical => + PopupStyles.OptionButton.margin?.vertical ?? 0; + + /// Provides test-side access to OptionFooterPadding. + public static float OptionFooterPadding => OptionBottomPadding; + + /// Provides test-side access to PaginationButtonHeight. + public static float PaginationButtonHeight => PopupStyles.PaginationButtonLeft.fixedHeight; + + /// Provides test-side access to PopupWidthValue. + public static float PopupWidthValue => PopupWidth; + + /// Provides test-side access to EmptySearchHorizontalPaddingValue. + public static float EmptySearchHorizontalPaddingValue => EmptySearchHorizontalPadding; + + /// Provides test-side access to EmptyResultsMessageValue. + public static string EmptyResultsMessageValue => EmptyResultsMessage; + + /// Provides test-side access to EmptySearchExtraPaddingValue. + public static float EmptySearchExtraPaddingValue => EmptySearchExtraPadding; + + /// Provides test-side access to CalculatePopupTargetHeight. + /// The rows on page used by the test. + /// The include pagination used by the test. + /// The result of the test-side operation. + public static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) + { + return StringInListDrawer.CalculatePopupTargetHeight(rowsOnPage, includePagination); + } + + /// Provides test-side access to CalculatePopupChromeHeight. + /// The include pagination used by the test. + /// The result of the test-side operation. + public static float CalculatePopupChromeHeight(bool includePagination) + { + return StringInListDrawer.CalculatePopupChromeHeight(includePagination); + } + + /// Provides test-side access to GetOptionRowHeight. + /// The result of the test-side operation. + public static float GetOptionRowHeight() + { + return StringInListDrawer.GetOptionRowHeight(); + } + + /// Provides test-side access to GetOptionControlHeight. + /// The result of the test-side operation. + public static float GetOptionControlHeight() + { + return StringInListDrawer.GetOptionControlHeight(); + } + + /// Provides test-side access to CalculateEmptySearchHeight. + /// The result of the test-side operation. + public static float CalculateEmptySearchHeight() + { + return StringInListDrawer.CalculateEmptySearchHeight(); + } + + /// Provides test-side access to CalculateEmptySearchHeightWithMeasurement. + /// The measured help height used by the test. + /// The result of the test-side operation. + public static float CalculateEmptySearchHeightWithMeasurement(float measuredHelpHeight) + { + return StringInListDrawer.CalculateEmptySearchHeight(measuredHelpHeight); + } + + /// Provides test-side access to CalculateRowsOnPage. + /// The filtered count used by the test. + /// The page size used by the test. + /// The current page used by the test. + /// The result of the test-side operation. + public static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) + { + return StringInListDrawer.CalculateRowsOnPage(filteredCount, pageSize, currentPage); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/StringInListDrawerTestAccess.cs.meta b/Tests/Editor/TestAccess/StringInListDrawerTestAccess.cs.meta new file mode 100644 index 000000000..0c9b0f3ae --- /dev/null +++ b/Tests/Editor/TestAccess/StringInListDrawerTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 35af79ad9a9ac4ab8059e8bbfb3ba5a4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/UnityHelpersSettingsTestAccess.cs b/Tests/Editor/TestAccess/UnityHelpersSettingsTestAccess.cs new file mode 100644 index 000000000..aec4f265f --- /dev/null +++ b/Tests/Editor/TestAccess/UnityHelpersSettingsTestAccess.cs @@ -0,0 +1,107 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Settings +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.IO; + using UnityEditor; + using UnityEngine; + using UnityEngine.Serialization; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.DataStructure.Adapters; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.CustomDrawers; + using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Editor.Utils.WButton; + using WallstopStudios.UnityHelpers.Editor.Utils.WGroup; + using WallstopStudios.UnityHelpers.Settings; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.Settings.UnityHelpersSettings; + using static WallstopStudios.UnityHelpers.Editor.Settings.UnityHelpersSettings.SerializedPropertyNames; + + /// Provides test-side setup and inspection for UnityHelpersSettings. + internal static class UnityHelpersSettingsTestAccess + { + internal static void SetWGroupAutoIncludeConfiguration( + WGroupAutoIncludeMode mode, + int rowCount + ) + { + UnityHelpersSettings settings = UnityHelpersSettings.instance; + settings._wgroupAutoIncludeMode = mode; + settings._wgroupAutoIncludeRowCount = Mathf.Clamp( + rowCount, + MinWGroupAutoIncludeRowCount, + MaxWGroupAutoIncludeRowCount + ); + settings.SaveSettings(); + } + + /// + /// Clears the cached SerializedObject for testing purposes. + /// + internal static void ClearCachedSerializedObject() + { + _cachedSettingsSerializedObject?.Dispose(); + _cachedSettingsSerializedObject = null; + } + + /// + /// Gets the serialized property name value for the given constant name. + /// Resolves fixture data names to the actual serialized property constants. + /// + internal static string GetPropertyNameValue(string constantName) + { + return constantName switch + { + nameof(SerializableTypeIgnorePatterns) => SerializableTypeIgnorePatterns, + nameof(SerializableTypePatternsInitialized) => SerializableTypePatternsInitialized, + nameof(SerializableTypePattern) => SerializableTypePattern, + nameof(LegacyWButtonPriorityColors) => LegacyWButtonPriorityColors, + nameof(WButtonCustomColors) => WButtonCustomColors, + nameof(WGroupFoldoutsStartCollapsed) => WGroupFoldoutsStartCollapsed, + nameof(WGroupFoldoutTweenEnabled) => WGroupFoldoutTweenEnabled, + nameof(WGroupFoldoutSpeed) => WGroupFoldoutSpeed, + nameof(WEnumToggleButtonsCustomColors) => WEnumToggleButtonsCustomColors, + nameof(UnityHelpersSettings.SerializedPropertyNames.InlineEditorFoldoutBehavior) => + UnityHelpersSettings.SerializedPropertyNames.InlineEditorFoldoutBehavior, + nameof(InlineEditorFoldoutTweenEnabled) => InlineEditorFoldoutTweenEnabled, + nameof(InlineEditorFoldoutSpeed) => InlineEditorFoldoutSpeed, + nameof(WButtonFoldoutTweenEnabled) => WButtonFoldoutTweenEnabled, + nameof(SerializableDictionaryFoldoutTweenEnabled) => + SerializableDictionaryFoldoutTweenEnabled, + nameof(SerializableSortedDictionaryFoldoutTweenEnabled) => + SerializableSortedDictionaryFoldoutTweenEnabled, + nameof(SerializableSetFoldoutTweenEnabled) => SerializableSetFoldoutTweenEnabled, + nameof(SerializableSortedSetFoldoutTweenEnabled) => + SerializableSortedSetFoldoutTweenEnabled, + nameof(FoldoutTweenSettingsInitialized) => FoldoutTweenSettingsInitialized, + nameof(SerializableDictionaryFoldoutSpeed) => SerializableDictionaryFoldoutSpeed, + nameof(SerializableSortedDictionaryFoldoutSpeed) => + SerializableSortedDictionaryFoldoutSpeed, + nameof(SerializableSetFoldoutSpeed) => SerializableSetFoldoutSpeed, + nameof(SerializableSortedSetFoldoutSpeed) => SerializableSortedSetFoldoutSpeed, + nameof(DetectAssetChangeLoopWindowSeconds) => DetectAssetChangeLoopWindowSeconds, + nameof(DeferAssetPostprocessorCallbacks) => DeferAssetPostprocessorCallbacks, + nameof(WButtonPriority) => WButtonPriority, + nameof(WButtonCustomColorButton) => WButtonCustomColorButton, + nameof(WButtonCustomColorText) => WButtonCustomColorText, + nameof(WButtonCustomColorHasText) => WButtonCustomColorHasText, + nameof(WEnumToggleButtonsSelectedBackground) => + WEnumToggleButtonsSelectedBackground, + nameof(WEnumToggleButtonsSelectedText) => WEnumToggleButtonsSelectedText, + nameof(WEnumToggleButtonsInactiveBackground) => + WEnumToggleButtonsInactiveBackground, + nameof(WEnumToggleButtonsInactiveText) => WEnumToggleButtonsInactiveText, + nameof(WEnumToggleButtonsHasSelectedText) => WEnumToggleButtonsHasSelectedText, + nameof(WEnumToggleButtonsHasInactiveText) => WEnumToggleButtonsHasInactiveText, + nameof(FailedTestsOutputDirectory) => FailedTestsOutputDirectory, + _ => null, + }; + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/UnityHelpersSettingsTestAccess.cs.meta b/Tests/Editor/TestAccess/UnityHelpersSettingsTestAccess.cs.meta new file mode 100644 index 000000000..d09d64efe --- /dev/null +++ b/Tests/Editor/TestAccess/UnityHelpersSettingsTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8820ffddcaf0909474d72e4f4e9f8bcd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/WButtonGUITestAccess.cs b/Tests/Editor/TestAccess/WButtonGUITestAccess.cs new file mode 100644 index 000000000..3316e98fc --- /dev/null +++ b/Tests/Editor/TestAccess/WButtonGUITestAccess.cs @@ -0,0 +1,193 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Utils.WButton +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using UnityEditor; + using UnityEditor.AnimatedValues; + using UnityEditorInternal; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.Utils.WButton.WButtonGUI; + + /// Provides test-side setup and inspection for WButtonGUI. + internal static class WButtonGUITestAccess + { + internal static Dictionary GetGroupCounts() + { + return GroupCounts; + } + + internal static Dictionary GetGroupNames() + { + return GroupNames; + } + + /// + /// For testing: sets group counts with simple int keys (legacy compatibility). + /// Creates group keys with the given draw order and empty group name. + /// + internal static void SetGroupCounts(Dictionary counts) + { + GroupCounts.Clear(); + foreach (KeyValuePair entry in counts) + { + WButtonGroupKey key = new( + WButtonAttribute.NoGroupPriority, + entry.Key, + null, + 0, + WButtonGroupPlacement.UseGlobalSetting + ); + GroupCounts[key] = entry.Value; + } + } + + /// + /// For testing: sets group names with simple int keys (legacy compatibility). + /// Creates group keys with the given draw order and empty group name. + /// + internal static void SetGroupNames(Dictionary names) + { + GroupNames.Clear(); + foreach (KeyValuePair entry in names) + { + WButtonGroupKey key = new( + WButtonAttribute.NoGroupPriority, + entry.Key, + null, + 0, + WButtonGroupPlacement.UseGlobalSetting + ); + GroupNames[key] = entry.Value; + } + } + + /// + /// For testing: clears all group counts and names. + /// + internal static void ClearGroupData() + { + GroupCounts.Clear(); + GroupNames.Clear(); + } + + /// + /// Clears conflicting draw order warnings. Used for testing. + /// + internal static void ClearConflictingDrawOrderWarnings() + { + ConflictingDrawOrderWarnings.Clear(); + } + + /// + /// Clears conflicting group priority warnings. Used for testing. + /// + internal static void ClearConflictingGroupPriorityWarnings() + { + ConflictingGroupPriorityWarnings.Clear(); + } + + /// + /// Clears conflicting group placement warnings. Used for testing. + /// + internal static void ClearConflictingGroupPlacementWarnings() + { + ConflictingGroupPlacementWarnings.Clear(); + } + + /// + /// Clears the conflict warning content cache. Used for testing. + /// + internal static void ClearConflictWarningContentCache() + { + ConflictWarningTextCache.Clear(); + GroupPriorityWarningTextCache.Clear(); + GroupPlacementWarningTextCache.Clear(); + } + + /// + /// Gets cached group placement warning text by group name. Used for testing. + /// + internal static bool TryGetGroupPlacementWarningText( + string groupName, + out string warningText + ) + { + if (string.IsNullOrEmpty(groupName)) + { + warningText = null; + return false; + } + + return GroupPlacementWarningTextCache.TryGetValue( + "placement_" + groupName, + out warningText + ); + } + + /// + /// Gets cached group priority warning text by group name. Used for testing. + /// + internal static bool TryGetGroupPriorityWarningText( + string groupName, + out string warningText + ) + { + if (string.IsNullOrEmpty(groupName)) + { + warningText = null; + return false; + } + + return GroupPriorityWarningTextCache.TryGetValue( + "priority_" + groupName, + out warningText + ); + } + + /// + /// Gets cached draw order warning text by group name. Used for testing. + /// + internal static bool TryGetDrawOrderWarningText(string groupName, out string warningText) + { + if (string.IsNullOrEmpty(groupName)) + { + warningText = null; + return false; + } + + return ConflictWarningTextCache.TryGetValue(groupName, out warningText); + } + + /// + /// Legacy overload for testing compatibility. + /// + internal static GUIContent BuildGroupHeader(int drawOrder) + { + WButtonGroupKey key = new( + WButtonAttribute.NoGroupPriority, + drawOrder, + null, + 0, + WButtonGroupPlacement.UseGlobalSetting + ); + return WButtonGUI.BuildGroupHeader(key); + } + + internal static GUIContent BuildGroupHeader(WButtonGroupKey key) + { + return WButtonGUI.BuildGroupHeader(key); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/WButtonGUITestAccess.cs.meta b/Tests/Editor/TestAccess/WButtonGUITestAccess.cs.meta new file mode 100644 index 000000000..17bce7b0e --- /dev/null +++ b/Tests/Editor/TestAccess/WButtonGUITestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 57edd0bf7453a7c7b1cfc081cc452560 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/WButtonStylesTestAccess.cs b/Tests/Editor/TestAccess/WButtonStylesTestAccess.cs new file mode 100644 index 000000000..a6af55619 --- /dev/null +++ b/Tests/Editor/TestAccess/WButtonStylesTestAccess.cs @@ -0,0 +1,44 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Utils.WButton +{ +#if UNITY_EDITOR + using System.Collections.Generic; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.Core.Helper; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.Utils.WButton.WButtonStyles; + + /// Provides test-side setup and inspection for WButtonStyles. + internal static class WButtonStylesTestAccess + { + /// + /// Gets the number of colored button styles currently retained, for testing. + /// + internal static int ColoredButtonStyleCount => ColoredButtonStyles.Count; + + /// + /// Gets the number of colored mini button styles currently retained, for testing. + /// + internal static int ColoredMiniButtonStyleCount => ColoredMiniButtonStyles.Count; + + /// + /// Gets the bound both colored style caches evict at. + /// + internal static int MaxColoredButtonStyleCount => MaxColoredButtonStyles; + + /// + /// Drops every cached colored style, destroying the textures each owns. + /// + internal static void ClearColoredStyleCaches() + { + ColoredButtonStyles.Clear(); + ColoredMiniButtonStyles.Clear(); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/WButtonStylesTestAccess.cs.meta b/Tests/Editor/TestAccess/WButtonStylesTestAccess.cs.meta new file mode 100644 index 000000000..e52e1f8cf --- /dev/null +++ b/Tests/Editor/TestAccess/WButtonStylesTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1094e00d749edaa0f3911bffef77fad9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/WInLineEditorDrawerTestAccess.cs b/Tests/Editor/TestAccess/WInLineEditorDrawerTestAccess.cs new file mode 100644 index 000000000..977545ff7 --- /dev/null +++ b/Tests/Editor/TestAccess/WInLineEditorDrawerTestAccess.cs @@ -0,0 +1,321 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers +{ +#if UNITY_EDITOR + using System.Collections.Generic; + using UnityEditor; + using UnityEditor.AnimatedValues; + using UnityEditorInternal; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils; + using WallstopStudios.UnityHelpers.Editor.Internal; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.CustomDrawers.WInLineEditorDrawer; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for WInLineEditorDrawer. + internal static class WInLineEditorDrawerTestAccess + { + /// + /// Test hook to get detailed height calculation info for diagnostics. + /// + internal static ( + float baseHeight, + float inlineHeight, + bool showHeader, + bool showBody, + float displayHeight + ) GetHeightCalculationDetails( + SerializedProperty property, + WInLineEditorAttribute inlineAttribute, + Object value, + float availableWidth + ) + { + if (value == null || property == null) + { + return (0f, 0f, false, false, 0f); + } + + float baseHeight = inlineAttribute.DrawObjectField + ? EditorGUI.GetPropertyHeight(property, GUIContent.none, false) + : EditorGUIUtility.singleLineHeight; + + WInLineEditorMode mode = InLineEditorShared.ResolveMode(inlineAttribute); + bool useStandaloneHeader = InLineEditorShared.ShouldDrawStandaloneHeader( + inlineAttribute + ); + bool showHeader = + useStandaloneHeader + && (inlineAttribute.DrawHeader || mode != WInLineEditorMode.AlwaysExpanded); + bool foldoutState = GetFoldoutState(property, inlineAttribute, mode); + bool showBody = mode == WInLineEditorMode.AlwaysExpanded || foldoutState; + + float inlineHeight = 0f; + float displayHeight = 0f; + if (showHeader) + { + inlineHeight += InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing; + } + + if (showBody) + { + InspectorHeightInfo inspectorHeightInfo = ResolveInspectorHeightInfo( + value, + inlineAttribute, + availableWidth + ); + displayHeight = inspectorHeightInfo.DisplayHeight; + inlineHeight += displayHeight; + } + + return (baseHeight, inlineHeight, showHeader, showBody, displayHeight); + } + + /// + /// Test hook to check if the force serialized inspector flag is enabled. + /// + internal static bool ForceSerializedInspector => _forceSerializedInspector; + + /// + /// Test hook to get extensive diagnostic info for debugging height calculation issues. + /// + internal static string GetExtensiveDiagnostics( + SerializedProperty property, + WInLineEditorAttribute inlineAttribute, + Object value, + float availableWidth + ) + { + if (value == null || property == null) + { + return "null property or value"; + } + + System.Text.StringBuilder sb = new(); + sb.AppendLine($"=== Extensive Diagnostics ==="); + sb.AppendLine($"Property path: {property.propertyPath}"); + sb.AppendLine($"Value type: {value.GetType().Name}"); + sb.AppendLine($"Available width: {availableWidth}"); + + sb.AppendLine($"--- Attribute ---"); + sb.AppendLine($" Mode: {inlineAttribute.Mode}"); + sb.AppendLine($" DrawObjectField: {inlineAttribute.DrawObjectField}"); + sb.AppendLine($" DrawHeader: {inlineAttribute.DrawHeader}"); + sb.AppendLine($" EnableScrolling: {inlineAttribute.EnableScrolling}"); + sb.AppendLine($" InspectorHeight: {inlineAttribute.InspectorHeight}"); + sb.AppendLine($" MinInspectorWidth: {inlineAttribute.MinInspectorWidth}"); + sb.AppendLine( + $" HasExplicitMinInspectorWidth: {inlineAttribute.HasExplicitMinInspectorWidth}" + ); + + WInLineEditorMode resolvedMode = InLineEditorShared.ResolveMode(inlineAttribute); + sb.AppendLine($"--- Mode Resolution ---"); + sb.AppendLine($" Resolved mode: {resolvedMode}"); + if (inlineAttribute.Mode == WInLineEditorMode.UseSettings) + { + UnityHelpersSettings.InlineEditorFoldoutBehavior behavior = + UnityHelpersSettings.GetInlineEditorFoldoutBehavior(); + sb.AppendLine($" Settings behavior: {behavior}"); + } + + string foldoutKey = BuildFoldoutKey(property); + bool foldoutInCache = InLineEditorSharedTestAccess.GetFoldoutState(foldoutKey); + bool foldoutState = GetFoldoutState(property, inlineAttribute, resolvedMode); + sb.AppendLine($"--- Foldout State ---"); + sb.AppendLine($" Foldout key: {foldoutKey}"); + sb.AppendLine($" In cache before GetFoldoutState: {foldoutInCache}"); + sb.AppendLine($" GetFoldoutState result: {foldoutState}"); + + bool useStandaloneHeader = InLineEditorShared.ShouldDrawStandaloneHeader( + inlineAttribute + ); + bool showHeader = + useStandaloneHeader + && (inlineAttribute.DrawHeader || resolvedMode != WInLineEditorMode.AlwaysExpanded); + bool showBody = resolvedMode == WInLineEditorMode.AlwaysExpanded || foldoutState; + sb.AppendLine($"--- Visibility ---"); + sb.AppendLine($" useStandaloneHeader: {useStandaloneHeader}"); + sb.AppendLine($" showHeader: {showHeader}"); + sb.AppendLine($" showBody: {showBody}"); + + sb.AppendLine($"--- Inspector Height ---"); + Editor editor = InLineEditorShared.GetOrCreateEditor(value); + SerializedObject analysisObject = GetSerializedObjectForAnalysis(editor, value); + using SerializedObject ownedAnalysisObject = editor == null ? analysisObject : null; + bool hasSerializedData = analysisObject != null; + bool hasSimpleLayout = + hasSerializedData && SerializedObjectHasOnlySimpleProperties(analysisObject); + bool canUseSerializedInspector = + hasSerializedData && ShouldUseSerializedInspector(editor); + sb.AppendLine( + $" Editor type: {(editor != null ? editor.GetType().FullName : "null")}" + ); + sb.AppendLine($" hasSerializedData: {hasSerializedData}"); + sb.AppendLine($" hasSimpleLayout: {hasSimpleLayout}"); + sb.AppendLine($" canUseSerializedInspector: {canUseSerializedInspector}"); + + if (hasSerializedData) + { + float serializedHeight = CalculateSerializedInspectorHeight(analysisObject); + sb.AppendLine($" Serialized inspector height: {serializedHeight}"); + + sb.AppendLine($" --- Properties ---"); + analysisObject.UpdateIfRequiredOrScript(); + SerializedProperty iterator = analysisObject.GetIterator(); + bool enterChildren = true; + while (iterator.NextVisible(enterChildren)) + { + float propHeight = EditorGUI.GetPropertyHeight(iterator, true); + bool isScript = string.Equals( + iterator.propertyPath, + InLineEditorShared.ScriptPropertyPath, + System.StringComparison.Ordinal + ); + sb.AppendLine( + $" {iterator.propertyPath}: {propHeight}px (type: {iterator.propertyType}){(isScript ? " [SCRIPT - skipped]" : "")}" + ); + enterChildren = false; + } + } + + InspectorHeightInfo heightInfo = ResolveInspectorHeightInfo( + value, + inlineAttribute, + availableWidth + ); + sb.AppendLine($"--- Height Info Result ---"); + sb.AppendLine($" ContentHeight: {heightInfo.ContentHeight}"); + sb.AppendLine($" DisplayHeight: {heightInfo.DisplayHeight}"); + sb.AppendLine($" UsesSerializedInspector: {heightInfo.UsesSerializedInspector}"); + sb.AppendLine($" HorizontalScrollbarHeight: {heightInfo.HorizontalScrollbarHeight}"); + sb.AppendLine( + $" RequiresHorizontalScrollbar: {heightInfo.RequiresHorizontalScrollbar}" + ); + sb.AppendLine($" PaddingHeight: {heightInfo.PaddingHeight}"); + + float inlineHeight = 0f; + if (showHeader) + { + inlineHeight += InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing; + } + if (showBody) + { + inlineHeight += heightInfo.DisplayHeight; + } + sb.AppendLine($"--- Final Inline Height ---"); + sb.AppendLine( + $" Header contribution: {(showHeader ? InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing : 0f)}" + ); + sb.AppendLine($" Body contribution: {(showBody ? heightInfo.DisplayHeight : 0f)}"); + sb.AppendLine($" Total inline height: {inlineHeight}"); + + return sb.ToString(); + } + + internal static void ClearAnimationCache() + { + FoldoutAnimations.Clear(); + } + + /// + /// Test hook to get the number of cached animation entries. + /// + internal static int GetAnimationCacheCount() + { + return FoldoutAnimations.Count; + } + + /// + /// Test hook to check if an animation entry exists for a specific key. + /// + internal static bool HasAnimationCacheEntry(string foldoutKey) + { + return FoldoutAnimations.ContainsKey(foldoutKey); + } + + internal static void ClearCachedState() + { + InLineEditorSharedTestAccess.ClearCachedState(); + PropertyWidths.Clear(); + FoldoutKeyCache.Clear(); + ScrollKeyCache.Clear(); + InspectorHeightCache.Clear(); + _lastInspectorHeightCacheFrame = -1; + ClearAnimationCache(); + } + + internal static void SetInlineFoldoutState(SerializedProperty property, bool expanded) + { + if (property == null) + { + return; + } + + string key = BuildFoldoutKey(property); + InLineEditorShared.SetFoldoutState(key, expanded); + } + + internal static bool UsesHorizontalScrollbar( + Object value, + WInLineEditorAttribute inlineAttribute, + float availableWidth + ) + { + if (value == null || inlineAttribute == null) + { + return false; + } + + InspectorHeightInfo inspectorHeightInfo = ResolveInspectorHeightInfo( + value, + inlineAttribute, + availableWidth + ); + return inspectorHeightInfo.RequiresHorizontalScrollbar; + } + + /// + /// Test hook to directly check horizontal scrollbar requirement with explicit parameters. + /// This bypasses editor creation and allows testing the decision logic directly. + /// + internal static bool RequiresHorizontalScrollbar( + bool enableScrolling, + float minInspectorWidth, + bool hasExplicitMinInspectorWidth, + bool hasSimpleLayout, + float availableWidth + ) + { + float effectiveWidth = Mathf.Max( + 0f, + availableWidth - (InLineEditorShared.ContentPadding * 2f) + ); + + const float MinimumUsableWidth = 200f; + bool widthIsTooNarrow = effectiveWidth < MinimumUsableWidth; + bool shouldRespectMinWidth = + hasExplicitMinInspectorWidth || !hasSimpleLayout || widthIsTooNarrow; + return enableScrolling + && 0f < minInspectorWidth + && shouldRespectMinWidth + && 0.5f < minInspectorWidth - effectiveWidth; + } + + /// + /// Test hook to calculate label width for a given available width. + /// + internal static float CalculateLabelWidth(float availableWidth) + { + return availableWidth * InLineEditorShared.DefaultLabelWidthRatio; + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/WInLineEditorDrawerTestAccess.cs.meta b/Tests/Editor/TestAccess/WInLineEditorDrawerTestAccess.cs.meta new file mode 100644 index 000000000..e80f58b18 --- /dev/null +++ b/Tests/Editor/TestAccess/WInLineEditorDrawerTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7dcd077ef9414d8ed02b8eb63d411711 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/WInLineEditorOdinDrawerTestAccess.cs b/Tests/Editor/TestAccess/WInLineEditorOdinDrawerTestAccess.cs new file mode 100644 index 000000000..3334aae66 --- /dev/null +++ b/Tests/Editor/TestAccess/WInLineEditorOdinDrawerTestAccess.cs @@ -0,0 +1,46 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers +{ +#if UNITY_EDITOR && WALLSTOP_UNITY_HELPERS_ODIN_INSPECTOR + using System; + using Sirenix.OdinInspector.Editor; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils; + using WallstopStudios.UnityHelpers.Editor.Internal; + using static WallstopStudios.UnityHelpers.Editor.CustomDrawers.WInLineEditorOdinDrawer; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for WInLineEditorOdinDrawer. + internal static class WInLineEditorOdinDrawerTestAccess + { + /// + /// Clears cached editors and state. Primarily for testing purposes. + /// + internal static void ClearCachedState() + { + InLineEditorSharedTestAccess.ClearCachedState(); + } + + /// + /// Test hook to set the foldout state for a given key. + /// + internal static void SetFoldoutState(string key, bool expanded) + { + InLineEditorShared.SetFoldoutState(key, expanded); + } + + /// + /// Test hook to get the foldout state for a given key. + /// + internal static bool GetFoldoutState(string key) + { + return InLineEditorSharedTestAccess.GetFoldoutState(key); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/WInLineEditorOdinDrawerTestAccess.cs.meta b/Tests/Editor/TestAccess/WInLineEditorOdinDrawerTestAccess.cs.meta new file mode 100644 index 000000000..76768ad38 --- /dev/null +++ b/Tests/Editor/TestAccess/WInLineEditorOdinDrawerTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eceb9fdbfb74eb83505ec0ae289a7f18 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAccess/WValueDropDownDrawerTestAccess.cs b/Tests/Editor/TestAccess/WValueDropDownDrawerTestAccess.cs new file mode 100644 index 000000000..7cdd3ffe2 --- /dev/null +++ b/Tests/Editor/TestAccess/WValueDropDownDrawerTestAccess.cs @@ -0,0 +1,181 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.Globalization; + using UnityEditor; + using UnityEditor.UIElements; + using UnityEngine; + using UnityEngine.UIElements; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Core.DataStructure.Adapters; + using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Base; + using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils; + using WallstopStudios.UnityHelpers.Editor.Settings; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.CustomDrawers.WValueDropDownDrawer; + + /// Provides test-side setup and inspection for WValueDropDownDrawer. + internal static class WValueDropDownDrawerTestAccess + { + /// + /// Gets the number of display-label sets currently retained, for testing. + /// + public static int DisplayLabelsCacheCount => DisplayLabelsCaches.Count; + + /// + /// Gets the bound the display-label cache evicts at, for testing. + /// + public static int MaxDisplayLabelsCacheCount => MaxDisplayLabelsCacheEntries; + + /// + /// Gets the number of formatted option labels currently retained, for testing. + /// + public static int FormattedOptionCacheCount => FormattedOptionCache.Count; + + /// + /// Gets the bound the formatted option cache evicts at, for testing. + /// + public static int MaxFormattedOptionCacheCount => MaxFormattedOptionCacheEntries; + + /// Provides test-side access to OptionButtonMarginVertical. + public static int OptionButtonMarginVertical => + PopupStyles.OptionButton.margin?.vertical ?? 0; + + /// Provides test-side access to OptionFooterPadding. + public static float OptionFooterPadding => OptionBottomPadding; + + /// Provides test-side access to PaginationButtonHeight. + public static float PaginationButtonHeight => PopupStyles.PaginationButtonLeft.fixedHeight; + + /// Provides test-side access to PopupWidthValue. + public static float PopupWidthValue => PopupWidth; + + /// Provides test-side access to EmptySearchHorizontalPaddingValue. + public static float EmptySearchHorizontalPaddingValue => EmptySearchHorizontalPadding; + + /// Provides test-side access to EmptyResultsMessageValue. + public static string EmptyResultsMessageValue => EmptyResultsMessage; + + /// Provides test-side access to EmptySearchExtraPaddingValue. + public static float EmptySearchExtraPaddingValue => EmptySearchExtraPadding; + + /// + /// Reads the cached display labels for a property path, populating them when absent. + /// + public static string[] GetOrCreateDisplayLabels(string cacheKey, object[] options) + { + return WValueDropDownDrawer.GetOrCreateDisplayLabels(cacheKey, options); + } + + /// + /// Drops every cached display-label set and formatted option label, for testing. + /// + public static void ClearCaches() + { + DisplayLabelsCaches.Clear(); + FormattedOptionCache.Clear(); + } + + /// Provides test-side access to CalculatePopupTargetHeight. + /// The rows on page used by the test. + /// The include pagination used by the test. + /// The result of the test-side operation. + public static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) + { + return WValueDropDownDrawer.CalculatePopupTargetHeight(rowsOnPage, includePagination); + } + + /// Provides test-side access to CalculatePopupChromeHeight. + /// The include pagination used by the test. + /// The result of the test-side operation. + public static float CalculatePopupChromeHeight(bool includePagination) + { + return WValueDropDownDrawer.CalculatePopupChromeHeight(includePagination); + } + + /// Provides test-side access to GetOptionRowHeight. + /// The result of the test-side operation. + public static float GetOptionRowHeight() + { + return WValueDropDownDrawer.GetOptionRowHeight(); + } + + /// Provides test-side access to GetOptionControlHeight. + /// The result of the test-side operation. + public static float GetOptionControlHeight() + { + return WValueDropDownDrawer.GetOptionControlHeight(); + } + + /// Provides test-side access to CalculateEmptySearchHeight. + /// The result of the test-side operation. + public static float CalculateEmptySearchHeight() + { + return WValueDropDownDrawer.CalculateEmptySearchHeight(); + } + + /// Provides test-side access to CalculateEmptySearchHeightWithMeasurement. + /// The measured help height used by the test. + /// The result of the test-side operation. + public static float CalculateEmptySearchHeightWithMeasurement(float measuredHelpHeight) + { + return WValueDropDownDrawer.CalculateEmptySearchHeight(measuredHelpHeight); + } + + /// Provides test-side access to CalculateRowsOnPage. + /// The filtered count used by the test. + /// The page size used by the test. + /// The current page used by the test. + /// The result of the test-side operation. + public static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) + { + return WValueDropDownDrawer.CalculateRowsOnPage(filteredCount, pageSize, currentPage); + } + + /// Provides test-side access to ResolveSelectedIndex. + /// The property used by the test. + /// The value type used by the test. + /// The options used by the test. + /// The result of the test-side operation. + public static int ResolveSelectedIndex( + SerializedProperty property, + Type valueType, + object[] options + ) + { + return WValueDropDownDrawer.ResolveSelectedIndex(property, valueType, options); + } + + /// Provides test-side access to MatchesAuthoredOption. + /// The serialized value used by the test. + /// The option used by the test. + /// The result of the test-side operation. + public static bool MatchesAuthoredOption(object serializedValue, object option) + { + return WValueDropDownDrawer.MatchesAuthoredOption(serializedValue, option); + } + + /// Provides test-side access to FormatOptionCached. + /// The option used by the test. + /// The result of the test-side operation. + public static string FormatOptionCached(object option) + { + return WValueDropDownDrawer.FormatOptionCached(option); + } + + /// Provides test-side access to BuildDisplayLabelsUncached. + /// The options used by the test. + /// The result of the test-side operation. + public static string[] BuildDisplayLabelsUncached(object[] options) + { + return WValueDropDownDrawer.BuildDisplayLabelsUncached(options); + } + } +#endif +} diff --git a/Tests/Editor/TestAccess/WValueDropDownDrawerTestAccess.cs.meta b/Tests/Editor/TestAccess/WValueDropDownDrawerTestAccess.cs.meta new file mode 100644 index 000000000..6be2393b5 --- /dev/null +++ b/Tests/Editor/TestAccess/WValueDropDownDrawerTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6dcfd7be2daf319a315eb38b2b2089dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestAssets/SharedTextureTestFixtures.cs b/Tests/Editor/TestAssets/SharedTextureTestFixtures.cs index 7f36b4793..3cf4a604f 100644 --- a/Tests/Editor/TestAssets/SharedTextureTestFixtures.cs +++ b/Tests/Editor/TestAssets/SharedTextureTestFixtures.cs @@ -477,7 +477,7 @@ int height /// /// All commonly used texture dimensions combined for comprehensive test coverage. - /// Suitable for use with . + /// Suitable for use with . /// public static readonly IReadOnlyList<(int width, int height)> AllCommonDimensions = new[] { @@ -812,9 +812,7 @@ string testKey /// Call this from OneTimeSetUp to batch texture creation. /// /// The collection of (width, height) tuples to pre-create. - public static void PrecreateDimensionsForTests( - IEnumerable<(int width, int height)> dimensions - ) + public static void PrecreateDimensions(IEnumerable<(int width, int height)> dimensions) { using (AssetDatabaseBatchHelper.BeginBatch()) { diff --git a/Tests/Editor/TestFramework/TestIMGUIExecutorTests.cs b/Tests/Editor/TestFramework/TestIMGUIExecutorTests.cs index 8943558a9..0accb205b 100644 --- a/Tests/Editor/TestFramework/TestIMGUIExecutorTests.cs +++ b/Tests/Editor/TestFramework/TestIMGUIExecutorTests.cs @@ -5,6 +5,7 @@ namespace WallstopStudios.UnityHelpers.Tests.EditorFramework { #if UNITY_EDITOR using System.Collections; + using System.Collections.Generic; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; @@ -58,6 +59,7 @@ public void RunIgnoresNullActionWithoutThrowing() [UnityTest] public IEnumerator RunMouseDownPumpsMouseDownBetweenLayoutAndRepaint() { + List receivedEvents = new(); bool sawLayout = false; bool sawMouseDown = false; bool sawRepaint = false; @@ -67,6 +69,9 @@ public IEnumerator RunMouseDownPumpsMouseDownBetweenLayoutAndRepaint() () => { Event currentEvent = Event.current; + receivedEvents.Add( + $"{currentEvent.type}/{currentEvent.rawType} button={currentEvent.button} position={currentEvent.mousePosition}" + ); if (currentEvent.type == EventType.Layout) { sawLayout = true; @@ -85,9 +90,19 @@ public IEnumerator RunMouseDownPumpsMouseDownBetweenLayoutAndRepaint() mousePosition ); - Assert.IsTrue(sawLayout, "Expected the offscreen pump to run Layout first."); - Assert.IsTrue(sawMouseDown, "Expected the offscreen pump to run MouseDown."); - Assert.IsTrue(sawRepaint, "Expected the offscreen pump to run Repaint last."); + string eventSnapshot = string.Join(", ", receivedEvents); + Assert.IsTrue( + sawLayout, + "Expected the offscreen pump to run Layout first. Received: " + eventSnapshot + ); + Assert.IsTrue( + sawMouseDown, + "Expected the offscreen pump to run MouseDown. Received: " + eventSnapshot + ); + Assert.IsTrue( + sawRepaint, + "Expected the offscreen pump to run Repaint last. Received: " + eventSnapshot + ); } } #endif diff --git a/Tests/Editor/TestTypes/ToggleTestAsset.cs b/Tests/Editor/TestTypes/ToggleTestAsset.cs index ca411239e..c463be7fc 100644 --- a/Tests/Editor/TestTypes/ToggleTestAsset.cs +++ b/Tests/Editor/TestTypes/ToggleTestAsset.cs @@ -24,6 +24,10 @@ internal sealed class ToggleTestAsset : ScriptableObject [StringInList("Idle", "Run", "Jump")] public string stateName = "Idle"; + [WEnumToggleButtons] + [StringInList(" ", "\t\r\n", "\u00a0\u2003", " Visible ")] + public string literalStateName = "Idle"; + [WEnumToggleButtons] [WValueDropDown(typeof(DropdownProvider), nameof(DropdownProvider.GetPriorityEntries))] public int priority = 1; diff --git a/Tests/Editor/Tools/AnimationCreatorWindowTestAccess.cs b/Tests/Editor/Tools/AnimationCreatorWindowTestAccess.cs new file mode 100644 index 000000000..6ffb5a7ef --- /dev/null +++ b/Tests/Editor/Tools/AnimationCreatorWindowTestAccess.cs @@ -0,0 +1,45 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Sprites +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Text; + using System.Text.RegularExpressions; + using CustomEditors; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Animation; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Core.Serialization; + using WallstopStudios.UnityHelpers.Editor.Extensions; + using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.Sprites.AnimationCreatorWindow; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for AnimationCreatorWindow. + internal static class AnimationCreatorWindowTestAccess + { + internal static AnimationClip CreateAnimationClip( + AnimationData data, + List validFrames + ) + { + return AnimationCreatorAPI.TryCreateClip( + data, + validFrames, + out AnimationClip clip, + out _ + ) + ? clip + : null; + } + } +#endif +} diff --git a/Tests/Editor/Tools/AnimationCreatorWindowTestAccess.cs.meta b/Tests/Editor/Tools/AnimationCreatorWindowTestAccess.cs.meta new file mode 100644 index 000000000..2e95c0aa1 --- /dev/null +++ b/Tests/Editor/Tools/AnimationCreatorWindowTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cb338a6fffa7a94fa614e510f69748d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Tools/AnimationCreatorWindowTests.cs b/Tests/Editor/Tools/AnimationCreatorWindowTests.cs index fd00b5dfd..15be2908a 100644 --- a/Tests/Editor/Tools/AnimationCreatorWindowTests.cs +++ b/Tests/Editor/Tools/AnimationCreatorWindowTests.cs @@ -517,7 +517,7 @@ float expectedFps frames = CreateSpriteList(5), }; - float result = AnimationCreatorWindow.GetCurrentFpsForTests(data, frameIndex); + float result = AnimationCreatorWindow.GetCurrentFps(data, frameIndex); Assert.AreEqual(expectedFps, result, 0.001f); } @@ -537,7 +537,7 @@ float expectedFps frames = CreateSpriteList(frameCount), }; - float result = AnimationCreatorWindow.GetCurrentFpsForTests(data, frameIndex); + float result = AnimationCreatorWindow.GetCurrentFps(data, frameIndex); Assert.AreEqual(expectedFps, result, 0.01f); } @@ -552,7 +552,7 @@ public void GetCurrentFpsCurveModeWithZeroFpsValueReturnsDefault() frames = CreateSpriteList(5), }; - float result = AnimationCreatorWindow.GetCurrentFpsForTests(data, 2); + float result = AnimationCreatorWindow.GetCurrentFps(data, 2); Assert.AreEqual(AnimationData.DefaultFramesPerSecond, result); } @@ -567,7 +567,7 @@ public void GetCurrentFpsCurveModeWithNegativeFpsValueReturnsDefault() frames = CreateSpriteList(5), }; - float result = AnimationCreatorWindow.GetCurrentFpsForTests(data, 2); + float result = AnimationCreatorWindow.GetCurrentFps(data, 2); Assert.AreEqual(AnimationData.DefaultFramesPerSecond, result); } @@ -582,7 +582,7 @@ public void GetCurrentFpsSingleFrameAnimationReturnsCorrectFps() frames = CreateSpriteList(1), }; - float result = AnimationCreatorWindow.GetCurrentFpsForTests(data, 0); + float result = AnimationCreatorWindow.GetCurrentFps(data, 0); Assert.AreEqual(6f, result, 0.01f); } @@ -654,7 +654,7 @@ float cycleOffset List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); Assert.IsTrue(clip != null, "Created animation clip should not be null"); @@ -686,7 +686,7 @@ public void CreateAnimationClipConstantFramerateHasUniformKeyframeTiming() List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -728,7 +728,7 @@ public void CreateAnimationClipCurveFramerateHasVariableKeyframeTiming() List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -762,7 +762,7 @@ public void CreateAnimationClipWithSingleFrameCreatesValidClip() List frames = CreateTrackedSpriteList(1); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); Assert.IsTrue(clip != null, "Created animation clip should not be null"); @@ -790,7 +790,7 @@ public void CreateAnimationClipPreservesFrameOrder() List frames = CreateTrackedSpriteList(5); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -838,7 +838,7 @@ public void GetCurrentFpsWithEmptyFramesListHandlesGracefully() frames = new List(), }; - float result = AnimationCreatorWindow.GetCurrentFpsForTests(data, 0); + float result = AnimationCreatorWindow.GetCurrentFps(data, 0); Assert.AreEqual(6f, result, 0.01f); } @@ -868,7 +868,7 @@ public void FullWorkflowCreateAnimationWithCurveFramerate() List frames = CreateTrackedSpriteList(8); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); Assert.IsTrue( @@ -913,7 +913,7 @@ public void CurvePresetsFlatCurveProducesConstantFps() for (int i = 0; i < 5; ++i) { - float fps = AnimationCreatorWindow.GetCurrentFpsForTests(data, i); + float fps = AnimationCreatorWindow.GetCurrentFps(data, i); Assert.AreEqual(24f, fps, 0.01f, $"Frame {i} should have constant FPS"); } } @@ -933,7 +933,7 @@ public void CurvePresetsEaseInCurveProducesIncreasingFps() float previousFps = 0f; for (int i = 0; i < 5; ++i) { - float fps = AnimationCreatorWindow.GetCurrentFpsForTests(data, i); + float fps = AnimationCreatorWindow.GetCurrentFps(data, i); Assert.Greater(fps, previousFps, $"Frame {i} FPS should be greater than previous"); previousFps = fps; } @@ -954,7 +954,7 @@ public void CurvePresetsEaseOutCurveProducesDecreasingFps() float previousFps = float.MaxValue; for (int i = 0; i < 5; ++i) { - float fps = AnimationCreatorWindow.GetCurrentFpsForTests(data, i); + float fps = AnimationCreatorWindow.GetCurrentFps(data, i); Assert.Less(fps, previousFps, $"Frame {i} FPS should be less than previous"); previousFps = fps; } @@ -973,7 +973,7 @@ public void CreateAnimationClipHasExactKeyframeCountNoTrailingNulls(int frameCou List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -1004,7 +1004,7 @@ public void CreateAnimationClipHasNoNullSpriteReferences(int frameCount) List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -1039,7 +1039,7 @@ float expectedDuration List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -1071,7 +1071,7 @@ public void CreateAnimationClipFirstKeyframeIsAtTimeZero() List frames = CreateTrackedSpriteList(5); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -1102,7 +1102,7 @@ public void CreateAnimationClipAllKeyframesHaveValidSprites() List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -1141,7 +1141,7 @@ public void CreateAnimationClipKeyframeSpritesMatchInputOrder() List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -1179,7 +1179,7 @@ public void CreateAnimationClipCurveFramerateNoTrailingNullsForNonPowerOfTwoFram List frames = CreateTrackedSpriteList(frameCount); AnimationClip clip = Track( - AnimationCreatorWindow.CreateAnimationClipForTests(data, frames) + AnimationCreatorWindowTestAccess.CreateAnimationClip(data, frames) ); EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings(clip); @@ -1203,125 +1203,6 @@ public void CreateAnimationClipCurveFramerateNoTrailingNullsForNonPowerOfTwoFram } } - [Test] - public void CreateAssetWritesUniqueClipsBesideSprite() - { - const string folder = "Assets/AnimationCreatorAPITests"; - const string spritePath = folder + "/frame.png"; - AssetDatabase.DeleteAsset(folder); - try - { - Assert.IsNotEmpty(AssetDatabase.CreateFolder("Assets", "AnimationCreatorAPITests")); - Texture2D texture = Track(new Texture2D(2, 2, TextureFormat.RGBA32, false)); - texture.SetPixels32( - new[] - { - new Color32(255, 0, 0, 255), - new Color32(0, 255, 0, 255), - new Color32(0, 0, 255, 255), - new Color32(255, 255, 255, 255), - } - ); - texture.Apply(); - File.WriteAllBytes( - Path.Combine(Application.dataPath, "AnimationCreatorAPITests", "frame.png"), - texture.EncodeToPNG() - ); - AssetDatabase.ImportAsset(spritePath); - TextureImporter importer = AssetImporter.GetAtPath(spritePath) as TextureImporter; - Assert.IsTrue(importer != null); - importer.textureType = TextureImporterType.Sprite; - importer.spriteImportMode = SpriteImportMode.Single; - importer.SaveAndReimport(); - Sprite sprite = AssetDatabase.LoadAssetAtPath(spritePath); - Assert.IsTrue(sprite != null); - - AnimationData data = new() - { - animationName = "Idle", - frames = new List { null, sprite }, - framesPerSecond = 12f, - loop = true, - }; - Assert.IsTrue( - AnimationCreatorAPI.TryCreateAsset( - data, - out string firstPath, - out string firstError - ), - firstError - ); - Assert.IsTrue( - AnimationCreatorAPI.TryCreateAsset( - data, - out string secondPath, - out string secondError - ), - secondError - ); - string batchedPath; - using (AssetDatabaseBatchHelper.BeginBatch(refreshOnDispose: false)) - { - Assert.IsTrue( - AnimationCreatorAPI.TryCreateAsset( - data, - out batchedPath, - out string batchError, - saveAssets: false - ), - batchError - ); - } - AssetDatabase.SaveAssets(); - Assert.AreNotEqual(firstPath, secondPath); - Assert.AreNotEqual(firstPath, batchedPath); - Assert.AreNotEqual(secondPath, batchedPath); - Assert.IsTrue(firstPath.StartsWith(folder + "/", StringComparison.Ordinal)); - Assert.IsTrue(secondPath.StartsWith(folder + "/", StringComparison.Ordinal)); - Assert.IsTrue(batchedPath.StartsWith(folder + "/", StringComparison.Ordinal)); - AnimationClip first = AssetDatabase.LoadAssetAtPath(firstPath); - AnimationClip second = AssetDatabase.LoadAssetAtPath(secondPath); - AnimationClip batched = AssetDatabase.LoadAssetAtPath(batchedPath); - Assert.IsTrue(first != null); - Assert.IsTrue(second != null); - Assert.IsTrue(batched != null); - EditorCurveBinding[] bindings = AnimationUtility.GetObjectReferenceCurveBindings( - first - ); - Assert.AreEqual(1, bindings.Length); - ObjectReferenceKeyframe[] keyframes = AnimationUtility.GetObjectReferenceCurve( - first, - bindings[0] - ); - Assert.AreEqual(1, keyframes.Length); - Assert.AreSame(sprite, keyframes[0].value); - - RestorableGlobal saveAssets = new( - () => AnimationCreatorAPI.SaveAssetsAction, - action => AnimationCreatorAPI.SaveAssetsAction = action - ); - using (saveAssets.Borrow(() => throw new IOException("save failed"))) - { - Assert.IsFalse( - AnimationCreatorAPI.TryCreateAsset( - data, - out string createdButUnsavedPath, - out string saveError - ) - ); - Assert.IsNotEmpty(createdButUnsavedPath); - StringAssert.Contains("save failed", saveError); - Assert.IsTrue( - AssetDatabase.LoadAssetAtPath(createdButUnsavedPath) != null - ); - } - } - finally - { - AssetDatabase.DeleteAsset(folder); - } - } - [Test] public void CreateAPIRejectsInvalidInputs() { diff --git a/Tests/Editor/Tools/ImageBlurToolIntegrationTests.cs b/Tests/Editor/Tools/ImageBlurToolIntegrationTests.cs index 4e8cf7a4f..6b2344614 100644 --- a/Tests/Editor/Tools/ImageBlurToolIntegrationTests.cs +++ b/Tests/Editor/Tools/ImageBlurToolIntegrationTests.cs @@ -89,7 +89,7 @@ public override void BaseSetUp() public override void TearDown() { base.TearDown(); - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } @@ -194,43 +194,31 @@ out string error } [Test] - public void DirectApiReportsCleanupFailureAfterImportingPublishedOutput() + public void PublishedOutputSurvivesAnActualStagingCleanupFailure() { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Ignore("Windows moves the staged file and has no post-publish cleanup."); - } - - string sourcePath = Path.Combine(_testRoot, "cleanup-warning.png").SanitizePath(); - string expectedOutputPath = Path.Combine(_testRoot, "cleanup-warning_blurred_2.png") - .SanitizePath(); - CreatePng(sourcePath, Color.magenta); - TrackAssetPath(expectedOutputPath); - Texture2D source = AssetDatabase.LoadAssetAtPath(sourcePath); - Assert.That(source, Is.Not.Null); - - RestorableGlobal> deleteStagedFile = new( - () => ExclusiveFilePublisher.DeleteStagedFile, - action => ExclusiveFilePublisher.DeleteStagedFile = action + string root = Path.Combine( + Application.temporaryCachePath, + Guid.NewGuid().ToString("N") ); - using ( - deleteStagedFile.Borrow(_ => - throw new IOException("Simulated staged cleanup failure.") - ) - ) + Directory.CreateDirectory(root); + try { - bool success = ImageBlurAPI.TryWriteAsset( - source, - 2, - out string outputPath, - out string error + string outputPath = Path.Combine(root, "output.txt"); + File.WriteAllText(outputPath, "published"); + string stagedDirectory = Path.Combine(root, "staged"); + Directory.CreateDirectory(stagedDirectory); + Exception warning = ExclusiveFilePublisher.RemoveStagedFileAfterPublication( + stagedDirectory, + outputPath ); - - Assert.That(success, Is.False); - Assert.That(outputPath, Is.EqualTo(expectedOutputPath)); - StringAssert.Contains("Published", error); - Assert.That(File.Exists(RelToFull(outputPath)), Is.True); - Assert.That(AssetDatabase.LoadAssetAtPath(outputPath), Is.Not.Null); + Assert.IsInstanceOf(warning); + StringAssert.Contains("Published", warning.Message); + Assert.AreEqual("published", File.ReadAllText(outputPath)); + Assert.IsTrue(Directory.Exists(stagedDirectory)); + } + finally + { + Directory.Delete(root, true); } } diff --git a/Tests/Editor/Tools/ImageBlurToolTestAccess.cs b/Tests/Editor/Tools/ImageBlurToolTestAccess.cs new file mode 100644 index 000000000..0ad87ff40 --- /dev/null +++ b/Tests/Editor/Tools/ImageBlurToolTestAccess.cs @@ -0,0 +1,65 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Tools +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using UnityEditor; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Extension; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Editor.CustomEditors; + using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Utils; + using static WallstopStudios.UnityHelpers.Editor.Tools.ImageBlurTool; + using Object = UnityEngine.Object; + + /// Provides test-side setup and inspection for ImageBlurTool. + internal static class ImageBlurToolTestAccess + { + internal static Texture2D Blurred(Texture2D original, int radius) + { + return CreateBlurredTexture(original, radius); + } + + internal static Texture2D Blurred(Texture2D original, int radius, bool runInParallel) + { + Color[] pixels = original.GetPixels(); + Color[] premultiplied = new Color[pixels.Length]; + for (int i = 0; i < pixels.Length; ++i) + { + premultiplied[i] = TextureResampling.Premultiply(pixels[i]); + } + Color[] output = new Color[pixels.Length]; + BlurJob job = new( + original.width, + original.height, + radius, + GenerateGaussianKernel(radius), + pixels, + premultiplied, + new Color[pixels.Length], + new Color[pixels.Length], + output + ); + job.ExecuteHorizontalPass(runInParallel); + job.ExecuteVerticalPass(runInParallel); + Texture2D blurred = new(original.width, original.height, original.format, false); + try + { + blurred.SetPixels(output); + blurred.Apply(); + return blurred; + } + catch + { + Object.DestroyImmediate(blurred); // UNH-SUPPRESS: Failed construction has not transferred the texture to caller tracking. + throw; + } + } + } +#endif +} diff --git a/Tests/Editor/Tools/ImageBlurToolTestAccess.cs.meta b/Tests/Editor/Tools/ImageBlurToolTestAccess.cs.meta new file mode 100644 index 000000000..88fa9127e --- /dev/null +++ b/Tests/Editor/Tools/ImageBlurToolTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a57c79908fc0ef44da8600090da6292 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Tools/ImageBlurToolTests.cs b/Tests/Editor/Tools/ImageBlurToolTests.cs index 405653c2f..63d5217dc 100644 --- a/Tests/Editor/Tools/ImageBlurToolTests.cs +++ b/Tests/Editor/Tools/ImageBlurToolTests.cs @@ -53,7 +53,7 @@ private static int CountTemporaryTextures() [TestCase(4)] public void KernelHasExpectedLengthAndNormalizes(int radius) { - float[] kernel = ImageBlurTool.KernelForTests(radius); + float[] kernel = ImageBlurTool.GenerateGaussianKernel(radius); Assert.NotNull(kernel); Assert.AreEqual(radius * 2 + 1, kernel.Length); float sum = 0f; @@ -270,7 +270,7 @@ public void BlurredTextureMatchesInputDimensions() } tex.Apply(); - Texture2D blurred = Track(ImageBlurTool.BlurredForTests(tex, 2)); + Texture2D blurred = Track(ImageBlurToolTestAccess.Blurred(tex, 2)); Assert.IsTrue(blurred != null); Assert.AreEqual(tex.width, blurred.width); Assert.AreEqual(tex.height, blurred.height); @@ -284,7 +284,7 @@ public void FailedBlurDoesNotLeakTemporaryTexture() Assert.IsFalse(source.isReadable); int temporaryTextureCount = CountTemporaryTextures(); - Assert.Throws(() => ImageBlurTool.BlurredForTests(source, 2)); + Assert.Throws(() => ImageBlurToolTestAccess.Blurred(source, 2)); Assert.That(CountTemporaryTextures(), Is.EqualTo(temporaryTextureCount)); } @@ -377,7 +377,7 @@ private Color[] Blur(int width, int height, Color[] pixels, int radius) Texture2D source = Track(new Texture2D(width, height, TextureFormat.RGBAFloat, false)); source.SetPixels(pixels); source.Apply(); - Texture2D blurred = Track(ImageBlurTool.BlurredForTests(source, radius)); + Texture2D blurred = Track(ImageBlurToolTestAccess.Blurred(source, radius)); return blurred.GetPixels(); } @@ -386,7 +386,9 @@ private Color[] Blur(int width, int height, Color[] pixels, int radius, bool run Texture2D source = Track(new Texture2D(width, height, TextureFormat.RGBAFloat, false)); source.SetPixels(pixels); source.Apply(); - Texture2D blurred = Track(ImageBlurTool.BlurredForTests(source, radius, runInParallel)); + Texture2D blurred = Track( + ImageBlurToolTestAccess.Blurred(source, radius, runInParallel) + ); return blurred.GetPixels(); } } diff --git a/Tests/Editor/Tools/ManualRecompileTests.cs b/Tests/Editor/Tools/ManualRecompileTests.cs index 01a89be81..ee0b351bc 100644 --- a/Tests/Editor/Tools/ManualRecompileTests.cs +++ b/Tests/Editor/Tools/ManualRecompileTests.cs @@ -1,691 +1,27 @@ // MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE - -namespace WallstopStudios.UnityHelpers.Tests.Tools +namespace WallstopStudios.UnityHelpers.Tests.Editor.Tools { - using System; - using System.Collections.Generic; - using System.IO; - using System.Text.RegularExpressions; using NUnit.Framework; using UnityEditor; using UnityEngine; using UnityEngine.TestTools; using WallstopStudios.UnityHelpers.Editor.Tools; - using WallstopStudios.UnityHelpers.Editor.Utils; + using WallstopStudios.UnityHelpers.Tests.Core; [TestFixture] - [NUnit.Framework.Category("Slow")] - [NUnit.Framework.Category("Integration")] - public sealed class ManualRecompileTests + public sealed class ManualRecompileTests : CommonTestBase { - /* - Assets is writable and importable in every package layout; package source can be external or read- - only. - */ - private const string TempFolderRelativePath = "Assets/__ManualRecompileTests__"; - - private readonly List createdAssetPaths = new(); - - private static IEnumerable ExceptionScenarioTestCases() - { - yield return new TestCaseData( - new Action( - (setAssetsRefreshed, setCompilationRequested) => - { - ManualRecompile.AssetsRefreshedForTests = () => - { - setAssetsRefreshed(); - throw new InvalidOperationException("AssetsRefreshed exception"); - }; - } - ), - true, - "AssetsRefreshedCallback" - ).SetName("Exception.InAssetsRefreshedCallback.FlagIsReset"); - - yield return new TestCaseData( - new Action( - (setAssetsRefreshed, setCompilationRequested) => - { - ManualRecompile.SkipCompilationRequestForTests = false; - ManualRecompile.AssetsRefreshedForTests = setAssetsRefreshed; - ManualRecompile.CompilationRequestedForTests = () => - { - setCompilationRequested(); - throw new InvalidOperationException("CompilationRequested exception"); - }; - } - ), - false, - "CompilationRequestedCallback" - ).SetName("Exception.InCompilationRequestedCallback.FlagIsReset"); - - yield return new TestCaseData( - new Action( - (setAssetsRefreshed, setCompilationRequested) => - { - ManualRecompile.AssetsRefreshedForTests = () => - { - setAssetsRefreshed(); - throw new ArgumentException("ArgumentException from callback"); - }; - } - ), - true, - "AssetsRefreshedCallbackWithArgumentException" - ).SetName("Exception.ArgumentException.FlagIsReset"); - - yield return new TestCaseData( - new Action( - (setAssetsRefreshed, setCompilationRequested) => - { - ManualRecompile.AssetsRefreshedForTests = () => - { - setAssetsRefreshed(); - throw new NullReferenceException( - "NullReferenceException from callback" - ); - }; - } - ), - true, - "AssetsRefreshedCallbackWithNullReferenceException" - ).SetName("Exception.NullReferenceException.FlagIsReset"); - } - - private static void EnsureParentDirectoryExists(string absolutePath) - { - string directoryPath = Path.GetDirectoryName(absolutePath) ?? string.Empty; - - if (0 < directoryPath.Length) - { - Directory.CreateDirectory(directoryPath); - } - } - - private static string GetAbsolutePath(string relativePath) - { - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - string normalizedRelativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); - return Path.Combine(projectRoot, normalizedRelativePath); - } - - [TearDown] - public void TearDown() - { - try - { - foreach (string assetPath in createdAssetPaths) - { - try - { - AssetDatabase.DeleteAsset(assetPath); - } - catch (Exception ex) - { - Debug.LogWarning($"Failed to delete asset '{assetPath}': {ex.Message}"); - } - } - - createdAssetPaths.Clear(); - } - finally - { - try - { - ManualRecompile.SkipCompilationRequestForTests = false; - ManualRecompile.IsCompilationPendingEvaluator = null; - ManualRecompile.AssetsRefreshedForTests = null; - ManualRecompile.CompilationRequestedForTests = null; - } - finally - { - string tempFolderAbsolutePath = GetAbsolutePath(TempFolderRelativePath); - - try - { - if (Directory.Exists(tempFolderAbsolutePath)) - { - FileUtil.DeleteFileOrDirectory(tempFolderAbsolutePath); - } - } - catch (Exception ex) - { - Debug.LogWarning( - $"Failed to delete temp folder '{tempFolderAbsolutePath}': {ex.Message}" - ); - } - - try - { - string tempFolderMetaPath = tempFolderAbsolutePath + ".meta"; - - if (File.Exists(tempFolderMetaPath)) - { - FileUtil.DeleteFileOrDirectory(tempFolderMetaPath); - } - } - catch (Exception ex) - { - Debug.LogWarning($"Failed to delete temp folder meta: {ex.Message}"); - } - - try - { - AssetDatabaseBatchHelper.RefreshIfNotBatching(); - } - catch (Exception ex) - { - Debug.LogWarning( - $"Failed to refresh asset database in teardown: {ex.Message}" - ); - } - } - } - } - - [Test] - public void RequestRefreshesAssetDatabaseBeforeCompile() - { - string className = $"ManualRecompileTemp_{Guid.NewGuid():N}"; - string assetRelativePath = $"{TempFolderRelativePath}/{className}.cs"; - string absolutePath = GetAbsolutePath(assetRelativePath); - - EnsureParentDirectoryExists(absolutePath); - - AssetDatabase.DisallowAutoRefresh(); - - try - { - File.WriteAllText( - absolutePath, - "using UnityEngine;\r\n" - + $"public sealed class {className} : MonoBehaviour {{ }}" - ); - createdAssetPaths.Add(assetRelativePath); - - MonoScript scriptBeforeRefresh = AssetDatabase.LoadAssetAtPath( - assetRelativePath - ); - Assert.IsTrue( - scriptBeforeRefresh == null, - $"Expected new script to remain hidden until we refresh. Asset path: '{assetRelativePath}', Absolute path: '{absolutePath}', File exists: {File.Exists(absolutePath)}" - ); - - ManualRecompile.SkipCompilationRequestForTests = true; - ManualRecompile.IsCompilationPendingEvaluator = () => false; - - // Asserting inside the callback is unreliable: AssetDatabase.Refresh need not complete immediately. - bool refreshCallbackInvoked = false; - ManualRecompile.AssetsRefreshedForTests = () => - { - refreshCallbackInvoked = true; - }; - - LogAssert.Expect( - LogType.Log, - new Regex( - "Asset database refreshed; compilation request skipped", - RegexOptions.IgnoreCase - ) - ); - - ManualRecompile.RequestFromMenu(); - - Assert.IsTrue( - refreshCallbackInvoked, - "AssetsRefreshedForTests callback should have been invoked" - ); - - // Some Unity versions with DisallowAutoRefresh leave the asset invisible even after a forced import. - MonoScript scriptAfterRefresh = AssetDatabase.LoadAssetAtPath( - assetRelativePath - ); - - if (scriptAfterRefresh == null) - { - AssetDatabase.AllowAutoRefresh(); - AssetDatabaseBatchHelper.RefreshIfNotBatching( - ImportAssetOptions.ForceSynchronousImport - ); - scriptAfterRefresh = AssetDatabase.LoadAssetAtPath( - assetRelativePath - ); - } - - Assert.IsTrue( - scriptAfterRefresh != null, - $"Manual recompile should make the new script visible. Asset path: '{assetRelativePath}', " - + $"File exists: {File.Exists(absolutePath)}, " - + $"EditorApplication.isCompiling: {EditorApplication.isCompiling}" - ); - } - finally - { - AssetDatabase.AllowAutoRefresh(); - } - } - [Test] - public void RequestSkipsWhenCompilationIsInProgress() + public void PendingCompilationSkipsTheRequest() { - ManualRecompile.IsCompilationPendingEvaluator = () => true; - ManualRecompile.SkipCompilationRequestForTests = true; - - bool compileRequested = false; - ManualRecompile.CompilationRequestedForTests = () => compileRequested = true; - + bool previouslyCompiling = EditorApplication.isCompiling; LogAssert.Expect( LogType.Log, - new Regex("Script compilation already in progress", RegexOptions.IgnoreCase) - ); - - ManualRecompile.RequestFromShortcut(); - - Assert.IsFalse( - compileRequested, - "Manual compile requests should be skipped when Unity is already compiling." - ); - } - - [Test] - public void RequestFromMenuSkipsWhenCompilationIsInProgress() - { - ManualRecompile.IsCompilationPendingEvaluator = () => true; - ManualRecompile.SkipCompilationRequestForTests = true; - - bool assetsRefreshed = false; - bool compileRequested = false; - ManualRecompile.AssetsRefreshedForTests = () => assetsRefreshed = true; - ManualRecompile.CompilationRequestedForTests = () => compileRequested = true; - - LogAssert.Expect( - LogType.Log, - new Regex("Script compilation already in progress", RegexOptions.IgnoreCase) - ); - - ManualRecompile.RequestFromMenu(); - - Assert.IsFalse( - assetsRefreshed, - "Asset refresh should be skipped when compilation is in progress." - ); - Assert.IsFalse( - compileRequested, - "Compilation request should be skipped when compilation is in progress." - ); - } - - [Test] - public void RequestInvokesAssetsRefreshedCallbackWhenNotCompiling() - { - ManualRecompile.IsCompilationPendingEvaluator = () => false; - ManualRecompile.SkipCompilationRequestForTests = true; - - bool assetsRefreshed = false; - ManualRecompile.AssetsRefreshedForTests = () => assetsRefreshed = true; - - LogAssert.Expect( - LogType.Log, - new Regex("Asset database refreshed", RegexOptions.IgnoreCase) - ); - - ManualRecompile.RequestFromMenu(); - - Assert.IsTrue( - assetsRefreshed, - "AssetsRefreshedForTests callback should be invoked when not compiling." - ); - } - - [Test] - public void RequestInvokesCompilationCallbackWhenSkipFlagIsFalse() - { - ManualRecompile.IsCompilationPendingEvaluator = () => false; - ManualRecompile.SkipCompilationRequestForTests = false; - - bool compileRequested = false; - ManualRecompile.CompilationRequestedForTests = () => compileRequested = true; - - LogAssert.Expect( - LogType.Log, - new Regex( - "Refreshed assets and requested script compilation", - RegexOptions.IgnoreCase - ) - ); - - ManualRecompile.RequestFromMenu(); - - Assert.IsTrue( - compileRequested, - "CompilationRequestedForTests callback should be invoked when SkipCompilationRequestForTests is false." - ); - } - - [TestCase(true, false, TestName = "CompilationPendingPreventsRequest")] - [TestCase(false, true, TestName = "NoCompilationPendingAllowsRequest")] - public void RequestBehaviorDependsOnCompilationState(bool isCompiling, bool expectRefresh) - { - ManualRecompile.IsCompilationPendingEvaluator = () => isCompiling; - ManualRecompile.SkipCompilationRequestForTests = true; - - bool assetsRefreshed = false; - ManualRecompile.AssetsRefreshedForTests = () => assetsRefreshed = true; - - if (isCompiling) - { - LogAssert.Expect( - LogType.Log, - new Regex("Script compilation already in progress", RegexOptions.IgnoreCase) - ); - } - else - { - LogAssert.Expect( - LogType.Log, - new Regex("Asset database refreshed", RegexOptions.IgnoreCase) - ); - } - - ManualRecompile.RequestFromMenu(); - - Assert.AreEqual( - expectRefresh, - assetsRefreshed, - $"Assets refresh state mismatch. IsCompiling: {isCompiling}, Expected refresh: {expectRefresh}, Actual refresh: {assetsRefreshed}" - ); - } - - [Test] - public void IsCompilationPendingEvaluatorSetToNullRestoresDefault() - { - Func customEvaluator = () => true; - ManualRecompile.IsCompilationPendingEvaluator = customEvaluator; - - ManualRecompile.IsCompilationPendingEvaluator = null; - - ManualRecompile.SkipCompilationRequestForTests = true; - - bool assetsRefreshed = false; - ManualRecompile.AssetsRefreshedForTests = () => assetsRefreshed = true; - - ManualRecompile.RequestFromMenu(); - - bool compilingAtTimeOfTest = EditorApplication.isCompiling; - if (compilingAtTimeOfTest) - { - Assert.IsFalse( - assetsRefreshed, - "When evaluator reset to default and Unity is compiling, refresh should be skipped." - ); - } - } - - [Test] - public void IsCompilationPendingHandlesNullEvaluatorGracefully() - { - ManualRecompile.isCompilationPendingEvaluator = null; - - ManualRecompile.SkipCompilationRequestForTests = true; - bool assetsRefreshed = false; - ManualRecompile.AssetsRefreshedForTests = () => assetsRefreshed = true; - - LogAssert.Expect( - LogType.Warning, - new Regex("Compilation pending evaluator is null", RegexOptions.IgnoreCase) - ); - - // Back on the default evaluator, the answer follows EditorApplication.isCompiling. - bool wasCompilingAtTimeOfCall = EditorApplication.isCompiling; - if (wasCompilingAtTimeOfCall) - { - LogAssert.Expect( - LogType.Log, - new Regex("compilation already in progress", RegexOptions.IgnoreCase) - ); - } - else - { - LogAssert.Expect( - LogType.Log, - new Regex("Asset database refreshed", RegexOptions.IgnoreCase) - ); - } - - ManualRecompile.RequestFromMenu(); - - if (wasCompilingAtTimeOfCall) - { - Assert.IsFalse( - assetsRefreshed, - "When evaluator reset to default and Unity is compiling, refresh should be skipped." - ); - } - else - { - Assert.IsTrue( - assetsRefreshed, - "Request should proceed after restoring null evaluator to default when Unity is not compiling" - ); - } - } - - [Test] - public void SkipCompilationFlagIsResetEvenIfCallbackThrows() - { - ManualRecompile.IsCompilationPendingEvaluator = () => false; - ManualRecompile.SkipCompilationRequestForTests = true; - - bool assetsRefreshed = false; - ManualRecompile.AssetsRefreshedForTests = () => - { - assetsRefreshed = true; - throw new InvalidOperationException("Test exception from callback"); - }; - - bool caughtException = false; - try - { - ManualRecompile.RequestFromMenu(); - } - catch (InvalidOperationException ex) - { - caughtException = true; - Assert.AreEqual( - "Test exception from callback", - ex.Message, - "Exception message should match the thrown exception" - ); - } - - Assert.IsTrue(caughtException, "Expected InvalidOperationException to be thrown"); - Assert.IsTrue(assetsRefreshed, "Callback should have been invoked"); - Assert.IsFalse( - ManualRecompile.SkipCompilationRequestForTests, - $"Skip flag should be reset even when callback throws. " - + $"AssetsRefreshed: {assetsRefreshed}, CaughtException: {caughtException}" - ); - } - - [Test] - public void CompilationCallbackExceptionDoesNotCorruptState() - { - ManualRecompile.IsCompilationPendingEvaluator = () => false; - ManualRecompile.SkipCompilationRequestForTests = false; - - bool compileRequested = false; - ManualRecompile.CompilationRequestedForTests = () => - { - compileRequested = true; - throw new InvalidOperationException("Test exception from compilation callback"); - }; - - try - { - ManualRecompile.RequestFromMenu(); - } - catch (InvalidOperationException) { } - - Assert.IsTrue(compileRequested, "Compilation callback should have been invoked"); - - ManualRecompile.SkipCompilationRequestForTests = true; - bool secondRequestRefreshed = false; - ManualRecompile.AssetsRefreshedForTests = () => secondRequestRefreshed = true; - ManualRecompile.CompilationRequestedForTests = null; - - LogAssert.Expect( - LogType.Log, - new Regex("Asset database refreshed", RegexOptions.IgnoreCase) - ); - - ManualRecompile.RequestFromMenu(); - - Assert.IsTrue( - secondRequestRefreshed, - "Second request should work normally after exception in first request" - ); - } - - [Test] - [TestCaseSource(nameof(ExceptionScenarioTestCases))] - public void SkipFlagIsResetOnExceptionInCallbacks( - Action setupCallbacks, - bool initialSkipFlag, - string scenarioDescription - ) - { - ManualRecompile.IsCompilationPendingEvaluator = () => false; - ManualRecompile.SkipCompilationRequestForTests = initialSkipFlag; - - bool assetsRefreshed = false; - bool compilationRequested = false; - - setupCallbacks(() => assetsRefreshed = true, () => compilationRequested = true); - - bool caughtException = false; - Exception thrownException = null; - - try - { - ManualRecompile.RequestFromMenu(); - } - catch (Exception ex) - { - caughtException = true; - thrownException = ex; - } - - Assert.IsTrue( - caughtException, - $"[{scenarioDescription}] Expected exception to be thrown" - ); - Assert.IsFalse( - ManualRecompile.SkipCompilationRequestForTests, - $"[{scenarioDescription}] Skip flag should be reset after exception. " - + $"AssetsRefreshed: {assetsRefreshed}, " - + $"CompilationRequested: {compilationRequested}, " - + $"InitialSkipFlag: {initialSkipFlag}, " - + $"ExceptionType: {thrownException?.GetType().Name}, " - + $"ExceptionMessage: {thrownException?.Message}" - ); - } - - [Test] - public void MultipleSequentialRequestsResetFlagCorrectly() - { - ManualRecompile.IsCompilationPendingEvaluator = () => false; - - int requestCount = 0; - - for (int i = 0; i < 3; ++i) - { - ManualRecompile.SkipCompilationRequestForTests = true; - ManualRecompile.AssetsRefreshedForTests = () => requestCount++; - - LogAssert.Expect( - LogType.Log, - new Regex("Asset database refreshed", RegexOptions.IgnoreCase) - ); - - ManualRecompile.RequestFromMenu(); - - Assert.IsFalse( - ManualRecompile.SkipCompilationRequestForTests, - $"Skip flag should be reset after request {i + 1}. RequestCount: {requestCount}" - ); - } - - Assert.AreEqual( - 3, - requestCount, - "All three requests should have completed. RequestCount mismatch." - ); - } - - [Test] - public void RequestDoesNotResetFlagWhenSkipFlagIsFalse() - { - ManualRecompile.IsCompilationPendingEvaluator = () => false; - ManualRecompile.SkipCompilationRequestForTests = false; - - bool compileRequested = false; - ManualRecompile.CompilationRequestedForTests = () => compileRequested = true; - - LogAssert.Expect( - LogType.Log, - new Regex( - "Refreshed assets and requested script compilation", - RegexOptions.IgnoreCase - ) - ); - - ManualRecompile.RequestFromMenu(); - - Assert.IsTrue(compileRequested, "Compilation should have been requested"); - Assert.IsFalse( - ManualRecompile.SkipCompilationRequestForTests, - "Skip flag should remain false when it was initially false" - ); - } - - [Test] - public void ExceptionInAssetsRefreshedDoesNotPreventFlagReset() - { - ManualRecompile.IsCompilationPendingEvaluator = () => false; - ManualRecompile.SkipCompilationRequestForTests = true; - - int callOrder = 0; - int assetsRefreshedOrder = -1; - - ManualRecompile.AssetsRefreshedForTests = () => - { - assetsRefreshedOrder = callOrder++; - throw new InvalidOperationException("Intentional test exception"); - }; - - bool caughtException = false; - try - { - ManualRecompile.RequestFromMenu(); - } - catch (InvalidOperationException) - { - caughtException = true; - } - - Assert.IsTrue(caughtException, "Exception should have been caught"); - Assert.AreEqual( - 0, - assetsRefreshedOrder, - "AssetsRefreshed callback should have been called first" - ); - Assert.IsFalse( - ManualRecompile.SkipCompilationRequestForTests, - $"Skip flag should be reset even when AssetsRefreshed throws. " - + $"CallOrder: {callOrder}, AssetsRefreshedOrder: {assetsRefreshedOrder}" + "[Unity Helpers] Script compilation already in progress; manual request skipped." ); + Assert.DoesNotThrow(() => ManualRecompile.Request(compilationPending: true)); + Assert.AreEqual(previouslyCompiling, EditorApplication.isCompiling); } } } diff --git a/Tests/Editor/Tools/ProtoSchemaExporterWindowTests.cs b/Tests/Editor/Tools/ProtoSchemaExporterWindowTests.cs index ed0776357..4a89541b1 100644 --- a/Tests/Editor/Tools/ProtoSchemaExporterWindowTests.cs +++ b/Tests/Editor/Tools/ProtoSchemaExporterWindowTests.cs @@ -57,7 +57,7 @@ private static ScrollView LastScrollView(VisualElement root) public override void BaseSetUp() { base.BaseSetUp(); - ProtoSchemaExporterWindow.SuppressUserPrompts = true; + _window = Track(ScriptableObject.CreateInstance()); _outputPath = Path.Combine( Path.Combine(Application.temporaryCachePath, OutputDirectory), @@ -73,7 +73,6 @@ public override void TearDown() File.Delete(_outputPath); } - ProtoSchemaExporterWindow.SuppressUserPrompts = false; base.TearDown(); } @@ -96,7 +95,7 @@ public void ExportWritesAProto3SchemaForTheSelectedAssembly() [Test] public void ExportWithoutContractsReportsInsteadOfWriting() { - _window.SetSelectedAssembliesForTest(Array.Empty()); + SetSelectedAssemblies(Array.Empty()); Assert.IsFalse(_window.ExportSchemaToPath(_outputPath)); Assert.IsFalse(File.Exists(_outputPath), "Nothing selected must not write a file."); @@ -105,9 +104,7 @@ public void ExportWithoutContractsReportsInsteadOfWriting() [Test] public void IndividualContractsCanBeSelectedWithinAnAssembly() { - _window.SetSelectedContractsForTest( - new[] { typeof(ProtoSchemaExporterSampleContract) } - ); + SetSelectedContracts(new[] { typeof(ProtoSchemaExporterSampleContract) }); Assert.IsTrue(_window.ExportSchemaToPath(_outputPath)); @@ -120,8 +117,8 @@ public void IndividualContractsCanBeSelectedWithinAnAssembly() public void PerAssemblyExportWritesOneNamedSchemaForTheSelectedAssembly() { string assemblyName = typeof(ProtoSchemaExporterSampleContract).Assembly.GetName().Name; - _window.ExportLayoutForTest = ProtoSchemaExporterWindow.ExportLayout.OneFilePerAssembly; - _window.SetSelectedAssembliesForTest(new[] { assemblyName }); + _window._exportLayout = ProtoSchemaExporterWindow.ExportLayout.OneFilePerAssembly; + SetSelectedAssemblies(new[] { assemblyName }); string outputDirectory = ExportToDirectory("per-assembly"); try @@ -141,10 +138,8 @@ public void PerAssemblyExportWritesOneNamedSchemaForTheSelectedAssembly() [Test] public void PerNamespaceExportGroupsEveryContractOfOneNamespaceIntoOneFile() { - _window.ExportLayoutForTest = ProtoSchemaExporterWindow - .ExportLayout - .OneFilePerNamespace; - _window.SetSelectedContractsForTest(SampleContracts); + _window._exportLayout = ProtoSchemaExporterWindow.ExportLayout.OneFilePerNamespace; + SetSelectedContracts(SampleContracts); string outputDirectory = ExportToDirectory("per-namespace"); try @@ -168,8 +163,8 @@ public void PerNamespaceExportGroupsEveryContractOfOneNamespaceIntoOneFile() [Test] public void PerContractExportWritesOneSelfContainedFilePerType() { - _window.ExportLayoutForTest = ProtoSchemaExporterWindow.ExportLayout.OneFilePerContract; - _window.SetSelectedContractsForTest(SampleContracts); + _window._exportLayout = ProtoSchemaExporterWindow.ExportLayout.OneFilePerContract; + SetSelectedContracts(SampleContracts); string outputDirectory = ExportToDirectory("per-contract"); try @@ -193,8 +188,8 @@ public void PerContractExportWritesOneSelfContainedFilePerType() [Test] public void AProtoPackageIsWrittenIntoEverySchema() { - _window.PackageNameForTest = "mygame.save"; - _window.SetSelectedContractsForTest(SampleContracts); + _window._packageName = "mygame.save"; + SetSelectedContracts(SampleContracts); Assert.IsTrue(_window.ExportSchemaToPath(_outputPath)); StringAssert.Contains("package mygame.save;", File.ReadAllText(_outputPath)); @@ -207,13 +202,13 @@ public void AProtoPackageIsWrittenIntoEverySchema() [TestCase("has-hyphen")] public void AMalformedProtoPackageRefusesInsteadOfWriting(string packageName) { - _window.PackageNameForTest = packageName; - _window.SetSelectedContractsForTest(SampleContracts); + _window._packageName = packageName; + SetSelectedContracts(SampleContracts); - Assert.IsFalse(_window.HasUsablePackageNameForTest); + Assert.IsFalse(_window.HasUsablePackageName()); Assert.IsFalse(_window.ExportSchemaToPath(_outputPath)); Assert.IsFalse(File.Exists(_outputPath), "A refused package must not write a file."); - StringAssert.Contains("not a proto3 package", _window.LastStatusForTest); + StringAssert.Contains("not a proto3 package", _window._lastStatus); } [TestCase("")] @@ -223,26 +218,26 @@ public void AMalformedProtoPackageRefusesInsteadOfWriting(string packageName) [TestCase("_leading.Underscore")] public void AnOmittedOrWellFormedProtoPackageIsAccepted(string packageName) { - _window.PackageNameForTest = packageName; + _window._packageName = packageName; - Assert.IsTrue(_window.HasUsablePackageNameForTest); + Assert.IsTrue(_window.HasUsablePackageName()); } [Test] public void TheSearchFilterMatchesTypeAndAssemblyNames() { - _window.SearchFilterForTest = nameof(ProtoSchemaExporterSecondSampleContract); + _window._searchFilter = nameof(ProtoSchemaExporterSecondSampleContract); CollectionAssert.AreEquivalent( new[] { typeof(ProtoSchemaExporterSecondSampleContract) }, - _window.VisibleContractsForTest + VisibleContracts() ); - _window.SearchFilterForTest = typeof(ProtoSchemaExporterSampleContract) + _window._searchFilter = typeof(ProtoSchemaExporterSampleContract) .Assembly.GetName() .Name; - CollectionAssert.IsSupersetOf(_window.VisibleContractsForTest, SampleContracts); + CollectionAssert.IsSupersetOf(VisibleContracts(), SampleContracts); } /// @@ -253,37 +248,33 @@ public void TheSearchFilterMatchesTypeAndAssemblyNames() [Test] public void EveryDeselectionReachesTheSerializedFieldImmediately() { - string secondKey = ProtoSchemaExporterWindow.ContractKeyForTest( + string secondKey = ProtoSchemaExporterWindow.ContractKey( typeof(ProtoSchemaExporterSecondSampleContract) ); - string firstKey = ProtoSchemaExporterWindow.ContractKeyForTest( + string firstKey = ProtoSchemaExporterWindow.ContractKey( typeof(ProtoSchemaExporterSampleContract) ); - _window.SetSelectedContractsForTest( - new[] { typeof(ProtoSchemaExporterSampleContract) } - ); + SetSelectedContracts(new[] { typeof(ProtoSchemaExporterSampleContract) }); - CollectionAssert.Contains(_window.PersistedExclusionsForTest, secondKey); - CollectionAssert.DoesNotContain(_window.PersistedExclusionsForTest, firstKey); + CollectionAssert.Contains(_window._excludedContractKeys, secondKey); + CollectionAssert.DoesNotContain(_window._excludedContractKeys, firstKey); } [Test] public void RestoringTheSerializedStateRebuildsTheLiveSelection() { - _window.SetSelectedContractsForTest( - new[] { typeof(ProtoSchemaExporterSampleContract) } - ); + SetSelectedContracts(new[] { typeof(ProtoSchemaExporterSampleContract) }); // What a domain reload does: the serialized list survives and OnEnable rebuilds the runtime set. _window.RestoreSelectionState(); CollectionAssert.DoesNotContain( - _window.SelectedContractsForTest, + _window.SelectedContracts(), typeof(ProtoSchemaExporterSecondSampleContract) ); CollectionAssert.Contains( - _window.SelectedContractsForTest, + _window.SelectedContracts(), typeof(ProtoSchemaExporterSampleContract) ); } @@ -291,18 +282,16 @@ public void RestoringTheSerializedStateRebuildsTheLiveSelection() [Test] public void ARefreshKeepsDeselectionsAndAdmitsEverythingElse() { - _window.SetSelectedContractsForTest( - new[] { typeof(ProtoSchemaExporterSampleContract) } - ); + SetSelectedContracts(new[] { typeof(ProtoSchemaExporterSampleContract) }); _window.RefreshInventory(); CollectionAssert.Contains( - _window.SelectedContractsForTest, + _window.SelectedContracts(), typeof(ProtoSchemaExporterSampleContract) ); CollectionAssert.DoesNotContain( - _window.SelectedContractsForTest, + _window.SelectedContracts(), typeof(ProtoSchemaExporterSecondSampleContract) ); } @@ -315,13 +304,13 @@ public void ARefreshKeepsDeselectionsAndAdmitsEverythingElse() public void EveryFileLayoutIsLabelledAndSelectable() { Assert.AreEqual( - ProtoSchemaExporterWindow.SelectableLayoutsForTest.Count, - ProtoSchemaExporterWindow.LayoutLabelsForTest.Count, + ProtoSchemaExporterWindow.SelectableLayouts.Length, + ProtoSchemaExporterWindow.LayoutLabels.Length, "Every selectable layout needs exactly one label." ); - CollectionAssert.AllItemsAreUnique(ProtoSchemaExporterWindow.SelectableLayoutsForTest); + CollectionAssert.AllItemsAreUnique(ProtoSchemaExporterWindow.SelectableLayouts); CollectionAssert.DoesNotContain( - ProtoSchemaExporterWindow.SelectableLayoutsForTest, + ProtoSchemaExporterWindow.SelectableLayouts, default(ProtoSchemaExporterWindow.ExportLayout), "The obsolete zero value must never be offered." ); @@ -334,32 +323,32 @@ public void GroupKeysThatSanitizeToOneNameGetSeparateFiles() Assert.AreEqual( "Game_Save.proto", - ProtoSchemaExporterWindow.UniqueFileNameForTest("Game:Save", used) + ProtoSchemaExporterWindow.UniqueFileName("Game:Save", used) ); Assert.AreEqual( "Game_Save-2.proto", - ProtoSchemaExporterWindow.UniqueFileNameForTest("Game/Save", used) + ProtoSchemaExporterWindow.UniqueFileName("Game/Save", used) ); Assert.AreEqual( "Global.proto", - ProtoSchemaExporterWindow.UniqueFileNameForTest(string.Empty, used) + ProtoSchemaExporterWindow.UniqueFileName(string.Empty, used) ); } [Test] public void AFailedExportIsReportedAsAFailure() { - _window.PackageNameForTest = "1bad"; + _window._packageName = "1bad"; Assert.IsFalse(_window.ExportSchemaToPath(_outputPath)); Assert.IsTrue( - _window.LastStatusIsFailureForTest, + _window._lastStatusIsFailure, "A refusal must not read as an informational status." ); - _window.PackageNameForTest = string.Empty; + _window._packageName = string.Empty; Assert.IsTrue(_window.ExportSchemaToPath(_outputPath)); - Assert.IsFalse(_window.LastStatusIsFailureForTest); + Assert.IsFalse(_window._lastStatusIsFailure); } /// @@ -379,7 +368,7 @@ public void AnUnwritablePathReportsInsteadOfThrowing() try { Assert.IsFalse(_window.ExportSchemaToPath(directoryPath)); - StringAssert.Contains("Could not write", _window.LastStatusForTest); + StringAssert.Contains("Could not write", _window._lastStatus); } finally { @@ -472,9 +461,7 @@ public void AnOutsideProjectPathWritesWithoutImporting() [Test] public void DirectExporterMatchesTheWindowForOneSelectedContract() { - _window.SetSelectedContractsForTest( - new[] { typeof(ProtoSchemaExporterSampleContract) } - ); + SetSelectedContracts(new[] { typeof(ProtoSchemaExporterSampleContract) }); Assert.IsTrue(_window.ExportSchemaToPath(_outputPath)); string windowSchema = File.ReadAllText(_outputPath); @@ -503,12 +490,46 @@ private string ExportToDirectory(string leafName) leafName ); DeleteDirectory(outputDirectory); - Assert.IsTrue( - _window.ExportSchemasToDirectory(outputDirectory), - _window.LastStatusForTest - ); + Assert.IsTrue(_window.ExportSchemasToDirectory(outputDirectory), _window._lastStatus); return outputDirectory; } + + private List VisibleContracts() + { + List visible = new(); + foreach (Type contract in _window._contracts) + { + if (_window.IsVisible(contract)) + { + visible.Add(contract); + } + } + return visible; + } + + private void SetSelectedContracts(IEnumerable contracts) + { + _window.SetSelection(_window._contracts, false); + _window.SetSelection(contracts ?? Array.Empty(), true); + } + + private void SetSelectedAssemblies(IEnumerable assemblyNames) + { + HashSet selectedAssemblies = new( + assemblyNames ?? Array.Empty(), + StringComparer.Ordinal + ); + _window.SetSelection(_window._contracts, false); + List selected = new(); + foreach (Type contract in _window._contracts) + { + if (selectedAssemblies.Contains(ProtoSchemaExporterWindow.AssemblyNameOf(contract))) + { + selected.Add(contract); + } + } + _window.SetSelection(selected, true); + } } [WProtoContract] diff --git a/Tests/Editor/Tools/TestRunReporterTestAccess.cs b/Tests/Editor/Tools/TestRunReporterTestAccess.cs new file mode 100644 index 000000000..71e9ab7fc --- /dev/null +++ b/Tests/Editor/Tools/TestRunReporterTestAccess.cs @@ -0,0 +1,25 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Tools +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.IO; + using UnityEditor; + using UnityEditor.Compilation; + using UnityEditor.TestTools.TestRunner.Api; + using UnityEngine; + using static WallstopStudios.UnityHelpers.Editor.Tools.TestRunReporter; + + /// Provides test-side setup and inspection for TestRunReporter. + internal static class TestRunReporterTestAccess + { + internal static void ClearRunSession() + { + ClearActiveOwner(); + } + } +#endif +} diff --git a/Tests/Editor/Tools/TestRunReporterTestAccess.cs.meta b/Tests/Editor/Tools/TestRunReporterTestAccess.cs.meta new file mode 100644 index 000000000..f4089c2cd --- /dev/null +++ b/Tests/Editor/Tools/TestRunReporterTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c858b362464ad2f0382dbdd7ec5904cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Tools/TestRunReporterTests.cs b/Tests/Editor/Tools/TestRunReporterTests.cs index 377df1d02..287313e59 100644 --- a/Tests/Editor/Tools/TestRunReporterTests.cs +++ b/Tests/Editor/Tools/TestRunReporterTests.cs @@ -24,7 +24,7 @@ public sealed class TestRunReporterTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - TestRunReporter.ClearRunSessionForTests(); + TestRunReporterTestAccess.ClearRunSession(); _hadSavedContent = false; _savedContent = string.Empty; if (!TestRunSummaryFile.TryGetSummaryPath(TestMode.PlayMode, out _playModePath)) @@ -45,7 +45,7 @@ public override void BaseSetUp() [TearDown] public override void TearDown() { - TestRunReporter.ClearRunSessionForTests(); + TestRunReporterTestAccess.ClearRunSession(); if (!string.IsNullOrEmpty(_playModePath)) { if (_hadSavedContent) diff --git a/Tests/Editor/Tools/TestRunSummaryFileTests.cs b/Tests/Editor/Tools/TestRunSummaryFileTests.cs index 44540c7e0..3c16e7cb2 100644 --- a/Tests/Editor/Tools/TestRunSummaryFileTests.cs +++ b/Tests/Editor/Tools/TestRunSummaryFileTests.cs @@ -40,7 +40,6 @@ public override void BaseSetUp() [TearDown] public override void TearDown() { - TestRunSummaryFile.AfterBeginClaimForTests = null; if (Directory.Exists(_workingDirectory)) { Directory.Delete(_workingDirectory, true); @@ -56,9 +55,6 @@ public override void TearDown() [TestCase("\u2003")] public void BlankSummaryPathsAreRefusedBeforeClaimingAFile(string path) { - bool claimed = false; - TestRunSummaryFile.AfterBeginClaimForTests = () => claimed = true; - Assert.That( TestRunSummaryFile.TryBeginRun( path, @@ -69,7 +65,7 @@ out string owner Is.False ); Assert.That(owner, Is.Empty); - Assert.That(claimed, Is.False); + Assert.That(Directory.GetFiles(_workingDirectory), Is.Empty); Assert.That(TestRunSummaryFile.IsMarkedRunning(path), Is.False); Assert.That(TestRunSummaryFile.TryReadOwner(path, out string readOwner), Is.False); Assert.That(readOwner, Is.Empty); @@ -295,58 +291,44 @@ public void BeginRunCreatesTheSummaryDirectory() public void ConcurrentCrossModeBeginRunCallsProduceOneOwner() { string competingPath = Path.Combine(_workingDirectory, "playmode-summary.txt"); - using ManualResetEventSlim claimAcquired = new(false); - using ManualResetEventSlim releaseClaim = new(false); - string firstOwner = string.Empty; - string secondOwner = string.Empty; - TestRunSummaryFile.AfterBeginClaimForTests = () => - { - claimAcquired.Set(); - releaseClaim.Wait(10_000); - }; - Task first = Task.Run(() => + using ( + FileStream claim = TestRunSummaryFile.OpenBeginClaim(_summaryPath, competingPath) + ) { - return TestRunSummaryFile.TryBeginRun( + Assert.IsFalse( + TestRunSummaryFile.TryBeginRun( + competingPath, + TestMode.PlayMode, + StartedUtc, + out string blockedOwner, + _summaryPath + ) + ); + Assert.IsEmpty(blockedOwner); + Assert.IsFalse(File.Exists(competingPath)); + } + Assert.IsTrue( + TestRunSummaryFile.TryBeginRun( _summaryPath, TestMode.EditMode, StartedUtc, - out firstOwner, + out string firstOwner, competingPath - ); - }); - Assert.IsTrue( - claimAcquired.Wait(10_000), - "The first caller must hold the shared claim." + ) ); - Task second = Task.Run(() => - { - return TestRunSummaryFile.TryBeginRun( + Assert.IsNotEmpty(firstOwner); + Assert.IsFalse( + TestRunSummaryFile.TryBeginRun( competingPath, TestMode.PlayMode, - StartedUtc.AddSeconds(1), - out secondOwner, + StartedUtc, + out string secondOwner, _summaryPath - ); - }); - - try - { - Assert.IsTrue(second.Wait(10_000)); - Assert.IsFalse(second.Result); - } - finally - { - releaseClaim.Set(); - Assert.IsTrue(first.Wait(10_000)); - TestRunSummaryFile.AfterBeginClaimForTests = null; - } - - Assert.IsTrue(first.Result); - Assert.IsNotEmpty(firstOwner); - Assert.AreEqual(string.Empty, secondOwner); + ) + ); + Assert.IsEmpty(secondOwner); Assert.IsTrue(TestRunSummaryFile.TryReadOwner(_summaryPath, out string storedOwner)); Assert.AreEqual(firstOwner, storedOwner); - Assert.IsFalse(File.Exists(competingPath)); } [Test] diff --git a/Tests/Editor/Tools/UnityMethodAnalyzerWindowTests.cs b/Tests/Editor/Tools/UnityMethodAnalyzerWindowTests.cs index f253725f7..bef3898f1 100644 --- a/Tests/Editor/Tools/UnityMethodAnalyzerWindowTests.cs +++ b/Tests/Editor/Tools/UnityMethodAnalyzerWindowTests.cs @@ -353,22 +353,20 @@ public void StartAnalysisWithNoValidDirectoriesSetsAppropriateMessage() } [Test] - public void StartAnalysisWithNoValidDirectoriesSignalsCompletionSource() + public void StartAnalysisWithNoValidDirectoriesCompletesAnalysisState() { UnityMethodAnalyzerWindow window = CreateWindow(); - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; window._isAnalyzing = false; window._sourcePaths = new List { "/nonexistent/path" }; window.StartAnalysis(); Assert.IsTrue( - tcs.Task.IsCompleted, - "TCS should be completed when no valid directories" + !window._isAnalyzing, + "Operation should be complete when no valid directories" ); - Assert.IsTrue(tcs.Task.Result, "TCS result should be true"); + Assert.IsTrue(!window._isAnalyzing, "Operation should no longer be running"); Assert.IsFalse(window._isAnalyzing, "Should not be analyzing"); } @@ -394,19 +392,20 @@ public void StartAnalysisWithEmptySourcePathsSetsAppropriateMessage() } [Test] - public void StartAnalysisWithEmptySourcePathsSignalsCompletionSource() + public void StartAnalysisWithEmptySourcePathsCompletesAnalysisState() { UnityMethodAnalyzerWindow window = CreateWindow(); - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; window._isAnalyzing = false; window._sourcePaths = new List(); window.StartAnalysis(); - Assert.IsTrue(tcs.Task.IsCompleted, "TCS should be completed when empty source paths"); - Assert.IsTrue(tcs.Task.Result, "TCS result should be true"); + Assert.IsTrue( + !window._isAnalyzing, + "Operation should be complete when empty source paths" + ); + Assert.IsTrue(!window._isAnalyzing, "Operation should no longer be running"); Assert.IsFalse(window._isAnalyzing, "Should not be analyzing"); } @@ -484,9 +483,6 @@ public IEnumerator SuccessfulAnalysisResetsUIState() window._isAnalyzing = false; window._sourcePaths = new List { _tempDir }; - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; - window.StartAnalysis(); AnalysisWaitResult result = default; @@ -554,8 +550,6 @@ public IEnumerator CompletionCallbackRaceConditionIsHandled() window._sourcePaths = new List { _tempDir }; // FinalizeAnalysis signals this, after HandleAnalysisCompletion runs. - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; window.StartAnalysis(); @@ -582,7 +576,7 @@ public IEnumerator CompletionCallbackRaceConditionIsHandled() $"isAnalyzing should be false after proper wait. ImmediatelyReset: {immediatelyReset}. {result}" ); Assert.IsTrue( - result.CompletionSourceSignaled, + result.UiCompletionObserved, $"Completion source should be signaled after proper wait. {result}" ); } @@ -602,9 +596,6 @@ public IEnumerator ProgressCallbackRaceConditionIsHandled() window._isAnalyzing = false; window._sourcePaths = new List { _tempDir }; - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; - window.StartAnalysis(); AnalysisWaitResult result = default; @@ -632,20 +623,17 @@ public IEnumerator ProgressCallbackRaceConditionIsHandled() } /// - /// Test that WaitForAnalysisCompletion works correctly even when _analysisCompletionSource - /// is not set. In this case, it should fall back to checking _isAnalyzing. + /// Verifies that the actual analysis task completes and its queued UI state is applied. /// [UnityTest] - public IEnumerator WaitForAnalysisCompletionWorksWithoutCompletionSource() + public IEnumerator WaitForAnalysisCompletionObservesTheRealOperation() { UnityMethodAnalyzerWindow window = CreateWindow(); - AddCompilerDiagnostic("NoTCSTest.cs"); + AddCompilerDiagnostic("RealOperationTest.cs"); window._isAnalyzing = false; window._sourcePaths = new List { _tempDir }; - window._analysisCompletionSource = null; - window.StartAnalysis(); AnalysisWaitResult result = default; @@ -653,11 +641,20 @@ public IEnumerator WaitForAnalysisCompletionWorksWithoutCompletionSource() Assert.IsFalse( result.IsAnalyzing, - $"isAnalyzing should be false after completion without TCS. {result}" + $"isAnalyzing should be false after the actual operation completes. {result}" ); - Assert.IsFalse( - result.CompletionSourceSignaled, - $"Completion source should NOT be signaled when null. {result}" + Assert.IsTrue( + result.AnalysisTaskCompleted, + $"The actual analysis task should complete. {result}" + ); + Assert.AreEqual( + TaskStatus.RanToCompletion, + result.AnalysisTaskStatus, + result.ToString() + ); + Assert.IsTrue( + result.UiCompletionObserved, + $"The queued completion should update the actual UI state. {result}" ); } @@ -677,9 +674,6 @@ public IEnumerator ProgressUpdatesCorrectlyDuringAnalysis() window._isAnalyzing = false; window._sourcePaths = new List { _tempDir }; - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; - window.StartAnalysis(); Assert.IsTrue(window._isAnalyzing, "Should be analyzing after StartAnalysis"); @@ -712,8 +706,6 @@ public IEnumerator RapidSuccessiveAnalysesResetProgressCorrectly() for (int iteration = 0; iteration < 3; ++iteration) { window._isAnalyzing = false; - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; window.StartAnalysis(); @@ -748,9 +740,6 @@ public IEnumerator LargeFileCountAnalysisResetsProgressCorrectly() window._isAnalyzing = false; window._sourcePaths = new List { _tempDir }; - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; - window.StartAnalysis(); AnalysisWaitResult result = default; @@ -1783,9 +1772,6 @@ public IEnumerator SuccessfulAnalysisResetsUIStateWithDiagnostics() window._isAnalyzing = false; window._sourcePaths = new List { _tempDir }; - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; - bool initialIsAnalyzing = window._isAnalyzing; string initialStatus = window._statusMessage; @@ -2085,35 +2071,16 @@ public void StartAnalysisWhileAlreadyAnalyzingDoesNothing() Assert.AreEqual(0.5f, window._analysisProgress, "Progress should not change"); } - [Test] - public void AnalysisCompletionSourceIsSignaledOnCompletion() - { - UnityMethodAnalyzerWindow window = CreateWindow(); - - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; - - Assert.IsFalse(tcs.Task.IsCompleted, "TCS should not be completed initially"); - - window.ResetAnalysisState(); - tcs.TrySetResult(true); - - Assert.IsTrue(tcs.Task.IsCompleted, "TCS should be completed after TrySetResult"); - Assert.IsTrue(tcs.Task.Result, "TCS result should be true"); - } - [Test] public void FinalizeAnalysisResetsStateAndSignalsCompletion() { UnityMethodAnalyzerWindow window = CreateWindow(); - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; window._isAnalyzing = true; window._analysisProgress = 0.5f; Assert.IsTrue(window._isAnalyzing, "Should be analyzing initially"); - Assert.IsFalse(tcs.Task.IsCompleted, "TCS should not be completed initially"); + Assert.IsFalse(!window._isAnalyzing, "Operation should initially be running"); window._cancellationTokenSource = new CancellationTokenSource(); window.CancelAnalysis(); @@ -2121,18 +2088,16 @@ public void FinalizeAnalysisResetsStateAndSignalsCompletion() Assert.IsFalse(window._isAnalyzing, "Should not be analyzing after finalize"); Assert.AreEqual(0f, window._analysisProgress, "Progress should be reset"); Assert.IsTrue( - tcs.Task.IsCompleted, - "TCS should be completed after FinalizeAnalysis via CancelAnalysis" + !window._isAnalyzing, + "Operation should be complete after FinalizeAnalysis via CancelAnalysis" ); } [Test] - public void FinalizeAnalysisIsIdempotentForTaskCompletionSource() + public void RepeatedCancellationKeepsTheOperationComplete() { UnityMethodAnalyzerWindow window = CreateWindow(); - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; window._cancellationTokenSource = new CancellationTokenSource(); window._isAnalyzing = true; @@ -2140,8 +2105,8 @@ public void FinalizeAnalysisIsIdempotentForTaskCompletionSource() window.CancelAnalysis(); window.CancelAnalysis(); - Assert.IsTrue(tcs.Task.IsCompleted, "TCS should be completed"); - Assert.IsTrue(tcs.Task.Result, "TCS result should be true"); + Assert.IsTrue(!window._isAnalyzing, "Operation should be complete"); + Assert.IsTrue(!window._isAnalyzing, "Operation should no longer be running"); } [Test] @@ -2162,21 +2127,21 @@ public void InitializeCanBeCalledMultipleTimesSafely() } /// - /// Data-driven test verifying that the TaskCompletionSource is always signaled + /// Data-driven test verifying that the analysis operation is always signaled /// when StartAnalysis is called with various source path configurations. /// [TestCase( null, true, - "TCS should be completed when source paths is null (no-op early return)" + "Operation should be complete when source paths is null (no-op early return)" )] - [TestCase(new string[0], true, "TCS should be completed when source paths is empty")] + [TestCase(new string[0], true, "Operation should be complete when source paths is empty")] [TestCase( new[] { "/nonexistent/path" }, true, - "TCS should be completed when directories dont exist" + "Operation should be complete when directories dont exist" )] - public void TaskCompletionSourceIsSignaledWithVariousSourcePathConfigs( + public void AnalysisOperationCompletesWithVariousSourcePathConfigs( string[] sourcePaths, bool shouldComplete, string description @@ -2184,21 +2149,22 @@ string description { UnityMethodAnalyzerWindow window = CreateWindow(); - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; window._sourcePaths = sourcePaths != null ? new List(sourcePaths) : null; window.StartAnalysis(); Assert.AreEqual( shouldComplete, - tcs.Task.IsCompleted, - $"{description}. Task status: {tcs.Task.Status}" + !window._isAnalyzing, + $"{description}. Task status: {window._isAnalyzing}" ); - if (tcs.Task.IsCompleted) + if (!window._isAnalyzing) { - Assert.IsTrue(tcs.Task.Result, "TCS result should be true when completed"); + Assert.IsTrue( + !window._isAnalyzing, + "Operation should no longer be running when completed" + ); } } @@ -2651,9 +2617,6 @@ private IEnumerator SuccessfulAnalysisResetsUIStateWithFileCount(int fileCount) window._isAnalyzing = false; window._sourcePaths = new List { _tempDir }; - TaskCompletionSource tcs = new(); - window._analysisCompletionSource = tcs; - window.StartAnalysis(); AnalysisWaitResult result = default; @@ -2698,7 +2661,7 @@ private void AddCompilerDiagnostic(string filename) /// The analysis task completion triggers a ContinueWith callback that runs on a /// thread pool thread, which enqueues HandleAnalysisCompletion onto the main thread /// queue. There's a race between the task completing and the ContinueWith callback - /// actually running, so we must loop until the completion source is signaled + /// actually running, so we must loop until the operation is no longer running /// (which happens at the end of HandleAnalysisCompletion via FinalizeAnalysis). /// private IEnumerator WaitForAnalysisCompletion( @@ -2708,7 +2671,7 @@ Action onComplete ) { Task analysisTask = window._analysisTask; - TaskCompletionSource tcs = window._analysisCompletionSource; + float startRealTime = Time.realtimeSinceStartup; int frameCount = 0; @@ -2743,14 +2706,14 @@ Action onComplete /* The ContinueWith runs on a thread pool thread and enqueues work on the main thread, - so flush and yield until the completion source is signaled -- which is what says + so flush and yield until the operation is no longer running -- which is what says HandleAnalysisCompletion ran and called FinalizeAnalysis. */ while ((Time.realtimeSinceStartup - startRealTime) < maxWaitTime) { UnityMethodAnalyzerWindow.FlushMainThreadQueue(); - bool completionSignaled = tcs != null && tcs.Task.IsCompleted; + bool completionSignaled = !window._isAnalyzing; bool noLongerAnalyzing = !window._isAnalyzing; if (completionSignaled || noLongerAnalyzing) @@ -2781,8 +2744,10 @@ HandleAnalysisCompletion ran and called FinalizeAnalysis. FrameCount = frameCount, AnalysisTaskCompleted = analysisTask != null && analysisTask.IsCompleted, AnalysisTaskStatus = analysisTask?.Status ?? TaskStatus.Created, - CompletionSourceSignaled = tcs?.Task.IsCompleted ?? false, - CompletionSourceStatus = tcs?.Task.Status ?? TaskStatus.Created, + UiCompletionObserved = !window._isAnalyzing, + UiCompletionStatus = window._isAnalyzing + ? TaskStatus.Running + : TaskStatus.RanToCompletion, IsAnalyzing = window._isAnalyzing, Progress = window._analysisProgress, StatusMessage = window._statusMessage, @@ -2905,8 +2870,8 @@ private struct AnalysisWaitResult public int FrameCount; public bool AnalysisTaskCompleted; public TaskStatus AnalysisTaskStatus; - public bool CompletionSourceSignaled; - public TaskStatus CompletionSourceStatus; + public bool UiCompletionObserved; + public TaskStatus UiCompletionStatus; public bool IsAnalyzing; public float Progress; public string StatusMessage; @@ -2915,7 +2880,7 @@ public override readonly string ToString() { return $"WaitTime: {WaitTime:F2}s, Frames: {FrameCount}, " + $"AnalysisTaskCompleted: {AnalysisTaskCompleted}, AnalysisTaskStatus: {AnalysisTaskStatus}, " - + $"TCSCompleted: {CompletionSourceSignaled}, TCSStatus: {CompletionSourceStatus}, " + + $"UiCompletionObserved: {UiCompletionObserved}, UiCompletionStatus: {UiCompletionStatus}, " + $"IsAnalyzing: {IsAnalyzing}, Progress: {Progress}, Status: '{StatusMessage}'"; } } diff --git a/Tests/Editor/Utils/DuplicateSingletonCleanupTests.cs b/Tests/Editor/Utils/DuplicateSingletonCleanupTests.cs index 25177b139..9a6e9060d 100644 --- a/Tests/Editor/Utils/DuplicateSingletonCleanupTests.cs +++ b/Tests/Editor/Utils/DuplicateSingletonCleanupTests.cs @@ -30,7 +30,7 @@ public sealed class DuplicateSingletonCleanupTests : CommonTestBase private const string DeeplyNestedFolder = "Assets/Resources/DuplicateCleanupTests/Nested/Deep"; private bool _previousEditorUiSuppress; - private bool _previousIgnoreCompilationState; + private bool _cleanedUp; /// @@ -207,14 +207,12 @@ public override void BaseSetUp() _cleanedUp = false; _previousEditorUiSuppress = EditorUi.Suppress; EditorUi.Suppress = true; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = true; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = true; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = true; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = true; // Unity may report isCompiling/isUpdating during a test run after AssetDatabase operations. - _previousIgnoreCompilationState = - ScriptableObjectSingletonCreator.IgnoreCompilationState; - ScriptableObjectSingletonCreator.IgnoreCompilationState = true; - ScriptableObjectSingletonCreator.TypeFilter = static type => + + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = static type => type == typeof(CleanupEnabledSingleton) || type == typeof(CleanupDisabledSingleton) || type == typeof(CleanupWithDataSingleton); @@ -226,7 +224,7 @@ public override void BaseSetUp() EnsureFolder(TestRoot); // Ensure the metadata folder exists to prevent modal dialogs EnsureFolder("Assets/Resources/Wallstop Studios/Unity Helpers"); - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); } [TearDown] @@ -236,13 +234,12 @@ public override void TearDown() CleanupTestAssets(); - ScriptableObjectSingletonCreator.TypeFilter = null; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = false; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = false; - ScriptableObjectSingletonCreator.IgnoreCompilationState = - _previousIgnoreCompilationState; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = false; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = false; + + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); base.TearDown(); EditorUi.Suppress = _previousEditorUiSuppress; @@ -257,13 +254,12 @@ public override IEnumerator UnityTearDown() CleanupTestAssets(); yield return null; - ScriptableObjectSingletonCreator.TypeFilter = null; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = false; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = false; - ScriptableObjectSingletonCreator.IgnoreCompilationState = - _previousIgnoreCompilationState; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = false; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = false; + + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); IEnumerator baseEnumerator = base.UnityTearDown(); while (baseEnumerator.MoveNext()) @@ -312,7 +308,7 @@ public IEnumerator CleanupRemovesIdenticalDuplicateWhenOptedIn() Is.Not.Null ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -356,7 +352,7 @@ public IEnumerator CleanupDoesNotRemoveDuplicateWhenNotOptedIn() Is.Not.Null ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -411,7 +407,7 @@ public IEnumerator CleanupPreservesDuplicateWithDifferentContent() RegexOptions.IgnoreCase ) ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -465,7 +461,7 @@ public IEnumerator CleanupRemovesMultipleDuplicates() Is.Not.Null ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -520,7 +516,7 @@ public IEnumerator CleanupRemovesEmptyFoldersAfterDeletion() $"Setup: Duplicate asset should exist at '{duplicatePath}'" ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); // Two yields: the folder cleanup runs after StopAssetEditing. yield return null; @@ -613,7 +609,7 @@ keep it populated. $"Setup: Other asset should exist at '{otherAssetPath}'" ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -657,7 +653,7 @@ public IEnumerator CleanupHandlesNoCanonicalAsset() Is.Not.Null ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -688,7 +684,7 @@ public IEnumerator CleanupHandlesSingleAssetNoDuplicates() Is.Not.Null ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -723,7 +719,7 @@ public IEnumerator CleanupComparesSerializedContentCorrectly() AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -809,7 +805,7 @@ public IEnumerator CleanupRemovesDeeplyNestedFolderHierarchy() AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); // Multiple yields to ensure Unity has time to process all AssetDatabase operations yield return null; @@ -879,7 +875,7 @@ public IEnumerator CleanupStopsAtFolderWithSiblingAsset() AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); // Multiple yields to ensure Unity has time to process all AssetDatabase operations yield return null; @@ -947,7 +943,7 @@ public IEnumerator CleanupHandlesEmptySubfoldersCorrectly() AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); // Multiple yields to ensure Unity has time to process all AssetDatabase operations yield return null; diff --git a/Tests/Editor/Utils/EditorUiSuppressionTests.cs b/Tests/Editor/Utils/EditorUiSuppressionTests.cs index 644741a43..b249010d3 100644 --- a/Tests/Editor/Utils/EditorUiSuppressionTests.cs +++ b/Tests/Editor/Utils/EditorUiSuppressionTests.cs @@ -37,7 +37,7 @@ public override void TearDown() { base.TearDown(); // Loop protection would otherwise trip as cleanup deletes several assets. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); CleanupTrackedFoldersAndAssets(); } diff --git a/Tests/Editor/Utils/RuntimeSingletonRecoveryTests.cs b/Tests/Editor/Utils/RuntimeSingletonRecoveryTests.cs index 49ecd66d9..3e2f31a3d 100644 --- a/Tests/Editor/Utils/RuntimeSingletonRecoveryTests.cs +++ b/Tests/Editor/Utils/RuntimeSingletonRecoveryTests.cs @@ -25,7 +25,7 @@ public sealed class RuntimeSingletonRecoveryTests : CommonTestBase public void PrepareSingletons() { UnityMainThreadGuard.Capture(Thread.CurrentThread); - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); } [TearDown] diff --git a/Tests/Editor/Utils/ScriptableObjectSingletonCreatorEditorTests.cs b/Tests/Editor/Utils/ScriptableObjectSingletonCreatorEditorTests.cs index bcd2aabe0..5b8e764ff 100644 --- a/Tests/Editor/Utils/ScriptableObjectSingletonCreatorEditorTests.cs +++ b/Tests/Editor/Utils/ScriptableObjectSingletonCreatorEditorTests.cs @@ -1,4 +1,4 @@ -// MIT License - Copyright (c) 2025 wallstop +// MIT License - Copyright (c) 2025-2026 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Tests.Utils @@ -18,6 +18,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Utils using WallstopStudios.UnityHelpers.Editor.Utils; using WallstopStudios.UnityHelpers.Tests.AssetProcessors; using WallstopStudios.UnityHelpers.Tests.Core; + using WallstopStudios.UnityHelpers.Utils; using Object = UnityEngine.Object; [TestFixture] @@ -37,7 +38,6 @@ public sealed class ScriptableObjectSingletonCreatorEditorTests : CommonTestBase private const string WrongAssetPathCaseVariant = WrongFolderCaseVariant + "/CreatorPathSingleton.asset"; private bool _previousEditorUiSuppress; - private bool _previousIgnoreCompilationState; private static void DeleteAssetIfExists(string assetPath) { @@ -245,17 +245,15 @@ public IEnumerator UnitySetUp() _previousEditorUiSuppress = EditorUi.Suppress; EditorUi.Suppress = true; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = true; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = true; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = true; // Unity may report isCompiling/isUpdating during a test run after AssetDatabase operations. - _previousIgnoreCompilationState = - ScriptableObjectSingletonCreator.IgnoreCompilationState; - ScriptableObjectSingletonCreator.IgnoreCompilationState = true; - ScriptableObjectSingletonCreator.TypeFilter = static type => + + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = static type => type == typeof(CreatorPathSingleton) || type == typeof(NestedDiskSingleton); - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); // Ensure the metadata folder exists to prevent modal dialogs EnsureFolder("Assets/Resources/Wallstop Studios/Unity Helpers"); DeleteAssetIfExists(TargetAssetPath); @@ -310,26 +308,123 @@ public override IEnumerator UnityTearDown() yield return null; TryDeleteEmptyFolder(ResourcesRoot); yield return null; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = false; - ScriptableObjectSingletonCreator.TypeFilter = null; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = false; - ScriptableObjectSingletonCreator.IgnoreCompilationState = - _previousIgnoreCompilationState; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = false; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = false; + + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); AssetDatabase.SaveAssets(); AssetDatabaseBatchHelper.RefreshIfNotBatching( ImportAssetOptions.ForceSynchronousImport ); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); EditorUi.Suppress = _previousEditorUiSuppress; yield return null; } + [UnityTest] + public IEnumerator CandidateFailureReleasesGuardAndLaterCreatesMissingAsset() + { + int batchDepth = AssetDatabaseBatchHelper.CurrentBatchDepth; + int unityBatchDepth = AssetDatabaseBatchHelper.ActualUnityBatchDepth; + InvalidOperationException expected = new InvalidOperationException( + nameof(CandidateFailureReleasesGuardAndLaterCreatesMissingAsset) + ); + SingletonCandidateList candidates = new SingletonCandidateList(() => + { + Assert.IsTrue(ScriptableObjectSingletonCreator._isEnsuring); + Assert.AreEqual(batchDepth + 1, AssetDatabaseBatchHelper.CurrentBatchDepth); + throw expected; + }); + + InvalidOperationException actual = Assert.Throws(() => + ScriptableObjectSingletonCreator.EnsureSingletonAssets(candidates) + ); + Assert.AreSame(expected, actual); + Assert.AreEqual(1, candidates.CountReadCount); + Assert.IsFalse(ScriptableObjectSingletonCreator._isEnsuring); + Assert.AreEqual(batchDepth, AssetDatabaseBatchHelper.CurrentBatchDepth); + Assert.AreEqual(unityBatchDepth, AssetDatabaseBatchHelper.ActualUnityBatchDepth); + Assert.IsTrue( + AssetDatabase.LoadAssetAtPath(TargetAssetPath) == null + ); + + ScriptableObjectSingletonCreator.EnsureSingletonAssets( + new[] { typeof(CreatorPathSingleton) } + ); + yield return null; + AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); + yield return null; + Assert.IsTrue( + AssetDatabase.LoadAssetAtPath(TargetAssetPath) != null + ); + Assert.IsFalse(ScriptableObjectSingletonCreator._isEnsuring); + } + + [Test] + public void CandidateEnumerationBlocksNestedEnsureUntilOuterAttemptEnds() + { + int batchDepth = AssetDatabaseBatchHelper.CurrentBatchDepth; + SingletonCandidateList nested = new SingletonCandidateList(() => + throw new InvalidOperationException( + nameof(CandidateEnumerationBlocksNestedEnsureUntilOuterAttemptEnds) + ) + ); + SingletonCandidateList candidates = new SingletonCandidateList(() => + { + Assert.IsTrue(ScriptableObjectSingletonCreator._isEnsuring); + ScriptableObjectSingletonCreator.EnsureSingletonAssets(nested); + }); + + ScriptableObjectSingletonCreator.EnsureSingletonAssets(candidates); + Assert.AreEqual(3, candidates.CountReadCount); + Assert.AreEqual(0, nested.CountReadCount); + Assert.IsFalse(ScriptableObjectSingletonCreator._isEnsuring); + Assert.AreEqual(batchDepth, AssetDatabaseBatchHelper.CurrentBatchDepth); + } + + [TestCase(1)] + [TestCase(3)] + public void CandidateFailurePreservesAnExistingBatch(int failureRead) + { + int batchDepth = AssetDatabaseBatchHelper.CurrentBatchDepth; + int unityBatchDepth = AssetDatabaseBatchHelper.ActualUnityBatchDepth; + InvalidOperationException expected = new InvalidOperationException( + nameof(CandidateFailurePreservesAnExistingBatch) + ); + int countReads = 0; + using (AssetDatabaseBatchHelper.BeginBatch(refreshOnDispose: false)) + { + int outerDepth = AssetDatabaseBatchHelper.CurrentBatchDepth; + int outerUnityDepth = AssetDatabaseBatchHelper.ActualUnityBatchDepth; + SingletonCandidateList candidates = new SingletonCandidateList(() => + { + ++countReads; + Assert.IsTrue(ScriptableObjectSingletonCreator._isEnsuring); + Assert.AreEqual(outerDepth + 1, AssetDatabaseBatchHelper.CurrentBatchDepth); + if (countReads == failureRead) + { + throw expected; + } + }); + InvalidOperationException actual = Assert.Throws(() => + ScriptableObjectSingletonCreator.EnsureSingletonAssets(candidates) + ); + Assert.AreSame(expected, actual); + Assert.AreEqual(failureRead, candidates.CountReadCount); + Assert.IsFalse(ScriptableObjectSingletonCreator._isEnsuring); + Assert.AreEqual(outerDepth, AssetDatabaseBatchHelper.CurrentBatchDepth); + Assert.AreEqual(outerUnityDepth, AssetDatabaseBatchHelper.ActualUnityBatchDepth); + } + Assert.AreEqual(batchDepth, AssetDatabaseBatchHelper.CurrentBatchDepth); + Assert.AreEqual(unityBatchDepth, AssetDatabaseBatchHelper.ActualUnityBatchDepth); + } + [UnityTest] public IEnumerator CreatesAssetAtAttributePath() { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -352,7 +447,7 @@ public IEnumerator RelocatesExistingAssetToAttributePath() AssetDatabase.SaveAssets(); yield return null; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; CreatorPathSingleton relocated = AssetDatabase.LoadAssetAtPath( @@ -378,7 +473,7 @@ public IEnumerator RelocatesExistingAssetToAttributePathFromMismatchedParentCase AssetDatabase.CreateAsset(instance, WrongAssetPathCaseVariant); AssetDatabase.SaveAssets(); yield return null; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -432,17 +527,18 @@ public IEnumerator CreatesAssetWhenFolderOnlyExistsOnDisk( duplicate folders are contractual. */ - Func originalFilter = ScriptableObjectSingletonCreator.TypeFilter; - ScriptableObjectSingletonCreator.TypeFilter = type => type == scenario.SingletonType; + Func originalFilter = ScriptableObjectSingletonCreatorTestAccess.TypeFilter; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = type => + type == scenario.SingletonType; try { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; } finally { - ScriptableObjectSingletonCreator.TypeFilter = originalFilter; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = originalFilter; } yield return WaitUntilFolderValid(scenario.FolderPath); @@ -488,7 +584,7 @@ public IEnumerator SkipsCreationWhenAssetFileExistsButIsNotImported() RegexOptions.IgnoreCase ) ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; LogAssert.ignoreFailingMessages = false; @@ -520,7 +616,7 @@ public IEnumerator SkipsCreationWhenAssetFileExistsButIsNotImported() AssetDatabaseBatchHelper.RefreshIfNotBatching(); yield return null; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -542,7 +638,7 @@ resulting asset instead. DeleteAssetIfExists(TargetAssetPath); yield return null; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return WaitUntilAssetLoaded(TargetAssetPath); Assert.IsTrue(AssetDatabase.LoadAssetAtPath(TargetAssetPath) != null); @@ -567,14 +663,14 @@ until it unloads. "Deleting the asset body file must unload it (its .meta/GUID is retained separately)." ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return WaitUntilAssetLoaded(TargetAssetPath); Assert.IsTrue(AssetDatabase.LoadAssetAtPath(TargetAssetPath) != null); } [UnityTest] - public IEnumerator EnsureSingletonAssetsCreatesFolderHierarchyWhenMissing() + public IEnumerator MetadataCreationPhaseCreatesFolderHierarchyWhenMissing() { string metadataFolder = "Assets/Resources/Wallstop Studios/Unity Helpers"; @@ -589,7 +685,13 @@ public IEnumerator EnsureSingletonAssetsCreatesFolderHierarchyWhenMissing() "Setup: Metadata folder should not exist before test" ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonMetadata created = + ScriptableObjectSingletonMetadataUtility.CreateMetadataAsset(); + Assert.IsTrue( + created != null, + "The real metadata creation phase must create its asset." + ); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); @@ -623,7 +725,7 @@ public IEnumerator EnsureSingletonAssetsDoesNotThrowWhenFoldersAlreadyExist() ); Assert.DoesNotThrow( - () => ScriptableObjectSingletonCreator.EnsureSingletonAssets(), + () => ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(), "EnsureSingletonAssets should not throw when folders already exist" ); yield return null; diff --git a/Tests/Editor/Utils/ScriptableObjectSingletonCreatorTests.cs b/Tests/Editor/Utils/ScriptableObjectSingletonCreatorTests.cs index 2c6b4979e..2506faf85 100644 --- a/Tests/Editor/Utils/ScriptableObjectSingletonCreatorTests.cs +++ b/Tests/Editor/Utils/ScriptableObjectSingletonCreatorTests.cs @@ -28,7 +28,6 @@ public sealed class ScriptableObjectSingletonCreatorTests : CommonTestBase { private const string TestRoot = "Assets/Resources/CreatorTests"; private bool _previousEditorUiSuppress; - private bool _previousIgnoreCompilationState; private static IEnumerable AssetImportWorkerEnvironmentScenarios() { @@ -287,14 +286,12 @@ public IEnumerator SetUp() } yield return null; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = true; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = true; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = true; // Unity may report isCompiling/isUpdating during a test run after AssetDatabase operations. - _previousIgnoreCompilationState = - ScriptableObjectSingletonCreator.IgnoreCompilationState; - ScriptableObjectSingletonCreator.IgnoreCompilationState = true; - ScriptableObjectSingletonCreator.TypeFilter = static type => + + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = static type => type == typeof(CaseMismatch) || type == typeof(Duplicate) || type == typeof(A.NameCollision) @@ -312,8 +309,8 @@ public IEnumerator SetUp() EnsureFolder("Assets/Resources/Wallstop Studios/Unity Helpers"); } - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); } [UnityTearDown] @@ -359,18 +356,17 @@ public override IEnumerator UnityTearDown() AssetDatabase.SaveAssets(); } - ScriptableObjectSingletonCreator.TypeFilter = null; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = false; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = false; - ScriptableObjectSingletonCreator.IgnoreCompilationState = - _previousIgnoreCompilationState; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = false; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = false; + + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); EditorUi.Suppress = _previousEditorUiSuppress; // CleanupAllKnownTestFolders already batches its operations internally. CleanupAllKnownTestFolders(); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); } public override void OneTimeTearDown() @@ -402,7 +398,7 @@ public IEnumerator DoesNotCreateDuplicateSubfolderOnCaseMismatch() + $"Subfolders of Assets/Resources: [{subFolderList}]" ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching( @@ -488,7 +484,7 @@ public IEnumerator SkipsCreationWhenTargetPathOccupied() } LogAssert.Expect(LogType.Warning, new Regex("target path already occupied")); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; Assert.IsTrue( @@ -502,7 +498,7 @@ public IEnumerator WarnsOnTypeNameCollision() EnsureFolder("Assets/Resources/CreatorTests/Collision"); LogAssert.Expect(LogType.Warning, new Regex("Type name collision")); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; Assert.IsTrue( @@ -519,13 +515,13 @@ public IEnumerator EnsureSingletonAssetsIsIdempotent() AssetDatabase.DeleteAsset(targetPath); yield return null; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; string firstGuid = AssetDatabase.AssetPathToGUID(targetPath); Assert.IsFalse(string.IsNullOrEmpty(firstGuid)); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; string secondGuid = AssetDatabase.AssetPathToGUID(targetPath); @@ -540,20 +536,21 @@ public IEnumerator SkipsEnsureInsideAssetImportWorkerProcess() yield return null; Func originalDetector = - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck; - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck = static () => true; + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector; + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector = static () => true; try { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; Assert.IsTrue(AssetDatabase.LoadAssetAtPath(targetPath) == null); } finally { - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck = originalDetector; + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector = + originalDetector; } - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; Assert.IsTrue(AssetDatabase.LoadAssetAtPath(targetPath) != null); } @@ -569,14 +566,14 @@ public IEnumerator EnvironmentVariablesTriggerWorkerDetection( string originalValue = Environment.GetEnvironmentVariable(environmentVariable); Func originalDetector = - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck; + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector; try { Environment.SetEnvironmentVariable(environmentVariable, "1"); - ScriptableObjectSingletonCreator.ResetAssetImportWorkerDetectionStateForTests(); - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck = null; + ScriptableObjectSingletonCreatorTestAccess.ResetAssetImportWorkerDetectionState(); + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector = null; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; Assert.IsTrue(AssetDatabase.LoadAssetAtPath(targetPath) == null); @@ -584,11 +581,12 @@ public IEnumerator EnvironmentVariablesTriggerWorkerDetection( finally { Environment.SetEnvironmentVariable(environmentVariable, originalValue); - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck = originalDetector; - ScriptableObjectSingletonCreator.ResetAssetImportWorkerDetectionStateForTests(); + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector = + originalDetector; + ScriptableObjectSingletonCreatorTestAccess.ResetAssetImportWorkerDetectionState(); } - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; Assert.IsTrue(AssetDatabase.LoadAssetAtPath(targetPath) != null); } @@ -601,19 +599,20 @@ public IEnumerator DetectorExceptionsDoNotBlockEnsure() yield return null; Func originalDetector = - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck; - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck = static () => + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector; + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector = static () => throw new InvalidOperationException("detector failure"); try { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; Assert.IsTrue(AssetDatabase.LoadAssetAtPath(targetPath) != null); } finally { - ScriptableObjectSingletonCreator.AssetImportWorkerProcessCheck = originalDetector; + ScriptableObjectSingletonCreator._defaultAssetImportWorkerDetector = + originalDetector; } } @@ -625,7 +624,7 @@ public IEnumerator RetriesCreationAfterTemporaryFolderBlock() string blockerMeta = retryFolder + ".meta"; string retryFolderVariant = retryFolder + " 1"; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); AssetDatabase.DeleteAsset(retryAsset); AssetDatabase.DeleteAsset(retryFolder); @@ -651,11 +650,11 @@ public IEnumerator RetriesCreationAfterTemporaryFolderBlock() Unity versions differ in blocked-folder error wording and counts; assert the observable asset and folder results. */ - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; LogAssert.ignoreFailingMessages = true; try { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); } finally { @@ -678,7 +677,7 @@ and folder results. // Via AssetDatabase first, so its internal state is cleared properly. AssetDatabase.DeleteAsset(retryFolder); CleanupRetryTestState(retryFolder, retryAsset, blockerMeta, retryFolderVariant); - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -711,7 +710,7 @@ and folder results. bool preRetryDirExists = Directory.Exists(GetAbsolutePath(retryFolder)); bool preRetryMetaExists = File.Exists(blockerMetaAbsolute); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -772,16 +771,16 @@ public IEnumerator DoesNotCreateAlternateFolderWhenFileConflicts() Unity versions differ in blocked-folder error wording and counts; assert the observable asset and folder results. */ - ScriptableObjectSingletonCreator.DisableAutomaticRetries = true; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = true; LogAssert.ignoreFailingMessages = true; try { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); } finally { LogAssert.ignoreFailingMessages = false; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; } yield return null; @@ -806,7 +805,7 @@ public IEnumerator AutomaticRetriesCanBeDisabled() string noRetryVariant = noRetryFolder + " 1"; string blockerMeta = noRetryFolder + ".meta"; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); AssetDatabase.DeleteAsset(noRetryAsset); AssetDatabase.DeleteAsset(noRetryFolder); @@ -839,18 +838,20 @@ public IEnumerator AutomaticRetriesCanBeDisabled() Unity versions differ in blocked-folder error wording and counts; assert the observable asset and folder results. */ - bool originalRetrySetting = ScriptableObjectSingletonCreator.DisableAutomaticRetries; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = true; + bool originalRetrySetting = + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = true; LogAssert.ignoreFailingMessages = true; try { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; } finally { LogAssert.ignoreFailingMessages = false; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = originalRetrySetting; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = + originalRetrySetting; } Assert.IsTrue( @@ -881,10 +882,10 @@ and folder results. "Asset should not be created automatically while retries are disabled" ); - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -892,7 +893,8 @@ and folder results. bool assetExists = AssetDatabase.LoadAssetAtPath(noRetryAsset) != null; bool variantExists = AssetDatabase.IsValidFolder(noRetryVariant); - ScriptableObjectSingletonCreator.DisableAutomaticRetries = originalRetrySetting; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = + originalRetrySetting; string absoluteAssetPath = GetAbsolutePath(noRetryAsset); bool assetFileOnDisk = File.Exists(absoluteAssetPath); @@ -921,7 +923,7 @@ public IEnumerator PartialSuccessResetsRetryCounter() string caseTestFolder = "Assets/Resources/CaseTest"; string caseTestAsset = caseTestFolder + "/CaseMismatch.asset"; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); AssetDatabase.DeleteAsset(retryAsset); if (AssetDatabase.IsValidFolder(retryFolder)) @@ -948,7 +950,7 @@ success and later recovery. LogAssert.ignoreFailingMessages = true; try { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); } finally @@ -957,7 +959,7 @@ success and later recovery. Cancel the queued retry before suppression ends; otherwise the next frame logs its expected folder failure again. */ - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); LogAssert.ignoreFailingMessages = false; } yield return null; @@ -986,7 +988,7 @@ folder failure again. AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -1011,7 +1013,7 @@ public IEnumerator AssetCreationDoesNotDeleteValidFilesOnDisk() string raceFolder = TestRoot + "/Race"; string raceAsset = raceFolder + "/AssetDatabaseRaceSingleton.asset"; - ScriptableObjectSingletonCreator.ResetRetryStateForTests(); + ScriptableObjectSingletonCreatorTestAccess.ResetRetryState(); AssetDatabase.DeleteAsset(raceAsset); if (AssetDatabase.IsValidFolder(raceFolder)) @@ -1034,7 +1036,7 @@ public IEnumerator AssetCreationDoesNotDeleteValidFilesOnDisk() "Race asset should not exist before test" ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -1081,7 +1083,7 @@ public IEnumerator CaseMismatchFolderIsReused( $"Setup: Folder '{existingFolder}' should exist" ); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -1220,7 +1222,7 @@ public IEnumerator CaseMismatchDiagnosticsAreHelpful() ); yield return null; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching(); @@ -1278,7 +1280,7 @@ public IEnumerator DefersEnsureDuringCompilation() yield return null; // EditorApplication.isCompiling cannot be set, so only the not-compiling path is exercised. - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.RefreshIfNotBatching( ImportAssetOptions.ForceSynchronousImport @@ -1288,44 +1290,6 @@ public IEnumerator DefersEnsureDuringCompilation() Assert.IsTrue(asset != null, "Asset should be created when not compiling or updating"); } - /// - /// Verifies that IgnoreCompilationState property allows bypassing the isCompiling/isUpdating check. - /// This is essential for tests that need to explicitly call EnsureSingletonAssets. - /// - [UnityTest] - public IEnumerator IgnoreCompilationStateAllowsBypassingCompilationCheck() - { - string targetPath = "Assets/Resources/CaseTest/CaseMismatch.asset"; - AssetDatabase.DeleteAsset(targetPath); - yield return null; - - bool previousIgnoreCompilationState = - ScriptableObjectSingletonCreator.IgnoreCompilationState; - - try - { - ScriptableObjectSingletonCreator.IgnoreCompilationState = true; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); - yield return null; - AssetDatabaseBatchHelper.RefreshIfNotBatching( - ImportAssetOptions.ForceSynchronousImport - ); - - Object asset = AssetDatabase.LoadAssetAtPath(targetPath); - Assert.IsTrue( - asset != null, - "Asset should be created when IgnoreCompilationState is true. " - + $"isCompiling={EditorApplication.isCompiling}, " - + $"isUpdating={EditorApplication.isUpdating}" - ); - } - finally - { - ScriptableObjectSingletonCreator.IgnoreCompilationState = - previousIgnoreCompilationState; - } - } - /// /// Verifies that SafeDestroyInstance does not throw when destroying a partially-created asset. /// This tests the fix for "Destroying assets is not permitted" errors. @@ -1497,7 +1461,7 @@ public IEnumerator MultipleEnsureCallsAreRobust() Assert.DoesNotThrow( () => { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); }, $"Ensure call {i + 1} should not throw" ); diff --git a/Tests/Editor/Utils/ScriptableObjectSingletonRecoveryTests.cs b/Tests/Editor/Utils/ScriptableObjectSingletonRecoveryTests.cs index 2d1a2d579..bbe195f12 100644 --- a/Tests/Editor/Utils/ScriptableObjectSingletonRecoveryTests.cs +++ b/Tests/Editor/Utils/ScriptableObjectSingletonRecoveryTests.cs @@ -26,6 +26,72 @@ namespace WallstopStudios.UnityHelpers.Tests.Utils [NUnit.Framework.Category("Fast")] public sealed class ScriptableObjectSingletonRecoveryTests : CommonTestBase { + [TestCase(null, TestName = "LoadPath.NullFolder.UsesTypeName")] + [TestCase("", TestName = "LoadPath.EmptyFolder.UsesTypeName")] + [TestCase(" \t\r\n", TestName = "LoadPath.BlankFolder.UsesTypeName")] + [TestCase("///", TestName = "LoadPath.SlashFolder.UsesTypeName")] + [TestCase("/ \t /", TestName = "LoadPath.SlashWrappedBlankFolder.UsesTypeName")] + [TestCase("/\u2003/", TestName = "LoadPath.SlashWrappedUnicodeBlankFolder.UsesTypeName")] + public void BlankResourceFolderFallsBackToTypeName(string resourcesPath) + { + Assert.AreEqual( + nameof(AbsentAssetSingleton), + AbsentAssetSingleton.BuildLoadPath(resourcesPath, nameof(AbsentAssetSingleton)) + ); + } + + [TestCase( + "Sub Folder", + "Sub Folder/AbsentAssetSingleton", + TestName = "LoadPath.EmbeddedSpaces.Preserved" + )] + [TestCase( + " /Sub/Folder/ ", + "Sub/Folder/AbsentAssetSingleton", + TestName = "LoadPath.OuterWhitespaceAndSlashes.Trimmed" + )] + public void NonblankResourceFolderPreservesPath(string resourcesPath, string expected) + { + Assert.AreEqual( + expected, + AbsentAssetSingleton.BuildLoadPath(resourcesPath, nameof(AbsentAssetSingleton)) + ); + } + + [TestCase(null, TestName = "LoadPath.NullType.Rejected")] + [TestCase("", TestName = "LoadPath.EmptyType.Rejected")] + [TestCase(" \t", TestName = "LoadPath.BlankType.Rejected")] + public void BlankTypeNameCannotBuildResourceLoadPath(string typeName) + { + Assert.That(AbsentAssetSingleton.BuildLoadPath("Sub/Folder", typeName), Is.Null); + } + + [TestCase(null, TestName = "CanonicalPath.Null.Rejected")] + [TestCase("", TestName = "CanonicalPath.Empty.Rejected")] + [TestCase(" \t", TestName = "CanonicalPath.Blank.Rejected")] + [TestCase("///", TestName = "CanonicalPath.Slashes.Rejected")] + [TestCase("/ \t /", TestName = "CanonicalPath.SlashWrappedBlank.Rejected")] + [TestCase("/\u2003/", TestName = "CanonicalPath.SlashWrappedUnicodeBlank.Rejected")] + public void BlankCanonicalPathCannotIdentifyAsset(string loadPath) + { + Assert.That(AbsentAssetSingleton.BuildCanonicalAssetPath(loadPath), Is.Null); + } + + [TestCase( + "Sub Folder/Settings", + "Assets/Resources/Sub Folder/Settings.asset", + TestName = "CanonicalPath.EmbeddedSpaces.Preserved" + )] + [TestCase( + "Sub\\Folder/Settings", + "Assets/Resources/Sub/Folder/Settings.asset", + TestName = "CanonicalPath.Backslashes.Normalized" + )] + public void NonblankCanonicalPathIdentifiesAsset(string loadPath, string expected) + { + Assert.AreEqual(expected, AbsentAssetSingleton.BuildCanonicalAssetPath(loadPath)); + } + [TearDown] public void ResetSingletons() { @@ -285,7 +351,7 @@ public void ShutdownDoesNotReloadADestroyedCachedAsset() Object.DestroyImmediate(asset); // UNH-SUPPRESS: destroyed cached state is the subject - RuntimeSingletonRegistry.NotifyApplicationQuittingForTesting(); + RuntimeStateTestUtilities.SimulateApplicationQuitting(); try { DestroyedAssetSingleton resolved = DestroyedAssetSingleton.Instance; @@ -296,7 +362,7 @@ public void ShutdownDoesNotReloadADestroyedCachedAsset() } finally { - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); } } diff --git a/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs b/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs index e876af1c8..18bef5b35 100644 --- a/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs +++ b/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs @@ -284,7 +284,7 @@ public override void CommonOneTimeSetUp() [UnitySetUp] public IEnumerator SetUp() { - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); _previousEditorUiSuppress = EditorUi.Suppress; EditorUi.Suppress = true; @@ -835,7 +835,7 @@ public IEnumerator ApplicationShutdownDoesNotEvaluateAnUnloadedSingleton() MissingResourceSingleton.ClearInstance(); Assert.IsFalse(MissingResourceSingleton._lazyInstance.IsValueCreated); - RuntimeSingletonRegistry.NotifyApplicationQuittingForTesting(); + RuntimeStateTestUtilities.SimulateApplicationQuitting(); try { Assert.IsTrue(MissingResourceSingleton.Instance == null); @@ -843,7 +843,7 @@ public IEnumerator ApplicationShutdownDoesNotEvaluateAnUnloadedSingleton() } finally { - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); } yield break; @@ -855,7 +855,7 @@ public IEnumerator ApplicationShutdownKeepsAnAlreadyLoadedSingletonAvailable() TestSingleton loaded = TestSingleton.Instance; Assert.IsTrue(loaded != null); - RuntimeSingletonRegistry.NotifyApplicationQuittingForTesting(); + RuntimeStateTestUtilities.SimulateApplicationQuitting(); try { Assert.AreSame(loaded, TestSingleton.Instance); @@ -863,7 +863,7 @@ public IEnumerator ApplicationShutdownKeepsAnAlreadyLoadedSingletonAvailable() } finally { - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); } yield break; @@ -957,13 +957,25 @@ public IEnumerator MetadataAssetTracksSingletonEntries( AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; + if ( + AssetDatabase.LoadAssetAtPath( + ScriptableObjectSingletonMetadata.AssetPath + ) == null + ) + { + Assert.IsTrue( + ScriptableObjectSingletonMetadataUtility.CreateMetadataAsset() != null, + "The fixture must create real metadata before testing singleton entry updates under suppression." + ); + } + using ( SingletonCreatorTestScope scope = SingletonCreatorTestScope.RestrictTo( scenario.SingletonType ) ) { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); } yield return null; @@ -1146,7 +1158,7 @@ public IEnumerator MetadataLoadPathChoosesCanonicalAssetWhenFolderContainsDuplic using (SingletonCreatorTestScope.RestrictTo(typeof(DeepPathResourceSingleton))) { - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); } DeepPathResourceSingleton.ClearInstance(); @@ -1277,7 +1289,7 @@ public IEnumerator OffThreadShutdownRefusalStillThrowsDescriptiveException() TestSingleton.ClearInstance(); yield return null; - RuntimeSingletonRegistry.NotifyApplicationQuittingForTesting(); + RuntimeStateTestUtilities.SimulateApplicationQuitting(); Task task; try { @@ -1293,7 +1305,7 @@ public IEnumerator OffThreadShutdownRefusalStillThrowsDescriptiveException() } finally { - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); } Assert.IsTrue(task.IsFaulted); @@ -1402,7 +1414,7 @@ private sealed class SingletonCreatorTestScope : IDisposable private readonly bool _previousIncludeTests; private readonly bool _previousIgnoreExclusion; private readonly bool _previousAllowAssetCreation; - private readonly bool _previousIgnoreCompilationState; + private readonly Func _previousFilter; private SingletonCreatorTestScope(Type[] allowedTypes) @@ -1419,20 +1431,21 @@ private SingletonCreatorTestScope(Type[] allowedTypes) EnsureMetadataFolder(); System.Collections.Generic.HashSet allowed = new(allowedTypes); - _previousIncludeTests = ScriptableObjectSingletonCreator.IncludeTestAssemblies; + _previousIncludeTests = + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies; _previousIgnoreExclusion = - ScriptableObjectSingletonCreator.IgnoreExclusionAttribute; + ScriptableObjectSingletonCreatorTestAccess.IgnoreExclusionAttribute; _previousAllowAssetCreation = - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression; - _previousIgnoreCompilationState = - ScriptableObjectSingletonCreator.IgnoreCompilationState; - _previousFilter = ScriptableObjectSingletonCreator.TypeFilter; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = true; - ScriptableObjectSingletonCreator.IgnoreExclusionAttribute = true; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression; + + _previousFilter = ScriptableObjectSingletonCreatorTestAccess.TypeFilter; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = true; + ScriptableObjectSingletonCreatorTestAccess.IgnoreExclusionAttribute = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = + true; // Unity may report isCompiling/isUpdating during a test run after AssetDatabase operations. - ScriptableObjectSingletonCreator.IgnoreCompilationState = true; - ScriptableObjectSingletonCreator.TypeFilter = type => + + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = type => { if (!allowed.Contains(type)) { @@ -1481,14 +1494,13 @@ private static void EnsureMetadataFolder() public void Dispose() { - ScriptableObjectSingletonCreator.TypeFilter = _previousFilter; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = _previousIncludeTests; - ScriptableObjectSingletonCreator.IgnoreExclusionAttribute = + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = _previousFilter; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = + _previousIncludeTests; + ScriptableObjectSingletonCreatorTestAccess.IgnoreExclusionAttribute = _previousIgnoreExclusion; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = _previousAllowAssetCreation; - ScriptableObjectSingletonCreator.IgnoreCompilationState = - _previousIgnoreCompilationState; } } } diff --git a/Tests/Editor/Utils/SingletonAssetPollutionPreventionTests.cs b/Tests/Editor/Utils/SingletonAssetPollutionPreventionTests.cs index 350e1fba3..fa119a37a 100644 --- a/Tests/Editor/Utils/SingletonAssetPollutionPreventionTests.cs +++ b/Tests/Editor/Utils/SingletonAssetPollutionPreventionTests.cs @@ -275,11 +275,12 @@ public IEnumerator SetUp() _previousEditorUiSuppress = EditorUi.Suppress; EditorUi.Suppress = true; - _previousIncludeTestAssemblies = ScriptableObjectSingletonCreator.IncludeTestAssemblies; - _previousTypeFilter = ScriptableObjectSingletonCreator.TypeFilter; - ScriptableObjectSingletonCreator.IncludeTestAssemblies = false; - ScriptableObjectSingletonCreator.TypeFilter = null; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = true; + _previousIncludeTestAssemblies = + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies; + _previousTypeFilter = ScriptableObjectSingletonCreatorTestAccess.TypeFilter; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = false; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = true; yield return CleanupExistingTestSingletonAssets(); } @@ -295,15 +296,16 @@ public override IEnumerator UnityTearDown() _createdAssets.Clear(); yield return CleanupExistingTestSingletonAssets(); yield return CleanupTestFolders(); - ScriptableObjectSingletonCreator.IncludeTestAssemblies = _previousIncludeTestAssemblies; - ScriptableObjectSingletonCreator.TypeFilter = _previousTypeFilter; - ScriptableObjectSingletonCreator.DisableAutomaticRetries = false; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = false; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = + _previousIncludeTestAssemblies; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = _previousTypeFilter; + ScriptableObjectSingletonCreatorTestAccess.DisableAutomaticRetries = false; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = false; EditorUi.Suppress = _previousEditorUiSuppress; AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); CleanupAllKnownTestFolders(); - AssetPostprocessorDeferral.FlushForTesting(); + AssetPostprocessorDeferralTestAccess.Flush(); yield return null; } @@ -452,11 +454,11 @@ public void MainProductionFoldersAreProtected() [UnityTest] public IEnumerator EnsureSingletonAssetsDoesNotCreateAssetsForExcludedTypes() { - ScriptableObjectSingletonCreator.IncludeTestAssemblies = true; - ScriptableObjectSingletonCreator.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = true; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; // Ensure the metadata folder exists to prevent modal dialogs EnsureMetadataFolder(); - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = true; List beforePaths = new(); foreach (Type type in AllTestSingletonTypesWithExclusion) @@ -471,7 +473,7 @@ public IEnumerator EnsureSingletonAssetsDoesNotCreateAssetsForExcludedTypes() } } - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -507,14 +509,14 @@ public IEnumerator EnsureSingletonAssetsDoesNotCreateAssetsForExcludedTypes() [UnityTest] public IEnumerator EnsureSingletonAssetsRespectsExclusionEvenWithIncludeTestAssembliesTrue() { - ScriptableObjectSingletonCreator.IncludeTestAssemblies = true; - ScriptableObjectSingletonCreator.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = true; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; // Ensure the metadata folder exists to prevent modal dialogs EnsureMetadataFolder(); - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = true; yield return CleanupExistingTestSingletonAssets(); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -545,14 +547,14 @@ public IEnumerator EnsureSingletonAssetsRespectsExclusionEvenWithIncludeTestAsse [UnityTest] public IEnumerator TypeFilterBypassDoesNotOverrideExclusionAttribute() { - ScriptableObjectSingletonCreator.IncludeTestAssemblies = true; - ScriptableObjectSingletonCreator.TypeFilter = _ => true; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = true; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = _ => true; // Ensure the metadata folder exists to prevent modal dialogs EnsureMetadataFolder(); - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = true; yield return CleanupExistingTestSingletonAssets(); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -593,10 +595,11 @@ public IEnumerator ResourcesFolderRemainsCleanAfterMultipleEnsureCalls() for (int i = 0; i < 3; ++i) { - ScriptableObjectSingletonCreator.IncludeTestAssemblies = true; - ScriptableObjectSingletonCreator.TypeFilter = null; - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = true; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = + true; + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; @@ -663,14 +666,14 @@ public void ExcludeFromSingletonCreationAttributeIsDefinedCorrectly() [UnityTest] public IEnumerator TestTypesWithoutExclusionNotCreatedWhenIncludeTestAssembliesIsFalse() { - ScriptableObjectSingletonCreator.IncludeTestAssemblies = false; - ScriptableObjectSingletonCreator.TypeFilter = null; + ScriptableObjectSingletonCreatorTestAccess.IncludeTestAssemblies = false; + ScriptableObjectSingletonCreatorTestAccess.TypeFilter = null; // Ensure the metadata folder exists to prevent modal dialogs EnsureMetadataFolder(); - ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression = true; + ScriptableObjectSingletonCreatorTestAccess.AllowAssetCreationDuringSuppression = true; yield return CleanupExistingTestSingletonAssets(); - ScriptableObjectSingletonCreator.EnsureSingletonAssets(); + ScriptableObjectSingletonCreatorTestAccess.EnsureSingletonAssets(); yield return null; AssetDatabaseBatchHelper.SaveAndRefreshIfNotBatching(); yield return null; diff --git a/Tests/Editor/Utils/WButton/WButtonIntegrationTests.cs b/Tests/Editor/Utils/WButton/WButtonIntegrationTests.cs index ce1643bb7..76746cd17 100644 --- a/Tests/Editor/Utils/WButton/WButtonIntegrationTests.cs +++ b/Tests/Editor/Utils/WButton/WButtonIntegrationTests.cs @@ -25,11 +25,11 @@ public sealed class WButtonIntegrationTests : BatchedEditorTestBase { private static void ClearWButtonCaches() { - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPriorityWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPlacementWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPriorityWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPlacementWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); } @@ -239,7 +239,7 @@ public void MultipleWButtonsInSameWGroupRenderTogether() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List sharedGroupKeys = groupCounts .Keys.Where(k => string.Equals(k._groupName, "SharedGroup", System.StringComparison.Ordinal) @@ -702,7 +702,7 @@ public void ChangesToPageSizeSettingTakeEffect() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); int totalButtons = groupCounts.Values.Sum(); Assert.That(totalButtons, Is.GreaterThan(0), "Should have buttons rendered"); diff --git a/Tests/Editor/Utils/WButton/WButtonRenderingTests.cs b/Tests/Editor/Utils/WButton/WButtonRenderingTests.cs index f02352ae9..3362411c7 100644 --- a/Tests/Editor/Utils/WButton/WButtonRenderingTests.cs +++ b/Tests/Editor/Utils/WButton/WButtonRenderingTests.cs @@ -25,11 +25,11 @@ public sealed class WButtonRenderingTests : BatchedEditorTestBase { private static void ClearWButtonCaches() { - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPriorityWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPlacementWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPriorityWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPlacementWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); } @@ -278,7 +278,7 @@ public void MixedPlacementGroupsRenderInCorrectOrder() Assert.That(drawnAtTop, Is.True, "Should render top placement group at top"); Assert.That(drawnAtBottom, Is.True, "Should render bottom placement group at bottom"); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List topGroups = groupCounts .Keys.Where(k => k._groupPlacement == WButtonGroupPlacement.Top) .ToList(); @@ -310,7 +310,7 @@ public void ButtonsInSameGroupAreRenderedTogether() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List testGroupKeys = groupCounts .Keys.Where(k => string.Equals(k._groupName, "TestGroup", System.StringComparison.Ordinal) @@ -345,7 +345,7 @@ public void ButtonsWithoutGroupAreRenderedInDefaultGroup() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List ungroupedKeys = groupCounts .Keys.Where(k => string.IsNullOrEmpty(k._groupName)) .ToList(); @@ -373,7 +373,7 @@ public void MultipleGroupsRenderInPriorityOrder() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey highPriorityKey = groupCounts.Keys.First(k => string.Equals(k._groupName, "HighPriority", System.StringComparison.Ordinal) ); @@ -408,7 +408,7 @@ public void GroupHeadersDisplayCorrectLabels() globalPlacementIsTop: true ); - Dictionary groupNames = WButtonGUI.GetGroupNamesForTesting(); + Dictionary groupNames = WButtonGUITestAccess.GetGroupNames(); Assert.That( groupNames.Values, @@ -420,14 +420,14 @@ public void GroupHeadersDisplayCorrectLabels() [Test] public void CustomGroupNamesAppearInHeaders() { - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); Dictionary names = new() { { -1, "MyCustomGroup" } }; Dictionary counts = new() { { -1, 2 }, { -2, 1 } }; - WButtonGUI.SetGroupNamesForTesting(names); - WButtonGUI.SetGroupCountsForTesting(counts); + WButtonGUITestAccess.SetGroupNames(names); + WButtonGUITestAccess.SetGroupCounts(counts); - GUIContent header = WButtonGUI.BuildGroupHeader(-1); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(-1); Assert.That( header.text, @@ -456,7 +456,7 @@ public void EmptyGroupsAreNotRendered() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); foreach (KeyValuePair entry in groupCounts) { @@ -644,7 +644,7 @@ public void PaginationAppearsWhenButtonCountExceedsPageSize() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); int totalButtons = groupCounts.Values.Sum(); Assert.That( @@ -1144,7 +1144,7 @@ public void SpecialCharactersInGroupNamesHandled() "Should handle special characters in group names" ); - Dictionary groupNames = WButtonGUI.GetGroupNamesForTesting(); + Dictionary groupNames = WButtonGUITestAccess.GetGroupNames(); Assert.That( groupNames.Values.Any(n => n.Contains("&")), Is.True, diff --git a/Tests/Editor/Utils/WGroupAnimationStateTests.cs b/Tests/Editor/Utils/WGroupAnimationStateTests.cs index 1abe6a164..22f227270 100644 --- a/Tests/Editor/Utils/WGroupAnimationStateTests.cs +++ b/Tests/Editor/Utils/WGroupAnimationStateTests.cs @@ -48,7 +48,7 @@ public void SetUp() _originalTweenEnabled = settings.WGroupFoldoutTweenEnabled; _originalTweenSpeed = settings.WGroupFoldoutSpeed; - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); } [TearDown] @@ -58,7 +58,7 @@ public void TearDown() settings.WGroupFoldoutTweenEnabled = _originalTweenEnabled; settings.WGroupFoldoutSpeed = _originalTweenSpeed; - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); } /// @@ -75,7 +75,7 @@ public void TearDown() [Test] public void TheAnimationCacheStopsAtItsBound() { - int bound = WGroupAnimationState.MaxCachedAnimations; + int bound = WGroupAnimationState.MaxFoldoutAnimations; Assert.Greater(bound, 0, "the bound must be positive for this to measure anything"); for (int index = 0; index < bound * 2; ++index) @@ -87,7 +87,7 @@ public void TheAnimationCacheStopsAtItsBound() _ = WGroupAnimationState.GetOrCreateAnim(definition, expanded: true); } - Assert.AreEqual(bound, WGroupAnimationState.CachedAnimationCount); + Assert.AreEqual(bound, WGroupAnimationState.FoldoutAnimations.Count); } [Test] @@ -247,7 +247,7 @@ public void ClearCacheRemovesAllAnimations() expanded: true ); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); AnimBool anim1After = WGroupAnimationState.GetOrCreateAnim(definition1, expanded: true); AnimBool anim2After = WGroupAnimationState.GetOrCreateAnim(definition2, expanded: true); @@ -337,7 +337,7 @@ public void GetOrCreateAnimRespectsVariousSpeedSettings(float speed) UnityHelpersSettings settings = UnityHelpersSettings.instance; settings.WGroupFoldoutSpeed = speed; - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); WGroupDefinition definition = CreateTestDefinition("TestGroup", "testProperty"); AnimBool anim = WGroupAnimationState.GetOrCreateAnim(definition, expanded: true); @@ -351,9 +351,9 @@ public void GetOrCreateAnimRespectsVariousSpeedSettings(float speed) [Test] public void ClearCacheCanBeCalledMultipleTimesSafely() { - WGroupAnimationState.ClearCache(); - WGroupAnimationState.ClearCache(); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); + WGroupAnimationState.FoldoutAnimations.Clear(); + WGroupAnimationState.FoldoutAnimations.Clear(); WGroupDefinition definition = CreateTestDefinition("TestGroup", "testProperty"); AnimBool anim = WGroupAnimationState.GetOrCreateAnim(definition, expanded: true); @@ -554,7 +554,7 @@ public void ClearCacheRemovesAllAnimationsIncludingThoseWithTargetInstanceIds() expanded: true ); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); AnimBool anim1After = WGroupAnimationState.GetOrCreateAnim( definition, diff --git a/Tests/Editor/Utils/WGroupGUITweenTests.cs b/Tests/Editor/Utils/WGroupGUITweenTests.cs index 8b54a5405..ad6037358 100644 --- a/Tests/Editor/Utils/WGroupGUITweenTests.cs +++ b/Tests/Editor/Utils/WGroupGUITweenTests.cs @@ -53,7 +53,7 @@ public void SetUp() _originalTweenEnabled = settings.WGroupFoldoutTweenEnabled; _originalTweenSpeed = settings.WGroupFoldoutSpeed; - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); } [TearDown] @@ -63,7 +63,7 @@ public void TearDown() settings.WGroupFoldoutTweenEnabled = _originalTweenEnabled; settings.WGroupFoldoutSpeed = _originalTweenSpeed; - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); } [Test] @@ -235,7 +235,7 @@ public void TweenDisabledNoAnimBoolCreatedForFadeProgress() hideHeader: false ); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); float progressExpanded = WGroupAnimationState.GetFadeProgress( definition, @@ -560,7 +560,7 @@ public void ClearCacheDuringAnimationDoesNotCauseIssues() AnimBool animBefore = WGroupAnimationState.GetOrCreateAnim(definition, expanded: true); Assert.IsTrue(animBefore != null, "AnimBool should exist before clearing cache."); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); AnimBool animAfter = WGroupAnimationState.GetOrCreateAnim(definition, expanded: true); Assert.IsTrue(animAfter != null, "AnimBool should be recreated after clearing cache."); @@ -646,7 +646,7 @@ public void VariousSpeedSettingsProduceValidAnimations(float speed) settings.WGroupFoldoutTweenEnabled = true; settings.WGroupFoldoutSpeed = speed; - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); WGroupDefinition definition = CreateTestDefinition( "VariableSpeedGroup", "testProperty", @@ -1070,7 +1070,7 @@ public void ClearCacheRemovesAllTargetInstanceIdEntries() Assert.IsTrue(anim2Before != null, "AnimBool 2 should exist before clearing."); Assert.IsTrue(anim3Before != null, "AnimBool 3 should exist before clearing."); - WGroupAnimationState.ClearCache(); + WGroupAnimationState.FoldoutAnimations.Clear(); AnimBool anim1After = WGroupAnimationState.GetOrCreateAnim( definition, diff --git a/Tests/Editor/Validation/StaleSerializedKeyRepairTests.cs b/Tests/Editor/Validation/StaleSerializedKeyRepairTests.cs index 022520e4e..f64ce87f6 100644 --- a/Tests/Editor/Validation/StaleSerializedKeyRepairTests.cs +++ b/Tests/Editor/Validation/StaleSerializedKeyRepairTests.cs @@ -40,7 +40,6 @@ public sealed class StaleSerializedKeyRepairTests private const string MonoBehaviourDocument = "--- !u!114 "; private const int SubObjectCount = 3; private const int DamagedWeight = 41; - private const string RewriteFailureMessage = "the reserialize refused this asset"; private string _folderName; private string _folder; @@ -149,6 +148,23 @@ private static int NonNullObjectCount(string assetPath) return count; } + private static void RemoveSubObject(string assetPath) + { + Object[] objects = AssetDatabase.LoadAllAssetsAtPath(assetPath); + Object main = AssetDatabase.LoadMainAssetAtPath(assetPath); + foreach (Object candidate in objects) + { + if (candidate != null && candidate != main) + { + Object.DestroyImmediate(candidate, true); // UNH-SUPPRESS UNH001: deleting the authored sub-asset is the subject of this test. + AssetDatabase.SaveAssets(); + return; + } + } + + Assert.Fail($"{assetPath} has no sub-asset to remove."); + } + [OneTimeSetUp] public void BuildSubjects() { @@ -327,8 +343,8 @@ public void SavingTheObjectTheEditorStillHoldsDoesNotBreakTheFileAgain() /// Nothing a test can author makes ForceReserializeAssets lose anything -- a /// VolumeProfile went from twenty serialized documents to one while the rewrite /// reported success, and five modelled HideFlags shapes reproduced none of it. So the - /// loss is supplied through the counter seam and everything after it is production code: the - /// comparison, the restore, and the forced re-import. The subject is damaged for real, in + /// completion phase is exercised after deleting a real sub-asset. The comparison, restore, + /// and forced re-import are the same phases the public repair uses. The subject is damaged in /// both places a real loss damages it, so both halves of the undo have something to reverse. /// The weight assertion is not vacuous: measured on 6000.4.6f1, writing the original bytes /// back without the re-import leaves the editor holding the damaged value, so @@ -345,24 +361,17 @@ public void ARewriteThatLosesObjectsIsUndoneInBothHalves() "The subject has no sub-objects, so a loss cannot be modelled against it." ); - int counts = 0; - StaleSerializedKeyRepairOutcome outcome = StaleSerializedKeyRepair.RepairAsset( + int before = NonNullObjectCount(_lostSubObjects); + DamageInPlace(_lostSubObjects); + RemoveSubObject(_lostSubObjects); + Assert.AreEqual(before - 1, NonNullObjectCount(_lostSubObjects)); + StaleSerializedKeyRepairOutcome outcome = StaleSerializedKeyRepair.CompleteRewrite( _lostSubObjects, - assetPath => - { - int held = NonNullObjectCount(assetPath); - ++counts; - if (counts < 2) - { - return held; - } - - return held - 1; - }, - assetPath => DamageInPlace(assetPath) + filePath, + original, + before ); - Assert.AreEqual(2, counts, "The rewrite was never counted on both sides of itself."); Assert.AreEqual( StaleSerializedKeyRepairOutcome.RefusedLostSubObjects, outcome, @@ -377,6 +386,7 @@ public void ARewriteThatLosesObjectsIsUndoneInBothHalves() AuthoredRequirementTestAsset restored = AssetDatabase.LoadAssetAtPath(_lostSubObjects); Assert.IsTrue(restored != null, _lostSubObjects); + Assert.AreEqual(before, NonNullObjectCount(_lostSubObjects)); Assert.AreEqual( 0, restored.weight, @@ -398,27 +408,13 @@ public void AnEditAfterRewriteIsPreservedWhenRollbackIsNeeded() try { - int counts = 0; - StaleSerializedKeyRepairOutcome outcome = StaleSerializedKeyRepair.RepairAsset( - _lostSubObjects, - assetPath => - { - int held = NonNullObjectCount(assetPath); - ++counts; - if (counts < 2) - { - return held; - } - - File.AppendAllText(filePath, "\n# concurrent edit\n"); - edited = File.ReadAllBytes(filePath); - return held - 1; - }, - assetPath => DamageInPlace(assetPath) + DamageInPlace(_lostSubObjects); + byte[] rewritten = File.ReadAllBytes(filePath); + File.AppendAllText(filePath, "\n# concurrent edit\n"); + edited = File.ReadAllBytes(filePath); + Assert.IsFalse( + StaleSerializedKeyRepair.Restore(_lostSubObjects, filePath, original, rewritten) ); - - Assert.AreEqual(2, counts); - Assert.AreEqual(StaleSerializedKeyRepairOutcome.RefusedUndoFailed, outcome); CollectionAssert.AreEqual(edited, File.ReadAllBytes(filePath)); } finally @@ -440,7 +436,7 @@ public void AnEditAfterRewriteIsPreservedWhenRollbackIsNeeded() /// root, which is how the package's own containers run. /// [Test] - public void AnUndoThatCannotWriteIsReportedRatherThanSwallowed() + public void RestorationThatCannotWriteIsReportedRatherThanSwallowed() { string filePath = AuthoredAssetPaths.ToFileSystemPath(_undoFailed); byte[] original = File.ReadAllBytes(filePath); @@ -451,28 +447,15 @@ public void AnUndoThatCannotWriteIsReportedRatherThanSwallowed() try { - int counts = 0; - StaleSerializedKeyRepairOutcome outcome = StaleSerializedKeyRepair.RepairAsset( - _undoFailed, - assetPath => - { - int held = NonNullObjectCount(assetPath); - ++counts; - if (counts < 2) - { - return held; - } - - File.Delete(filePath); - Directory.CreateDirectory(filePath); - return held - 1; - } - ); - - Assert.AreEqual( - StaleSerializedKeyRepairOutcome.RefusedUndoFailed, - outcome, - "An undo that could not write must not report the same refusal as one that did." + StaleSerializedKeyRepairOutcome rewriteOutcome = + StaleSerializedKeyRepair.RepairAsset(_undoFailed); + Assert.AreEqual(StaleSerializedKeyRepairOutcome.Repaired, rewriteOutcome); + byte[] rewritten = File.ReadAllBytes(filePath); + File.Delete(filePath); + Directory.CreateDirectory(filePath); + Assert.IsFalse( + StaleSerializedKeyRepair.Restore(_undoFailed, filePath, original, rewritten), + "Restoration must report that the destination is now a directory." ); } finally @@ -497,37 +480,33 @@ public void AnUndoThatCannotWriteIsReportedRatherThanSwallowed() /// Every guard RefusedUnreadable describes has already passed by the time the rewrite /// runs: the file existed, its bytes were read, and its objects loaded. Reporting it there /// sent a human at permissions and paths while the real failure was Unity's rewrite call. - /// Nothing a test can author makes ForceReserializeAssets throw, so the throw is - /// supplied through the rewrite seam and everything after it -- the log, the byte restore - /// and the forced re-import -- is production code + /// A real filesystem exception exercises the repair's shared recovery phase: its log, + /// byte restore, and forced re-import. This does not force Unity's rewrite API to throw /// (#679). /// [Test] - public void ARewriteThatThrowsIsNotReportedAsUnreadable() + public void RewriteFailureRecoveryIsNotReportedAsUnreadable() { string filePath = AuthoredAssetPaths.ToFileSystemPath(_rewriteThrew); byte[] original = File.ReadAllBytes(filePath); + DamageInPlace(_rewriteThrew); + Exception rewriteError = ReadDirectoryFailure(); LogAssert.Expect( LogType.Error, new Regex( Regex.Escape( - $"Rewriting {_rewriteThrew} threw: {typeof(InvalidOperationException).FullName}: {RewriteFailureMessage}" + $"Rewriting {_rewriteThrew} threw: {rewriteError.GetType().FullName}" ) ) ); + StaleSerializedKeyRepairOutcome outcome = + StaleSerializedKeyRepair.RecoverRewriteFailure( + _rewriteThrew, + filePath, + original, + rewriteError + ); - int rewrites = 0; - StaleSerializedKeyRepairOutcome outcome = StaleSerializedKeyRepair.RepairAsset( - _rewriteThrew, - null, - _ => - { - ++rewrites; - throw new InvalidOperationException(RewriteFailureMessage); - } - ); - - Assert.AreEqual(1, rewrites, "The rewrite seam never ran, so nothing threw."); Assert.AreEqual( StaleSerializedKeyRepairOutcome.RefusedRewriteThrew, outcome, @@ -538,6 +517,10 @@ public void ARewriteThatThrowsIsNotReportedAsUnreadable() File.ReadAllBytes(filePath), "The refusal did not put the original bytes back." ); + AuthoredRequirementTestAsset restored = + AssetDatabase.LoadAssetAtPath(_rewriteThrew); + Assert.IsTrue(restored != null); + Assert.AreEqual(0, restored.weight); } /// @@ -555,16 +538,17 @@ public void ARewriteThatThrowsIsNotReportedAsUnreadable() /// because a $ would also assert whatever Unity appends to a message. /// [Test] - public void ARewriteThatThrewAndCouldNotBeUndoneReportsTheWorstOutcome() + public void RewriteFailureRecoveryThatCannotRestoreReportsTheWorstOutcome() { string filePath = AuthoredAssetPaths.ToFileSystemPath(_rewriteUndoFailed); byte[] original = File.ReadAllBytes(filePath); + Exception rewriteError = ReadDirectoryFailure(); LogAssert.Expect( LogType.Error, new Regex( "(?![\\s\\S]*(being put back|Nothing was repaired))" + Regex.Escape( - $"Rewriting {_rewriteUndoFailed} threw: {typeof(InvalidOperationException).FullName}: {RewriteFailureMessage}" + $"Rewriting {_rewriteUndoFailed} threw: {rewriteError.GetType().FullName}" ) ) ); @@ -575,23 +559,16 @@ public void ARewriteThatThrewAndCouldNotBeUndoneReportsTheWorstOutcome() try { - StaleSerializedKeyRepairOutcome outcome = StaleSerializedKeyRepair.RepairAsset( - _rewriteUndoFailed, - null, - _ => - { - File.Delete(filePath); - Directory.CreateDirectory(filePath); - throw new InvalidOperationException(RewriteFailureMessage); - } - ); - - Assert.AreEqual( - StaleSerializedKeyRepairOutcome.RefusedUndoFailed, - outcome, - "A rewrite that threw and could not be undone must report the outcome that needs " - + "a human, not the one that says the bytes came back." - ); + File.Delete(filePath); + Directory.CreateDirectory(filePath); + StaleSerializedKeyRepairOutcome outcome = + StaleSerializedKeyRepair.RecoverRewriteFailure( + _rewriteUndoFailed, + filePath, + original, + rewriteError + ); + Assert.AreEqual(StaleSerializedKeyRepairOutcome.RefusedUndoFailed, outcome); } finally { @@ -631,19 +608,35 @@ public void RefusedUnreadableStillMeansUnreadable(string assetPath) [Test] public void AnAssetThatLoadsNoObjectsIsRefusedAsUnreadable() { - int counts = 0; + string assetPath = $"{_folder}/Unimported.unityhelpersunknown"; + string filePath = AuthoredAssetPaths.ToFileSystemPath(assetPath); + byte[] original = { 1, 2, 3 }; + File.WriteAllBytes(filePath, original); + Assert.AreEqual(0, NonNullObjectCount(assetPath)); StaleSerializedKeyRepairOutcome outcome = StaleSerializedKeyRepair.RepairAsset( - _rewriteThrew, - _ => - { - ++counts; - return 0; - }, - _ => Assert.Fail("The rewrite ran for an asset that loaded nothing.") + assetPath ); - - Assert.AreEqual(1, counts, "The count seam never ran, so the guard was not reached."); Assert.AreEqual(StaleSerializedKeyRepairOutcome.RefusedUnreadable, outcome); + CollectionAssert.AreEqual(original, File.ReadAllBytes(filePath)); + } + + private Exception ReadDirectoryFailure() + { + try + { + File.ReadAllBytes(AuthoredAssetPaths.ToFileSystemPath(_folder)); + } + catch (IOException exception) + { + return exception; + } + catch (UnauthorizedAccessException exception) + { + return exception; + } + + Assert.Fail("Reading a directory as file bytes did not fail."); + return null; } private string CreateStaleKeyAsset(string name) diff --git a/Tests/Editor/Validation/ValidationAutoRunTestAccess.cs b/Tests/Editor/Validation/ValidationAutoRunTestAccess.cs new file mode 100644 index 000000000..b64dd388a --- /dev/null +++ b/Tests/Editor/Validation/ValidationAutoRunTestAccess.cs @@ -0,0 +1,28 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Editor.Validation.Continuous +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using UnityEditor; + using WallstopStudios.UnityHelpers.Editor.AssetProcessors; + using static WallstopStudios.UnityHelpers.Editor.Validation.Continuous.ValidationAutoRun; + + /// Provides test-side setup and inspection for ValidationAutoRun. + internal static class ValidationAutoRunTestAccess + { + internal static void CompleteRun(ValidationRun run) + { + ValidationAutoRun.CompleteRun(run); + } + + internal static void ClearPending() + { + Pending.Clear(); + TriggerSources.Clear(); + } + } +#endif +} diff --git a/Tests/Editor/Validation/ValidationAutoRunTestAccess.cs.meta b/Tests/Editor/Validation/ValidationAutoRunTestAccess.cs.meta new file mode 100644 index 000000000..1d451c881 --- /dev/null +++ b/Tests/Editor/Validation/ValidationAutoRunTestAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 05f97999069d006390e90a2a6a19e270 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Validation/ValidationPreferencesTests.cs b/Tests/Editor/Validation/ValidationPreferencesTests.cs index 46a6068aa..8eeb8cf06 100644 --- a/Tests/Editor/Validation/ValidationPreferencesTests.cs +++ b/Tests/Editor/Validation/ValidationPreferencesTests.cs @@ -29,7 +29,7 @@ public void PreservePreferences() public void RestorePreferences() { ValidationScheduler.Stop(); - ValidationAutoRun.ClearPendingForTesting(); + ValidationAutoRunTestAccess.ClearPending(); ValidationAutoRun.Enabled = _automatic; ValidationPreferences.Enabled = _enabled; } @@ -47,8 +47,7 @@ public void DisablingDropsPendingWorkAndPreservesAutomaticPreference(bool automa "Assets/Pending.asset", typeof(ScriptableObject) ), - }, - _ => null + } ); Assert.IsTrue(ValidationScheduler.TryStart(blocker)); ValidationAutoRun.Enabled = automatic; diff --git a/Tests/Editor/Validation/ValidationReportingTests.cs b/Tests/Editor/Validation/ValidationReportingTests.cs index f09ce537e..04e9b8bcd 100644 --- a/Tests/Editor/Validation/ValidationReportingTests.cs +++ b/Tests/Editor/Validation/ValidationReportingTests.cs @@ -19,9 +19,8 @@ namespace WallstopStudios.UnityHelpers.Tests.Editor.Validation /// JSON report says, and what makes a batch run exit non-zero. /// /// - /// Everything here is driven from constructed findings and an injected loader rather than from - /// the asset database, so the assertions are about the reporting contract rather than about - /// whatever assets the test project happens to hold. + /// Rules report constructed findings through ordinary asset-database-backed runs. Load-failure + /// schema controls exercise the same failure-recording phase used by the production loader. /// [TestFixture] public sealed class ValidationReportingTests : CommonTestBase @@ -110,19 +109,13 @@ private static ValidationRun RunOver(params ValidationFinding[] findings) }; ValidationRun run = new ValidationRun( new List { new ScriptedRule(findings) }, - targets, - Never + targets ); while (!run.Step(double.MaxValue)) { } return run; } - private static Object Never(ValidationTarget target) - { - return null; - } - [Test] public void ParsingIgnoresBlankLinesCommentsAndDuplicates() { @@ -355,8 +348,11 @@ public void TheReportKeepsTheLoaderFailureSchema() new List { new ValidationTarget(FirstGuid, "Assets/A.asset", typeof(ScriptableObject)), - }, - target => throw new InvalidOperationException("load failed") + } + ); + run.RecordLoadFailure( + new ValidationTarget(FirstGuid, "Assets/A.asset", typeof(ScriptableObject)), + new InvalidOperationException("load failed") ); while (!run.Step(double.MaxValue)) { } @@ -427,8 +423,7 @@ public void ARuleThatThrewBlocksWhateverTheThresholdIs() new List { new ValidationTarget(FirstGuid, "Assets/A.asset", typeof(ScriptableObject)), - }, - Never + } ); while (!run.Step(double.MaxValue)) { } diff --git a/Tests/Editor/Validation/ValidationResultsTests.cs b/Tests/Editor/Validation/ValidationResultsTests.cs index f07627b55..0a80d53c1 100644 --- a/Tests/Editor/Validation/ValidationResultsTests.cs +++ b/Tests/Editor/Validation/ValidationResultsTests.cs @@ -58,7 +58,7 @@ params string[] guids ); } - return new ValidationRun(rules, targets, _ => null); + return new ValidationRun(rules, targets); } private static ValidationRun CreateRejectedRun(RejectedRunState state) @@ -500,9 +500,9 @@ public void SuccessfulWindowCompletionReplacesResultsAndClearsStatus() UnityEngine.ScriptableObject.CreateInstance() ); - window.CompleteForTesting(complete); + window.Complete(complete); - Assert.AreEqual(string.Empty, window.StatusForTesting); + Assert.AreEqual(string.Empty, window._status); CollectionAssert.AreEqual(new[] { SecondGuid }, ValidationResults.RecordedAssetGuids); Assert.AreEqual(1, ValidationResults.Snapshot().Count); } @@ -524,12 +524,9 @@ public void FailedWindowCompletionRetainsResultsLogsAndExplainsTheState() UnityEngine.ScriptableObject.CreateInstance() ); - window.CompleteForTesting(failed); + window.Complete(failed); - Assert.AreEqual( - "Validation failed. Previous results retained.", - window.StatusForTesting - ); + Assert.AreEqual("Validation failed. Previous results retained.", window._status); Assert.AreEqual(1, ValidationResults.CheckedAssetCount); Assert.AreEqual("previous", ValidationResults.Snapshot()[0].Message); } @@ -549,9 +546,9 @@ public void CancellingAnIncompleteWindowRunRetainsResultsWithoutFailures() UnityEngine.ScriptableObject.CreateInstance() ); - window.CompleteForTesting(cancelled); + window.Complete(cancelled); - Assert.AreEqual("Cancelled. Previous results retained.", window.StatusForTesting); + Assert.AreEqual("Cancelled. Previous results retained.", window._status); CollectionAssert.AreEqual(new[] { FirstGuid }, ValidationResults.RecordedAssetGuids); } @@ -572,9 +569,9 @@ public void CancellingAfterAProcessedFailureStillLogsThatFailure() UnityEngine.ScriptableObject.CreateInstance() ); - window.CompleteForTesting(cancelled); + window.Complete(cancelled); - Assert.AreEqual("Cancelled. Previous results retained.", window.StatusForTesting); + Assert.AreEqual("Cancelled. Previous results retained.", window._status); CollectionAssert.AreEqual(new[] { FirstGuid }, ValidationResults.RecordedAssetGuids); } @@ -583,7 +580,7 @@ public void AFailedIncrementalRunKeepsItsTargetsQueuedWithoutImmediateRetry() { bool wasEnabled = ValidationAutoRun.Enabled; ValidationAutoRun.Enabled = true; - ValidationAutoRun.ClearPendingForTesting(); + ValidationAutoRunTestAccess.ClearPending(); ValidationRun failed = Run( new List { new ThrowingRule() }, FirstGuid, @@ -596,14 +593,14 @@ public void AFailedIncrementalRunKeepsItsTargetsQueuedWithoutImmediateRetry() try { - ValidationAutoRun.CompleteRunForTesting(failed); + ValidationAutoRunTestAccess.CompleteRun(failed); Assert.AreEqual(2, ValidationAutoRun.PendingCount); Assert.IsFalse(ValidationResults.HasRun); } finally { - ValidationAutoRun.ClearPendingForTesting(); + ValidationAutoRunTestAccess.ClearPending(); ValidationAutoRun.Enabled = wasEnabled; } } @@ -613,7 +610,7 @@ public void AFailedIncrementalRunDoesNotClaimRequeueWhenAutoRunIsDisabled() { bool wasEnabled = ValidationAutoRun.Enabled; ValidationAutoRun.Enabled = false; - ValidationAutoRun.ClearPendingForTesting(); + ValidationAutoRunTestAccess.ClearPending(); ValidationRun failed = Run(new ThrowingRule(), FirstGuid); ExpectError( UnityEngine.LogType.Warning, @@ -622,13 +619,13 @@ public void AFailedIncrementalRunDoesNotClaimRequeueWhenAutoRunIsDisabled() try { - ValidationAutoRun.CompleteRunForTesting(failed); + ValidationAutoRunTestAccess.CompleteRun(failed); Assert.AreEqual(0, ValidationAutoRun.PendingCount); } finally { - ValidationAutoRun.ClearPendingForTesting(); + ValidationAutoRunTestAccess.ClearPending(); ValidationAutoRun.Enabled = wasEnabled; } } diff --git a/Tests/Editor/Validation/ValidationRunTests.cs b/Tests/Editor/Validation/ValidationRunTests.cs index d4473366b..e81c0c868 100644 --- a/Tests/Editor/Validation/ValidationRunTests.cs +++ b/Tests/Editor/Validation/ValidationRunTests.cs @@ -18,8 +18,8 @@ namespace WallstopStudios.UnityHelpers.Tests.Editor.Validation /// that it always makes progress. /// /// - /// The run is driven by an injected loader rather than the asset database, so every assertion - /// here is about the engine rather than about whatever assets the test project happens to hold. + /// Runs use the actual asset database. Asset-binding controls create isolated fixture assets; + /// missing paths exercise the ordinary null-load policy without altering the loader. /// [TestFixture] public sealed class ValidationRunTests : CommonTestBase @@ -40,11 +40,6 @@ private static ValidationFinding Finding(Object target) ); } - private static Object Never(ValidationTarget target) - { - return null; - } - private static List OneTarget() { return new List @@ -78,7 +73,7 @@ public override void OneTimeTearDown() [Test] public void AnEmptyRunIsCompleteBeforeItStarts() { - ValidationRun run = new ValidationRun(null, null, Never); + ValidationRun run = new ValidationRun(null, null); Assert.IsTrue(run.IsComplete); Assert.AreEqual(0, run.TotalCount); @@ -103,8 +98,7 @@ public void NullRulesAndUnusableTargetsAreDroppedRatherThanThrown() ValidationRun run = new ValidationRun( new List { null, counted, null }, - targets, - Never + targets ); Assert.AreEqual(1, run.TotalCount); @@ -124,61 +118,54 @@ public void MissingTargetPathDisplaysItsGuid(string assetPath) } [Test] - public void AnAssetNoRuleClaimsIsNeverLoaded() + public void AnAssetNoRuleClaimsIsNotValidated() { CountingRule declining = new CountingRule(false); - int loads = 0; - - ValidationRun run = new ValidationRun( - new List { declining }, - TwoTargets(), - target => - { - ++loads; - return null; - } - ); + ValidationRun run = new ValidationRun(new[] { declining }, TwoTargets()); Assert.IsTrue(run.Step(1000.0)); Assert.AreEqual(2, declining.AppliesToCalls); Assert.AreEqual(0, declining.ValidateCalls); - Assert.AreEqual(0, loads, "A declined asset must not be deserialized."); + Assert.IsEmpty(run.Findings); + Assert.IsEmpty(run.Failures); } [Test] - public void AClaimedAssetIsLoadedOncePerAssetNotOncePerRule() + public void RulesShareTheLoadedAssetEvenWhenTheFirstRuleDeletesItsPath() { - CountingRule first = new CountingRule(true); - CountingRule second = new CountingRule(true); - int loads = 0; - - ValidationRun run = new ValidationRun( - new List { first, second }, - TwoTargets(), - target => - { - ++loads; - return null; - } + ValidationTarget target = CreateLoadTarget( + nameof(RulesShareTheLoadedAssetEvenWhenTheFirstRuleDeletesItsPath), + out Object asset ); + CountingRule first = new CountingRule(true) + { + OnValidate = _ => Assert.IsTrue(AssetDatabase.DeleteAsset(target.AssetPath)), + }; + CountingRule second = new CountingRule(true); + ValidationRun run = new ValidationRun(new[] { first, second }, new[] { target }); Assert.IsTrue(run.Step(1000.0)); - Assert.AreEqual(2, first.ValidateCalls); - Assert.AreEqual(2, second.ValidateCalls); - Assert.AreEqual(2, loads); + Assert.AreEqual(1, first.ValidateCalls); + Assert.AreEqual(1, second.ValidateCalls); + Assert.AreSame(asset, first.LastAsset); + Assert.AreSame( + first.LastAsset, + second.LastAsset, + "The second rule must receive the original loaded reference, not reload the deleted path." + ); + Assert.IsTrue(ValidationRun.LoadMainAsset(target) == null); + Assert.IsEmpty(run.Failures); } [Test] public void TheLoadedAssetReachesTheRuleAndTheFinding() { - ScriptableObject asset = Track(ScriptableObject.CreateInstance()); - ReportingRule reporting = new ReportingRule(ValidationSeverity.Error, 1); - - ValidationRun run = new ValidationRun( - new List { reporting }, - OneTarget(), - target => asset + ValidationTarget target = CreateLoadTarget( + nameof(TheLoadedAssetReachesTheRuleAndTheFinding), + out Object asset ); + ReportingRule reporting = new ReportingRule(ValidationSeverity.Error, 1); + ValidationRun run = new ValidationRun(new[] { reporting }, new[] { target }); Assert.IsTrue(run.Step(1000.0)); Assert.AreEqual(1, run.Findings.Count); @@ -196,8 +183,7 @@ public void EveryFindingARuleReportsIsCollected(int perAsset) ValidationRun run = new ValidationRun( new List { reporting }, - TwoTargets(), - Never + TwoTargets() ); Assert.IsTrue(run.Step(1000.0)); @@ -213,8 +199,7 @@ public void ARuleThatThrowsWhileValidatingIsRecordedAndTheOthersStillRun() ValidationRun run = new ValidationRun( new List { broken, healthy }, - TwoTargets(), - Never + TwoTargets() ); Assert.IsTrue(run.Step(1000.0)); @@ -233,8 +218,7 @@ public void PartialFindingsFromAThrowingRuleAreDiscarded() ValidationRun run = new ValidationRun( new List { broken }, - OneTarget(), - Never + OneTarget() ); Assert.IsTrue(run.Step(1000.0)); @@ -249,33 +233,30 @@ public void PartialFindingsFromAThrowingRuleAreDiscarded() public void ARuleThatThrowsWhileClaimingIsRecordedAndSkipped() { ThrowingRule broken = new ThrowingRule(throwFromAppliesTo: true); - int loads = 0; ValidationRun run = new ValidationRun( new List { broken }, - OneTarget(), - target => - { - ++loads; - return null; - } + OneTarget() ); Assert.IsTrue(run.Step(1000.0)); Assert.AreEqual(1, run.Failures.Count); Assert.AreEqual(0, broken.ValidateCalls); - Assert.AreEqual(0, loads); } [Test] - public void ALoaderThatThrowsIsBlamedOnTheLoadAndTheRuleStillRuns() + public void ARecordedLoadFailureIsDistinctFromRuleFailuresAndDoesNotStopValidation() { ReportingRule reporting = new ReportingRule(ValidationSeverity.Warning, 1); ValidationRun run = new ValidationRun( new List { reporting }, - OneTarget(), - target => throw new InvalidOperationException("import is broken") + OneTarget() + ); + + run.RecordLoadFailure( + OneTarget()[0], + new InvalidOperationException("import is broken") ); Assert.IsTrue(run.Step(1000.0)); @@ -291,8 +272,7 @@ public void ARuleWithNoUsableIdIsStillDistinguishableFromALoadFailure() { ValidationRun run = new ValidationRun( new List { new NamelessThrowingRule() }, - OneTarget(), - Never + OneTarget() ); Assert.IsTrue(run.Step(1000.0)); @@ -309,8 +289,7 @@ public void ANonPositiveBudgetStillAdvancesExactlyOneAsset(double budget) ValidationRun run = new ValidationRun( new List { counted }, - TwoTargets(), - Never + TwoTargets() ); Assert.IsFalse(run.Step(budget)); @@ -326,8 +305,7 @@ public void CancellingKeepsWhatWasAlreadyFoundAndStopsTheRest() ValidationRun run = new ValidationRun( new List { reporting }, - TwoTargets(), - Never + TwoTargets() ); Assert.IsFalse(run.Step(0.0)); @@ -500,6 +478,22 @@ public void EnumeratingAFolderThatDoesNotExistIsEmptyRatherThanThrown() Assert.IsEmpty(ValidationTargets.Enumerate("Assets/NoSuchFolderForValidationTests")); } + private ValidationTarget CreateLoadTarget(string assetName, out Object asset) + { + string folder = "Assets/" + nameof(ValidationRunTests) + "Load"; + if (!AssetDatabase.IsValidFolder(folder)) + { + AssetDatabase.CreateFolder("Assets", nameof(ValidationRunTests) + "Load"); + } + TrackFolder(folder); + string path = folder + "/" + assetName + ".asset"; + asset = Track(ScriptableObject.CreateInstance()); + AssetDatabase.CreateAsset(asset, path); + TrackAssetPath(path); + AssetDatabase.SaveAssets(); + return new ValidationTarget(AssetDatabase.AssetPathToGUID(path), path, asset.GetType()); + } + /// Counts what the engine asked it, and answers the same way every time. private sealed class CountingRule : IValidationRule { @@ -511,6 +505,10 @@ private sealed class CountingRule : IValidationRule internal int ValidateCalls { get; private set; } + internal Object LastAsset { get; private set; } + + internal Action OnValidate { get; set; } + private readonly bool _claims; internal CountingRule(bool claims) @@ -531,6 +529,8 @@ List findings ) { ++ValidateCalls; + LastAsset = asset; + OnValidate?.Invoke(asset); } } diff --git a/Tests/Editor/Validation/ValidationSchedulerTests.cs b/Tests/Editor/Validation/ValidationSchedulerTests.cs index 599515b5f..312b5dbaa 100644 --- a/Tests/Editor/Validation/ValidationSchedulerTests.cs +++ b/Tests/Editor/Validation/ValidationSchedulerTests.cs @@ -27,7 +27,7 @@ public sealed class ValidationSchedulerTests : CommonTestBase private static ValidationRun EmptyRun() { - return new ValidationRun(null, null, Never); + return new ValidationRun(null, null); } private static ValidationRun PendingRun() @@ -41,16 +41,10 @@ private static ValidationRun PendingRun() "Assets/First.asset", typeof(ScriptableObject) ), - }, - Never + } ); } - private static Object Never(ValidationTarget target) - { - return null; - } - [SetUp] public void EnableSentinel() { @@ -257,7 +251,6 @@ public void DisablingSentinelCancelsWorkAndRefusesCallbackRestart() [TestCase(0)] [TestCase(1)] - [TestCase(2)] public void DisablingInsideForeignCallbacksStopsTheOldRunWithoutStealingItsReplacement( int boundary ) @@ -279,7 +272,7 @@ int boundary validate = () => { ++calls; - if (boundary == 2) + if (boundary == 1) cancel(); }, }; @@ -293,13 +286,6 @@ int boundary "Assets/Pending.asset", typeof(ScriptableObject) ), - }, - _ => - { - ++calls; - if (boundary == 1) - cancel(); - return null; } ); try diff --git a/Tests/Editor/Validation/ValidationWorkspaceInteractionTests.cs b/Tests/Editor/Validation/ValidationWorkspaceInteractionTests.cs index a967fe124..6e2d14634 100644 --- a/Tests/Editor/Validation/ValidationWorkspaceInteractionTests.cs +++ b/Tests/Editor/Validation/ValidationWorkspaceInteractionTests.cs @@ -254,11 +254,7 @@ public void NativePanelCallbacksRetainDraftAndPersistSettings() "Assets/NoScanProbe.asset", typeof(ScriptableObject) ), - }, - _ => - throw new InvalidOperationException( - "The acceptance probe must not scan assets." - ) + } ); Assert.IsTrue(ValidationScheduler.TryStart(blocker)); Submit(ButtonWithText(builder, "Save Rule")); diff --git a/Tests/Editor/Validation/ValidationWorkspaceReportTests.cs b/Tests/Editor/Validation/ValidationWorkspaceReportTests.cs index f8910df05..526fa9356 100644 --- a/Tests/Editor/Validation/ValidationWorkspaceReportTests.cs +++ b/Tests/Editor/Validation/ValidationWorkspaceReportTests.cs @@ -45,8 +45,7 @@ private static ValidationRun Run(params ValidationFinding[] findings) new[] { new ValidationTarget("guid", "Assets/Test.asset", typeof(ScriptableObject)), - }, - _ => null + } ); while (!run.Step(double.MaxValue)) { } return run; diff --git a/Tests/Editor/WButton/WButtonConflictDetectionTests.cs b/Tests/Editor/WButton/WButtonConflictDetectionTests.cs index 6a0c14f04..0a195e332 100644 --- a/Tests/Editor/WButton/WButtonConflictDetectionTests.cs +++ b/Tests/Editor/WButton/WButtonConflictDetectionTests.cs @@ -34,22 +34,22 @@ public sealed class WButtonConflictDetectionTests : BatchedEditorTestBase public void SetUp() { base.BaseSetUp(); - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPriorityWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPlacementWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPriorityWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPlacementWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); } [TearDown] public override void TearDown() { - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPriorityWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPlacementWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPriorityWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPlacementWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); base.TearDown(); } @@ -75,7 +75,7 @@ public void MixedExplicitAndDefaultPlacementDoesNotGenerateWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings.ContainsKey("Debug Tools"), @@ -109,7 +109,7 @@ public void MixedExplicitAndDefaultPlacementUsesExplicitPlacement() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List debugToolsGroups = groupCounts .Keys.Where(k => @@ -176,7 +176,7 @@ public void AllDefaultPlacementDoesNotGenerateWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings, @@ -206,7 +206,7 @@ public void AllSameExplicitPlacementDoesNotGenerateWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings, @@ -236,7 +236,7 @@ public void MultipleExplicitPlacementConflictGeneratesWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings.ContainsKey("ConflictGroup"), @@ -282,7 +282,7 @@ public void MultipleExplicitPlacementConflictUsesFirstDeclaredPlacement() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List conflictGroups = groupCounts .Keys.Where(k => @@ -323,7 +323,7 @@ public void ExistingPlacementConflictTargetStillGeneratesWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings.ContainsKey("ConflictGroup"), @@ -353,7 +353,7 @@ public void MixedExplicitAndDefaultPriorityDoesNotGenerateWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPriorityWarnings(); + WButtonGUI.ConflictingGroupPriorityWarnings; Assert.That( warnings.ContainsKey("Setup"), @@ -387,7 +387,7 @@ public void MixedExplicitAndDefaultPriorityUsesExplicitPriority() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List setupGroups = groupCounts .Keys.Where(k => @@ -450,7 +450,7 @@ public void AllDefaultPriorityDoesNotGenerateWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPriorityWarnings(); + WButtonGUI.ConflictingGroupPriorityWarnings; Assert.That( warnings, @@ -480,7 +480,7 @@ public void AllSameExplicitPriorityDoesNotGenerateWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPriorityWarnings(); + WButtonGUI.ConflictingGroupPriorityWarnings; Assert.That( warnings, @@ -510,7 +510,7 @@ public void MultipleExplicitPriorityConflictGeneratesWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPriorityWarnings(); + WButtonGUI.ConflictingGroupPriorityWarnings; Assert.That( warnings.ContainsKey("ConflictGroup"), @@ -556,7 +556,7 @@ public void MultipleExplicitPriorityConflictUsesFirstDeclaredPriority() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List conflictGroups = groupCounts .Keys.Where(k => @@ -597,7 +597,7 @@ public void ExistingPriorityConflictTargetStillGeneratesWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPriorityWarnings(); + WButtonGUI.ConflictingGroupPriorityWarnings; Assert.That( warnings.ContainsKey("ConflictGroup"), @@ -661,9 +661,9 @@ public void NoWarningsForMixedPlacementGroupsWithSingleExplicitValue() ); IReadOnlyDictionary placementWarnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; IReadOnlyDictionary priorityWarnings = - WButtonGUI.GetConflictingGroupPriorityWarnings(); + WButtonGUI.ConflictingGroupPriorityWarnings; Assert.That( placementWarnings, @@ -698,7 +698,7 @@ public void SingleButtonGroupWithExplicitPlacementDoesNotGenerateWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings, @@ -728,7 +728,7 @@ public void SingleButtonGroupWithDefaultPlacementDoesNotGenerateWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings, @@ -842,7 +842,7 @@ public void PlacementConflictWarningOnlyContainsExplicitValues() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That(warnings.ContainsKey("ConflictGroup"), Is.True); Assert.That( @@ -880,7 +880,7 @@ public void PriorityConflictWarningOnlyContainsExplicitValues() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPriorityWarnings(); + WButtonGUI.ConflictingGroupPriorityWarnings; Assert.That(warnings.ContainsKey("ConflictGroup"), Is.True); Assert.That( diff --git a/Tests/Editor/WButton/WButtonConflictWarningDisplayTests.cs b/Tests/Editor/WButton/WButtonConflictWarningDisplayTests.cs index fb0ff036d..ee12386a9 100644 --- a/Tests/Editor/WButton/WButtonConflictWarningDisplayTests.cs +++ b/Tests/Editor/WButton/WButtonConflictWarningDisplayTests.cs @@ -30,11 +30,11 @@ public sealed class WButtonConflictWarningDisplayTests : BatchedEditorTestBase { private static void ClearAllCaches() { - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPriorityWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPlacementWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPriorityWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPlacementWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); } @@ -60,7 +60,7 @@ private static IReadOnlyDictionary< WButtonGUI.GroupPlacementConflictInfo > GetPlacementWarnings() { - return WButtonGUI.GetConflictingGroupPlacementWarnings(); + return WButtonGUI.ConflictingGroupPlacementWarnings; } private static IReadOnlyDictionary< @@ -68,7 +68,7 @@ private static IReadOnlyDictionary< WButtonGUI.GroupPriorityConflictInfo > GetPriorityWarnings() { - return WButtonGUI.GetConflictingGroupPriorityWarnings(); + return WButtonGUI.ConflictingGroupPriorityWarnings; } private static IReadOnlyDictionary< @@ -76,7 +76,7 @@ private static IReadOnlyDictionary< WButtonGUI.DrawOrderConflictInfo > GetDrawOrderWarnings() { - return WButtonGUI.GetConflictingDrawOrderWarnings(); + return WButtonGUI.ConflictingDrawOrderWarnings; } private static IEnumerable PlacementConflictDetectionCases() @@ -207,17 +207,17 @@ out string warningText switch (warningType) { case "placement": - return WButtonGUI.TryGetGroupPlacementWarningTextForTesting( + return WButtonGUITestAccess.TryGetGroupPlacementWarningText( groupName, out warningText ); case "priority": - return WButtonGUI.TryGetGroupPriorityWarningTextForTesting( + return WButtonGUITestAccess.TryGetGroupPriorityWarningText( groupName, out warningText ); case "drawOrder": - return WButtonGUI.TryGetDrawOrderWarningTextForTesting( + return WButtonGUITestAccess.TryGetDrawOrderWarningText( groupName, out warningText ); @@ -682,8 +682,8 @@ public void ConflictWarningCachingBehavesCorrectly(bool shouldClearCache) if (shouldClearCache) { - WButtonGUI.ClearConflictingGroupPlacementWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearConflictingGroupPlacementWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); IReadOnlyDictionary< string, @@ -846,7 +846,7 @@ public IEnumerator InvalidEnumValuesHandledGracefully() "Expected DrawButtons to render at least one group when the canonical group placement is an invalid enum value." ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); int invalidPlacementGroupCount = 0; foreach (KeyValuePair entry in groupCounts) { @@ -896,7 +896,7 @@ public IEnumerator InvalidEnumValuesHandledGracefully() "Expected conflict values to include the explicit Top placement." ); - bool hasCachedWarning = WButtonGUI.TryGetGroupPlacementWarningTextForTesting( + bool hasCachedWarning = WButtonGUITestAccess.TryGetGroupPlacementWarningText( "InvalidPlacementGroup", out string warningText ); diff --git a/Tests/Editor/WButton/WButtonConflictingDrawOrderTests.cs b/Tests/Editor/WButton/WButtonConflictingDrawOrderTests.cs index 9be14c4c2..fb147f675 100644 --- a/Tests/Editor/WButton/WButtonConflictingDrawOrderTests.cs +++ b/Tests/Editor/WButton/WButtonConflictingDrawOrderTests.cs @@ -33,18 +33,18 @@ public sealed class WButtonConflictingDrawOrderTests : BatchedEditorTestBase public void SetUp() { base.BaseSetUp(); - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); } [TearDown] public override void TearDown() { - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); base.TearDown(); } @@ -67,7 +67,7 @@ public void ConflictingDrawOrderButtonsAreMergedIntoSingleGroup() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List setupGroups = groupCounts .Keys.Where(k => string.Equals(k._groupName, "Setup", System.StringComparison.Ordinal) @@ -105,7 +105,7 @@ public void ConflictingDrawOrderUsesFirstDeclaredButtonDrawOrder() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey setupGroup = groupCounts.Keys.First(k => string.Equals(k._groupName, "Setup", System.StringComparison.Ordinal) ); @@ -136,7 +136,7 @@ public void ConflictingDrawOrderGeneratesWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; Assert.That(warnings, Contains.Key("Setup"), "Should have warning for Setup group"); WButtonGUI.DrawOrderConflictInfo conflict = warnings.ValueFor("Setup"); @@ -164,7 +164,7 @@ public void ThreeWayConflictMergesAllButtonsIntoOneGroup() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List actionsGroups = groupCounts .Keys.Where(k => string.Equals(k._groupName, "Actions", System.StringComparison.Ordinal) @@ -201,7 +201,7 @@ public void ThreeWayConflictUsesFirstDeclaredDrawOrder() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey actionsGroup = groupCounts.Keys.First(k => string.Equals(k._groupName, "Actions", System.StringComparison.Ordinal) ); @@ -228,7 +228,7 @@ public void ThreeWayConflictWarningContainsAllDrawOrders() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; Assert.That(warnings, Contains.Key("Actions")); WButtonGUI.DrawOrderConflictInfo conflict = warnings.ValueFor("Actions"); @@ -264,7 +264,7 @@ public void MultipleConflictingGroupsAreHandledIndependently() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List groupAKeys = groupCounts .Keys.Where(k => @@ -309,7 +309,7 @@ public void MultipleConflictingGroupsUseCorrectCanonicalDrawOrders() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey groupA = groupCounts.Keys.First(k => string.Equals(k._groupName, "GroupA", System.StringComparison.Ordinal) @@ -349,7 +349,7 @@ public void MultipleConflictingGroupsGenerateSeparateWarnings() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; Assert.That(warnings, Contains.Key("GroupA")); Assert.That(warnings, Contains.Key("GroupB")); @@ -382,7 +382,7 @@ public void MixedGroupedAndUngroupedButtonsAreHandledCorrectly() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List setupGroups = groupCounts .Keys.Where(k => @@ -424,7 +424,7 @@ public void UngroupedButtonsRemainSeparateByDrawOrder() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List ungroupedGroups = groupCounts .Keys.Where(k => string.IsNullOrEmpty(k._groupName)) .ToList(); @@ -463,7 +463,7 @@ public void FirstDeclarationWinsEvenWithLowerDrawOrderSecond() ); // The first-declared button has drawOrder 10; -10 is numerically lower but declared second. - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey testGroup = groupCounts.Keys.First(k => string.Equals(k._groupName, "Test", System.StringComparison.Ordinal) ); @@ -493,7 +493,7 @@ public void ExtremeDrawOrderValuesAreMergedCorrectly() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List extremeGroups = groupCounts .Keys.Where(k => string.Equals(k._groupName, "Extreme", System.StringComparison.Ordinal) @@ -524,7 +524,7 @@ public void ExtremeDrawOrderConflictGeneratesWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; Assert.That(warnings, Contains.Key("Extreme")); Assert.That(warnings.ValueFor("Extreme")._allDrawOrders, Contains.Item(int.MinValue)); @@ -553,7 +553,7 @@ public void CrossPlacementConflictUsesFirstDeclaredPlacement() Assert.That(drawnTop, Is.True, "Group should render at top placement"); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey crossGroup = groupCounts.Keys.First(k => string.Equals(k._groupName, "CrossPlacement", System.StringComparison.Ordinal) ); @@ -594,10 +594,10 @@ public void CrossPlacementConflictReverseUsesFirstDeclaredDrawOrder() Assert.That( drawnTop, Is.True, - $"Group with UseGlobalSetting should render at Top when globalPlacementIsTop=true. Groups: {string.Join(", ", WButtonGUI.GetGroupCountsForTesting().Keys.Select(k => $"{k._groupName}:{k._groupPlacement}"))}" + $"Group with UseGlobalSetting should render at Top when globalPlacementIsTop=true. Groups: {string.Join(", ", WButtonGUITestAccess.GetGroupCounts().Keys.Select(k => $"{k._groupName}:{k._groupPlacement}"))}" ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey crossGroup = groupCounts.Keys.First(k => string.Equals(k._groupName, "CrossPlacement", System.StringComparison.Ordinal) ); @@ -638,7 +638,7 @@ public void NoConflictWhenAllButtonsHaveSameDrawOrder() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; Assert.That( warnings.ContainsKey("NoConflict"), @@ -646,7 +646,7 @@ public void NoConflictWhenAllButtonsHaveSameDrawOrder() "Should not generate warning for matching draw orders" ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List noConflictGroups = groupCounts .Keys.Where(k => string.Equals(k._groupName, "NoConflict", System.StringComparison.Ordinal) @@ -676,7 +676,7 @@ public void SingleButtonGroupHasNoConflict() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; Assert.That(warnings.ContainsKey("Single"), Is.False); } @@ -753,7 +753,7 @@ public void GroupNameIsResolvedCorrectlyForConflictingGroup() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupNames = WButtonGUI.GetGroupNamesForTesting(); + Dictionary groupNames = WButtonGUITestAccess.GetGroupNames(); WButtonGroupKey setupKey = groupNames.Keys.FirstOrDefault(k => string.Equals(k._groupName, "Setup", System.StringComparison.Ordinal) ); @@ -788,10 +788,10 @@ public void ButtonsAreAlwaysRenderedEvenWithConflictingDrawOrders() Assert.That( anyDrawn, Is.True, - $"Buttons must always be rendered. Groups found: {string.Join(", ", WButtonGUI.GetGroupCountsForTesting().Keys.Select(k => $"{k._groupName}:{k._drawOrder}:{k._groupPlacement}"))}" + $"Buttons must always be rendered. Groups found: {string.Join(", ", WButtonGUITestAccess.GetGroupCounts().Keys.Select(k => $"{k._groupName}:{k._drawOrder}:{k._groupPlacement}"))}" ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); int totalButtons = groupCounts.Values.Sum(); Assert.That(totalButtons, Is.EqualTo(2), "Both buttons must be in groups"); } @@ -816,7 +816,7 @@ public void LargeConflictingGroupStillRendersAllButtons() ); Assert.That(anyDrawn, Is.True); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); int totalButtons = groupCounts.Values.Sum(); Assert.That(totalButtons, Is.EqualTo(3)); } @@ -842,7 +842,7 @@ public void ButtonsWithUseGlobalSettingRespectGlobalPlacement() globalPlacementIsTop: false ); - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); bool drawnBottom = WButtonGUI.DrawButtons( editor, @@ -893,7 +893,7 @@ public void BothPlacementsRenderAllButtonsWhenCalled() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); int totalButtons = groupCounts.Values.Sum(); Assert.That(totalButtons, Is.EqualTo(4), "All 4 buttons must be rendered"); @@ -924,10 +924,10 @@ public void GroupWithCanonicalDrawOrderPreservesDrawOrderValue() Assert.That( drawnTop, Is.True, - $"Group with UseGlobalSetting must render at Top when globalPlacementIsTop=true. Groups: {string.Join(", ", WButtonGUI.GetGroupCountsForTesting().Keys.Select(k => $"{k._groupName}:{k._drawOrder}:{k._groupPlacement}"))}" + $"Group with UseGlobalSetting must render at Top when globalPlacementIsTop=true. Groups: {string.Join(", ", WButtonGUITestAccess.GetGroupCounts().Keys.Select(k => $"{k._groupName}:{k._drawOrder}:{k._groupPlacement}"))}" ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey setupGroup = groupCounts.Keys.First(k => string.Equals(k._groupName, "Setup", System.StringComparison.Ordinal) ); @@ -968,7 +968,7 @@ public void WarningContainsAllConflictingDrawOrders() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; Assert.That(warnings, Contains.Key("Setup")); WButtonGUI.DrawOrderConflictInfo conflict = warnings.ValueFor("Setup"); @@ -997,7 +997,7 @@ public void WarningShowsCanonicalDrawOrderFromFirstDeclaredButton() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; WButtonGUI.DrawOrderConflictInfo conflict = warnings.ValueFor("Setup"); @@ -1030,7 +1030,7 @@ public void EmptyGroupNameButtonsRemainSeparate() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List ungroupedKeys = groupCounts .Keys.Where(k => string.IsNullOrEmpty(k._groupName)) .ToList(); @@ -1062,7 +1062,7 @@ public void NoWarningForGroupsWithoutConflicts() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingDrawOrderWarnings(); + WButtonGUI.ConflictingDrawOrderWarnings; Assert.That( warnings.Count, @@ -1089,15 +1089,15 @@ public void ClearMethodsProperlyResetState() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Assert.That(WButtonGUI.GetGroupCountsForTesting().Count, Is.GreaterThan(0)); - Assert.That(WButtonGUI.GetConflictingDrawOrderWarnings().Count, Is.GreaterThan(0)); + Assert.That(WButtonGUITestAccess.GetGroupCounts().Count, Is.GreaterThan(0)); + Assert.That(WButtonGUI.ConflictingDrawOrderWarnings.Count, Is.GreaterThan(0)); - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); - Assert.That(WButtonGUI.GetGroupCountsForTesting().Count, Is.EqualTo(0)); - Assert.That(WButtonGUI.GetConflictingDrawOrderWarnings().Count, Is.EqualTo(0)); + Assert.That(WButtonGUITestAccess.GetGroupCounts().Count, Is.EqualTo(0)); + Assert.That(WButtonGUI.ConflictingDrawOrderWarnings.Count, Is.EqualTo(0)); } [Test] @@ -1120,7 +1120,7 @@ public void FirstDeclaredButtonDeterminesCanonicalDrawOrderNotLowest() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey testGroup = groupCounts.Keys.First(k => string.Equals(k._groupName, "Test", System.StringComparison.Ordinal) ); @@ -1152,7 +1152,7 @@ public void FirstDeclaredButtonDeterminesCanonicalDrawOrderNotHighest() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey crossGroup = groupCounts.Keys.First(k => string.Equals(k._groupName, "CrossPlacement", System.StringComparison.Ordinal) ); @@ -1190,7 +1190,7 @@ public void GroupsWithLowerDrawOrderRenderFirst() UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List sortedKeys = groupCounts.Keys.OrderBy(k => k._drawOrder).ToList(); int previousDrawOrder = int.MinValue; @@ -1278,7 +1278,7 @@ bool expectedDrawn drawn, Is.EqualTo(expectedDrawn), $"With globalPlacementIsTop={globalPlacementIsTop} and placementToTry={placementToTry}, drawn should be {expectedDrawn}. " - + $"Groups found: {string.Join(", ", WButtonGUI.GetGroupCountsForTesting().Keys.Select(k => $"{k._groupName}:{k._drawOrder}:{k._groupPlacement}"))}" + + $"Groups found: {string.Join(", ", WButtonGUITestAccess.GetGroupCounts().Keys.Select(k => $"{k._groupName}:{k._drawOrder}:{k._groupPlacement}"))}" ); } @@ -1300,7 +1300,7 @@ public void DrawOrderDoesNotDeterminePlacement(int drawOrder) WButtonGroupPlacement.UseGlobalSetting ); - GUIContent header = WButtonGUI.BuildGroupHeader(key); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(key); Assert.That( header.text, Does.Contain(WButtonStyles.TopGroupLabel.text).Or.EqualTo("TestGroup"), @@ -1315,13 +1315,13 @@ public void ExplicitGroupPlacementDeterminesLabelStyle( string expectedLabelName ) { - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); Dictionary counts = new() { { 0, 1 } }; - WButtonGUI.SetGroupCountsForTesting(counts); + WButtonGUITestAccess.SetGroupCounts(counts); WButtonGroupKey key = new(WButtonAttribute.NoGroupPriority, 0, null, 0, placement); - GUIContent header = WButtonGUI.BuildGroupHeader(key); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(key); GUIContent expectedLabel = placement == WButtonGroupPlacement.Top ? WButtonStyles.TopGroupLabel diff --git a/Tests/Editor/WButton/WButtonDrawOrderTests.cs b/Tests/Editor/WButton/WButtonDrawOrderTests.cs index bac3c890c..af633aeec 100644 --- a/Tests/Editor/WButton/WButtonDrawOrderTests.cs +++ b/Tests/Editor/WButton/WButtonDrawOrderTests.cs @@ -216,7 +216,7 @@ public void GroupKeySorting() public void DrawOrderZeroIsTopPlacement() { WButtonGroupKey key = new(0, 0, null, 0, WButtonGroupPlacement.UseGlobalSetting); - GUIContent header = WButtonGUI.BuildGroupHeader(key); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(key); Assert.That(header, Is.Not.Null); } @@ -226,11 +226,11 @@ public void DrawOrderMinusOneIsTopPlacement() { WButtonGroupKey key = new(0, -1, null, 0, WButtonGroupPlacement.UseGlobalSetting); - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); Dictionary counts = new() { { -1, 1 } }; - WButtonGUI.SetGroupCountsForTesting(counts); + WButtonGUITestAccess.SetGroupCounts(counts); - GUIContent header = WButtonGUI.BuildGroupHeader(key); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(key); Assert.That(header, Is.Not.Null); Assert.That(header.text, Is.EqualTo(WButtonStyles.TopGroupLabel.text)); @@ -241,11 +241,11 @@ public void ExplicitBottomPlacementUsesBottomLabel() { WButtonGroupKey key = new(0, -2, null, 0, WButtonGroupPlacement.Bottom); - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); Dictionary counts = new() { { -2, 1 } }; - WButtonGUI.SetGroupCountsForTesting(counts); + WButtonGUITestAccess.SetGroupCounts(counts); - GUIContent header = WButtonGUI.BuildGroupHeader(key); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(key); Assert.That(header, Is.Not.Null, "Header should not be null"); Assert.That( @@ -260,11 +260,11 @@ public void UseGlobalSettingWithAnyDrawOrderUsesTopLabel() { WButtonGroupKey key = new(0, -2, null, 0, WButtonGroupPlacement.UseGlobalSetting); - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); Dictionary counts = new() { { -2, 1 } }; - WButtonGUI.SetGroupCountsForTesting(counts); + WButtonGUITestAccess.SetGroupCounts(counts); - GUIContent header = WButtonGUI.BuildGroupHeader(key); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(key); Assert.That(header, Is.Not.Null, "Header should not be null"); Assert.That( diff --git a/Tests/Editor/WButton/WButtonGroupPlacementTests.cs b/Tests/Editor/WButton/WButtonGroupPlacementTests.cs index 517473849..912757554 100644 --- a/Tests/Editor/WButton/WButtonGroupPlacementTests.cs +++ b/Tests/Editor/WButton/WButtonGroupPlacementTests.cs @@ -33,22 +33,22 @@ public sealed class WButtonGroupPlacementTests : BatchedEditorTestBase public void SetUp() { base.BaseSetUp(); - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPriorityWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPlacementWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPriorityWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPlacementWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); } [TearDown] public override void TearDown() { - WButtonGUI.ClearGroupDataForTesting(); - WButtonGUI.ClearConflictingDrawOrderWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPriorityWarningsForTesting(); - WButtonGUI.ClearConflictingGroupPlacementWarningsForTesting(); - WButtonGUI.ClearConflictWarningContentCacheForTesting(); + WButtonGUITestAccess.ClearGroupData(); + WButtonGUITestAccess.ClearConflictingDrawOrderWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPriorityWarnings(); + WButtonGUITestAccess.ClearConflictingGroupPlacementWarnings(); + WButtonGUITestAccess.ClearConflictWarningContentCache(); WButtonGUI.ClearContextCache(); base.TearDown(); } @@ -303,7 +303,7 @@ public void ConflictingGroupPlacementUsesFirstDeclaredPlacement() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List conflictGroups = groupCounts .Keys.Where(k => string.Equals(k._groupName, "ConflictGroup", System.StringComparison.Ordinal) @@ -348,7 +348,7 @@ public void ConflictingGroupPlacementGeneratesWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings.ContainsKey("ConflictGroup"), @@ -392,7 +392,7 @@ public void ConflictingGroupPlacementReverseUsesFirstDeclaredPlacement() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List conflictGroups = groupCounts .Keys.Where(k => string.Equals(k._groupName, "ConflictGroup", System.StringComparison.Ordinal) @@ -431,7 +431,7 @@ public void MixedPlacementGroupsRenderInCorrectLocations() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List topGroups = groupCounts .Keys.Where(k => @@ -501,7 +501,7 @@ public void GroupPriorityControlsRenderOrder() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey group0 = groupCounts.Keys.First(k => string.Equals(k._groupName, "Group0", System.StringComparison.Ordinal) @@ -562,7 +562,7 @@ public void ConflictingGroupPriorityUsesFirstDeclaredPriority() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List conflictGroups = groupCounts .Keys.Where(k => string.Equals(k._groupName, "ConflictGroup", System.StringComparison.Ordinal) @@ -598,7 +598,7 @@ public void ConflictingGroupPriorityGeneratesWarning() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPriorityWarnings(); + WButtonGUI.ConflictingGroupPriorityWarnings; Assert.That( warnings.ContainsKey("ConflictGroup"), @@ -642,7 +642,7 @@ public void UngroupedButtonsIgnoreGroupPlacementAndPriority() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); List ungroupedKeys = groupCounts .Keys.Where(k => string.IsNullOrEmpty(k._groupName)) .ToList(); @@ -688,7 +688,7 @@ public void ComplexGroupingOrdersCorrectly() globalPlacementIsTop: true ); - Dictionary groupCounts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary groupCounts = WButtonGUITestAccess.GetGroupCounts(); WButtonGroupKey highPriorityTop = groupCounts.Keys.First(k => string.Equals(k._groupName, "HighPriorityTop", System.StringComparison.Ordinal) @@ -820,7 +820,7 @@ public void NoPlacementWarningsForConsistentGroups() ); IReadOnlyDictionary warnings = - WButtonGUI.GetConflictingGroupPlacementWarnings(); + WButtonGUI.ConflictingGroupPlacementWarnings; Assert.That( warnings, diff --git a/Tests/Editor/WButton/WButtonGuiHeaderTests.cs b/Tests/Editor/WButton/WButtonGuiHeaderTests.cs index 6c67566f4..da83bd0b0 100644 --- a/Tests/Editor/WButton/WButtonGuiHeaderTests.cs +++ b/Tests/Editor/WButton/WButtonGuiHeaderTests.cs @@ -17,21 +17,21 @@ public sealed class WButtonGuiHeaderTests [SetUp] public void SetUp() { - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); } [TearDown] public void TearDown() { - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); } [Test] public void BuildGroupHeaderNoGroupingSuffixWhenSingleGroup() { - WButtonGUI.ClearGroupDataForTesting(); + WButtonGUITestAccess.ClearGroupData(); - GUIContent header = WButtonGUI.BuildGroupHeader(-1); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(-1); Assert.That(header.text, Is.EqualTo(WButtonStyles.TopGroupLabel.text)); } @@ -40,10 +40,10 @@ public void BuildGroupHeaderAppendsDrawOrderWhenMultipleGroups() { // The legacy API creates UseGlobalSetting keys, so draw order cannot decide the header style. Dictionary counts = new() { { -1, 3 }, { -5, 2 } }; - WButtonGUI.SetGroupCountsForTesting(counts); + WButtonGUITestAccess.SetGroupCounts(counts); - GUIContent topHeader = WButtonGUI.BuildGroupHeader(-1); - GUIContent bottomHeader = WButtonGUI.BuildGroupHeader(-5); + GUIContent topHeader = WButtonGUITestAccess.BuildGroupHeader(-1); + GUIContent bottomHeader = WButtonGUITestAccess.BuildGroupHeader(-5); Assert.That( topHeader.text, @@ -62,13 +62,13 @@ public void BuildGroupHeaderAppendsDrawOrderWhenMultipleGroups() public void BuildGroupHeaderUsesCustomGroupNameWhenProvided() { Dictionary counts = new() { { -1, 2 }, { -4, 1 } }; - WButtonGUI.SetGroupCountsForTesting(counts); + WButtonGUITestAccess.SetGroupCounts(counts); Dictionary names = new() { { -4, "Networking" } }; - WButtonGUI.SetGroupNamesForTesting(names); + WButtonGUITestAccess.SetGroupNames(names); - GUIContent custom = WButtonGUI.BuildGroupHeader(-4); - GUIContent defaultHeader = WButtonGUI.BuildGroupHeader(-1); + GUIContent custom = WButtonGUITestAccess.BuildGroupHeader(-4); + GUIContent defaultHeader = WButtonGUITestAccess.BuildGroupHeader(-1); Assert.That(custom.text, Is.EqualTo("Networking")); Assert.That(defaultHeader.text, Is.EqualTo($"{WButtonStyles.TopGroupLabel.text} (-1)")); @@ -80,12 +80,12 @@ public void BuildGroupHeaderExplicitBottomPlacementUsesBottomLabel() WButtonGroupKey topKey = new(0, -1, null, 0, WButtonGroupPlacement.Top); WButtonGroupKey bottomKey = new(0, -2, null, 0, WButtonGroupPlacement.Bottom); - Dictionary counts = WButtonGUI.GetGroupCountsForTesting(); + Dictionary counts = WButtonGUITestAccess.GetGroupCounts(); counts[topKey] = 2; counts[bottomKey] = 1; - GUIContent topHeader = WButtonGUI.BuildGroupHeader(topKey); - GUIContent bottomHeader = WButtonGUI.BuildGroupHeader(bottomKey); + GUIContent topHeader = WButtonGUITestAccess.BuildGroupHeader(topKey); + GUIContent bottomHeader = WButtonGUITestAccess.BuildGroupHeader(bottomKey); Assert.That( topHeader.text, @@ -120,9 +120,9 @@ string expectedLabelPrefix ) { Dictionary counts = new() { { drawOrder, 1 }, { drawOrder - 10, 1 } }; - WButtonGUI.SetGroupCountsForTesting(counts); + WButtonGUITestAccess.SetGroupCounts(counts); - GUIContent header = WButtonGUI.BuildGroupHeader(drawOrder); + GUIContent header = WButtonGUITestAccess.BuildGroupHeader(drawOrder); Assert.That( header.text, diff --git a/Tests/Editor/WButton/WButtonStyleTextureEvictionTests.cs b/Tests/Editor/WButton/WButtonStyleTextureEvictionTests.cs index 2c70a8348..fcd0b9634 100644 --- a/Tests/Editor/WButton/WButtonStyleTextureEvictionTests.cs +++ b/Tests/Editor/WButton/WButtonStyleTextureEvictionTests.cs @@ -41,19 +41,19 @@ private static Color DistinctColor(int index) [SetUp] public void SetUp() { - WButtonStyles.TestHooks.ClearColoredStyleCaches(); + WButtonStylesTestAccess.ClearColoredStyleCaches(); } [TearDown] public void TearDown() { - WButtonStyles.TestHooks.ClearColoredStyleCaches(); + WButtonStylesTestAccess.ClearColoredStyleCaches(); } [Test] public void TheCacheStopsAtItsBound() { - int bound = WButtonStyles.TestHooks.MaxColoredButtonStyleCount; + int bound = WButtonStylesTestAccess.MaxColoredButtonStyleCount; Assert.Greater(bound, 0, "the bound must be positive for this to measure anything"); for (int index = 0; index < bound * 2; ++index) @@ -61,7 +61,7 @@ public void TheCacheStopsAtItsBound() _ = WButtonStyles.GetColoredButtonStyle(DistinctColor(index), Color.white); } - Assert.AreEqual(bound, WButtonStyles.TestHooks.ColoredButtonStyleCount); + Assert.AreEqual(bound, WButtonStylesTestAccess.ColoredButtonStyleCount); } /// @@ -77,7 +77,7 @@ public void ChurnDoesNotDestroyTheTexturesOfARenewedEntry() Texture2D background = style.normal.background; Assert.IsTrue(background != null, "the probe must have had a live background"); - int bound = WButtonStyles.TestHooks.MaxColoredButtonStyleCount; + int bound = WButtonStylesTestAccess.MaxColoredButtonStyleCount; GUIStyle firstChurned = WButtonStyles.GetColoredButtonStyle( DistinctColor(0), Color.white @@ -113,7 +113,7 @@ public void EvictionDestroysTheTexturesTheEntryOwned() Texture2D background = style.normal.background; Assert.IsTrue(background != null, "the probe must have had a live background"); - int bound = WButtonStyles.TestHooks.MaxColoredButtonStyleCount; + int bound = WButtonStylesTestAccess.MaxColoredButtonStyleCount; for (int index = 0; index < bound + 1; ++index) { _ = WButtonStyles.GetColoredButtonStyle(DistinctColor(index), Color.white); @@ -121,7 +121,7 @@ public void EvictionDestroysTheTexturesTheEntryOwned() Assert.AreEqual( bound, - WButtonStyles.TestHooks.ColoredButtonStyleCount, + WButtonStylesTestAccess.ColoredButtonStyleCount, "the churn never reached the bound, so nothing was evicted" ); Assert.IsTrue(background == null, "the evicted entry's texture outlived its entry"); @@ -136,11 +136,11 @@ public void ClearingDestroysEveryTextureItHeld() ); Texture2D background = style.normal.background; Assert.IsTrue(background != null, "the probe must have had a live background"); - Assert.AreEqual(1, WButtonStyles.TestHooks.ColoredButtonStyleCount); + Assert.AreEqual(1, WButtonStylesTestAccess.ColoredButtonStyleCount); - WButtonStyles.TestHooks.ClearColoredStyleCaches(); + WButtonStylesTestAccess.ClearColoredStyleCaches(); - Assert.AreEqual(0, WButtonStyles.TestHooks.ColoredButtonStyleCount); + Assert.AreEqual(0, WButtonStylesTestAccess.ColoredButtonStyleCount); Assert.IsTrue(background == null, "Clear dropped a texture without destroying it"); } } diff --git a/Tests/Editor/WGroup/WGroupHasVisibleChildrenTests.cs b/Tests/Editor/WGroup/WGroupHasVisibleChildrenTests.cs index 7a7192a3b..b583714cd 100644 --- a/Tests/Editor/WGroup/WGroupHasVisibleChildrenTests.cs +++ b/Tests/Editor/WGroup/WGroupHasVisibleChildrenTests.cs @@ -32,7 +32,7 @@ public override void BaseSetUp() _originalIndentLevel = EditorGUI.indentLevel; EditorGUI.indentLevel = 0; _previousConfiguration = UnityHelpersSettings.GetWGroupAutoIncludeConfiguration(); - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -43,7 +43,7 @@ public override void TearDown() { WGroupLayoutBuilder.ClearCache(); EditorGUI.indentLevel = _originalIndentLevel; - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( _previousConfiguration.Mode, _previousConfiguration.RowCount ); diff --git a/Tests/Editor/WGroup/WGroupIndentBehaviorTests.cs b/Tests/Editor/WGroup/WGroupIndentBehaviorTests.cs index 045f0fb49..b6e47c922 100644 --- a/Tests/Editor/WGroup/WGroupIndentBehaviorTests.cs +++ b/Tests/Editor/WGroup/WGroupIndentBehaviorTests.cs @@ -152,11 +152,11 @@ public override void BaseSetUp() { base.BaseSetUp(); WGroupLayoutBuilder.ClearCache(); - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); _originalIndentLevel = EditorGUI.indentLevel; EditorGUI.indentLevel = 0; _previousConfiguration = UnityHelpersSettings.GetWGroupAutoIncludeConfiguration(); - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -166,9 +166,9 @@ public override void BaseSetUp() public override void TearDown() { WGroupLayoutBuilder.ClearCache(); - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); EditorGUI.indentLevel = _originalIndentLevel; - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( _previousConfiguration.Mode, _previousConfiguration.RowCount ); diff --git a/Tests/Editor/WGroup/WGroupIndentLevelTests.cs b/Tests/Editor/WGroup/WGroupIndentLevelTests.cs index 00b470dcc..835d07823 100644 --- a/Tests/Editor/WGroup/WGroupIndentLevelTests.cs +++ b/Tests/Editor/WGroup/WGroupIndentLevelTests.cs @@ -31,11 +31,11 @@ public override void BaseSetUp() { base.BaseSetUp(); WGroupLayoutBuilder.ClearCache(); - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); _originalIndentLevel = EditorGUI.indentLevel; EditorGUI.indentLevel = 0; _previousConfiguration = UnityHelpersSettings.GetWGroupAutoIncludeConfiguration(); - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -45,9 +45,9 @@ public override void BaseSetUp() public override void TearDown() { WGroupLayoutBuilder.ClearCache(); - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); EditorGUI.indentLevel = _originalIndentLevel; - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( _previousConfiguration.Mode, _previousConfiguration.RowCount ); @@ -168,7 +168,7 @@ public void IndentCompensationAtVariousLevels( [Test] public void ScopeDepthIncreasesWithNonZeroPadding() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); int originalDepth = GroupGUIWidthUtility.CurrentScopeDepth; using (GroupGUIWidthUtility.PushContentPadding(10f, 5f, 5f)) @@ -190,7 +190,7 @@ public void ScopeDepthIncreasesWithNonZeroPadding() [Test] public void ScopeDepthDoesNotIncreaseWithZeroPadding() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); int originalDepth = GroupGUIWidthUtility.CurrentScopeDepth; using (GroupGUIWidthUtility.PushContentPadding(0f, 0f, 0f)) @@ -206,7 +206,7 @@ public void ScopeDepthDoesNotIncreaseWithZeroPadding() [Test] public void NestedPaddingScopesAccumulateDepth() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); int originalDepth = GroupGUIWidthUtility.CurrentScopeDepth; using (GroupGUIWidthUtility.PushContentPadding(10f, 5f, 5f)) @@ -243,7 +243,7 @@ public void NestedPaddingScopesAccumulateDepth() [Test] public void LeftAndRightPaddingAccumulateSeparately() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); using (GroupGUIWidthUtility.PushContentPadding(12f, 8f, 4f)) { @@ -289,7 +289,7 @@ public void LeftAndRightPaddingAccumulateSeparately() [Test] public void ApplyCurrentPaddingAdjustsRectCorrectly() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect original = new(0f, 0f, 400f, 100f); using (GroupGUIWidthUtility.PushContentPadding(30f, 20f, 10f)) @@ -305,7 +305,7 @@ public void ApplyCurrentPaddingAdjustsRectCorrectly() [Test] public void ApplyCurrentPaddingWithNoPaddingReturnsOriginal() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); Rect original = new(50f, 25f, 300f, 50f); Rect adjusted = GroupGUIWidthUtility.ApplyCurrentPadding(original); @@ -316,7 +316,7 @@ public void ApplyCurrentPaddingWithNoPaddingReturnsOriginal() [Test] public void MixedZeroAndNonZeroPaddingScopesTrackCorrectly() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); int originalDepth = GroupGUIWidthUtility.CurrentScopeDepth; using (GroupGUIWidthUtility.PushContentPadding(10f, 5f, 5f)) @@ -349,7 +349,7 @@ public void MixedZeroAndNonZeroPaddingScopesTrackCorrectly() [Test] public void HorizontalPaddingEqualsLeftPlusRight() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); float leftPadding = 12f; float rightPadding = 8f; @@ -495,7 +495,7 @@ public void ConsecutiveIndentCompensationCallsRestoreCorrectly() [Test] public void PaddingScopeDisposalOrderMatters() { - GroupGUIWidthUtility.ResetForTests(); + GroupGUIWidthUtilityTestAccess.Reset(); IDisposable outer = GroupGUIWidthUtility.PushContentPadding(10f, 5f, 5f); IDisposable inner = GroupGUIWidthUtility.PushContentPadding(8f, 4f, 4f); diff --git a/Tests/Editor/WGroup/WGroupLayoutTests.cs b/Tests/Editor/WGroup/WGroupLayoutTests.cs index 77cb786a5..8658bdbe5 100644 --- a/Tests/Editor/WGroup/WGroupLayoutTests.cs +++ b/Tests/Editor/WGroup/WGroupLayoutTests.cs @@ -297,7 +297,7 @@ public override void BaseSetUp() WGroupLayoutBuilder.ClearCache(); _previousConfiguration = UnityHelpersSettings.GetWGroupAutoIncludeConfiguration(); - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -308,7 +308,7 @@ public override void TearDown() { WGroupLayoutBuilder.ClearCache(); - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( _previousConfiguration.Mode, _previousConfiguration.RowCount ); @@ -683,7 +683,7 @@ public void AutoIncludeModeAffectsGroupCapture( bool expectNotAutoIncludedInGroup ) { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests(mode, rowCount); + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration(mode, rowCount); WGroupLayoutBuilder.ClearCache(); WGroupAutoIncludeTestTarget target = @@ -726,7 +726,7 @@ public void AutoIncludeModeWithMultipleGroups( string expectedCapturingGroup ) { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests(mode, rowCount); + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration(mode, rowCount); WGroupLayoutBuilder.ClearCache(); WGroupLayoutTestTarget target = CreateScriptableObject(); @@ -776,7 +776,7 @@ public void ExplicitAutoIncludeCountOverridesGlobalSettings( string[] expectedProperties ) { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests(mode, rowCount); + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration(mode, rowCount); WGroupLayoutBuilder.ClearCache(); WGroupExplicitAutoIncludeTestTarget target = @@ -824,7 +824,7 @@ string[] expectedProperties [Test] public void ExplicitInfiniteAutoIncludeCapturesAllSubsequent() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -873,7 +873,7 @@ public void ExplicitInfiniteAutoIncludeCapturesAllSubsequent() [Test] public void ExplicitZeroAutoIncludeCapturesNoSubsequent() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.Infinite, 0 ); @@ -923,7 +923,7 @@ public void ExplicitZeroAutoIncludeCapturesNoSubsequent() [Test] public void FiniteModeWithZeroRowCountBehavesLikeNone() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.Finite, 0 ); @@ -957,7 +957,7 @@ public void FiniteModeWithZeroRowCountBehavesLikeNone() public void HideInInspectorFieldsExcludedFromAutoInclude() { // Finite mode with enough budget to capture every field, were HideInInspector not respected. - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.Finite, 6 ); @@ -1023,7 +1023,7 @@ public void HideInInspectorFieldsExcludedFromAutoInclude() public void ExplicitlyGroupedHiddenFieldsAreIncluded() { // None mode, so auto-include cannot interfere. - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -1074,7 +1074,7 @@ public void ExplicitlyGroupedHiddenFieldsAreIncluded() [Test] public void HideInInspectorExcludedInInfiniteMode() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.Infinite, 0 ); @@ -1130,7 +1130,7 @@ public void HideInInspectorExcludedInInfiniteMode() [Test] public void UngroupedHiddenFieldsInHiddenPropertyPaths() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -1179,7 +1179,7 @@ public void UngroupedHiddenFieldsInHiddenPropertyPaths() [Test] public void PropertyOperationIsHiddenInInspectorFlagSetCorrectly() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -1288,7 +1288,7 @@ public void PropertyOperationIsHiddenInInspectorFlagSetCorrectly() [Test] public void GroupOperationIsHiddenInInspectorAlwaysFalse() { - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); diff --git a/Tests/Editor/WGroup/WGroupNestedLayoutTests.cs b/Tests/Editor/WGroup/WGroupNestedLayoutTests.cs index 8b197b622..35046b301 100644 --- a/Tests/Editor/WGroup/WGroupNestedLayoutTests.cs +++ b/Tests/Editor/WGroup/WGroupNestedLayoutTests.cs @@ -88,7 +88,7 @@ public override void BaseSetUp() WGroupLayoutBuilder.ClearCache(); _previousConfiguration = UnityHelpersSettings.GetWGroupAutoIncludeConfiguration(); - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( UnityHelpersSettings.WGroupAutoIncludeMode.None, 0 ); @@ -99,7 +99,7 @@ public override void TearDown() { WGroupLayoutBuilder.ClearCache(); - UnityHelpersSettings.SetWGroupAutoIncludeConfigurationForTests( + UnityHelpersSettingsTestAccess.SetWGroupAutoIncludeConfiguration( _previousConfiguration.Mode, _previousConfiguration.RowCount ); diff --git a/Tests/Editor/Windows/FitTextureSizeWindowTests.cs b/Tests/Editor/Windows/FitTextureSizeWindowTests.cs index 1431fef7c..5b97f305d 100644 --- a/Tests/Editor/Windows/FitTextureSizeWindowTests.cs +++ b/Tests/Editor/Windows/FitTextureSizeWindowTests.cs @@ -53,7 +53,7 @@ public override void BaseSetUp() AssetPostprocessorTestHandlers.AssertCleanAndClearAll(); base.BaseSetUp(); // Loop protection would otherwise trip across many texture tests in succession. - DetectAssetChangeProcessor.ResetForTesting(); + DetectAssetChangeProcessorTestAccess.Reset(); EnsureFolder(Root); } diff --git a/Tests/Editor/Windows/PrefabCheckerTests.cs b/Tests/Editor/Windows/PrefabCheckerTests.cs index 682bd63a9..5b6b96369 100644 --- a/Tests/Editor/Windows/PrefabCheckerTests.cs +++ b/Tests/Editor/Windows/PrefabCheckerTests.cs @@ -242,29 +242,10 @@ public void ScanFoldersReportsDisabledRootAndHonorsExplicitOptions() AssetDatabaseBatchHelper.RefreshIfNotBatching(); PrefabChecker.ScanOptions options = new(); - int progressCalls = 0; - int clearCalls = 0; - System.Func previousProgress = EditorUi.ProgressForTesting; - System.Action previousClear = EditorUi.ProgressClearedForTesting; - PrefabChecker.ScanResult found; - try - { - EditorUi.ProgressForTesting = () => - { - ++progressCalls; - return false; - }; - EditorUi.ProgressClearedForTesting = () => clearCalls++; - found = PrefabChecker.ScanFolders(new[] { folder.Replace('/', '\\') }, options); - } - finally - { - EditorUi.ProgressForTesting = previousProgress; - EditorUi.ProgressClearedForTesting = previousClear; - } - - Assert.AreEqual(0, progressCalls); - Assert.AreEqual(0, clearCalls); + PrefabChecker.ScanResult found = PrefabChecker.ScanFolders( + new[] { folder.Replace('/', '\\') }, + options + ); Assert.IsTrue(found.Error == null); Assert.AreEqual(1, found.PrefabsChecked); diff --git a/Tests/Runtime/Attributes/RelationalComponentExtensionsTests.cs b/Tests/Runtime/Attributes/RelationalComponentExtensionsTests.cs index b77fba125..1ebd496ed 100644 --- a/Tests/Runtime/Attributes/RelationalComponentExtensionsTests.cs +++ b/Tests/Runtime/Attributes/RelationalComponentExtensionsTests.cs @@ -55,7 +55,7 @@ public void AssignmentMatchesDirectQueriesAcrossCacheAndHierarchyChanges( [Values(0, 1, 2)] int capability ) { - using IDisposable scope = ReflectionHelpers.OverrideReflectionCapabilities( + using IDisposable scope = new ReflectionCapabilityScope( capability == 0, capability == 1 ); diff --git a/Tests/Runtime/Core/Threading/SingleThreadedThreadPoolDrainTests.cs b/Tests/Runtime/Core/Threading/SingleThreadedThreadPoolDrainTests.cs index b903fb42a..a9bfc9e72 100644 --- a/Tests/Runtime/Core/Threading/SingleThreadedThreadPoolDrainTests.cs +++ b/Tests/Runtime/Core/Threading/SingleThreadedThreadPoolDrainTests.cs @@ -4,6 +4,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Core.Threading { #if !SINGLE_THREADED + using System; using System.Collections; using System.Threading; using System.Threading.Tasks; @@ -30,6 +31,213 @@ public sealed class SingleThreadedThreadPoolDrainTests // Bound the gate wait so an assertion failure cannot stall teardown. private const int GateTimeoutMilliseconds = 5_000; + [TestCase(false, 0)] + [TestCase(false, 1)] + [TestCase(false, 2)] + [TestCase(true, 0)] + [TestCase(true, 1)] + [TestCase(true, 2)] + public void DisposalClearsPendingWorkWhileWaitingForRunningItem( + bool runInBackground, + int delegateKind + ) + { + using ManualResetEventSlim entered = new(false); + using ManualResetEventSlim release = new(false); + using SingleThreadedThreadPool threadPool = new(runInBackground); + int queuedRuns = 0; + threadPool.Enqueue(() => + { + entered.Set(); + release.Wait(DrainTimeoutMilliseconds); + }); + Task disposal = null; + try + { + Assert.IsTrue(entered.Wait(GateTimeoutMilliseconds)); + for (int index = 0; index < QueuedItems; ++index) + { + switch (delegateKind) + { + case 0: + threadPool.Enqueue( + (Action)(() => Interlocked.Increment(ref queuedRuns)) + ); + break; + case 1: + threadPool.Enqueue( + (Func)( + () => + { + Interlocked.Increment(ref queuedRuns); + return Task.CompletedTask; + } + ) + ); + break; + case 2: + threadPool.Enqueue( + (Func)( + () => + { + Interlocked.Increment(ref queuedRuns); + return default; + } + ) + ); + break; + } + } + Assert.AreEqual(QueuedItems + 1, threadPool.Count); + disposal = threadPool.DisposeAsync().AsTask(); + Assert.IsFalse(threadPool.IsAcceptingWork); + Assert.IsFalse(disposal.IsCompleted); + Assert.AreEqual( + 1, + threadPool.Count, + "Shutdown must release queued delegates before the running item finishes." + ); + } + finally + { + release.Set(); + if (disposal != null) + { + Assert.IsTrue(disposal.Wait(GateTimeoutMilliseconds)); + } + } + Assert.AreEqual(0, threadPool.Count); + Assert.AreEqual(0, Volatile.Read(ref queuedRuns)); + } + + [TestCase(false)] + [TestCase(true)] + public void ConcurrentSubmissionsCannotRetainWorkAfterDisposal(bool runInBackground) + { + using ManualResetEventSlim entered = new(false); + using ManualResetEventSlim release = new(false); + using ManualResetEventSlim submit = new(false); + using SingleThreadedThreadPool threadPool = new(runInBackground); + int queuedRuns = 0; + Action pending = () => Interlocked.Increment(ref queuedRuns); + threadPool.Enqueue(() => + { + entered.Set(); + release.Wait(DrainTimeoutMilliseconds); + }); + Task[] producers = new Task[4]; + Task disposal = null; + try + { + Assert.IsTrue(entered.Wait(GateTimeoutMilliseconds)); + threadPool.Enqueue(pending); + for (int index = 0; index < producers.Length; ++index) + { + producers[index] = Task.Run(() => + { + submit.Wait(DrainTimeoutMilliseconds); + for (int item = 0; item < 1000; ++item) + { + threadPool.Enqueue(pending); + } + }); + } + submit.Set(); + disposal = threadPool.DisposeAsync().AsTask(); + Assert.IsTrue(Task.WaitAll(producers, GateTimeoutMilliseconds)); + threadPool.Enqueue(pending); + Assert.AreEqual(1, threadPool.Count); + } + finally + { + submit.Set(); + release.Set(); + if (disposal != null) + { + Assert.IsTrue(disposal.Wait(GateTimeoutMilliseconds)); + } + } + Assert.AreEqual(0, threadPool.Count); + Assert.AreEqual(0, Volatile.Read(ref queuedRuns)); + } + + [TestCase(false)] + [TestCase(true)] + public void DrainRacingDisposalCannotReportDiscardedWorkAsCompleted(bool runInBackground) + { + for (int attempt = 0; attempt < QueuedItems; ++attempt) + { + using ManualResetEventSlim entered = new(false); + using ManualResetEventSlim release = new(false); + using SingleThreadedThreadPool threadPool = new(runInBackground); + int queuedRuns = 0; + threadPool.Enqueue(() => + { + entered.Set(); + release.Wait(DrainTimeoutMilliseconds); + }); + Task drain = null; + Task disposal = null; + try + { + Assert.IsTrue(entered.Wait(GateTimeoutMilliseconds)); + threadPool.Enqueue(() => Interlocked.Increment(ref queuedRuns)); + drain = threadPool.DrainAsync().AsTask(); + Assert.IsFalse(drain.IsCompleted); + disposal = threadPool.DisposeAsync().AsTask(); + } + finally + { + release.Set(); + if (disposal != null) + { + Assert.IsTrue(disposal.Wait(GateTimeoutMilliseconds)); + } + } + Assert.IsTrue(drain.Wait(GateTimeoutMilliseconds)); + Assert.IsFalse(drain.Result, "Disposal discarded accepted work during this drain."); + Assert.AreEqual(0, Volatile.Read(ref queuedRuns)); + Assert.AreEqual(0, threadPool.Count); + } + } + + [Test] + public void AlreadyCanceledDrainOfIdlePoolPreservesCompletedSuccess() + { + using CancellationTokenSource canceled = new(); + using SingleThreadedThreadPool threadPool = new(); + canceled.Cancel(); + Task drain = threadPool.DrainAsync(canceled.Token).AsTask(); + Assert.IsTrue(drain.IsCompleted); + Assert.IsTrue(drain.Result); + } + + [Test] + public void AlreadyCanceledDrainWithRunningWorkReportsFailure() + { + using ManualResetEventSlim entered = new(false); + using ManualResetEventSlim release = new(false); + using CancellationTokenSource canceled = new(); + using SingleThreadedThreadPool threadPool = new(); + threadPool.Enqueue(() => + { + entered.Set(); + release.Wait(DrainTimeoutMilliseconds); + }); + try + { + Assert.IsTrue(entered.Wait(GateTimeoutMilliseconds)); + canceled.Cancel(); + Task drain = threadPool.DrainAsync(canceled.Token).AsTask(); + Assert.IsTrue(drain.IsCompleted); + Assert.IsFalse(drain.Result); + } + finally + { + release.Set(); + } + } + [UnityTest] public IEnumerator DrainRunsEveryQueuedItemBeforeDisposal() { diff --git a/Tests/Runtime/DataStructures/CacheTests.cs b/Tests/Runtime/DataStructures/CacheTests.cs index 36efada20..b25e4a199 100644 --- a/Tests/Runtime/DataStructures/CacheTests.cs +++ b/Tests/Runtime/DataStructures/CacheTests.cs @@ -14,6 +14,7 @@ namespace WallstopStudios.UnityHelpers.Tests.DataStructures using System.Collections.Generic; using NUnit.Framework; using WallstopStudios.UnityHelpers.Core.DataStructure; + using WallstopStudios.UnityHelpers.Tests.TestDoubles; [TestFixture] [NUnit.Framework.Category("Fast")] @@ -746,15 +747,15 @@ public void SlruResizeRebalancesProtectedEntriesAndClearPreservesTheNewQuota() cache.Resize(8); - Assert.AreEqual(6, cache.ProtectedCapacityForTesting); - Assert.AreEqual(6, cache.ProtectedCountForTesting); - Assert.AreEqual(2, cache.ProbationCountForTesting); + Assert.AreEqual(6, cache._protectedCapacity); + Assert.AreEqual(6, cache._protectedCount); + Assert.AreEqual(2, cache._probationCount); cache.Clear(); - Assert.AreEqual(6, cache.ProtectedCapacityForTesting); - Assert.AreEqual(0, cache.ProtectedCountForTesting); - Assert.AreEqual(0, cache.ProbationCountForTesting); + Assert.AreEqual(6, cache._protectedCapacity); + Assert.AreEqual(0, cache._protectedCount); + Assert.AreEqual(0, cache._probationCount); } [Test] @@ -780,7 +781,7 @@ public void SlruAdaptiveGrowthUsesTheGrownPhysicalCapacityForItsProtectedQuota() cache.Set(11, 11); Assert.AreEqual(15, cache.Capacity); - Assert.AreEqual(12, cache.ProtectedCapacityForTesting); + Assert.AreEqual(12, cache._protectedCapacity); } private float TimeProvider() @@ -839,6 +840,406 @@ public void SetUp() _currentTime = 0f; } + [TestCase(0f, true)] + [TestCase(1f, true)] + [TestCase(-1f, false)] + public void DeterministicJitterRetainsStoredLifetimeResidual( + float startTime, + bool expectedPresent + ) + { + _currentTime = startTime; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfterWriteSeconds = float.MaxValue, + UseJitter = true, + JitterMaxSeconds = 2f, + } + ); + cache._random = new EdgeCaseRandom(floatFallback: 0.5f); + cache.Set(1, 7); + _currentTime = float.MaxValue; + Assert.AreEqual(expectedPresent, cache.TryGet(1, out _)); + } + + [Test] + public void SlidingAccessDiscardsStoredJitterResidual() + { + _currentTime = 0f; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfterWriteSeconds = float.MaxValue, + ExpireAfterAccessSeconds = float.MaxValue, + UseJitter = true, + JitterMaxSeconds = 2f, + } + ); + cache._random = new EdgeCaseRandom(floatFallback: 0.5f); + cache.Set(1, 7); + Assert.IsTrue(cache.TryGet(1, out _)); + _currentTime = float.MaxValue; + Assert.IsFalse(cache.TryGet(1, out _)); + } + + [Test] + public void ThrowingLifetimeFunctionPreservesStoredJitterResidual() + { + _currentTime = 0f; + bool failLifetime = false; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfter = (_, _) => + failLifetime ? throw new InvalidOperationException() : float.MaxValue, + UseJitter = true, + JitterMaxSeconds = 2f, + } + ); + cache._random = new EdgeCaseRandom(floatFallback: 0.5f); + cache.Set(1, 7); + _currentTime = 1f; + failLifetime = true; + Assert.Throws(() => cache.Set(1, 8)); + _currentTime = float.MaxValue; + Assert.IsTrue(cache.TryGet(1, out int value)); + Assert.AreEqual(7, value); + } + + [TestCase(1f, true)] + [TestCase(0f, false)] + [TestCase(-1f, false)] + public void LargeLifetimeRetainsSmallElapsedResidualAcrossAllReaders( + float startTime, + bool expectedPresent + ) + { + _currentTime = startTime; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfterWriteSeconds = float.MaxValue, + } + ); + cache.Set(1, 7); + _currentTime = float.MaxValue; + Assert.AreEqual(expectedPresent, cache.ContainsKey(1)); + List keys = new(); + cache.GetKeys(keys); + Assert.AreEqual(expectedPresent ? 1 : 0, keys.Count); + cache.CleanUp(); + Assert.AreEqual(expectedPresent ? 1 : 0, cache.Count); + Assert.AreEqual(expectedPresent, cache.TryGet(1, out _)); + } + + [TestCase(0, 100f)] + [TestCase(1, 100f)] + [TestCase(2, 100f)] + [TestCase(3, 100f)] + [TestCase(0, 16777216f)] + [TestCase(1, 16777216f)] + [TestCase(2, 16777216f)] + [TestCase(3, 16777216f)] + [TestCase(0, float.MaxValue)] + [TestCase(1, float.MaxValue)] + [TestCase(2, float.MaxValue)] + [TestCase(3, float.MaxValue)] + public void PositiveLifetimeSurvivesRepeatedReadsWithoutElapsedTime( + int expirationPolicy, + float initialTime + ) + { + _currentTime = initialTime; + CacheOptions options = new() { TimeProvider = TimeProvider }; + if (expirationPolicy == 0) + options.ExpireAfterWriteSeconds = 1f; + if (expirationPolicy == 1) + options.ExpireAfterAccessSeconds = 1f; + if (expirationPolicy == 2) + options.ExpireAfter = static (_, _) => 1f; + using Cache cache = new(options); + cache.Set(1, 7, expirationPolicy == 3 ? 1f : null); + Assert.IsTrue(cache.TryGet(1, out int first)); + Assert.AreEqual(7, first); + Assert.IsTrue(cache.TryGet(1, out int second)); + Assert.AreEqual(7, second); + } + + [TestCase(100f)] + [TestCase(16777216f)] + [TestCase(float.MaxValue)] + [TestCase(float.PositiveInfinity)] + public void DisabledExpirationPreservesEntriesAtEveryClock(float currentTime) + { + _currentTime = currentTime; + using Cache cache = new( + new CacheOptions { TimeProvider = TimeProvider } + ); + cache.Set(1, 7); + cache.CleanUp(); + Assert.AreEqual(1, cache.Count); + Assert.IsTrue(cache.ContainsKey(1)); + Assert.IsTrue(cache.TryGet(1, out int value)); + Assert.AreEqual(7, value); + } + + [TestCase(100f, 3f, 2f, 4f)] + [TestCase(33554432f, 5f, 4f, 8f)] + public void FiniteExpirationUsesElapsedTimeAcrossReadsEnumerationAndCleanup( + float initialTime, + float lifetime, + float withinLifetime, + float afterLifetime + ) + { + _currentTime = initialTime; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfterWriteSeconds = lifetime, + } + ); + cache.Set(1, 7); + _currentTime = initialTime + withinLifetime; + Assert.IsTrue(cache.ContainsKey(1)); + List keys = new(); + cache.GetKeys(keys); + CollectionAssert.AreEqual(new[] { 1 }, keys); + CollectionAssert.AreEqual(new[] { 1 }, cache.Keys); + cache.CleanUp(); + Assert.AreEqual(1, cache.Count); + _currentTime = initialTime + afterLifetime; + cache.GetKeys(keys); + Assert.AreEqual(0, keys.Count); + CollectionAssert.IsEmpty(cache.Keys); + cache.CleanUp(); + Assert.AreEqual(0, cache.Count); + Assert.IsFalse(cache.TryGet(1, out _)); + } + + [TestCase(100f)] + [TestCase(16777216f)] + public void ExpirationIncludesExactLifetimeBoundary(float initialTime) + { + _currentTime = initialTime; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfterWriteSeconds = 2f, + } + ); + cache.Set(1, 7); + _currentTime = initialTime + 2f; + Assert.IsFalse(cache.TryGet(1, out _)); + } + + [TestCase(false)] + [TestCase(true)] + public void FiniteLifetimeAndJitterExpireAcrossTheFiniteClockRange(bool useJitter) + { + _currentTime = -float.MaxValue; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfterWriteSeconds = float.MaxValue, + UseJitter = useJitter, + JitterMaxSeconds = float.MaxValue, + } + ); + cache.Set(1, 7); + Assert.IsTrue(cache.TryGet(1, out _)); + _currentTime = float.MaxValue; + Assert.IsFalse(cache.TryGet(1, out _)); + } + + [TestCase(float.NaN, true)] + [TestCase(float.PositiveInfinity, true)] + [TestCase(float.NegativeInfinity, false)] + [TestCase(0f, false)] + [TestCase(-1f, false)] + public void CustomLifetimePreservesNonfiniteAndNonpositivePolicies( + float lifetime, + bool expectedPresent + ) + { + _currentTime = 100f; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfter = (_, _) => lifetime, + } + ); + cache.Set(1, 7); + Assert.AreEqual(expectedPresent, cache.TryGet(1, out _)); + } + + [TestCase(float.NaN, float.NaN, true)] + [TestCase(float.PositiveInfinity, float.PositiveInfinity, false)] + [TestCase(float.NegativeInfinity, float.NegativeInfinity, false)] + [TestCase(100f, float.PositiveInfinity, false)] + [TestCase(float.PositiveInfinity, 100f, true)] + [TestCase(100f, float.NegativeInfinity, true)] + [TestCase(100f, float.NaN, true)] + public void ActiveExpirationPreservesNonfiniteClockComparisons( + float initialTime, + float readTime, + bool expectedPresent + ) + { + _currentTime = initialTime; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfterWriteSeconds = 1f, + } + ); + cache.Set(1, 7); + _currentTime = readTime; + Assert.AreEqual(expectedPresent, cache.TryGet(1, out _)); + } + + [TestCase(false)] + [TestCase(true)] + public void UpdatingEntryReplacesItsExpirationPolicy(bool disableExpiration) + { + _currentTime = 100f; + using Cache cache = new( + new CacheOptions { TimeProvider = TimeProvider } + ); + cache.Set(1, 7, disableExpiration ? 1f : null); + cache.Set(1, 8, disableExpiration ? null : 1f); + Assert.IsTrue(cache.TryGet(1, out int value)); + Assert.AreEqual(8, value); + _currentTime = float.MaxValue; + Assert.AreEqual(disableExpiration, cache.TryGet(1, out _)); + } + + [Test] + public void NonfiniteClockPreservesRoundedNegativeOverflowExpiration() + { + _currentTime = -float.MaxValue; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfter = static (_, _) => -float.MaxValue, + } + ); + cache.Set(1, 7); + _currentTime = float.NegativeInfinity; + Assert.IsFalse(cache.TryGet(1, out _)); + } + + [TestCase(false)] + [TestCase(true)] + public void ThrowingLifetimeFunctionPreservesExistingValueAndExpiration( + bool inspectExpiration + ) + { + _currentTime = 100f; + bool failLifetime = false; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfter = (_, _) => + failLifetime ? throw new InvalidOperationException() : 2f, + } + ); + cache.Set(1, 7); + _currentTime = 101f; + failLifetime = true; + Assert.Throws(() => cache.Set(1, 8)); + if (inspectExpiration) + { + _currentTime = 102f; + Assert.IsFalse(cache.TryGet(1, out _)); + } + else + { + Assert.IsTrue(cache.TryGet(1, out int value)); + Assert.AreEqual(7, value); + } + } + + [TestCase(false)] + [TestCase(true)] + public void ThrowingLifetimeFunctionDoesNotConsumeStorageOrEvictExistingEntries( + bool atCapacity + ) + { + _currentTime = 100f; + bool failLifetime = false; + using Cache cache = new( + new CacheOptions + { + InitialCapacity = 2, + MaximumSize = 2, + TimeProvider = TimeProvider, + ExpireAfter = (_, _) => + failLifetime ? throw new InvalidOperationException() : 2f, + } + ); + cache.Set(1, 7); + if (atCapacity) + { + cache.Set(3, 9); + } + int originalCount = cache.Count; + failLifetime = true; + for (int attempt = 0; attempt < 3; ++attempt) + { + Assert.Throws(() => cache.Set(2, 8)); + Assert.AreEqual(originalCount, cache.Count); + Assert.IsTrue(cache.TryGet(1, out int originalValue)); + Assert.AreEqual(7, originalValue); + if (atCapacity) + { + Assert.IsTrue(cache.TryGet(3, out int otherValue)); + Assert.AreEqual(9, otherValue); + } + } + failLifetime = false; + cache.Set(2, 8); + Assert.AreEqual(2, cache.Count); + Assert.IsTrue(cache.TryGet(2, out int value)); + Assert.AreEqual(8, value); + } + + [Test] + public void SlidingAccessPreservesExistingPrecedenceAndBackwardClockBehavior() + { + _currentTime = 100f; + using Cache cache = new( + new CacheOptions + { + TimeProvider = TimeProvider, + ExpireAfterWriteSeconds = 10f, + ExpireAfterAccessSeconds = 4f, + ExpireAfter = static (_, _) => 1f, + } + ); + cache.Set(1, 7, 2f); + _currentTime = 101f; + Assert.IsTrue(cache.TryGet(1, out _)); + _currentTime = 103f; + Assert.IsTrue(cache.TryGet(1, out _)); + _currentTime = 102f; + Assert.IsTrue(cache.ContainsKey(1)); + _currentTime = 107f; + Assert.IsFalse(cache.TryGet(1, out _)); + } + [Test] public void ExpireAfterWriteEvictsExpiredEntries() { diff --git a/Tests/Runtime/DataStructures/SerializableNullableTests.cs b/Tests/Runtime/DataStructures/SerializableNullableTests.cs index d418fd885..6eda624a7 100644 --- a/Tests/Runtime/DataStructures/SerializableNullableTests.cs +++ b/Tests/Runtime/DataStructures/SerializableNullableTests.cs @@ -266,7 +266,8 @@ public void JsonSerializationCapturesMutations() public void OnBeforeSerializeClearsStaleValueWhenEmpty() { SerializableNullable wrapped = default; - wrapped.ForceStateForTesting(hasValue: false, rawValue: 99); + wrapped._hasValue = false; + wrapped._value = 99; ISerializationCallbackReceiver receiver = wrapped; receiver.OnBeforeSerialize(); @@ -280,7 +281,8 @@ public void OnBeforeSerializeClearsStaleValueWhenEmpty() public void OnAfterDeserializeClearsStaleValueWhenEmpty() { SerializableNullable wrapped = default; - wrapped.ForceStateForTesting(hasValue: false, rawValue: 123); + wrapped._hasValue = false; + wrapped._value = 123; ISerializationCallbackReceiver receiver = wrapped; receiver.OnAfterDeserialize(); diff --git a/Tests/Runtime/DataStructures/SerializableTypeTests.cs b/Tests/Runtime/DataStructures/SerializableTypeTests.cs index 506964b3e..5a2d75c69 100644 --- a/Tests/Runtime/DataStructures/SerializableTypeTests.cs +++ b/Tests/Runtime/DataStructures/SerializableTypeTests.cs @@ -43,7 +43,7 @@ public void FilteredDescriptorCacheStopsAtItsBound() } Assert.LessOrEqual( - SerializableTypeCatalog.CachedFilterResultCountForTesting, + SerializableTypeCatalog.FilterCache.Count, 8, "Every prefix of a typed search term must not become a retained entry." ); @@ -80,7 +80,7 @@ public void EvictionDoesNotChangeFilteredResults() SerializableTypeCatalog.MaxCachedFilterResults = 2; _ = SerializableTypeCatalog.GetFilteredDescriptors("eviction-probe-a"); _ = SerializableTypeCatalog.GetFilteredDescriptors("eviction-probe-b"); - Assert.AreEqual(2, SerializableTypeCatalog.CachedFilterResultCountForTesting); + Assert.AreEqual(2, SerializableTypeCatalog.FilterCache.Count); IReadOnlyList afterEvictionResult = SerializableTypeCatalog.GetFilteredDescriptors("SerializableType"); @@ -113,14 +113,14 @@ public void LoweringFilterCacheBoundEvictsExistingResultsImmediately() _ = SerializableTypeCatalog.GetFilteredDescriptors($"retune-{index}"); } Assert.Greater( - SerializableTypeCatalog.CachedFilterResultCountForTesting, + SerializableTypeCatalog.FilterCache.Count, 2, "The cache must exceed the target bound before it is shrunk." ); SerializableTypeCatalog.MaxCachedFilterResults = 2; - Assert.AreEqual(2, SerializableTypeCatalog.CachedFilterResultCountForTesting); + Assert.AreEqual(2, SerializableTypeCatalog.FilterCache.Count); } finally { @@ -376,24 +376,24 @@ public void ResolvingSimpleTypesDoesNotBuildDescriptorCatalog() SerializableTypeCatalog.ConfigureTypeNameIgnorePatterns( new[] { "^TypeThatCannotExistForDescriptorCacheTest$" } ); - Assert.IsFalse(SerializableTypeCatalog.IsDescriptorCacheInitializedForTesting); + Assert.IsFalse(SerializableTypeCatalog._descriptorByName != null); Type resolved = SerializableTypeCatalog.Resolve( typeof(SerializableType).AssemblyQualifiedName ); Assert.AreSame(typeof(SerializableType), resolved); - Assert.IsFalse(SerializableTypeCatalog.IsDescriptorCacheInitializedForTesting); + Assert.IsFalse(SerializableTypeCatalog._descriptorByName != null); Type missing = SerializableTypeCatalog.Resolve(MissingTypeName); Assert.IsTrue(missing == null); - Assert.IsFalse(SerializableTypeCatalog.IsDescriptorCacheInitializedForTesting); + Assert.IsFalse(SerializableTypeCatalog._descriptorByName != null); Type moved = SerializableTypeCatalog.Resolve( $"{typeof(SerializableType).FullName}, MissingAssembly" ); Assert.AreSame(typeof(SerializableType), moved); - Assert.IsFalse(SerializableTypeCatalog.IsDescriptorCacheInitializedForTesting); + Assert.IsFalse(SerializableTypeCatalog._descriptorByName != null); string movedGeneric = AssemblyQualifiedTypeNameBuilder .Build(typeof(List)) @@ -402,7 +402,7 @@ public void ResolvingSimpleTypesDoesNotBuildDescriptorCatalog() typeof(List), SerializableTypeCatalog.Resolve(movedGeneric) ); - Assert.IsFalse(SerializableTypeCatalog.IsDescriptorCacheInitializedForTesting); + Assert.IsFalse(SerializableTypeCatalog._descriptorByName != null); } finally { diff --git a/Tests/Runtime/DataStructures/StringWrapperTests.cs b/Tests/Runtime/DataStructures/StringWrapperTests.cs index 7cd90631f..ce78bbb63 100644 --- a/Tests/Runtime/DataStructures/StringWrapperTests.cs +++ b/Tests/Runtime/DataStructures/StringWrapperTests.cs @@ -285,14 +285,14 @@ public void EvictionDropsTheLeastRecentlyRequestedString() _ = StringWrapper.Get("third"); Assert.IsFalse( - StringWrapper.IsCachedForTesting("second"), + StringWrapper.Cache.ContainsKey("second"), "The least recently requested string is the one that must go." ); Assert.IsTrue( - StringWrapper.IsCachedForTesting("first"), + StringWrapper.Cache.ContainsKey("first"), "Requesting a string again must renew it, not merely read it." ); - Assert.IsTrue(StringWrapper.IsCachedForTesting("third")); + Assert.IsTrue(StringWrapper.Cache.ContainsKey("third")); } /// diff --git a/Tests/Runtime/DataStructures/TimedCacheTests.cs b/Tests/Runtime/DataStructures/TimedCacheTests.cs index b1cea6ccc..af5a4abe7 100644 --- a/Tests/Runtime/DataStructures/TimedCacheTests.cs +++ b/Tests/Runtime/DataStructures/TimedCacheTests.cs @@ -15,6 +15,155 @@ namespace WallstopStudios.UnityHelpers.Tests.DataStructures [NUnit.Framework.Category("Fast")] public sealed class TimedCacheTests { + [TestCase(1f, 0f, 1)] + [TestCase(0f, 0f, 1)] + [TestCase(-1f, 0f, 2)] + [TestCase(0f, 1f, 1)] + [TestCase(-1f, 1f, 1)] + [TestCase(-2f, 1f, 2)] + public void LargeLifetimeAndJitterRetainSmallElapsedResidual( + float startTime, + float jitter, + int expectedValue + ) + { + float now = startTime; + int calls = 0; + TimedCache cache = new( + () => ++calls, + float.MaxValue, + useJitter: true, + timeProvider: () => now, + jitterOverride: jitter + ); + Assert.AreEqual(1, cache.Value); + now = float.MaxValue; + Assert.AreEqual(expectedValue, cache.Value); + } + + [Test] + public void ResetDiscardsLargeLifetimeJitterResidual() + { + float now = -1f; + int calls = 0; + TimedCache cache = new( + () => ++calls, + float.MaxValue, + useJitter: true, + timeProvider: () => now, + jitterOverride: 1f + ); + Assert.AreEqual(1, cache.Value); + cache.Reset(); + Assert.AreEqual(2, cache.Value); + now = float.MaxValue; + Assert.AreEqual(3, cache.Value); + } + + [TestCase(16777216f, 3f, 0f, 16777220f)] + [TestCase(-33554432f, 3f, 0f, -33554428f)] + [TestCase(-float.MaxValue, float.MaxValue * 0.75f, float.MaxValue * 0.75f, float.MaxValue)] + public void ExpirationPreservesFiniteElapsedTime( + float initialTime, + float lifetime, + float jitter, + float expiredTime + ) + { + float now = initialTime; + int calls = 0; + TimedCache cache = new( + () => ++calls, + lifetime, + useJitter: true, + timeProvider: () => now, + jitterOverride: jitter + ); + Assert.AreEqual(1, cache.Value); + now = initialTime + 2f; + Assert.AreEqual(1, cache.Value); + now = expiredTime; + Assert.AreEqual(2, cache.Value); + Assert.AreEqual(2, cache.Value); + } + + [TestCase(false)] + [TestCase(true)] + public void FailedRefreshPreservesValueTimerAndInitialJitter(bool manualReset) + { + float now = 0f; + int calls = 0; + bool failClock = false; + int clockReads = 0; + TimedCache cache = new( + () => ++calls, + 1f, + useJitter: true, + timeProvider: () => + { + ++clockReads; + if (failClock && (manualReset || 1 < clockReads)) + { + throw new InvalidOperationException(); + } + return now; + }, + jitterOverride: 2f + ); + Assert.AreEqual(1, cache.Value); + now = 4f; + failClock = true; + clockReads = 0; + if (manualReset) + { + Assert.Throws(() => cache.Reset()); + } + else + { + Assert.Throws(() => + { + _ = cache.Value; + }); + } + failClock = false; + now = 2f; + Assert.AreEqual(1, cache.Value); + now = 4f; + int refreshed = cache.Value; + Assert.AreEqual(calls, refreshed); + now = 5.5f; + Assert.AreEqual(refreshed + 1, cache.Value); + } + + [Test] + public void FailedProducerPreservesInitialJitter() + { + float now = 0f; + int calls = 0; + bool failProducer = false; + TimedCache cache = new( + () => failProducer ? throw new InvalidOperationException() : ++calls, + 1f, + useJitter: true, + timeProvider: () => now, + jitterOverride: 2f + ); + Assert.AreEqual(1, cache.Value); + now = 4f; + failProducer = true; + Assert.Throws(() => + { + _ = cache.Value; + }); + failProducer = false; + now = 2f; + Assert.AreEqual(1, cache.Value); + now = 4f; + Assert.AreEqual(2, cache.Value); + now = 5.5f; + Assert.AreEqual(3, cache.Value); + } + [Test] public void ConstructorValidatesArguments() { @@ -379,22 +528,32 @@ public void JitterCausesFirstExpirationToVaryFromTtl(float jitterOverride) $"Jitter should delay expiration. Now={time.Now:F3}s, jitter={jitterOverride:F3}s." ); - float deltaToBoundary = MathF.Max(jitterOverride - Epsilon, 0f); - if (0f < deltaToBoundary) + double expiration = (double)CacheTtl + jitterOverride; + float lastCachedTime = (float)expiration; + if (expiration < lastCachedTime) { - time.Advance(deltaToBoundary); - Assert.AreEqual( - 1, - cache.Value, - $"Cache expired before reaching TTL + jitter. Now={time.Now:F3}s, boundary={(CacheTtl + jitterOverride):F3}s." + lastCachedTime = BitConverter.Int32BitsToSingle( + BitConverter.SingleToInt32Bits(lastCachedTime) - 1 ); } + float firstExpiredTime = BitConverter.Int32BitsToSingle( + BitConverter.SingleToInt32Bits(lastCachedTime) + 1 + ); + Assert.IsTrue(lastCachedTime <= expiration); + Assert.IsTrue(expiration < firstExpiredTime); - time.Advance(Epsilon * 2f); + time.Now = lastCachedTime; + Assert.AreEqual( + 1, + cache.Value, + $"Cache expired before TTL + jitter. Now={time.Now:R}s, boundary={expiration:R}s." + ); + + time.Now = firstExpiredTime; Assert.AreEqual( 2, cache.Value, - $"Cache failed to expire after surpassing TTL + jitter. Now={time.Now:F3}s, boundary={(CacheTtl + jitterOverride):F3}s." + $"Cache failed to expire beyond TTL + jitter. Now={time.Now:R}s, boundary={expiration:R}s." ); Assert.AreEqual( 2, diff --git a/Tests/Runtime/Extensions/BorrowedBase64Tests.cs b/Tests/Runtime/Extensions/BorrowedBase64Tests.cs index 6644b10df..b19888123 100644 --- a/Tests/Runtime/Extensions/BorrowedBase64Tests.cs +++ b/Tests/Runtime/Extensions/BorrowedBase64Tests.cs @@ -604,7 +604,7 @@ public void BorrowedWholeCallAvoidsLeaseWorkWithShippedPositiveControl() DisposalLease probe = DisposalLeases.Acquire(); try { - int slot = probe.SlotForTests; + int slot = probe._slot; Assert.IsTrue(probe.TryClaim()); long before = DisposalLeases.CurrentGeneration(slot); Assert.AreEqual("abc", "YWJj".FromBase64()); diff --git a/Tests/Runtime/Extensions/UnityExtensionsComprehensiveTests.cs b/Tests/Runtime/Extensions/UnityExtensionsComprehensiveTests.cs index 07a310f3a..1aba6daec 100644 --- a/Tests/Runtime/Extensions/UnityExtensionsComprehensiveTests.cs +++ b/Tests/Runtime/Extensions/UnityExtensionsComprehensiveTests.cs @@ -3060,20 +3060,12 @@ public void ApplyPixelDragThresholdHandlesLiveNullAndDestroyedEventSystems() Assert.IsFalse(eventSystem.TryApplyPixelDragThresholdForCurrentDpi(10)); - try - { - UnityExtensions.ScreenDpiProvider = () => 320f; - EventSystem liveEventSystem = CreateEventSystem(); - Assert.IsTrue(liveEventSystem.TryApplyPixelDragThresholdForCurrentDpi(10)); - Assert.AreEqual(20, liveEventSystem.pixelDragThreshold); - - UnityEngine.Object.DestroyImmediate(liveEventSystem); // UNH-SUPPRESS UNH001: the subject is destroyed-object fail-soft behavior. - Assert.IsFalse(liveEventSystem.TryApplyPixelDragThresholdForCurrentDpi(10)); - } - finally - { - UnityExtensions.ResetScreenDpiProvider(); - } + EventSystem liveEventSystem = CreateEventSystem(); + int expected = UnityExtensions.CalculatePixelDragThreshold(10, Screen.dpi); + Assert.IsTrue(liveEventSystem.TryApplyPixelDragThresholdForCurrentDpi(10)); + Assert.AreEqual(expected, liveEventSystem.pixelDragThreshold); + UnityEngine.Object.DestroyImmediate(liveEventSystem); // UNH-SUPPRESS UNH001: the subject is destroyed-object fail-soft behavior. + Assert.IsFalse(liveEventSystem.TryApplyPixelDragThresholdForCurrentDpi(10)); } [Test] diff --git a/Tests/Runtime/Helper/DurableFileTests.cs b/Tests/Runtime/Helper/DurableFileTests.cs index d34fe357e..b2c3d440e 100644 --- a/Tests/Runtime/Helper/DurableFileTests.cs +++ b/Tests/Runtime/Helper/DurableFileTests.cs @@ -238,50 +238,50 @@ public void CreateAllBytesRejectsAnExistingDestinationWithoutChangingIt() } [Test] - public void CreateAllBytesRejectsADestinationCreatedAfterStaging() + public void PublicationRejectsADestinationCreatedAfterStaging() { string path = Path.Combine(_testDirectory, "late-collision.bin"); - + string staged = WriteDirectly("late-collision-stage.tmp", "staged contents"); + File.WriteAllText(path, "later writer"); Assert.IsFalse( - DurableFile.TryCreateAllBytes( + DurableFile.TryPublishStagedFileWithoutOverwrite( + staged, path, - new byte[] { 1, 2, 3 }, - out Exception error, - _ => File.WriteAllText(path, "later writer") + out bool leavesStaged ) ); - - Assert.IsInstanceOf(error); + Assert.IsTrue(leavesStaged); Assert.AreEqual("later writer", File.ReadAllText(path)); - string stagedPath = (string)error.Data[DurableFile.PreservedStagingPathDataKey]; - Assert.IsTrue(File.Exists(stagedPath)); - CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, File.ReadAllBytes(stagedPath)); + Assert.AreEqual("staged contents", File.ReadAllText(staged)); } [Test] - public void CreateAllBytesRejectsReplacedStagingContentsAndPreservesDestination() + public void PublicationVerificationRejectsReplacedStagingContentsAndPreservesDestination() { string path = Path.Combine(_testDirectory, "replaced-staging.bin"); + string staged = Path.Combine(_testDirectory, "replaced-stage.tmp"); + byte[] original = { 1, 2, 3 }; byte[] replacement = { 9, 8, 7 }; - - Assert.IsFalse( - DurableFile.TryCreateAllBytes( + File.WriteAllBytes(staged, original); + File.Delete(staged); + File.WriteAllBytes(staged, replacement); + Assert.IsTrue( + DurableFile.TryPublishStagedFileWithoutOverwrite( + staged, path, - new byte[] { 1, 2, 3 }, - out Exception error, - _ => - { - string[] stagedPaths = Directory.GetFiles(_testDirectory, "*.create.*.tmp"); - Assert.AreEqual(1, stagedPaths.Length); - File.Delete(stagedPaths[0]); - File.WriteAllBytes(stagedPaths[0], replacement); - } + out bool leavesStaged ) ); - - Assert.IsInstanceOf(error); + IOException error = Assert.Throws(() => + DurableFile.VerifyCreatedContents(path, original) + ); StringAssert.Contains("inspect it before retrying", error.Message); CollectionAssert.AreEqual(replacement, File.ReadAllBytes(path)); + if (leavesStaged) + { + CollectionAssert.AreEqual(replacement, File.ReadAllBytes(staged)); + File.Delete(staged); + } } [Test] @@ -747,119 +747,96 @@ public void AFailedWriteAfterStagingRemovesTheStagedFile() { string path = Path.Combine(_testDirectory, "save.json"); Directory.CreateDirectory(path); -#if UNITY_EDITOR - string scriptPath = Path.Combine(_testDirectory, "probe-failed-write-staging.ps1"); - WriteStagingOwnershipProbe(scriptPath); - int childExitCode = -1; - DurableFile.BeforeStagedCleanupForTests = temporaryPath => - { - childExitCode = TryOverwriteStagingInAnotherProcess(scriptPath, temporaryPath); - }; -#endif - - try - { - Assert.IsFalse( - DurableFile.TryWriteAllText(path, "the new document", out Exception error) - ); - - Assert.IsTrue(error != null); - } - finally - { -#if UNITY_EDITOR - DurableFile.BeforeStagedCleanupForTests = null; -#endif - } - -#if UNITY_EDITOR - Assert.AreEqual(23, childExitCode, "Cleanup must retain staging ownership."); -#endif + Assert.IsFalse( + DurableFile.TryWriteAllText(path, "the new document", out Exception error) + ); + Assert.IsTrue(error != null); Assert.IsFalse(File.Exists(path + DurableFile.TemporarySuffix)); } #if UNITY_EDITOR - [Test] - public void AnotherProcessCannotReplaceStagingBeforeItIsPublished() + [TestCase(false)] + [TestCase(true)] + public void StagingOwnershipRejectsAnotherProcessThroughPublicationAndCleanup(bool cleanup) { string path = WriteDirectly("cross-process.json", "previous document"); + string staged = path + DurableFile.TemporarySuffix; string scriptPath = Path.Combine(_testDirectory, "overwrite-staging.ps1"); WriteStagingOwnershipProbe(scriptPath); - int childExitCode = -1; - DurableFile.BeforeStagedSwapForTests = temporaryPath => - { - childExitCode = TryOverwriteStagingInAnotherProcess(scriptPath, temporaryPath); - }; - - try - { - Assert.IsTrue( - DurableFile.TryWriteAllText(path, "writer A document", out Exception error), - error?.ToString() - ); - } - finally + Assert.IsTrue( + DurableFile.TryOpenStagingOwnership( + staged, + out FileStream ownership, + out Exception error + ), + error?.ToString() + ); + using (ownership) { - DurableFile.BeforeStagedSwapForTests = null; + using (FileStream stream = DurableFile.OpenStagingStream(staged, false)) + { + byte[] bytes = Encoding.UTF8.GetBytes("writer A document"); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + Assert.AreEqual(23, TryOverwriteStagingInAnotherProcess(scriptPath, staged)); + if (cleanup) + { + DurableFile.DiscardStagedFile(staged); + Assert.AreEqual( + 23, + TryOverwriteStagingInAnotherProcess(scriptPath, staged), + "Cleanup must retain ownership until release." + ); + } + else + { + DurableFile.Swap(staged, path); + } } - - Assert.AreEqual(23, childExitCode, "The second process must not acquire staging."); - Assert.AreEqual("writer A document", File.ReadAllText(path)); - Assert.IsFalse(File.Exists(path + DurableFile.TemporarySuffix)); + Assert.AreEqual( + cleanup ? "previous document" : "writer A document", + File.ReadAllText(path) + ); + Assert.IsFalse(File.Exists(staged)); + Assert.IsTrue(DurableFile.TryWriteAllText(path, "retry", out error), error?.ToString()); } [TestCase(false)] [TestCase(true)] - public void CompareThenReplaceDoesNotProtectAgainstNoncooperatingDestinationEdits(bool text) + public void PublicationOwnershipDoesNotPreventNoncooperatingDestinationEdits(bool text) { string path = WriteDirectly("external-edit.txt", "expected"); - bool observedExternalEdit = false; - DurableFile.BeforeStagedSwapForTests = _ => - { - File.WriteAllText(path, "external edit"); - observedExternalEdit = string.Equals( - File.ReadAllText(path), - "external edit", - StringComparison.Ordinal - ); - }; - try + string staged = path + DurableFile.TemporarySuffix; + Assert.IsTrue( + DurableFile.TryOpenStagingOwnership( + staged, + out FileStream ownership, + out Exception error + ), + error?.ToString() + ); + using (ownership) { if (text) { - Assert.IsTrue( - DurableFile.TryCompareThenReplaceAllText( - path, - true, - "expected", - "replacement", - out bool exchanged, - out Exception error - ), - error?.ToString() - ); - Assert.IsTrue(exchanged); + Assert.AreEqual("expected", File.ReadAllText(path)); + File.WriteAllText(staged, "replacement"); } else { - Assert.IsTrue( - DurableFile.TryCompareThenReplaceBytes( - path, - Encoding.UTF8.GetBytes("expected"), - Encoding.UTF8.GetBytes("replacement"), - out Exception error - ), - error?.ToString() + CollectionAssert.AreEqual( + Encoding.UTF8.GetBytes("expected"), + File.ReadAllBytes(path) ); + File.WriteAllBytes(staged, Encoding.UTF8.GetBytes("replacement")); } + File.WriteAllText(path, "external edit"); + Assert.AreEqual("external edit", File.ReadAllText(path)); + DurableFile.Swap(staged, path); } - finally - { - DurableFile.BeforeStagedSwapForTests = null; - } - Assert.IsTrue(observedExternalEdit); Assert.AreEqual("replacement", File.ReadAllText(path)); - Assert.IsFalse(File.Exists(path + DurableFile.TemporarySuffix)); + Assert.IsFalse(File.Exists(staged)); } [TestCase(0, false)] @@ -868,121 +845,104 @@ out Exception error [TestCase(1, true)] [TestCase(2, false)] [TestCase(2, true)] - public void AsyncReplacementRefusesCancellationAfterStaging( + public void PublicationRefusesCancellationAfterRealAsyncStaging( int operation, bool destinationExists ) { string path = Path.Combine(_testDirectory, "cancel-after-staging.txt"); + string staged = path + DurableFile.TemporarySuffix; if (destinationExists) { File.WriteAllText(path, "previous document"); } string source = WriteDirectly("cancel-copy-source.txt", "replacement"); using CancellationTokenSource cancellation = new(); - bool reachedPublication = false; - DurableFile.BeforeStagedSwapForTests = stagingPath => - { - reachedPublication = true; - Assert.AreEqual("replacement", File.ReadAllText(stagingPath)); - cancellation.Cancel(); - }; - Exception failure; - try + Assert.IsTrue( + DurableFile.TryOpenStagingOwnership( + staged, + out FileStream ownership, + out Exception error + ), + error?.ToString() + ); + using (ownership) { Task write; switch (operation) { case 0: write = DurableFile - .WriteAllTextAsync(path, "replacement", cancellation.Token) + .WriteAllTextAsync(staged, "replacement", cancellation.Token) .AsTask(); break; case 1: write = DurableFile .WriteAllBytesAsync( - path, + staged, Encoding.UTF8.GetBytes("replacement"), cancellation.Token ) .AsTask(); break; default: - write = DurableFile.CopyAsync(source, path, cancellation.Token).AsTask(); + write = DurableFile.CopyAsync(source, staged, cancellation.Token).AsTask(); break; } Assert.IsTrue( write.Wait(TimeSpan.FromSeconds(10)), - "The write must reach a terminal result." + "Staging must reach a terminal result." ); - failure = write.Result; - } - finally - { + Assert.IsTrue(write.Result == null, write.Result?.ToString()); + Assert.AreEqual("replacement", File.ReadAllText(staged)); cancellation.Cancel(); - DurableFile.BeforeStagedSwapForTests = null; - } - Assert.IsTrue(reachedPublication); - Assert.IsInstanceOf(failure); - Assert.AreEqual(destinationExists, File.Exists(path)); - if (destinationExists) - { - Assert.AreEqual("previous document", File.ReadAllText(path)); + Assert.Throws(() => + DurableFile.PublishStagedFile(staged, path, cancellation.Token) + ); + Assert.AreEqual(destinationExists, File.Exists(path)); + if (destinationExists) + { + Assert.AreEqual("previous document", File.ReadAllText(path)); + } + DurableFile.DiscardStagedFile(staged); + Assert.IsFalse(File.Exists(staged)); } - Assert.IsFalse(File.Exists(path + DurableFile.TemporarySuffix)); Assert.AreEqual("replacement", File.ReadAllText(source)); - Assert.IsTrue( - DurableFile.TryWriteAllText(path, "retry", out Exception retryError), - retryError?.ToString() - ); + Assert.IsTrue(DurableFile.TryWriteAllText(path, "retry", out error), error?.ToString()); Assert.AreEqual("retry", File.ReadAllText(path)); } [Test] - public void CompareThenReplaceOwnsStagingFromBeforeComparisonThroughSwap() + public void StagingOwnershipSpansComparisonAndPublication() { string path = WriteDirectly("compare-cross-process.bin", "expected"); + string staged = path + DurableFile.TemporarySuffix; string scriptPath = Path.Combine(_testDirectory, "compare-staging-probe.ps1"); WriteStagingOwnershipProbe(scriptPath); - int beforeReadExitCode = -1; - int beforeSwapExitCode = -1; - DurableFile.BeforeCompareReadForTests = temporaryPath => - { - beforeReadExitCode = TryOverwriteStagingInAnotherProcess(scriptPath, temporaryPath); - }; - DurableFile.BeforeStagedSwapForTests = temporaryPath => - { - beforeSwapExitCode = TryOverwriteStagingInAnotherProcess(scriptPath, temporaryPath); - }; - - try + Assert.IsTrue( + DurableFile.TryOpenStagingOwnership( + staged, + out FileStream ownership, + out Exception error + ), + error?.ToString() + ); + using (ownership) { - Assert.IsTrue( - DurableFile.TryCompareThenReplaceBytes( - path, - Encoding.UTF8.GetBytes("expected"), - Encoding.UTF8.GetBytes("replacement"), - out Exception error - ), - error?.ToString() + Assert.AreEqual(23, TryOverwriteStagingInAnotherProcess(scriptPath, staged)); + CollectionAssert.AreEqual( + Encoding.UTF8.GetBytes("expected"), + File.ReadAllBytes(path) ); + using (FileStream stream = DurableFile.OpenStagingStream(staged, false)) + { + byte[] replacement = Encoding.UTF8.GetBytes("replacement"); + stream.Write(replacement, 0, replacement.Length); + stream.Flush(true); + } + Assert.AreEqual(23, TryOverwriteStagingInAnotherProcess(scriptPath, staged)); + DurableFile.Swap(staged, path); } - finally - { - DurableFile.BeforeCompareReadForTests = null; - DurableFile.BeforeStagedSwapForTests = null; - } - - Assert.AreEqual( - 23, - beforeReadExitCode, - "A second writer acquired staging before compare." - ); - Assert.AreEqual( - 23, - beforeSwapExitCode, - "A second writer acquired staging before swap." - ); Assert.AreEqual("replacement", File.ReadAllText(path)); } #endif @@ -1130,24 +1090,58 @@ public IEnumerator WriteBytesAsyncPreservesDestinationWhenStagingFails() Assert.AreEqual("previous document", File.ReadAllText(path)); } - [UnityTest] - public IEnumerator WriteBytesAsyncReportsCancellationWithoutChangingDestination() + [TestCase(0, false)] + [TestCase(0, true)] + [TestCase(1, false)] + [TestCase(1, true)] + [TestCase(2, false)] + [TestCase(2, true)] + public void AsyncReplacementsRejectPreCancellationWithoutChangingDestination( + int operation, + bool exists + ) { - string path = WriteDirectly("save.bin", "previous document"); + string path = Path.Combine(_testDirectory, "pre-canceled.txt"); + if (exists) + { + File.WriteAllText(path, "original"); + } + string source = WriteDirectly("pre-canceled-source.txt", "source"); using CancellationTokenSource cancellation = new(); cancellation.Cancel(); - - Task write = DurableFile - .WriteAllBytesAsync(path, new byte[] { 1, 2 }, cancellation.Token) - .AsTask(); - while (!write.IsCompleted) + Task write; + switch (operation) { - yield return null; + case 0: + write = DurableFile + .WriteAllTextAsync(path, "replacement", cancellation.Token) + .AsTask(); + break; + case 1: + write = DurableFile + .WriteAllBytesAsync(path, new byte[] { 1, 2 }, cancellation.Token) + .AsTask(); + break; + default: + write = DurableFile.CopyAsync(source, path, cancellation.Token).AsTask(); + break; } - + Assert.IsTrue( + write.Wait(TimeSpan.FromSeconds(10)), + "Cancellation must reach a terminal result." + ); Assert.IsInstanceOf(write.Result); - Assert.AreEqual("previous document", File.ReadAllText(path)); + Assert.AreEqual(exists, File.Exists(path)); + if (exists) + { + Assert.AreEqual("original", File.ReadAllText(path)); + } + Assert.AreEqual("source", File.ReadAllText(source)); Assert.IsFalse(File.Exists(path + DurableFile.TemporarySuffix)); + Assert.IsTrue( + DurableFile.TryWriteAllText(path, "retry", out Exception error), + error?.ToString() + ); } [UnityTest] diff --git a/Tests/Runtime/Helper/HelpersTests.cs b/Tests/Runtime/Helper/HelpersTests.cs index 8f88bf716..285de27a4 100644 --- a/Tests/Runtime/Helper/HelpersTests.cs +++ b/Tests/Runtime/Helper/HelpersTests.cs @@ -115,21 +115,7 @@ public void CommandLineArgumentsRejectBlankNamesBeforeReadingProcessArguments(st Assert.IsTrue(Helpers.GetCommandLineArgument(arguments, name) == null); Assert.IsEmpty(Helpers.GetCommandLineArguments(arguments, name)); - int providerCalls = 0; - try - { - Helpers.CommandLineArgumentProvider = () => - { - ++providerCalls; - return arguments; - }; - Assert.IsTrue(Helpers.GetCommandLineArgument(name) == null); - Assert.AreEqual(0, providerCalls); - } - finally - { - Helpers.ResetCommandLineArgumentProvider(); - } + Assert.IsTrue(Helpers.GetCommandLineArgument(name) == null); } [TestCase(" -scene ", " ", TestName = "CommandLine.Literal.PaddedName")] @@ -159,17 +145,6 @@ public void CurrentCommandLineArgumentLookupFailsSoftWhenNameIsAbsent() Helpers.GetCommandLineArgument("-wallstop-missing-session-279-argument") == null ); Assert.IsTrue(Helpers.GetCommandLineArgument(string.Empty) == null); - - try - { - Helpers.CommandLineArgumentProvider = () => - throw new InvalidOperationException("arguments unavailable"); - Assert.IsTrue(Helpers.GetCommandLineArgument("-scene") == null); - } - finally - { - Helpers.ResetCommandLineArgumentProvider(); - } } [Test] @@ -477,28 +452,32 @@ instead of assuming one frame is short. } [UnityTest] - public IEnumerator StartFunctionAsCoroutineAppliesJitterBeforeFirstInvocation() + public IEnumerator StartFunctionAsCoroutineWaitBeforeHonorsIntervalWithJitterEnabled() { CoroutineHost host = CreateHost(); host.ResetState(); /* - Jitter is clamped to the interval; both must exceed slow batchmode frames to avoid timing- - dependent first-frame assertions. + The required initial interval exceeds slow batchmode frames so the waitBefore + assertion remains independent of the sampled jitter. */ const float interval = 0.25f; - Helpers.JitterSampler = _ => interval; float start = Time.time; Coroutine coroutine = host.StartFunctionAsCoroutine( host.Increment, interval, - useJitter: true + useJitter: true, + waitBefore: true ); try { - Assert.AreEqual(0, host.InvocationCount, "Jitter must not invoke synchronously."); + Assert.AreEqual( + 0, + host.InvocationCount, + "waitBefore must not invoke synchronously with jitter enabled." + ); float timeout = Time.time + interval + 5f; while (host.InvocationCount == 0 && Time.time < timeout) @@ -509,18 +488,17 @@ dependent first-frame assertions. Assert.Greater( host.InvocationCount, 0, - "Invocation never occurred after jitter delay elapsed." + "Invocation never occurred after the waitBefore delay elapsed." ); Assert.GreaterOrEqual( Time.time - start, - interval * 0.5f, - "Jitter should delay the first invocation." + interval, + "waitBefore should delay the first invocation by at least the interval." ); } finally { host.StopCoroutine(coroutine); - Helpers.ResetJitterSampler(); } } @@ -529,7 +507,6 @@ public IEnumerator StartFunctionAsCoroutineHandlesZeroIntervalWithJitter() { CoroutineHost host = CreateHost(); host.ResetState(); - Helpers.JitterSampler = _ => 0f; Coroutine coroutine = host.StartFunctionAsCoroutine( host.Increment, @@ -549,50 +526,18 @@ public IEnumerator StartFunctionAsCoroutineHandlesZeroIntervalWithJitter() finally { host.StopCoroutine(coroutine); - Helpers.ResetJitterSampler(); } } - [UnityTest] - public IEnumerator StartFunctionAsCoroutineIgnoresInvalidJitterValues() + [TestCase(float.NaN, 0f)] + [TestCase(-5f, 0f)] + [TestCase(0f, 0f)] + [TestCase(0.025f, 0.025f)] + [TestCase(0.2f, 0.05f)] + [TestCase(float.PositiveInfinity, 0.05f)] + public void InitialJitterClampsSampleToInterval(float sample, float expected) { - CoroutineHost host = CreateHost(); - host.ResetState(); - - Helpers.JitterSampler = _ => float.NaN; - Coroutine coroutine = host.StartFunctionAsCoroutine( - host.Increment, - 0.01f, - useJitter: true - ); - - try - { - yield return null; - Assert.Greater(host.InvocationCount, 0, "NaN jitter should be treated as zero."); - } - finally - { - host.StopCoroutine(coroutine); - } - - Helpers.JitterSampler = _ => -5f; - coroutine = host.StartFunctionAsCoroutine(host.Increment, 0.01f, useJitter: true); - - try - { - yield return null; - Assert.Greater( - host.InvocationCount, - 0, - "Negative jitter should be clamped to zero." - ); - } - finally - { - host.StopCoroutine(coroutine); - Helpers.ResetJitterSampler(); - } + Assert.AreEqual(expected, Helpers.ClampInitialJitter(sample, 0.05f)); } [UnityTest] @@ -673,41 +618,6 @@ public IEnumerator CoroutineHelpersThrowOnNullActions() yield break; } - [UnityTest] - public IEnumerator StartFunctionAsCoroutineClampsExcessiveJitter() - { - CoroutineHost host = CreateHost(); - host.ResetState(); - Helpers.JitterSampler = delay => delay * 4f; - - Coroutine coroutine = host.StartFunctionAsCoroutine( - host.Increment, - 0.05f, - useJitter: true - ); - - try - { - float start = Time.time; - while (host.InvocationCount == 0 && Time.time - start < 0.2f) - { - yield return null; - } - - Assert.Greater(host.InvocationCount, 0, "Invocation never occurred."); - Assert.Less( - Time.time - start, - 0.15f, - "Jitter should have been clamped to the base interval." - ); - } - finally - { - host.StopCoroutine(coroutine); - Helpers.ResetJitterSampler(); - } - } - [UnityTest] public IEnumerator ExecuteFunctionAfterDelayInvokesOnce() { diff --git a/Tests/Runtime/Helper/LayerHelperTests.cs b/Tests/Runtime/Helper/LayerHelperTests.cs index 9da612091..f64f3cab6 100644 --- a/Tests/Runtime/Helper/LayerHelperTests.cs +++ b/Tests/Runtime/Helper/LayerHelperTests.cs @@ -3,111 +3,74 @@ namespace WallstopStudios.UnityHelpers.Tests.Helper { - using System; + using System.Collections.Generic; using NUnit.Framework; using WallstopStudios.UnityHelpers.Core.Helper; using WallstopStudios.UnityHelpers.Tests.Core; [TestFixture] - [NUnit.Framework.Category("Fast")] + [Category("Fast")] public sealed class LayerHelperTests : CommonTestBase { [TearDown] public void ClearCaches() { - InvokeClearLayerNames(); - Helpers.ResetLayerNameProvider(); + Helpers.CLearLayerNames(); } [Test] public void GetAllLayerNamesCachesResultsUntilCleared() { - int invocation = 0; - Helpers.LayerNameProvider = () => - { - ++invocation; - return new[] { "LayerA", "LayerB" }; - }; - Helpers.ResetLayerCache(); string[] first = Helpers.GetAllLayerNames(); - Assert.AreEqual(1, invocation, "Provider should be invoked on first call."); - - Helpers.LayerNameProvider = () => - { - ++invocation; - return new[] { "LayerChanged" }; - }; - - string[] second = Helpers.GetAllLayerNames(); - CollectionAssert.AreEqual(new[] { "LayerA", "LayerB" }, second); - + Assert.IsTrue(first != null); + Assert.AreSame(first, Helpers.GetAllLayerNames()); Helpers.ResetLayerCache(); - string[] third = Helpers.GetAllLayerNames(); - Assert.AreEqual(2, invocation, "Reset should force provider refresh."); - CollectionAssert.AreEqual(new[] { "LayerChanged" }, third); + Assert.IsFalse(Helpers.LayerCacheInitialized); + CollectionAssert.AreEqual(first, Helpers.GetAllLayerNames()); } [Test] public void GetAllLayerNamesBufferMatchesArray() { string[] layers = Helpers.GetAllLayerNames(); - Assume.That(layers, Is.Not.Null.And.Not.Empty); - - System.Collections.Generic.List buffer = new() { "placeholder" }; + List buffer = new() { "placeholder" }; Helpers.GetAllLayerNames(buffer); - - CollectionAssert.AreEquivalent(layers, buffer); + CollectionAssert.AreEqual(layers, buffer); } [Test] - public void FallingBackToRuntimeLayerProviderStillReturnsLayers() + public void GetAllLayerNamesReturnsLiveRuntimeLayers() { - Helpers.LayerNameProvider = () => Array.Empty(); - ClearCaches(); - + Helpers.ResetLayerCache(); string[] layers = Helpers.GetAllLayerNames(); Assert.IsTrue(layers != null); Assert.IsNotEmpty(layers); + Assert.Contains("Default", layers); } [Test] - public void ResetLayerCacheForcesProviderRefresh() + public void ResetLayerCacheDiscardsStaleNames() { - int invocation = 0; - Helpers.LayerNameProvider = () => - { - ++invocation; - return invocation == 1 ? new[] { "LayerZero" } : new[] { "LayerOne", "LayerTwo" }; - }; - + string[] expected = Helpers.GetAllLayerNames(); + Helpers.CachedLayerNames = new[] { "StaleLayer" }; + Helpers.LayerCacheInitialized = true; + CollectionAssert.AreEqual(new[] { "StaleLayer" }, Helpers.GetAllLayerNames()); Helpers.ResetLayerCache(); - _ = Helpers.GetAllLayerNames(); - Helpers.ResetLayerCache(); - string[] refreshed = Helpers.GetAllLayerNames(); - - CollectionAssert.AreEqual(new[] { "LayerOne", "LayerTwo" }, refreshed); + CollectionAssert.AreEqual(expected, Helpers.GetAllLayerNames()); } #if UNITY_EDITOR [Test] public void ProjectChangeResetsLayerCache() { - Helpers.LayerNameProvider = () => new[] { "LayerInitial" }; - Helpers.ResetLayerCache(); - _ = Helpers.GetAllLayerNames(); - - Helpers.LayerNameProvider = () => new[] { "LayerUpdated" }; + string[] expected = Helpers.GetAllLayerNames(); + Helpers.CachedLayerNames = new[] { "StaleLayer" }; + Helpers.LayerCacheInitialized = true; Helpers.HandleProjectChangedForHelpers(); - - string[] updated = Helpers.GetAllLayerNames(); - CollectionAssert.AreEqual(new[] { "LayerUpdated" }, updated); + Assert.IsFalse(Helpers.LayerCacheInitialized); + CollectionAssert.AreEqual(expected, Helpers.GetAllLayerNames()); } #endif - - private static void InvokeClearLayerNames() - { - Helpers.CLearLayerNames(); - } } } diff --git a/Tests/Runtime/Helper/ReflectionHelperCapabilityMatrixTests.cs b/Tests/Runtime/Helper/ReflectionHelperCapabilityMatrixTests.cs index cac9c9986..0b366655a 100644 --- a/Tests/Runtime/Helper/ReflectionHelperCapabilityMatrixTests.cs +++ b/Tests/Runtime/Helper/ReflectionHelperCapabilityMatrixTests.cs @@ -37,7 +37,7 @@ the runtime capability probe. } using ( - ReflectionHelpers.OverrideReflectionCapabilities( + new ReflectionCapabilityScope( expressions: true, dynamicIl: ReflectionHelpers.DynamicIlEnabled ) @@ -52,23 +52,13 @@ the runtime capability probe. Assert.Ignore("Dynamic IL is not available on this platform."); } - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: true - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: true)) { assertion(); } break; case CapabilityMode.Reflection: - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { assertion(); } @@ -265,19 +255,19 @@ public void FieldGetterCachesRemainStrategyScoped() { FieldInfo field = typeof(TestClass).GetField(nameof(TestClass.intValue)); Func expressionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionGetter = ReflectionHelpers.GetFieldGetter(field); } Func dynamicGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicGetter = ReflectionHelpers.GetFieldGetter(field); } Func reflectionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionGetter = ReflectionHelpers.GetFieldGetter(field); } @@ -323,7 +313,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetter, Is.Not.SameAs(reflectionGetter)); Assert.That(dynamicGetter, Is.Not.SameAs(reflectionGetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionGetterSecond = ReflectionHelpers.GetFieldGetter( field @@ -331,13 +321,13 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetterSecond, Is.SameAs(expressionGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicGetterSecond = ReflectionHelpers.GetFieldGetter(field); Assert.That(dynamicGetterSecond, Is.SameAs(dynamicGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionGetterSecond = ReflectionHelpers.GetFieldGetter( field @@ -353,19 +343,19 @@ public void FieldSetterCachesRemainStrategyScoped() { FieldInfo field = typeof(TestClass).GetField(nameof(TestClass.intValue)); Action expressionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionSetter = ReflectionHelpers.GetFieldSetter(field); } Action dynamicSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicSetter = ReflectionHelpers.GetFieldSetter(field); } Action reflectionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionSetter = ReflectionHelpers.GetFieldSetter(field); } @@ -411,7 +401,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetter, Is.Not.SameAs(reflectionSetter)); Assert.That(dynamicSetter, Is.Not.SameAs(reflectionSetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Action expressionSetterSecond = ReflectionHelpers.GetFieldSetter( field @@ -419,7 +409,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetterSecond, Is.SameAs(expressionSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Action dynamicSetterSecond = ReflectionHelpers.GetFieldSetter( field @@ -427,7 +417,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicSetterSecond, Is.SameAs(dynamicSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Action reflectionSetterSecond = ReflectionHelpers.GetFieldSetter( field @@ -443,19 +433,19 @@ public void FieldGetterTypedCachesRemainStrategyScoped() { FieldInfo field = typeof(TestClass).GetField(nameof(TestClass.intValue)); Func expressionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionGetter = ReflectionHelpers.GetFieldGetter(field); } Func dynamicGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicGetter = ReflectionHelpers.GetFieldGetter(field); } Func reflectionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionGetter = ReflectionHelpers.GetFieldGetter(field); } @@ -501,7 +491,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetter, Is.Not.SameAs(reflectionGetter)); Assert.That(dynamicGetter, Is.Not.SameAs(reflectionGetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionGetterSecond = ReflectionHelpers.GetFieldGetter< TestClass, @@ -510,7 +500,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetterSecond, Is.SameAs(expressionGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicGetterSecond = ReflectionHelpers.GetFieldGetter< TestClass, @@ -519,7 +509,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicGetterSecond, Is.SameAs(dynamicGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionGetterSecond = ReflectionHelpers.GetFieldGetter< TestClass, @@ -534,19 +524,19 @@ public void FieldSetterTypedCachesRemainStrategyScoped() { FieldInfo field = typeof(TestClass).GetField(nameof(TestClass.intValue)); FieldSetter expressionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionSetter = ReflectionHelpers.GetFieldSetter(field); } FieldSetter dynamicSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicSetter = ReflectionHelpers.GetFieldSetter(field); } FieldSetter reflectionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionSetter = ReflectionHelpers.GetFieldSetter(field); } @@ -592,14 +582,14 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetter, Is.Not.SameAs(reflectionSetter)); Assert.That(dynamicSetter, Is.Not.SameAs(reflectionSetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { FieldSetter expressionSetterSecond = ReflectionHelpers.GetFieldSetter(field); Assert.That(expressionSetterSecond, Is.SameAs(expressionSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { FieldSetter dynamicSetterSecond = ReflectionHelpers.GetFieldSetter< TestClass, @@ -608,7 +598,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicSetterSecond, Is.SameAs(dynamicSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { FieldSetter reflectionSetterSecond = ReflectionHelpers.GetFieldSetter(field); @@ -628,19 +618,19 @@ public void StaticFieldGetterCachesRemainStrategyScoped() { TestClass.StaticIntValue = 111; Func expressionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionGetter = ReflectionHelpers.GetStaticFieldGetter(field); } Func dynamicGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicGetter = ReflectionHelpers.GetStaticFieldGetter(field); } Func reflectionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionGetter = ReflectionHelpers.GetStaticFieldGetter(field); } @@ -686,7 +676,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetter, Is.Not.SameAs(reflectionGetter)); Assert.That(dynamicGetter, Is.Not.SameAs(reflectionGetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionGetterSecond = ReflectionHelpers.GetStaticFieldGetter( field @@ -694,7 +684,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetterSecond, Is.SameAs(expressionGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicGetterSecond = ReflectionHelpers.GetStaticFieldGetter( field @@ -702,7 +692,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicGetterSecond, Is.SameAs(dynamicGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionGetterSecond = ReflectionHelpers.GetStaticFieldGetter( field @@ -727,19 +717,19 @@ public void StaticFieldSetterCachesRemainStrategyScoped() try { Action expressionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionSetter = ReflectionHelpers.GetStaticFieldSetter(field); } Action dynamicSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicSetter = ReflectionHelpers.GetStaticFieldSetter(field); } Action reflectionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionSetter = ReflectionHelpers.GetStaticFieldSetter(field); } @@ -785,7 +775,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetter, Is.Not.SameAs(reflectionSetter)); Assert.That(dynamicSetter, Is.Not.SameAs(reflectionSetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Action expressionSetterSecond = ReflectionHelpers.GetStaticFieldSetter( field @@ -793,7 +783,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetterSecond, Is.SameAs(expressionSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Action dynamicSetterSecond = ReflectionHelpers.GetStaticFieldSetter( field @@ -801,7 +791,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicSetterSecond, Is.SameAs(dynamicSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Action reflectionSetterSecond = ReflectionHelpers.GetStaticFieldSetter( field @@ -827,19 +817,19 @@ public void StaticFieldGetterTypedCachesRemainStrategyScoped() { TestClass.StaticIntValue = 314; Func expressionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionGetter = ReflectionHelpers.GetStaticFieldGetter(field); } Func dynamicGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicGetter = ReflectionHelpers.GetStaticFieldGetter(field); } Func reflectionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionGetter = ReflectionHelpers.GetStaticFieldGetter(field); } @@ -885,7 +875,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetter, Is.Not.SameAs(reflectionGetter)); Assert.That(dynamicGetter, Is.Not.SameAs(reflectionGetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionGetterSecond = ReflectionHelpers.GetStaticFieldGetter( field @@ -893,7 +883,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetterSecond, Is.SameAs(expressionGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicGetterSecond = ReflectionHelpers.GetStaticFieldGetter( field @@ -901,7 +891,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicGetterSecond, Is.SameAs(dynamicGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionGetterSecond = ReflectionHelpers.GetStaticFieldGetter( field @@ -926,19 +916,19 @@ public void StaticFieldSetterTypedCachesRemainStrategyScoped() try { Action expressionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionSetter = ReflectionHelpers.GetStaticFieldSetter(field); } Action dynamicSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicSetter = ReflectionHelpers.GetStaticFieldSetter(field); } Action reflectionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionSetter = ReflectionHelpers.GetStaticFieldSetter(field); } @@ -984,14 +974,14 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetter, Is.Not.SameAs(reflectionSetter)); Assert.That(dynamicSetter, Is.Not.SameAs(reflectionSetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Action expressionSetterSecond = ReflectionHelpers.GetStaticFieldSetter(field); Assert.That(expressionSetterSecond, Is.SameAs(expressionSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Action dynamicSetterSecond = ReflectionHelpers.GetStaticFieldSetter( field @@ -999,7 +989,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicSetterSecond, Is.SameAs(dynamicSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Action reflectionSetterSecond = ReflectionHelpers.GetStaticFieldSetter(field); @@ -1075,19 +1065,19 @@ public void PropertyGetterCachesRemainStrategyScoped() nameof(VariantPropertyClass.ObjectProperty) ); Func expressionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionGetter = ReflectionHelpers.GetPropertyGetter(property); } Func dynamicGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicGetter = ReflectionHelpers.GetPropertyGetter(property); } Func reflectionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionGetter = ReflectionHelpers.GetPropertyGetter(property); } @@ -1133,7 +1123,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetter, Is.Not.SameAs(reflectionGetter)); Assert.That(dynamicGetter, Is.Not.SameAs(reflectionGetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionGetterSecond = ReflectionHelpers.GetPropertyGetter( property @@ -1141,7 +1131,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetterSecond, Is.SameAs(expressionGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicGetterSecond = ReflectionHelpers.GetPropertyGetter( property @@ -1149,7 +1139,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicGetterSecond, Is.SameAs(dynamicGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionGetterSecond = ReflectionHelpers.GetPropertyGetter( property @@ -1166,19 +1156,19 @@ public void PropertySetterCachesRemainStrategyScoped() nameof(VariantPropertyClass.ObjectProperty) ); Action expressionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionSetter = ReflectionHelpers.GetPropertySetter(property); } Action dynamicSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicSetter = ReflectionHelpers.GetPropertySetter(property); } Action reflectionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionSetter = ReflectionHelpers.GetPropertySetter(property); } @@ -1224,7 +1214,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetter, Is.Not.SameAs(reflectionSetter)); Assert.That(dynamicSetter, Is.Not.SameAs(reflectionSetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Action expressionSetterSecond = ReflectionHelpers.GetPropertySetter( property @@ -1232,7 +1222,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetterSecond, Is.SameAs(expressionSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Action dynamicSetterSecond = ReflectionHelpers.GetPropertySetter( property @@ -1240,7 +1230,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicSetterSecond, Is.SameAs(dynamicSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Action reflectionSetterSecond = ReflectionHelpers.GetPropertySetter( property @@ -1260,19 +1250,19 @@ public void IndexerGetterCachesRemainStrategyScoped() IndexerClass instance = new() { [3] = 42 }; PropertyInfo indexer = typeof(IndexerClass).GetProperty("Item"); Func expressionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionGetter = ReflectionHelpers.GetIndexerGetter(indexer); } Func dynamicGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicGetter = ReflectionHelpers.GetIndexerGetter(indexer); } Func reflectionGetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionGetter = ReflectionHelpers.GetIndexerGetter(indexer); } @@ -1318,21 +1308,21 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionGetter, Is.Not.SameAs(reflectionGetter)); Assert.That(dynamicGetter, Is.Not.SameAs(reflectionGetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionGetterSecond = ReflectionHelpers.GetIndexerGetter(indexer); Assert.That(expressionGetterSecond, Is.SameAs(expressionGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicGetterSecond = ReflectionHelpers.GetIndexerGetter(indexer); Assert.That(dynamicGetterSecond, Is.SameAs(dynamicGetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionGetterSecond = ReflectionHelpers.GetIndexerGetter(indexer); @@ -1350,19 +1340,19 @@ public void IndexerSetterCachesRemainStrategyScoped() IndexerClass instance = new(); PropertyInfo indexer = typeof(IndexerClass).GetProperty("Item"); Action expressionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionSetter = ReflectionHelpers.GetIndexerSetter(indexer); } Action dynamicSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicSetter = ReflectionHelpers.GetIndexerSetter(indexer); } Action reflectionSetter; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionSetter = ReflectionHelpers.GetIndexerSetter(indexer); } @@ -1408,21 +1398,21 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionSetter, Is.Not.SameAs(reflectionSetter)); Assert.That(dynamicSetter, Is.Not.SameAs(reflectionSetter)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Action expressionSetterSecond = ReflectionHelpers.GetIndexerSetter(indexer); Assert.That(expressionSetterSecond, Is.SameAs(expressionSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Action dynamicSetterSecond = ReflectionHelpers.GetIndexerSetter(indexer); Assert.That(dynamicSetterSecond, Is.SameAs(dynamicSetter)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Action reflectionSetterSecond = ReflectionHelpers.GetIndexerSetter(indexer); @@ -1444,19 +1434,19 @@ public void ConstructorInvokerCachesRemainStrategyScoped() new[] { typeof(int), typeof(string), typeof(bool) } ); Func expressionInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionInvoker = ReflectionHelpers.GetConstructor(ctor); } Func dynamicInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicInvoker = ReflectionHelpers.GetConstructor(ctor); } Func reflectionInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionInvoker = ReflectionHelpers.GetConstructor(ctor); } @@ -1502,7 +1492,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionInvoker, Is.Not.SameAs(reflectionInvoker)); Assert.That(dynamicInvoker, Is.Not.SameAs(reflectionInvoker)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionInvokerSecond = ReflectionHelpers.GetConstructor( ctor @@ -1510,7 +1500,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionInvokerSecond, Is.SameAs(expressionInvoker)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicInvokerSecond = ReflectionHelpers.GetConstructor( ctor @@ -1518,7 +1508,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicInvokerSecond, Is.SameAs(dynamicInvoker)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionInvokerSecond = ReflectionHelpers.GetConstructor( ctor @@ -1538,7 +1528,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy public void ParameterlessConstructorCachesRemainStrategyScoped() { Func expressionCreator; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionCreator = ReflectionHelpers.GetParameterlessConstructor( typeof(TestConstructorClass) @@ -1546,7 +1536,7 @@ public void ParameterlessConstructorCachesRemainStrategyScoped() } Func dynamicCreator; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicCreator = ReflectionHelpers.GetParameterlessConstructor( typeof(TestConstructorClass) @@ -1554,7 +1544,7 @@ public void ParameterlessConstructorCachesRemainStrategyScoped() } Func reflectionCreator; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionCreator = ReflectionHelpers.GetParameterlessConstructor( typeof(TestConstructorClass) @@ -1602,14 +1592,14 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionCreator, Is.Not.SameAs(reflectionCreator)); Assert.That(dynamicCreator, Is.Not.SameAs(reflectionCreator)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionCreatorSecond = ReflectionHelpers.GetParameterlessConstructor(typeof(TestConstructorClass)); Assert.That(expressionCreatorSecond, Is.SameAs(expressionCreator)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicCreatorSecond = ReflectionHelpers.GetParameterlessConstructor( typeof(TestConstructorClass) @@ -1617,7 +1607,7 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(dynamicCreatorSecond, Is.SameAs(dynamicCreator)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionCreatorSecond = ReflectionHelpers.GetParameterlessConstructor(typeof(TestConstructorClass)); @@ -1633,21 +1623,21 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy public void ParameterlessConstructorTypedCachesRemainStrategyScoped() { Func expressionCreator; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionCreator = ReflectionHelpers.GetParameterlessConstructor(); } Func dynamicCreator; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicCreator = ReflectionHelpers.GetParameterlessConstructor(); } Func reflectionCreator; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionCreator = ReflectionHelpers.GetParameterlessConstructor(); @@ -1694,21 +1684,21 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionCreator, Is.Not.SameAs(reflectionCreator)); Assert.That(dynamicCreator, Is.Not.SameAs(reflectionCreator)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionCreatorSecond = ReflectionHelpers.GetParameterlessConstructor(); Assert.That(expressionCreatorSecond, Is.SameAs(expressionCreator)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicCreatorSecond = ReflectionHelpers.GetParameterlessConstructor(); Assert.That(dynamicCreatorSecond, Is.SameAs(dynamicCreator)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionCreatorSecond = ReflectionHelpers.GetParameterlessConstructor(); @@ -1727,19 +1717,19 @@ public void MethodInvokerCachesRemainStrategyScoped() nameof(TestMethodClass.InstanceMethodWithParam) ); Func expressionInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionInvoker = ReflectionHelpers.GetMethodInvoker(method); } Func dynamicInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicInvoker = ReflectionHelpers.GetMethodInvoker(method); } Func reflectionInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionInvoker = ReflectionHelpers.GetMethodInvoker(method); } @@ -1785,21 +1775,21 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionInvoker, Is.Not.SameAs(reflectionInvoker)); Assert.That(dynamicInvoker, Is.Not.SameAs(reflectionInvoker)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionInvokerSecond = ReflectionHelpers.GetMethodInvoker(method); Assert.That(expressionInvokerSecond, Is.SameAs(expressionInvoker)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicInvokerSecond = ReflectionHelpers.GetMethodInvoker(method); Assert.That(dynamicInvokerSecond, Is.SameAs(dynamicInvoker)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionInvokerSecond = ReflectionHelpers.GetMethodInvoker(method); @@ -1820,19 +1810,19 @@ public void StaticMethodInvokerCachesRemainStrategyScoped() ); TestMethodClass.ResetStatic(); Func expressionInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { expressionInvoker = ReflectionHelpers.GetStaticMethodInvoker(method); } Func dynamicInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { dynamicInvoker = ReflectionHelpers.GetStaticMethodInvoker(method); } Func reflectionInvoker; - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { reflectionInvoker = ReflectionHelpers.GetStaticMethodInvoker(method); } @@ -1878,21 +1868,21 @@ out ReflectionHelpers.ReflectionDelegateStrategy reflectionStrategy Assert.That(expressionInvoker, Is.Not.SameAs(reflectionInvoker)); Assert.That(dynamicInvoker, Is.Not.SameAs(reflectionInvoker)); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func expressionInvokerSecond = ReflectionHelpers.GetStaticMethodInvoker(method); Assert.That(expressionInvokerSecond, Is.SameAs(expressionInvoker)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func dynamicInvokerSecond = ReflectionHelpers.GetStaticMethodInvoker(method); Assert.That(dynamicInvokerSecond, Is.SameAs(dynamicInvoker)); } - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func reflectionInvokerSecond = ReflectionHelpers.GetStaticMethodInvoker(method); @@ -1963,7 +1953,7 @@ public void StaticPropertyGetterExpressionUsesCompiledDelegate() BindingFlags.Static | BindingFlags.Public ); - using (ReflectionHelpers.OverrideReflectionCapabilities(true, false)) + using (new ReflectionCapabilityScope(true, false)) { Func getter = ReflectionHelpers.GetPropertyGetter(property); Assert.That( @@ -2005,7 +1995,7 @@ public void StaticPropertyGetterDynamicIlUsesEmittedDelegate() BindingFlags.Static | BindingFlags.Public ); - using (ReflectionHelpers.OverrideReflectionCapabilities(false, true)) + using (new ReflectionCapabilityScope(false, true)) { Func getter = ReflectionHelpers.GetPropertyGetter(property); Assert.That( @@ -2042,7 +2032,7 @@ public void StaticPropertyGetterReflectionUsesFallbackDelegate() BindingFlags.Static | BindingFlags.Public ); - using (ReflectionHelpers.OverrideReflectionCapabilities(false, false)) + using (new ReflectionCapabilityScope(false, false)) { Func getter = ReflectionHelpers.GetPropertyGetter(property); Assert.That( diff --git a/Tests/Runtime/Helper/ReflectionHelperTests.cs b/Tests/Runtime/Helper/ReflectionHelperTests.cs index 75dfca47e..3033ab274 100644 --- a/Tests/Runtime/Helper/ReflectionHelperTests.cs +++ b/Tests/Runtime/Helper/ReflectionHelperTests.cs @@ -369,12 +369,7 @@ private static void RunWithDynamicIlOnly(Action assertion) Assert.Ignore("Dynamic IL is not available on this platform."); } - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: true - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: true)) { assertion(); } @@ -2375,12 +2370,7 @@ public void ParameterlessConstructorDelegateCreatesInstances() [Test] public void ParameterlessConstructorFallbackUsesReflectionWhenExpressionsDisabled() { - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { Func creator = ReflectionHelpers.GetParameterlessConstructor(); @@ -2408,12 +2398,7 @@ public void ConstructorInvokerFallsBackWhenExpressionsDisabled() ConstructorInfo ctor = typeof(GenericTestClass).GetConstructor( new[] { typeof(int) } ); - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { Func invoker = ReflectionHelpers.GetConstructor(ctor); object result = invoker(new object[] { 64 }); @@ -2435,12 +2420,7 @@ public void IndexerGetterThrowsOnInvalidIndexCount() public void IndexerHelpersFallbackWhenExpressionsDisabled() { PropertyInfo idxProp = typeof(IndexerClass).GetProperty("Item"); - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { Func getter = ReflectionHelpers.GetIndexerGetter(idxProp); Action setter = ReflectionHelpers.GetIndexerSetter( @@ -2514,12 +2494,7 @@ public void TypedFieldGetterCastsReferenceTypes() public void TypedFieldSetterFallbacksWhenCapabilitiesDisabled() { FieldInfo field = typeof(TestClass).GetField(nameof(TestClass.intValue)); - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { FieldSetter setter = ReflectionHelpers.GetFieldSetter< TestClass, @@ -2537,12 +2512,7 @@ public void TypedPropertySetterFallbacksWhenCapabilitiesDisabled() PropertyInfo property = typeof(GenericTestClass).GetProperty( nameof(GenericTestClass.Value) ); - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { Action, int> setter = ReflectionHelpers.GetPropertySetter< GenericTestClass, @@ -2561,12 +2531,7 @@ public void TypedPropertyGetterFallbacksWhenCapabilitiesDisabled() nameof(GenericTestClass.Value) ); GenericTestClass instance = new(45); - using ( - ReflectionHelpers.OverrideReflectionCapabilities( - expressions: false, - dynamicIl: false - ) - ) + using (new ReflectionCapabilityScope(expressions: false, dynamicIl: false)) { Func, int> getter = ReflectionHelpers.GetPropertyGetter< GenericTestClass, diff --git a/Tests/Runtime/Helper/ReflectionHelpersTypeScanningTests.cs b/Tests/Runtime/Helper/ReflectionHelpersTypeScanningTests.cs index 14c746f3e..39db74c21 100644 --- a/Tests/Runtime/Helper/ReflectionHelpersTypeScanningTests.cs +++ b/Tests/Runtime/Helper/ReflectionHelpersTypeScanningTests.cs @@ -110,10 +110,10 @@ public void TryResolveTypeRecoversMovedGenericArgument(Type expected) ); Assert.IsTrue(Type.GetType(moved, throwOnError: false) == null); - int cachedNamesBefore = ReflectionHelpers.ResolvedTypeCacheCountForTesting; + int cachedNamesBefore = ReflectionHelpers.TypeResolutionCache.Count; Assert.AreSame(expected, ReflectionHelpers.TryResolveType(moved)); Assert.AreSame(expected, ReflectionHelpers.TryResolveType(moved)); - Assert.AreEqual(cachedNamesBefore, ReflectionHelpers.ResolvedTypeCacheCountForTesting); + Assert.AreEqual(cachedNamesBefore, ReflectionHelpers.TypeResolutionCache.Count); } [Test] @@ -138,7 +138,7 @@ public void TryResolveTypeRejectsMissingGenericArgument() [Test] public void UnresolvableTypeNamesAreNotCached() { - int before = ReflectionHelpers.ResolvedTypeCacheCountForTesting; + int before = ReflectionHelpers.TypeResolutionCache.Count; for (int index = 0; index < 256; ++index) { @@ -150,7 +150,7 @@ public void UnresolvableTypeNamesAreNotCached() Assert.AreEqual( before, - ReflectionHelpers.ResolvedTypeCacheCountForTesting, + ReflectionHelpers.TypeResolutionCache.Count, "A name no loaded assembly declares must leave no entry behind." ); } @@ -165,12 +165,12 @@ public void ASuccessfulResolutionIsStillCached() string assemblyQualifiedName = typeof(PrewarmTesterComponent).AssemblyQualifiedName; Assert.IsTrue(ReflectionHelpers.TryResolveType(assemblyQualifiedName) != null); - int afterFirst = ReflectionHelpers.ResolvedTypeCacheCountForTesting; + int afterFirst = ReflectionHelpers.TypeResolutionCache.Count; Assert.IsTrue(ReflectionHelpers.TryResolveType(assemblyQualifiedName) != null); Assert.AreEqual( afterFirst, - ReflectionHelpers.ResolvedTypeCacheCountForTesting, + ReflectionHelpers.TypeResolutionCache.Count, "A repeated successful resolution must be served from the cache." ); } diff --git a/Tests/Runtime/Helper/SingletonAutoLoaderTests.cs b/Tests/Runtime/Helper/SingletonAutoLoaderTests.cs index 7ff5a5680..5d9c8351d 100644 --- a/Tests/Runtime/Helper/SingletonAutoLoaderTests.cs +++ b/Tests/Runtime/Helper/SingletonAutoLoaderTests.cs @@ -48,12 +48,39 @@ private static AttributeMetadataCache.AutoLoadSingletonEntry CreateScriptableEnt ); } + private static void ClearLoaderCaches() + { + lock (SingletonAutoLoader._loaderBuildLock) + { + SingletonAutoLoader._cachedLoaders.Clear(); + SingletonAutoLoader._runtimeInstanceProperties.Clear(); + SingletonAutoLoader._scriptableInstanceProperties.Clear(); + } + lock (SingletonAutoLoader._executionLock) + { + SingletonAutoLoader._executedLoadTypes.Clear(); + } + } + + private static void ExecuteEntries( + RuntimeInitializeLoadType loadType, + params AttributeMetadataCache.AutoLoadSingletonEntry[] entries + ) + { + SingletonAutoLoader.ExecuteEntries( + entries, + loadType, + enforceSingleExecution: false, + requirePlayMode: false + ); + } + [SetUp] public override void BaseSetUp() { base.BaseSetUp(); // Reset static caches so each test emits the warning its LogAssert expectation consumes. - SingletonAutoLoader.ClearCachesForTests(); + ClearLoaderCaches(); AutoRuntimeSingleton.ClearForTests(); AutoScriptableSingleton.ClearForTests(); RuntimeMismatchSingleton.ClearForTests(); @@ -63,8 +90,7 @@ public override void BaseSetUp() [UnityTest] public IEnumerator AutoLoaderInitializesRuntimeSingletons() { - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( RuntimeInitializeLoadType.BeforeSplashScreen, CreateRuntimeEntry() ); @@ -85,8 +111,7 @@ public IEnumerator AutoLoaderInitializesScriptableSingletons() instance.hideFlags = HideFlags.DontSave; Track(instance); - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( RuntimeInitializeLoadType.BeforeSplashScreen, CreateScriptableEntry() ); @@ -98,32 +123,61 @@ public IEnumerator AutoLoaderInitializesScriptableSingletons() } [UnityTest] - public IEnumerator AutoLoaderSkipsWhenNotPlaying() + public IEnumerator AutoLoaderRuntimeEntryRespectsActualPlayMode() { - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: false, + bool isPlaying = Application.isPlaying; + SingletonAutoLoader.ExecuteEntries( + new[] { CreateRuntimeEntry() }, RuntimeInitializeLoadType.BeforeSplashScreen, - CreateRuntimeEntry() + enforceSingleExecution: false, + requirePlayMode: true ); yield return null; - Assert.IsFalse(AutoRuntimeSingleton.HasInstance); - Assert.AreEqual(0, AutoRuntimeSingleton.AwakenCount); + Assert.AreEqual(isPlaying, AutoRuntimeSingleton.HasInstance); + Assert.AreEqual( + isPlaying, + SingletonAutoLoader._cachedLoaders.ContainsKey( + typeof(AutoRuntimeSingleton).AssemblyQualifiedName + ) + ); + if (isPlaying) + { + Assert.GreaterOrEqual(AutoRuntimeSingleton.AwakenCount, 1); + Track(AutoRuntimeSingleton.Instance.gameObject); + } + else + { + Assert.AreEqual(0, AutoRuntimeSingleton.AwakenCount); + } } [UnityTest] - public IEnumerator AutoLoaderSkipsScriptableSingletonsWhenNotPlaying() + public IEnumerator AutoLoaderScriptableEntryRespectsActualPlayMode() { - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: false, + AutoScriptableSingleton instance = Track( + ScriptableObject.CreateInstance() + ); + instance.hideFlags = HideFlags.DontSave; + AutoScriptableSingleton.ClearForTests(); + bool isPlaying = Application.isPlaying; + SingletonAutoLoader.ExecuteEntries( + new[] { CreateScriptableEntry() }, RuntimeInitializeLoadType.BeforeSplashScreen, - CreateScriptableEntry() + enforceSingleExecution: false, + requirePlayMode: true ); yield return null; - Assert.IsFalse(AutoScriptableSingleton.HasInstance); + Assert.AreEqual(isPlaying, AutoScriptableSingleton.HasInstance); + Assert.AreEqual( + isPlaying, + SingletonAutoLoader._cachedLoaders.ContainsKey( + typeof(AutoScriptableSingleton).AssemblyQualifiedName + ) + ); Assert.AreEqual(0, AutoScriptableSingleton.CreatedCount); } @@ -135,8 +189,7 @@ public IEnumerator AutoLoaderLogsWarningWhenTypeCannotBeResolved() new Regex("Unable to resolve type", RegexOptions.IgnoreCase) ); - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( RuntimeInitializeLoadType.BeforeSplashScreen, new AttributeMetadataCache.AutoLoadSingletonEntry( "Missing.Type, UnknownAssembly", @@ -156,8 +209,7 @@ public IEnumerator AutoLoaderWarnsWhenRuntimeEntryTargetsScriptableSingleton() new Regex("does not derive from RuntimeSingleton", RegexOptions.IgnoreCase) ); - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( RuntimeInitializeLoadType.BeforeSplashScreen, CreateRuntimeEntry() ); @@ -176,8 +228,7 @@ public IEnumerator AutoLoaderWarnsWhenScriptableEntryTargetsRuntimeSingleton() new Regex("does not derive from ScriptableObjectSingleton", RegexOptions.IgnoreCase) ); - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( RuntimeInitializeLoadType.BeforeSplashScreen, CreateScriptableEntry() ); @@ -191,8 +242,7 @@ public IEnumerator AutoLoaderWarnsWhenScriptableEntryTargetsRuntimeSingleton() [UnityTest] public IEnumerator AutoLoaderSkipsEntriesWithDifferentLoadType() { - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( RuntimeInitializeLoadType.AfterSceneLoad, new AttributeMetadataCache.AutoLoadSingletonEntry( typeof(AutoRuntimeSingleton).AssemblyQualifiedName, @@ -210,8 +260,7 @@ public IEnumerator AutoLoaderSkipsEntriesWithDifferentLoadType() [UnityTest] public IEnumerator AutoLoaderIgnoresDuplicateEntries() { - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( RuntimeInitializeLoadType.BeforeSplashScreen, CreateRuntimeEntry(), CreateRuntimeEntry() @@ -229,8 +278,7 @@ public IEnumerator MissingTypeWarningEmittedOnceForDuplicates() new Regex("Unable to resolve type", RegexOptions.IgnoreCase) ); - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( RuntimeInitializeLoadType.BeforeSplashScreen, new AttributeMetadataCache.AutoLoadSingletonEntry( "Unknown.Type, MissingAssembly", @@ -261,8 +309,7 @@ public IEnumerator AutoLoaderExecutesOnlyMatchingLoadType( ? RuntimeInitializeLoadType.AfterSceneLoad : RuntimeInitializeLoadType.BeforeSplashScreen; - SingletonAutoLoader.ExecuteEntriesForTests( - simulatePlayMode: true, + ExecuteEntries( loadType, CreateRuntimeEntry(loadType), CreateRuntimeEntry(otherLoadType) diff --git a/Tests/Runtime/Helper/UnityMainThreadDispatcherTests.cs b/Tests/Runtime/Helper/UnityMainThreadDispatcherTests.cs index a3367bb10..1f4b2e61a 100644 --- a/Tests/Runtime/Helper/UnityMainThreadDispatcherTests.cs +++ b/Tests/Runtime/Helper/UnityMainThreadDispatcherTests.cs @@ -175,9 +175,9 @@ public IEnumerator DrainPendingActionsForTestingExecutesQueuedActions() dispatcher.RunOnMainThread(() => executionCount++); - Assert.AreEqual(1, UnityMainThreadDispatcher.GetPendingActionCountForTesting()); + Assert.AreEqual(1, RuntimeStateTestUtilities.GetPendingActionCount()); - int remainingPendingActions = UnityMainThreadDispatcher.DrainPendingActionsForTesting(); + int remainingPendingActions = RuntimeStateTestUtilities.DrainPendingActions(); Assert.AreEqual(0, remainingPendingActions); Assert.AreEqual(1, executionCount); @@ -431,7 +431,7 @@ public IEnumerator DestroyExistingDispatcherReturnsFalseWhenMissingDeferred() } [UnityTest] - public IEnumerator CreateTestScopeReEnablesAutoCreationAndCleansUp() + public IEnumerator AutoCreationScopeReEnablesAutoCreationAndCleansUp() { UnityMainThreadDispatcherTestHelper.DestroyDispatcherIfExists( immediate: !Application.isPlaying @@ -441,7 +441,9 @@ public IEnumerator CreateTestScopeReEnablesAutoCreationAndCleansUp() Assert.IsTrue(UnityMainThreadDispatcher.AutoCreationEnabled); UnityMainThreadDispatcher.AutoCreationScope scope = - UnityMainThreadDispatcher.CreateTestScope(destroyImmediate: !Application.isPlaying); + RuntimeStateTestUtilities.CreateDispatcherScope( + destroyImmediate: !Application.isPlaying + ); Assert.IsTrue(scope != null, "Test scope should not be null"); Assert.IsTrue(UnityMainThreadDispatcher.AutoCreationEnabled); @@ -878,7 +880,7 @@ public void RunAsyncReportsTheTokenThatCanceledTheWork() _ => delegateWork.Task, testCase.passCallerToken ? callerSource.Token : CancellationToken.None ); - UnityMainThreadDispatcher.DrainPendingActionsForTesting(); + RuntimeStateTestUtilities.DrainPendingActions(); if (testCase.cancelCallerAfterStart) { diff --git a/Tests/Runtime/Helper/WallMathStatisticsTests.cs b/Tests/Runtime/Helper/WallMathStatisticsTests.cs index 5861e08b3..417c87f27 100644 --- a/Tests/Runtime/Helper/WallMathStatisticsTests.cs +++ b/Tests/Runtime/Helper/WallMathStatisticsTests.cs @@ -1186,7 +1186,7 @@ public void MeanListScratchDoesNotAcquireADisposalLease(IList values, double exp DisposalLease probe = DisposalLeases.Acquire(); try { - int slot = probe.SlotForTests; + int slot = probe._slot; bool released = probe.TryClaim(); long before = DisposalLeases.CurrentGeneration(slot); /* @@ -1217,11 +1217,11 @@ public void MeanListScratchGenerationObservationDetectsBalancedLeaseWork() DisposalLease balanced = default; try { - int slot = probe.SlotForTests; + int slot = probe._slot; bool released = probe.TryClaim(); long before = DisposalLeases.CurrentGeneration(slot); balanced = DisposalLeases.Acquire(); - int reusedSlot = balanced.SlotForTests; + int reusedSlot = balanced._slot; bool balancedReleased = balanced.TryClaim(); long after = DisposalLeases.CurrentGeneration(slot); diff --git a/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs b/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs index 7e94ce98a..1f67bfb0a 100644 --- a/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs +++ b/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs @@ -406,7 +406,9 @@ private AttributeMetadataCache CreateCacheFor(Type componentType) }; cache._relationalTypeMetadata = relationalTypes; - cache.ForceRebuildForTests(); + global::WallstopStudios.UnityHelpers.Tests.Core.AttributeMetadataCacheTestUtilities.Rebuild( + cache + ); return cache; } diff --git a/Tests/Runtime/Integrations/VContainer/RelationalComponentsVContainerTests.cs b/Tests/Runtime/Integrations/VContainer/RelationalComponentsVContainerTests.cs index e5d62102c..d5aec87a1 100644 --- a/Tests/Runtime/Integrations/VContainer/RelationalComponentsVContainerTests.cs +++ b/Tests/Runtime/Integrations/VContainer/RelationalComponentsVContainerTests.cs @@ -535,7 +535,9 @@ private AttributeMetadataCache CreateCacheFor(Type componentType) }; cache._relationalTypeMetadata = relationalTypes; - cache.ForceRebuildForTests(); + global::WallstopStudios.UnityHelpers.Tests.Core.AttributeMetadataCacheTestUtilities.Rebuild( + cache + ); return cache; } diff --git a/Tests/Runtime/Integrations/Zenject/RelationalComponentsZenjectTests.cs b/Tests/Runtime/Integrations/Zenject/RelationalComponentsZenjectTests.cs index 5ee201491..3fd8f8f28 100644 --- a/Tests/Runtime/Integrations/Zenject/RelationalComponentsZenjectTests.cs +++ b/Tests/Runtime/Integrations/Zenject/RelationalComponentsZenjectTests.cs @@ -574,7 +574,9 @@ private AttributeMetadataCache CreateCacheFor(Type componentType) }; cache._relationalTypeMetadata = relationalTypes; - cache.ForceRebuildForTests(); + global::WallstopStudios.UnityHelpers.Tests.Core.AttributeMetadataCacheTestUtilities.Rebuild( + cache + ); return cache; } diff --git a/Tests/Runtime/Performance/ProtoSerializationStartupPerformanceTests.cs b/Tests/Runtime/Performance/ProtoSerializationStartupPerformanceTests.cs index 165d5770a..b3608a99d 100644 --- a/Tests/Runtime/Performance/ProtoSerializationStartupPerformanceTests.cs +++ b/Tests/Runtime/Performance/ProtoSerializationStartupPerformanceTests.cs @@ -228,24 +228,9 @@ private static StartupContract MakeStartupContract() } [Test, Timeout(BenchmarkTimeoutMilliseconds)] - public void CompareGeneratedRegistrationAndFirstApiUse() + public void CompareFirstApiUse() { Assert.AreEqual(28, StartupContractClosures.Length); - Assert.IsTrue( - global::WallstopStudios - .UnityHelpers - .Generated - .WProtoGeneratedRegistrar - .HasRecordedFirstRegistration - ); - Assert.Greater( - global::WallstopStudios - .UnityHelpers - .Generated - .WProtoGeneratedRegistrar - .FirstRegistrationElapsedTimestampTicks, - 0 - ); PrimeSharedStartupPaths(); double[] wallstopProtoSerialize = new double[StartupRounds]; @@ -353,25 +338,12 @@ public void CompareGeneratedRegistrationAndFirstApiUse() protobufNetDeserialize ); - double registrationMicroseconds = TimestampTicksToMicroseconds( - global::WallstopStudios - .UnityHelpers - .Generated - .WProtoGeneratedRegistrar - .FirstRegistrationElapsedTimestampTicks - ); double wallstopProtoSerializeApiMedian = Median(wallstopProtoSerialize); double protobufNetSerializeMedian = Median(protobufNetSerialize); double wallstopProtoDeserializeApiMedian = Median(wallstopProtoDeserialize); double protobufNetDeserializeMedian = Median(protobufNetDeserialize); - double registrationAndSerializeMedian = - registrationMicroseconds + wallstopProtoSerializeApiMedian; - double registrationAndDeserializeMedian = - registrationMicroseconds + wallstopProtoDeserializeApiMedian; - UnityEngine.Debug.Log( - $"One-time generated assembly registration: {registrationMicroseconds:0.00} us. " - + $"API values are medians of {StartupRounds} fresh generic contract closures; " + $"API values are medians of {StartupRounds} fresh generic contract closures; " + "process startup and shared JIT warmup are excluded." ); UnityEngine.Debug.Log( @@ -385,10 +357,10 @@ public void CompareGeneratedRegistrationAndFirstApiUse() "| --------- | ------------------:| ----------------:| -------:|" ); UnityEngine.Debug.Log( - $"| One-time assembly registration + median first serialize | {registrationAndSerializeMedian, 18:0.00} | {protobufNetSerializeMedian, 16:0.00} | {protobufNetSerializeMedian / registrationAndSerializeMedian, 7:0.00}x |" + $"| Median first serialize API call | {wallstopProtoSerializeApiMedian, 18:0.00} | {protobufNetSerializeMedian, 16:0.00} | {protobufNetSerializeMedian / wallstopProtoSerializeApiMedian, 7:0.00}x |" ); UnityEngine.Debug.Log( - $"| One-time assembly registration + median first deserialize | {registrationAndDeserializeMedian, 18:0.00} | {protobufNetDeserializeMedian, 16:0.00} | {protobufNetDeserializeMedian / registrationAndDeserializeMedian, 7:0.00}x |" + $"| Median first deserialize API call | {wallstopProtoDeserializeApiMedian, 18:0.00} | {protobufNetDeserializeMedian, 16:0.00} | {protobufNetDeserializeMedian / wallstopProtoDeserializeApiMedian, 7:0.00}x |" ); } diff --git a/Tests/Runtime/Performance/ReflectionPerformanceTests.cs b/Tests/Runtime/Performance/ReflectionPerformanceTests.cs index 8a8a4bf62..5ec3fb02c 100644 --- a/Tests/Runtime/Performance/ReflectionPerformanceTests.cs +++ b/Tests/Runtime/Performance/ReflectionPerformanceTests.cs @@ -10,6 +10,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using System.Runtime.InteropServices; using NUnit.Framework; using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Tests.Core; [TestFixture] [Category("Performance")] @@ -38,7 +39,7 @@ private static StrategyRunResult RunStrategy(StrategyConfig config) { if (config.RequiresOverride) { - capabilityOverride = ReflectionHelpers.OverrideReflectionCapabilities( + capabilityOverride = new ReflectionCapabilityScope( config.ExpressionsOverride, config.DynamicIlOverride ); diff --git a/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs b/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs index 8d1f2641b..a9737838a 100644 --- a/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs +++ b/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs @@ -1,4 +1,4 @@ -// MIT License - Copyright (c) 2025 wallstop +// MIT License - Copyright (c) 2025-2026 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance @@ -23,7 +23,9 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance public sealed class SpatialTree2DPerformanceTests { private const float PointBoundsSize = 0.001f; - private const int BenchmarkTimeoutMilliseconds = 180_000; + + // Query timers require 236 seconds before construction, warmup, validation, and reporting. + private const int BenchmarkTimeoutMilliseconds = 600_000; private const int WarmupIterations = 3; private static (string Label, float Radius)[] BuildRangeBenchmarks(DatasetSpec dataset) diff --git a/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs b/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs index f4ed4ef8e..b0f0eab8e 100644 --- a/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs +++ b/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs @@ -1,4 +1,4 @@ -// MIT License - Copyright (c) 2025 wallstop +// MIT License - Copyright (c) 2025-2026 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance @@ -24,7 +24,9 @@ public sealed class SpatialTree3DPerformanceTests { private const float BoundsTolerance3D = 1e-1f; private const float PointBoundsSize = 0.001f; - private const int BenchmarkTimeoutMilliseconds = 180_000; + + // Query timers require 236 seconds before construction, warmup, validation, and reporting. + private const int BenchmarkTimeoutMilliseconds = 600_000; private const int WarmupIterations = 3; private static (string Label, float Radius)[] BuildRangeBenchmarks(DatasetSpec dataset) diff --git a/Tests/Runtime/Pool/GlobalPoolRegistryTests.cs b/Tests/Runtime/Pool/GlobalPoolRegistryTests.cs index b72b27bb5..b0a3bb8c4 100644 --- a/Tests/Runtime/Pool/GlobalPoolRegistryTests.cs +++ b/Tests/Runtime/Pool/GlobalPoolRegistryTests.cs @@ -26,7 +26,7 @@ public void SetUp() // Time 0 reads as uninitialized in the tracker. _currentTime = 1f; TestPoolItem.ResetIdCounter(); - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); GlobalPoolRegistry.ResetBudgetSettings(); PoolPurgeSettings.ResetToDefaults(); _wasMemoryPressureEnabled = MemoryPressureMonitor.Enabled; @@ -36,7 +36,7 @@ public void SetUp() [TearDown] public void TearDown() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); GlobalPoolRegistry.ResetBudgetSettings(); PoolPurgeSettings.ResetToDefaults(); MemoryPressureMonitor.Reset(); @@ -368,8 +368,9 @@ public void EnforceBudgetInvokesCallbacksOutsideRegistryLock() Triggers = PurgeTrigger.Explicit, OnPurge = (_, _) => { - purgeCallbackHeldRegistryLock = - GlobalPoolRegistry.IsRegistryLockHeldByCurrentThread(); + purgeCallbackHeldRegistryLock = System.Threading.Monitor.IsEntered( + GlobalPoolRegistry.RegistryLock + ); }, }; @@ -378,8 +379,9 @@ public void EnforceBudgetInvokesCallbacksOutsideRegistryLock() preWarmCount: 2, onDisposal: _ => { - disposalCallbackHeldRegistryLock = - GlobalPoolRegistry.IsRegistryLockHeldByCurrentThread(); + disposalCallbackHeldRegistryLock = System.Threading.Monitor.IsEntered( + GlobalPoolRegistry.RegistryLock + ); }, options: options ); @@ -497,7 +499,7 @@ public void ClearRemovesAllPools() Assert.AreEqual(2, GlobalPoolRegistry.RegisteredCount); - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); Assert.AreEqual(0, GlobalPoolRegistry.RegisteredCount); } diff --git a/Tests/Runtime/Pool/MemoryPressureTests.cs b/Tests/Runtime/Pool/MemoryPressureTests.cs index 3648a2d58..8f7183f60 100644 --- a/Tests/Runtime/Pool/MemoryPressureTests.cs +++ b/Tests/Runtime/Pool/MemoryPressureTests.cs @@ -1187,6 +1187,30 @@ public void ConcurrentPropertyAccessDoesNotThrow() Assert.IsEmpty(exceptions, "Concurrent property reads and writes should not throw"); } + [TestCase(12f, 0f, MemoryPressureLevel.Medium)] + [TestCase(0f, 2000f, MemoryPressureLevel.Medium)] + [TestCase(12f, 2000f, MemoryPressureLevel.High)] + [TestCase(4f, 0f, MemoryPressureLevel.Low)] + [TestCase(0f, 1000f, MemoryPressureLevel.Low)] + [TestCase(float.NaN, float.NaN, MemoryPressureLevel.None)] + public void PressureScoringUsesActualRateThresholds( + float gcRate, + float growthRate, + MemoryPressureLevel expected + ) + { + Assert.AreEqual( + expected, + MemoryPressureMonitor.CalculatePressureFromMetrics( + 0f, + gcRate, + growthRate, + gcRateThreshold: 4f, + growthRateThreshold: 1000f + ) + ); + } + private float TestTimeProvider() { return _currentTime; diff --git a/Tests/Runtime/Pool/PoolLifecycleHooksTests.cs b/Tests/Runtime/Pool/PoolLifecycleHooksTests.cs index fc679ab1d..c22c4ad15 100644 --- a/Tests/Runtime/Pool/PoolLifecycleHooksTests.cs +++ b/Tests/Runtime/Pool/PoolLifecycleHooksTests.cs @@ -23,7 +23,7 @@ public void SetUp() _currentTime = 1f; TestPoolItem.ResetIdCounter(); PoolPurgeSettings.ResetToDefaults(); - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); // Disable memory pressure monitoring to ensure deterministic test behavior _wasMemoryPressureEnabled = MemoryPressureMonitor.Enabled; MemoryPressureMonitor.Enabled = false; @@ -33,7 +33,7 @@ public void SetUp() public void TearDown() { PoolPurgeSettings.ResetToDefaults(); - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); MemoryPressureMonitor.Enabled = _wasMemoryPressureEnabled; } @@ -234,7 +234,7 @@ public void PoolUnregistersFromGlobalRegistryOnDispose() [Test] public void MultiplePoolsRegisterWithGlobalRegistry() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); using WallstopGenericPool pool1 = new( () => new TestPoolItem(), @@ -303,7 +303,7 @@ public void ClearRemovesAllRegistrations() Assert.GreaterOrEqual(GlobalPoolRegistry.RegisteredCount, 2); - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); Assert.AreEqual(0, GlobalPoolRegistry.RegisteredCount); } @@ -311,7 +311,7 @@ public void ClearRemovesAllRegistrations() [Test] public void PurgeAllPoolsPurgesAllRegisteredPools() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -348,7 +348,7 @@ public void PurgeAllPoolsPurgesAllRegisteredPools() [Test] public void PurgeAllPoolsWithDefaultParametersPurgesAll() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -372,7 +372,7 @@ public void PurgeAllPoolsWithDefaultParametersPurgesAll() [Test] public void PurgeAllPoolsReturnsZeroWhenNoPoolsRegistered() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); int purged = PoolPurgeSettings.PurgeAllPools(); @@ -382,7 +382,7 @@ public void PurgeAllPoolsReturnsZeroWhenNoPoolsRegistered() [Test] public void PurgeAllPoolsTracksCorrectReason() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); List reasons = new(); PoolOptions options = new() @@ -415,7 +415,7 @@ public void PurgeAllPoolsTracksCorrectReason() [Test] public void PurgeAllPoolsContinuesOnIndividualPoolException() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions normalOptions = new() { @@ -499,7 +499,7 @@ public void IPurgeablePurgeReturnsCorrectCount() [Test] public void PurgeAllPoolsRespectsMinRetainCount() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -526,7 +526,7 @@ public void PurgeAllPoolsRespectsMinRetainCount() [Test] public void MemoryPressurePurgeIgnoresHysteresisButRespectsMinRetain() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -562,7 +562,7 @@ private float TestTimeProvider() [Test] public void ConcurrentPoolRegistrationIsThreadSafe() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); List> pools = new(); object poolsLock = new(); const int threadCount = 8; @@ -604,7 +604,7 @@ public void ConcurrentPoolRegistrationIsThreadSafe() [Test] public void ConcurrentPurgeAllPoolsIsThreadSafe() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); List> pools = new(); PoolOptions options = new() @@ -706,9 +706,9 @@ public void UnregisterLifecycleHooksIsIdempotent() { Assert.DoesNotThrow(() => { - PoolPurgeSettings.UnregisterLifecycleHooks(); - PoolPurgeSettings.UnregisterLifecycleHooks(); - PoolPurgeSettings.UnregisterLifecycleHooks(); + PoolStateTestUtilities.UnregisterLifecycleHooks(); + PoolStateTestUtilities.UnregisterLifecycleHooks(); + PoolStateTestUtilities.UnregisterLifecycleHooks(); }); } @@ -718,7 +718,7 @@ public void CanReRegisterAfterUnregister() Assert.DoesNotThrow(() => { PoolPurgeSettings.RegisterLifecycleHooks(); - PoolPurgeSettings.UnregisterLifecycleHooks(); + PoolStateTestUtilities.UnregisterLifecycleHooks(); PoolPurgeSettings.RegisterLifecycleHooks(); }); } @@ -726,7 +726,7 @@ public void CanReRegisterAfterUnregister() [Test] public void PurgeAllPoolsHandlesEmptyPools() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); using WallstopGenericPool emptyPool = new( () => new TestPoolItem(), @@ -747,7 +747,7 @@ public void PurgeAllPoolsHandlesEmptyPools() [Test] public void PurgeAllPoolsHandlesMixedPoolSizes() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -783,7 +783,7 @@ public void PurgeAllPoolsHandlesMixedPoolSizes() [Test] public void DisposedPoolsAreCleanedFromRegistry() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); WallstopGenericPool pool1 = new( () => new TestPoolItem(), @@ -819,7 +819,7 @@ public void DisposedPoolsAreCleanedFromRegistry() [Test] public void HysteresisBlocksPurgeWhenRespected() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); // Time 0 reads as uninitialized in the tracker. _currentTime = 1f; @@ -861,7 +861,7 @@ public void HysteresisBlocksPurgeWhenRespected() [Test] public void HysteresisIsBypassedWhenIgnored() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -955,7 +955,7 @@ public void DirectPurgeCanBypassHysteresis() [Test] public void LowMemoryHandlerUsesIgnoreHysteresis() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -988,7 +988,7 @@ public void LowMemoryHandlerUsesIgnoreHysteresis() [Test] public void AppBackgroundedHandlerRespectsHysteresis() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); // Time 0 reads as uninitialized in the tracker. _currentTime = 1f; @@ -1026,7 +1026,7 @@ public void AppBackgroundedHandlerRespectsHysteresis() [Test] public void MemoryPressureBypassesMaxPurgesPerOperation() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); const int preWarmCount = 25; const int maxPurgesPerOp = 5; @@ -1067,7 +1067,7 @@ public void MemoryPressureBypassesMaxPurgesPerOperation() [Test] public void MemoryPressureTracksFullPurgeStatistics() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); const int preWarmCount = 15; const int maxPurgesPerOp = 5; diff --git a/Tests/Runtime/Pool/PoolSizeAwarePoliciesTests.cs b/Tests/Runtime/Pool/PoolSizeAwarePoliciesTests.cs index 332a8c3ae..d0aa94fa5 100644 --- a/Tests/Runtime/Pool/PoolSizeAwarePoliciesTests.cs +++ b/Tests/Runtime/Pool/PoolSizeAwarePoliciesTests.cs @@ -23,7 +23,8 @@ public sealed class PoolSizeAwarePoliciesTests public void SetUp() { PoolPurgeSettings.ResetToDefaults(); - PoolSizeEstimator.ClearCaches(); + PoolSizeEstimator.SizeCache.Clear(); + PoolSizeEstimator.LohCache.Clear(); _wasMemoryPressureEnabled = MemoryPressureMonitor.Enabled; MemoryPressureMonitor.Enabled = false; } @@ -32,7 +33,8 @@ public void SetUp() public void TearDown() { PoolPurgeSettings.ResetToDefaults(); - PoolSizeEstimator.ClearCaches(); + PoolSizeEstimator.SizeCache.Clear(); + PoolSizeEstimator.LohCache.Clear(); MemoryPressureMonitor.Enabled = _wasMemoryPressureEnabled; } @@ -192,7 +194,8 @@ public void ClearCachesRemovesCachedEstimates() { int firstSize = PoolSizeEstimator.EstimateItemSizeBytes(); - PoolSizeEstimator.ClearCaches(); + PoolSizeEstimator.SizeCache.Clear(); + PoolSizeEstimator.LohCache.Clear(); int secondSize = PoolSizeEstimator.EstimateItemSizeBytes(); Assert.AreEqual(firstSize, secondSize, "Size should be consistent after cache clear"); diff --git a/Tests/Runtime/Pool/PoolStateTestUtilities.cs b/Tests/Runtime/Pool/PoolStateTestUtilities.cs new file mode 100644 index 000000000..f1599a3ef --- /dev/null +++ b/Tests/Runtime/Pool/PoolStateTestUtilities.cs @@ -0,0 +1,51 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Runtime.Pool +{ + using System.Threading; + using UnityEngine; + using UnityEngine.SceneManagement; + using WallstopStudios.UnityHelpers.Utils; + + internal static class PoolStateTestUtilities + { + internal static void ClearBuiltInTypeConfigurations() + { + lock (PoolPurgeSettings.ConfigLock) + { + PoolPurgeSettings.BuiltInTypeConfigurations.Clear(); + PoolPurgeSettings.BuiltInGenericTypeConfigurations.Clear(); + } + + Volatile.Write(ref PoolPurgeSettings._builtInDefaultsInitialized, 0); + } + + internal static void ReinitializeBuiltInDefaults() + { + ClearBuiltInTypeConfigurations(); + PoolPurgeSettings.EnsureBuiltInDefaultsInitialized(); + } + + internal static void UnregisterLifecycleHooks() + { + if (Interlocked.Exchange(ref PoolPurgeSettings._lifecycleHooksRegistered, 0) == 0) + { + return; + } + + Application.lowMemory -= PoolPurgeSettings.OnLowMemory; + Application.focusChanged -= PoolPurgeSettings.OnFocusChanged; + SceneManager.sceneUnloaded -= PoolPurgeSettings.OnSceneUnloaded; + } + + internal static void ClearRegistry() + { + lock (GlobalPoolRegistry.RegistryLock) + { + GlobalPoolRegistry.RegisteredPools.Clear(); + } + Volatile.Write(ref GlobalPoolRegistry._lastBudgetEnforcementTime, 0f); + } + } +} diff --git a/Tests/Runtime/Pool/PoolStateTestUtilities.cs.meta b/Tests/Runtime/Pool/PoolStateTestUtilities.cs.meta new file mode 100644 index 000000000..eb9a85625 --- /dev/null +++ b/Tests/Runtime/Pool/PoolStateTestUtilities.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0df9f81b56897e00173d188e8c4b9266 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Pool/SceneUnloadPurgeTests.cs b/Tests/Runtime/Pool/SceneUnloadPurgeTests.cs index 4dea1398a..307e03478 100644 --- a/Tests/Runtime/Pool/SceneUnloadPurgeTests.cs +++ b/Tests/Runtime/Pool/SceneUnloadPurgeTests.cs @@ -39,7 +39,7 @@ public void SetUp() _currentTime = 1f; TestPoolItem.ResetIdCounter(); PoolPurgeSettings.ResetToDefaults(); - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); // Disable memory pressure monitoring to ensure deterministic test behavior _wasMemoryPressureEnabled = MemoryPressureMonitor.Enabled; MemoryPressureMonitor.Enabled = false; @@ -49,7 +49,7 @@ public void SetUp() public void TearDown() { PoolPurgeSettings.ResetToDefaults(); - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); MemoryPressureMonitor.Enabled = _wasMemoryPressureEnabled; } @@ -76,7 +76,7 @@ public void ResetToDefaultsRestoresPurgeOnSceneUnload() [Test] public void PurgeAllPoolsWithSceneUnloadedReasonPurgesAllPools() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -114,7 +114,7 @@ public void PurgeAllPoolsWithSceneUnloadedReasonPurgesAllPools() [Test] public void PurgeAllPoolsWithSceneUnloadedReasonTracksCorrectReason() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); List reasons = new(); PoolOptions options = new() @@ -147,7 +147,7 @@ public void PurgeAllPoolsWithSceneUnloadedReasonTracksCorrectReason() [Test] public void SceneUnloadedPurgeRespectsMinRetainCount() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -174,7 +174,7 @@ public void SceneUnloadedPurgeRespectsMinRetainCount() [Test] public void SceneUnloadedPurgeRespectsHysteresis() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); // Time 0 reads as uninitialized in the tracker. _currentTime = 1f; @@ -216,7 +216,7 @@ public void SceneUnloadedPurgeRespectsHysteresis() [Test] public void SceneUnloadedPurgeCanBypassHysteresisWhenIgnored() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); // Time 0 reads as uninitialized in the tracker. _currentTime = 1f; @@ -255,7 +255,7 @@ public void SceneUnloadedPurgeCanBypassHysteresisWhenIgnored() [Test] public void SceneUnloadedPurgeAfterHysteresisExpiresPurgesNormally() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); // Time 0 reads as uninitialized in the tracker. _currentTime = 1f; @@ -292,7 +292,7 @@ public void SceneUnloadedPurgeAfterHysteresisExpiresPurgesNormally() [Test] public void SceneUnloadedPurgeHandlesEmptyPools() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); using WallstopGenericPool emptyPool = new( () => new TestPoolItem(), @@ -316,7 +316,7 @@ public void SceneUnloadedPurgeHandlesEmptyPools() [Test] public void SceneUnloadedPurgeHandlesMixedPoolSizes() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -358,7 +358,7 @@ public void SceneUnloadedPurgeHandlesMixedPoolSizes() [Test] public void SceneUnloadedPurgeReturnsZeroWhenNoPoolsRegistered() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); int purged = PoolPurgeSettings.PurgeAllPools( respectHysteresis: true, @@ -448,7 +448,7 @@ public void SceneUnloadedPurgeStatisticsAreTracked() [Test] public void SceneUnloadedPurgeWithWarmRetainCount() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -482,7 +482,7 @@ public void SceneUnloadedPurgeWithWarmRetainCount() [Test] public void SceneUnloadedPurgeIdlePoolIgnoresWarmRetain() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); PoolOptions options = new() { @@ -519,7 +519,7 @@ private float TestTimeProvider() [Test] public void ConcurrentSceneUnloadPurgesAreThreadSafe() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); List> pools = new(); PoolOptions options = new() @@ -608,7 +608,7 @@ public void ConcurrentPurgeOnSceneUnloadToggleIsThreadSafe() [Test] public void MultiplePurgeReasonsCanBeMixed() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); List reasons = new(); PoolOptions options = new() @@ -753,7 +753,7 @@ public void SceneUnloadedPurgeWithReasonBypassesMaxPurgesPerOperation() [Test] public void ForceFullPurgeAllWithSceneUnloadedReason() { - GlobalPoolRegistry.Clear(); + PoolStateTestUtilities.ClearRegistry(); const int preWarmCount = 25; const int maxPurgesPerOp = 5; diff --git a/Tests/Runtime/Pool/TypeAwarePoliciesTests.cs b/Tests/Runtime/Pool/TypeAwarePoliciesTests.cs index 52e5c3929..cc4a5672d 100644 --- a/Tests/Runtime/Pool/TypeAwarePoliciesTests.cs +++ b/Tests/Runtime/Pool/TypeAwarePoliciesTests.cs @@ -41,7 +41,7 @@ private static IEnumerable ArrayTypeTestCases() public void SetUp() { PoolPurgeSettings.ResetToDefaults(); - PoolPurgeSettings.ReinitializeBuiltInDefaults(); + PoolStateTestUtilities.ReinitializeBuiltInDefaults(); _wasMemoryPressureEnabled = MemoryPressureMonitor.Enabled; MemoryPressureMonitor.Enabled = false; } @@ -56,7 +56,7 @@ public void TearDown() [Test] public void BuiltInDefaultsAreInitializedLazily() { - PoolPurgeSettings.ClearBuiltInTypeConfigurations(); + PoolStateTestUtilities.ClearBuiltInTypeConfigurations(); Assert.IsFalse( PoolPurgeSettings.BuiltInDefaultsInitialized, @@ -77,10 +77,10 @@ public void ReinitializeBuiltInDefaultsClearsAndReinitializes() PoolPurgeSettings.GetEffectiveOptions>(); Assert.IsTrue(PoolPurgeSettings.BuiltInDefaultsInitialized); - PoolPurgeSettings.ClearBuiltInTypeConfigurations(); + PoolStateTestUtilities.ClearBuiltInTypeConfigurations(); Assert.IsFalse(PoolPurgeSettings.BuiltInDefaultsInitialized); - PoolPurgeSettings.ReinitializeBuiltInDefaults(); + PoolStateTestUtilities.ReinitializeBuiltInDefaults(); Assert.IsTrue(PoolPurgeSettings.BuiltInDefaultsInitialized); } @@ -664,7 +664,7 @@ public void BuiltInDefaultsInitializationIsThreadSafe() Exception capturedException = null; int successCount = 0; - PoolPurgeSettings.ClearBuiltInTypeConfigurations(); + PoolStateTestUtilities.ClearBuiltInTypeConfigurations(); System.Threading.Thread[] threads = new System.Threading.Thread[threadCount]; for (int t = 0; t < threadCount; ++t) diff --git a/Tests/Runtime/Pool/WallstopGenericPoolTests.cs b/Tests/Runtime/Pool/WallstopGenericPoolTests.cs index 39a7f91df..d98c38157 100644 --- a/Tests/Runtime/Pool/WallstopGenericPoolTests.cs +++ b/Tests/Runtime/Pool/WallstopGenericPoolTests.cs @@ -1267,6 +1267,179 @@ public void ThrowingReturnCallbackDoesNotLeaveAPhantomRental() Assert.AreEqual(2, disposeCount); } + [TestCase(false, 0, false, false, TestName = "RentFailure.Created.Producer.Disposal")] + [TestCase( + false, + 0, + false, + true, + TestName = "RentFailure.Created.Producer.ThrowingDisposal" + )] + [TestCase(false, 1, false, false, TestName = "RentFailure.Created.OnGet.Disposal")] + [TestCase(false, 1, false, true, TestName = "RentFailure.Created.OnGet.ThrowingDisposal")] + [TestCase(true, 1, false, false, TestName = "RentFailure.Pooled.OnGet.Disposal")] + [TestCase(true, 1, false, true, TestName = "RentFailure.Pooled.OnGet.ThrowingDisposal")] + [TestCase(false, 1, true, false, TestName = "RentFailure.Disposed.OnGet.Disposal")] + [TestCase(false, 1, true, true, TestName = "RentFailure.Disposed.OnGet.ThrowingDisposal")] + [TestCase(false, 0, true, false, TestName = "RentFailure.Disposed.Producer.Disposal")] + [TestCase( + false, + 0, + true, + true, + TestName = "RentFailure.Disposed.Producer.ThrowingDisposal" + )] + public void FailedRentRetiresOwnedItemAndPreservesFailure( + bool preWarm, + int failurePhase, + bool disposed, + bool disposalThrows + ) + { + int disposalCount = 0; + bool fail = false; + InvalidOperationException original = new InvalidOperationException("rent failed"); + using WallstopGenericPool pool = new( + () => + { + if (fail && failurePhase == 0) + { + throw original; + } + return new TestPoolItem(); + }, + preWarmCount: preWarm ? 1 : 0, + onGet: _ => + { + if (fail && failurePhase == 1) + { + throw original; + } + }, + onDisposal: _ => + { + ++disposalCount; + if (disposalThrows) + { + throw new InvalidOperationException("disposal failed"); + } + }, + options: new PoolOptions + { + Triggers = PurgeTrigger.Explicit, + TimeProvider = TestTimeProvider, + } + ); + if (disposed) + { + pool.Dispose(); + } + fail = true; + Exception observed = Assert.Throws(() => pool.Get()); + Assert.That(observed, Is.SameAs(original)); + Assert.That(pool.CurrentlyRented, Is.Zero); + Assert.That(pool.Count, Is.Zero); + Assert.That(disposalCount, Is.EqualTo(failurePhase == 1 ? 1 : 0)); + fail = false; + using (pool.Get()) + { + Assert.That(pool.CurrentlyRented, Is.EqualTo(disposed ? 0 : 1)); + } + Assert.That(pool.CurrentlyRented, Is.Zero); + } + + [TestCase(0, false, TestName = "WarmFailure.Producer")] + [TestCase(1, false, TestName = "WarmFailure.OnGet")] + [TestCase(2, false, TestName = "WarmFailure.OnRelease")] + [TestCase(0, true, TestName = "WarmFailure.Producer.ThrowingDisposal")] + [TestCase(1, true, TestName = "WarmFailure.OnGet.ThrowingDisposal")] + [TestCase(2, true, TestName = "WarmFailure.OnRelease.ThrowingDisposal")] + public void FailedPrewarmRetiresEveryCreatedItem(int failurePhase, bool disposalThrows) + { + int created = 0; + int disposed = 0; + InvalidOperationException original = new InvalidOperationException("warm failed"); + Exception observed = Assert.Throws(() => + new WallstopGenericPool( + () => + { + if (created == 1 && failurePhase == 0) + { + throw original; + } + ++created; + return new TestPoolItem(); + }, + preWarmCount: 3, + onGet: _ => + { + if (created == 2 && failurePhase == 1) + { + throw original; + } + }, + onRelease: _ => + { + if (created == 2 && failurePhase == 2) + { + throw original; + } + }, + onDisposal: _ => + { + ++disposed; + if (disposalThrows) + { + throw new InvalidOperationException("warm disposal failed"); + } + }, + options: new PoolOptions + { + Triggers = PurgeTrigger.Explicit, + TimeProvider = TestTimeProvider, + } + ) + ); + Assert.That(observed, Is.SameAs(original)); + Assert.That(created, Is.EqualTo(failurePhase == 0 ? 1 : 2)); + Assert.That(disposed, Is.EqualTo(created)); + } + +#if !SINGLE_THREADED + [Test] + public void AcquisitionCallbackCanQueryPoolFromWorker() + { + bool query = false; + WallstopGenericPool pool = null; + pool = new WallstopGenericPool( + () => new TestPoolItem(), + preWarmCount: 1, + onGet: _ => + { + if (query) + { + Task read = Task.Run(() => pool.Count); + Assert.That(read.Wait(TimeSpan.FromSeconds(5)), Is.True); + Assert.That(read.Result, Is.Zero); + } + }, + options: new PoolOptions + { + Triggers = PurgeTrigger.Explicit, + TimeProvider = TestTimeProvider, + } + ); + using (pool) + { + query = true; + using (pool.Get()) + { + Assert.That(pool.CurrentlyRented, Is.EqualTo(1)); + } + } + } +#endif + private float TestTimeProvider() { return _currentTime; @@ -2083,7 +2256,7 @@ public void LoweringPoolTypeResolverBoundEvictsExistingNamesImmediately() PoolTypeResolver.MaxCachedTypeNames = 2; - Assert.AreEqual(2, PoolTypeResolver.CachedTypeNameCountForTesting); + Assert.AreEqual(2, PoolTypeResolver.SimplifiedTypeNameCache.Count); } finally { @@ -2174,7 +2347,7 @@ public void PoolTypeConfigurationRecoversMovedGenericArgument() ) == null ); Assert.IsTrue(PoolTypeResolver.ResolveType(moved.Replace("]]", "]")) == null); - Assert.AreEqual(0, PoolTypeResolver.CachedTypeNameCountForTesting); + Assert.AreEqual(0, PoolTypeResolver.SimplifiedTypeNameCache.Count); } finally { diff --git a/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs b/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs index ad2ad3ffe..6935398d0 100644 --- a/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs +++ b/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs @@ -15,7 +15,7 @@ public sealed class ProtoInterfaceResolutionEdgeTests [SetUp] public void SetUp() { - Serializer.ClearProtobufRootCacheForTesting( + ProtoRootTestUtilities.ClearRootCaches( typeof(IWidget), typeof(AbstractBase), typeof(RegisteredAbstractBase) diff --git a/Tests/Runtime/Serialization/ProtoRootRegistrationTests.cs b/Tests/Runtime/Serialization/ProtoRootRegistrationTests.cs index e507cd6d8..553a10c8a 100644 --- a/Tests/Runtime/Serialization/ProtoRootRegistrationTests.cs +++ b/Tests/Runtime/Serialization/ProtoRootRegistrationTests.cs @@ -16,7 +16,7 @@ public sealed class ProtoRootRegistrationTests [SetUp] public void SetUp() { - Serializer.ClearProtobufRootCacheForTesting( + ProtoRootTestUtilities.ClearRootCaches( typeof(IAnimal), typeof(IInferredAnimal), typeof(IPlainAbstractAnimal) diff --git a/Tests/Runtime/Serialization/ProtoRootTestUtilities.cs b/Tests/Runtime/Serialization/ProtoRootTestUtilities.cs new file mode 100644 index 000000000..e844717f2 --- /dev/null +++ b/Tests/Runtime/Serialization/ProtoRootTestUtilities.cs @@ -0,0 +1,35 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System; + using WallstopStudios.UnityHelpers.Core.Serialization; + using WallstopProto = WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + internal static class ProtoRootTestUtilities + { + internal static void ClearRootCaches(params Type[] declaredTypes) + { + if (declaredTypes == null || declaredTypes.Length == 0) + { + Serializer.ProtobufRootCache.Clear(); + Serializer.ExplicitProtobufRootCache.Clear(); + WallstopProto.WProtoDeclaredRootProvider.ReleaseAllClaims(); + return; + } + + foreach (Type declaredType in declaredTypes) + { + if (declaredType == null) + { + continue; + } + + Serializer.ProtobufRootCache.TryRemove(declaredType, out _); + Serializer.ExplicitProtobufRootCache.TryRemove(declaredType, out _); + WallstopProto.WProtoDeclaredRootProvider.ReleaseClaim(declaredType); + } + } + } +} diff --git a/Tests/Runtime/Serialization/ProtoRootTestUtilities.cs.meta b/Tests/Runtime/Serialization/ProtoRootTestUtilities.cs.meta new file mode 100644 index 000000000..ccacab2d0 --- /dev/null +++ b/Tests/Runtime/Serialization/ProtoRootTestUtilities.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b779003887f08ae796edc85c675d47d2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Serialization/WProtoDeclaredRootTests.cs b/Tests/Runtime/Serialization/WProtoDeclaredRootTests.cs index 293749da2..d3b171312 100644 --- a/Tests/Runtime/Serialization/WProtoDeclaredRootTests.cs +++ b/Tests/Runtime/Serialization/WProtoDeclaredRootTests.cs @@ -59,7 +59,7 @@ naive formatter drops. public void ReleaseRoots() { // A claim is process-global; leaking one leaves IRandom unserved for every test after. - Serializer.ClearProtobufRootCacheForTesting(typeof(IRandom)); + ProtoRootTestUtilities.ClearRootCaches(typeof(IRandom)); } [Test] @@ -195,7 +195,7 @@ public void AConsumersOwnRootStopsWallstopProtoAnswering() "and the read side must fall back rather than throw" ); - Serializer.ClearProtobufRootCacheForTesting(typeof(IRandom)); + ProtoRootTestUtilities.ClearRootCaches(typeof(IRandom)); Assert.IsTrue( WProtoFacade.TrySerialize(generator, out byte[] _), diff --git a/Tests/Runtime/Serialization/WProtoMapContractTests.cs b/Tests/Runtime/Serialization/WProtoMapContractTests.cs index 11ac9d13f..fcc14d051 100644 --- a/Tests/Runtime/Serialization/WProtoMapContractTests.cs +++ b/Tests/Runtime/Serialization/WProtoMapContractTests.cs @@ -59,6 +59,7 @@ private static string Encode(T value) byte[] buffer = new byte[formatter.Measure(value)]; WProtoWriter writer = new(buffer); Assert.IsTrue(formatter.Write(ref writer, value)); + Assert.AreEqual(buffer.Length, writer.Position, "Measure disagreed with Write"); return ToHex(writer.Written); } @@ -86,6 +87,101 @@ private static T RoundTrip(T value) return restored; } + [TestCase(WProtoButtonType.None, 0d, "0A020800")] + [TestCase(WProtoButtonType.None, 1d, "0A0B080011000000000000F03F")] + [TestCase(WProtoButtonType.Primary, 0d, "0A020801")] + [TestCase(WProtoButtonType.Primary, 1d, "0A0B080111000000000000F03F")] + public void EnumMapKeysMatchV3GoldenBytes( + WProtoButtonType key, + double value, + string expected + ) + { + WProtoZeroKeyMapContract contract = new WProtoZeroKeyMapContract + { + ByEnum = new Dictionary { { key, value } }, + }; +#if !ENABLE_IL2CPP + // protobuf-net tuple-discovery reflection calls an icall IL2CPP lacks. + using MemoryStream stream = new MemoryStream(); + ProtoBuf.Serializer.Serialize(stream, contract); + Assert.AreEqual(expected, ToHex(stream.ToArray())); +#endif + Assert.AreEqual(expected, Encode(contract)); + } + + [TestCase(0, 0d, "1200")] + [TestCase(0, 1d, "120911000000000000F03F")] + [TestCase(1, 0d, "12020801")] + [TestCase(1, 1d, "120B080111000000000000F03F")] + public void IntegerMapKeysRetainDefaultOmission(int key, double value, string expected) + { + WProtoZeroKeyMapContract contract = new WProtoZeroKeyMapContract + { + ByInteger = new Dictionary { { key, value } }, + }; +#if !ENABLE_IL2CPP + using MemoryStream stream = new MemoryStream(); + ProtoBuf.Serializer.Serialize(stream, contract); + Assert.AreEqual(expected, ToHex(stream.ToArray())); +#endif + Assert.AreEqual(expected, Encode(contract)); + } + + [TestCase(0f, "1A0911000000000000F03F")] + [TestCase(1.5f, "1A0E0D0000C03F11000000000000F03F")] + public void SingleMapKeysMatchV3GoldenBytes(float key, string expected) + { + WProtoZeroKeyMapContract contract = new WProtoZeroKeyMapContract + { + BySingle = new Dictionary { { key, 1d } }, + }; +#if !ENABLE_IL2CPP + using MemoryStream stream = new MemoryStream(); + ProtoBuf.Serializer.Serialize(stream, contract); + Assert.AreEqual(expected, ToHex(stream.ToArray())); +#endif + Assert.AreEqual(expected, Encode(contract)); + } + + [TestCase(0d, "220911000000000000F03F")] + [TestCase(1.5d, "221209000000000000F83F11000000000000F03F")] + public void DoubleMapKeysMatchV3GoldenBytes(double key, string expected) + { + WProtoZeroKeyMapContract contract = new WProtoZeroKeyMapContract + { + ByDouble = new Dictionary { { key, 1d } }, + }; +#if !ENABLE_IL2CPP + using MemoryStream stream = new MemoryStream(); + ProtoBuf.Serializer.Serialize(stream, contract); + Assert.AreEqual(expected, ToHex(stream.ToArray())); +#endif + Assert.AreEqual(expected, Encode(contract)); + } + + [TestCase("0A00", 0d)] + [TestCase("0A020800", 0d)] + [TestCase("0A0911000000000000F03F", 1d)] + [TestCase("0A0B080011000000000000F03F", 1d)] + public void EnumMapReadsHistoricalKeylessAndExplicitZeroEntries(string hex, double expected) + { + WProtoReader reader = new WProtoReader(Parse(hex)); + Assert.IsTrue( + WProtoFormatterProvider + .Get() + .TryRead(ref reader, out WProtoZeroKeyMapContract read) + ); + Assert.AreEqual(1, read.ByEnum.Count); +#if !ENABLE_IL2CPP + using MemoryStream stream = new MemoryStream(Parse(hex)); + WProtoZeroKeyMapContract oracle = + ProtoBuf.Serializer.Deserialize(stream); + CollectionAssert.AreEquivalent(oracle.ByEnum, read.ByEnum); +#endif + Assert.AreEqual(expected, read.ByEnum[WProtoButtonType.None]); + } + [Test] public void AnEntryIsAMessageWithTheKeyAtOneAndTheValueAtTwo() { diff --git a/Tests/Runtime/Serialization/WProtoPackingOptOutTests.cs b/Tests/Runtime/Serialization/WProtoPackingOptOutTests.cs new file mode 100644 index 000000000..e6244d081 --- /dev/null +++ b/Tests/Runtime/Serialization/WProtoPackingOptOutTests.cs @@ -0,0 +1,292 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System; + using System.Collections.Generic; + using NUnit.Framework; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// Pins repeated write packing policy and read compatibility. + [TestFixture] + public sealed class WProtoPackingOptOutTests + { + private static void AssertEncoded(T value, string hex) + { + byte[] bytes = Encode(value); + CollectionAssert.AreEqual(Parse(hex), bytes); + } + + private static void AssertShortBuffer(T value) + { + IWProtoFormatter formatter = WProtoFormatterProvider.Get(); + WProtoWriter writer = new WProtoWriter(new byte[formatter.Measure(value) - 1]); + Assert.IsFalse(formatter.Write(ref writer, value)); + } + + private static byte[] Encode(T value) + { + IWProtoFormatter formatter = WProtoFormatterProvider.Get(); + int measured = formatter.Measure(value); + byte[] bytes = new byte[measured]; + WProtoWriter writer = new WProtoWriter(bytes); + Assert.IsTrue(formatter.Write(ref writer, value)); + Assert.AreEqual(measured, writer.Position); + return bytes; + } + + private static T Decode(byte[] bytes) + { + WProtoReader reader = new WProtoReader(bytes); + Assert.IsTrue(WProtoFormatterProvider.Get().TryRead(ref reader, out T value)); + Assert.IsTrue(reader.End); + Assert.IsFalse(reader.Malformed); + return value; + } + + private static byte[] Parse(string hex) + { + byte[] bytes = new byte[hex.Length / 2]; + for (int index = 0; index < bytes.Length; ++index) + { + bytes[index] = Convert.ToByte(hex.Substring(index * 2, 2), 16); + } + return bytes; + } + + [TestCase(0, "08030801080408000802", TestName = "Unpacked.IntArray")] + [TestCase(1, "10001002", TestName = "Unpacked.IntList")] + [TestCase(2, "180018AC02", TestName = "Unpacked.EnumArray")] + [TestCase(3, "200020AC02", TestName = "Unpacked.EnumList")] + [TestCase(4, "29000000000000000029000000000000F03F", TestName = "Unpacked.DoubleArray")] + [TestCase(5, "30003001", TestName = "Unpacked.BoolArray")] + [TestCase(6, "3A003A0161", TestName = "Unpacked.StringArray")] + [TestCase(7, "42020001", TestName = "Packed.ExplicitTrue")] + [TestCase(8, "48004861", TestName = "Unpacked.CharArray")] + public void ExplicitWritePolicyProducesExpectedBytes(int shape, string hex) + { + WProtoUnpackedRepeatedContract value = new WProtoUnpackedRepeatedContract(); + switch (shape) + { + case 0: + value.Ints = new[] { 3, 1, 4, 0, 2 }; + break; + case 1: + value.IntList = new List { 0, 2 }; + break; + case 2: + value.Modes = new[] { WProtoRepeatedMode.None, WProtoRepeatedMode.Careful }; + break; + case 3: + value.ModeList = new List + { + WProtoRepeatedMode.None, + WProtoRepeatedMode.Careful, + }; + break; + case 4: + value.Doubles = new[] { 0d, 1d }; + break; + case 5: + value.Flags = new[] { false, true }; + break; + case 6: + value.Texts = new[] { string.Empty, "a" }; + break; + case 7: + value.PackedInts = new[] { 0, 1 }; + break; + case 8: + value.Chars = new[] { '\0', 'a' }; + break; + } + AssertEncoded(value, hex); + } + + [TestCase(false, TestName = "Unpacked.NullCollections")] + [TestCase(true, TestName = "Unpacked.EmptyCollections")] + public void NullAndEmptyCollectionsWriteNothing(bool empty) + { + WProtoUnpackedRepeatedContract value = new WProtoUnpackedRepeatedContract(); + WProtoUnpackedRepeatedBox generic = new WProtoUnpackedRepeatedBox(); + if (empty) + { + value.Ints = Array.Empty(); + value.IntList = new List(); + value.Modes = Array.Empty(); + value.ModeList = new List(); + generic.Array = Array.Empty(); + generic.List = new List(); + } + AssertEncoded(value, string.Empty); + AssertEncoded(generic, string.Empty); + Assert.IsTrue(Decode(Array.Empty()).Ints == null); + Assert.IsTrue( + Decode>(Array.Empty()).Array == null + ); + } + + [Test] + public void AnEmptyDefaultPackedRunRetainsExistingOmission() + { + WProtoUnpackedRepeatedBox value = new WProtoUnpackedRepeatedBox + { + DefaultPacked = Array.Empty(), + }; + CollectionAssert.AreEqual(Array.Empty(), Encode(value)); + } + + [Test] + public void GenericIntegerArrayAndListUseUnpackedWhileDefaultStillPacks() + { + WProtoUnpackedRepeatedBox value = new WProtoUnpackedRepeatedBox + { + Array = new[] { 0, -1, int.MaxValue }, + List = new List { 0, 2 }, + DefaultPacked = new[] { 0, 2 }, + }; + byte[] bytes = Encode(value); + AssertEncoded(value, "080008FFFFFFFFFFFFFFFFFF0108FFFFFFFF07100010021A020002"); + WProtoUnpackedRepeatedBox restored = Decode>(bytes); + CollectionAssert.AreEqual(value.Array, restored.Array); + CollectionAssert.AreEqual(value.List, restored.List); + CollectionAssert.AreEqual(value.DefaultPacked, restored.DefaultPacked); + } + + [Test] + public void GenericEnumArrayAndListUseUnpacked() + { + WProtoUnpackedRepeatedBox value = + new WProtoUnpackedRepeatedBox + { + Array = new[] { WProtoRepeatedMode.None, WProtoRepeatedMode.Careful }, + List = new List + { + WProtoRepeatedMode.None, + WProtoRepeatedMode.Fast, + }, + }; + AssertEncoded(value, "080008AC0210001001"); + WProtoUnpackedRepeatedBox restored = Decode< + WProtoUnpackedRepeatedBox + >(Encode(value)); + CollectionAssert.AreEqual(value.Array, restored.Array); + CollectionAssert.AreEqual(value.List, restored.List); + } + + [Test] + public void GenericStringArrayAndListRemainUnpacked() + { + WProtoUnpackedRepeatedBox value = new WProtoUnpackedRepeatedBox + { + Array = new[] { string.Empty, "a" }, + List = new List { "b" }, + }; + AssertEncoded(value, "0A000A0161120162"); + WProtoUnpackedRepeatedBox restored = Decode>( + Encode(value) + ); + CollectionAssert.AreEqual(value.Array, restored.Array); + CollectionAssert.AreEqual(value.List, restored.List); + } + + [TestCase("08000801", TestName = "Read.Unpacked")] + [TestCase("0A020001", TestName = "Read.Packed")] + [TestCase("08000A0101", TestName = "Read.Mixed")] + public void UnpackedWritePolicyReadsEitherWireForm(string hex) + { + byte[] payload = Parse(hex); + CollectionAssert.AreEqual( + new[] { 0, 1 }, + Decode(payload).Ints + ); + CollectionAssert.AreEqual( + new[] { 0, 1 }, + Decode>(payload).Array + ); + } + + [Test] + public void UnpackedEnumWritePolicyReadsMixedRuns() + { + WProtoUnpackedRepeatedContract value = Decode( + Parse("18001A02AC02") + ); + CollectionAssert.AreEqual( + new[] { WProtoRepeatedMode.None, WProtoRepeatedMode.Careful }, + value.Modes + ); + WProtoUnpackedRepeatedBox generic = Decode< + WProtoUnpackedRepeatedBox + >(Parse("08000A02AC02")); + CollectionAssert.AreEqual( + new[] { WProtoRepeatedMode.None, WProtoRepeatedMode.Careful }, + generic.Array + ); + } + + [TestCase(false, TestName = "ShortBuffer.Concrete")] + [TestCase(true, TestName = "ShortBuffer.Generic")] + public void UnpackedWriteReportsAShortBuffer(bool generic) + { + if (generic) + { + AssertShortBuffer( + new WProtoUnpackedRepeatedBox { Array = new[] { 0, 1, int.MaxValue } } + ); + } + else + { + AssertShortBuffer( + new WProtoUnpackedRepeatedContract { Ints = new[] { 0, 1, int.MaxValue } } + ); + } + } + + [TestCase("08", TestName = "Malformed.Unpacked")] + [TestCase("0A0201", TestName = "Malformed.Packed")] + public void UnpackedWritePolicyDoesNotRelaxReadValidation(string hex) + { + byte[] bytes = Parse(hex); + WProtoReader concrete = new WProtoReader(bytes); + Assert.IsFalse( + WProtoFormatterProvider + .Get() + .TryRead(ref concrete, out _) + ); + WProtoReader generic = new WProtoReader(bytes); + Assert.IsFalse( + WProtoFormatterProvider + .Get>() + .TryRead(ref generic, out _) + ); + } + + [Test] + public void ALargeUnpackedRunMeasuresExactlyAndRoundTrips() + { + int[] values = new int[10000]; + for (int index = 0; index < values.Length; ++index) + { + values[index] = index % 3 == 0 ? 0 : index; + } + WProtoUnpackedRepeatedContract concrete = new WProtoUnpackedRepeatedContract + { + Ints = values, + }; + WProtoUnpackedRepeatedBox generic = new WProtoUnpackedRepeatedBox + { + Array = values, + }; + CollectionAssert.AreEqual( + values, + Decode(Encode(concrete)).Ints + ); + CollectionAssert.AreEqual( + values, + Decode>(Encode(generic)).Array + ); + } + } +} diff --git a/Tests/Runtime/Serialization/WProtoPackingOptOutTests.cs.meta b/Tests/Runtime/Serialization/WProtoPackingOptOutTests.cs.meta new file mode 100644 index 000000000..7bdc93d59 --- /dev/null +++ b/Tests/Runtime/Serialization/WProtoPackingOptOutTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b48f5ec9e5c9a21da572a68725c57ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Serialization/WProtoSchemaTextTests.cs b/Tests/Runtime/Serialization/WProtoSchemaTextTests.cs index 2e1656cd9..379f85669 100644 --- a/Tests/Runtime/Serialization/WProtoSchemaTextTests.cs +++ b/Tests/Runtime/Serialization/WProtoSchemaTextTests.cs @@ -42,6 +42,72 @@ private static List MessageOrderOf(string schema) return names; } + [Test] + public void ExplicitUnpackedRepeatedMembersRenderTheirWritePolicy() + { + Assert.IsTrue( + WProtoSchemaText.TryWriteSchema( + new[] + { + typeof(WProtoUnpackedRepeatedContract), + typeof(WProtoUnpackedRepeatedBox), + }, + "test.pkg", + null, + out string schema, + out IReadOnlyList diagnostics + ) + ); + Assert.AreEqual(1, diagnostics.Count); + StringAssert.Contains("not a valid proto3 identifier", diagnostics[0]); + StringAssert.Contains("repeated int32 Ints = 1 [packed = false];", schema); + StringAssert.Contains("repeated int32 IntList = 2 [packed = false];", schema); + StringAssert.Contains( + "repeated WProtoRepeatedMode Modes = 3 [packed = false];", + schema + ); + StringAssert.Contains( + "repeated WProtoRepeatedMode ModeList = 4 [packed = false];", + schema + ); + StringAssert.Contains("repeated fixed64 Doubles = 5 [packed = false];", schema); + StringAssert.Contains("repeated bool Flags = 6 [packed = false];", schema); + StringAssert.Contains("repeated string Texts = 7;", schema); + StringAssert.Contains("repeated int32 PackedInts = 8;", schema); + StringAssert.Contains("repeated uint32 Chars = 9 [packed = false];", schema); + StringAssert.Contains("repeated int32 Array = 1 [packed = false];", schema); + StringAssert.Contains("repeated int32 List = 2 [packed = false];", schema); + StringAssert.Contains("repeated int32 DefaultPacked = 3;", schema); + } + + [Test] + public void UnpackedSurrogateElementsRenderMessagesWithoutPackingOptions() + { + Dictionary surrogates = new Dictionary + { + { typeof(int), typeof(SchemaForeignVectorSurrogate) }, + { typeof(WProtoRepeatedMode), typeof(SchemaForeignVectorSurrogate) }, + }; + Assert.IsTrue( + WProtoSchemaText.TryWriteSchema( + new[] { typeof(WProtoUnpackedRepeatedContract) }, + null, + surrogates, + out string schema, + out IReadOnlyList diagnostics + ) + ); + Assert.IsEmpty(diagnostics); + StringAssert.Contains("repeated SchemaForeignVectorSurrogate Ints = 1;", schema); + StringAssert.Contains("repeated SchemaForeignVectorSurrogate IntList = 2;", schema); + StringAssert.Contains("repeated SchemaForeignVectorSurrogate Modes = 3;", schema); + StringAssert.Contains("repeated SchemaForeignVectorSurrogate ModeList = 4;", schema); + StringAssert.DoesNotContain( + "repeated SchemaForeignVectorSurrogate Ints = 1 [packed", + schema + ); + } + [Test] public void SimpleContractRendersTheFullSchemaText() { diff --git a/Tests/Runtime/Serialization/WProtoUnpackedRepeatedBox.cs b/Tests/Runtime/Serialization/WProtoUnpackedRepeatedBox.cs new file mode 100644 index 000000000..5a9dceba7 --- /dev/null +++ b/Tests/Runtime/Serialization/WProtoUnpackedRepeatedBox.cs @@ -0,0 +1,26 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System.Collections.Generic; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// A generic repeated contract with unpacked and default write policies. + /// The element type. + [WProtoContract] + public sealed partial class WProtoUnpackedRepeatedBox + { + /// The Array run. + [WProtoMember(1, IsPacked = false)] + public T[] Array; + + /// The List run. + [WProtoMember(2, IsPacked = false)] + public List List; + + /// The DefaultPacked run. + [WProtoMember(3)] + public T[] DefaultPacked; + } +} diff --git a/Tests/Runtime/Serialization/WProtoUnpackedRepeatedBox.cs.meta b/Tests/Runtime/Serialization/WProtoUnpackedRepeatedBox.cs.meta new file mode 100644 index 000000000..a7a3d6304 --- /dev/null +++ b/Tests/Runtime/Serialization/WProtoUnpackedRepeatedBox.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ff6c763ec765690eb8c56ecf746f7f2b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Serialization/WProtoUnpackedRepeatedContract.cs b/Tests/Runtime/Serialization/WProtoUnpackedRepeatedContract.cs new file mode 100644 index 000000000..d1284ea5e --- /dev/null +++ b/Tests/Runtime/Serialization/WProtoUnpackedRepeatedContract.cs @@ -0,0 +1,49 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System.Collections.Generic; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// Repeated members with explicit write packing policies. + [WProtoContract] + public sealed partial class WProtoUnpackedRepeatedContract + { + /// The Ints run. + [WProtoMember(1, IsPacked = false)] + public int[] Ints; + + /// The IntList run. + [WProtoMember(2, IsPacked = false)] + public List IntList; + + /// The Modes run. + [WProtoMember(3, IsPacked = false)] + public WProtoRepeatedMode[] Modes; + + /// The ModeList run. + [WProtoMember(4, IsPacked = false)] + public List ModeList; + + /// The Doubles run. + [WProtoMember(5, IsPacked = false)] + public double[] Doubles; + + /// The Flags run. + [WProtoMember(6, IsPacked = false)] + public bool[] Flags; + + /// The Texts run. + [WProtoMember(7, IsPacked = false)] + public string[] Texts; + + /// The PackedInts run. + [WProtoMember(8, IsPacked = true)] + public int[] PackedInts; + + /// The Chars run. + [WProtoMember(9, IsPacked = false)] + public char[] Chars; + } +} diff --git a/Tests/Runtime/Serialization/WProtoUnpackedRepeatedContract.cs.meta b/Tests/Runtime/Serialization/WProtoUnpackedRepeatedContract.cs.meta new file mode 100644 index 000000000..d32752522 --- /dev/null +++ b/Tests/Runtime/Serialization/WProtoUnpackedRepeatedContract.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25224fe7db049a91ae6e997d95404744 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Serialization/WProtoZeroKeyMapContract.cs b/Tests/Runtime/Serialization/WProtoZeroKeyMapContract.cs new file mode 100644 index 000000000..294e4e2a2 --- /dev/null +++ b/Tests/Runtime/Serialization/WProtoZeroKeyMapContract.cs @@ -0,0 +1,35 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System.Collections.Generic; + using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization.WallstopProto; + + /// Mirrors enum and integer map-key compatibility controls in players. + [ProtoContract] + [WProtoContract] + public sealed partial class WProtoZeroKeyMapContract + { + /// A byte-backed enum key with a double value. + [ProtoMember(1)] + [WProtoMember(1)] + public Dictionary ByEnum; + + /// An integer key with a double value. + [ProtoMember(2)] + [WProtoMember(2)] + public Dictionary ByInteger; + + /// A floating-point key whose default remains omitted. + [ProtoMember(3)] + [WProtoMember(3)] + public Dictionary BySingle; + + /// A double key whose default remains omitted. + [ProtoMember(4)] + [WProtoMember(4)] + public Dictionary ByDouble; + } +} diff --git a/Tests/Runtime/Serialization/WProtoZeroKeyMapContract.cs.meta b/Tests/Runtime/Serialization/WProtoZeroKeyMapContract.cs.meta new file mode 100644 index 000000000..579cdca50 --- /dev/null +++ b/Tests/Runtime/Serialization/WProtoZeroKeyMapContract.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b46b433bcedcd291df8e5193e38d5d23 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Tags/AttributeUtilitiesTests.cs b/Tests/Runtime/Tags/AttributeUtilitiesTests.cs index 279cde485..1f03ce0b3 100644 --- a/Tests/Runtime/Tags/AttributeUtilitiesTests.cs +++ b/Tests/Runtime/Tags/AttributeUtilitiesTests.cs @@ -569,7 +569,7 @@ public IEnumerator ExtensionHelpersBridgeTagAndEffectQueries() EffectHandler handler = entity.GetComponent(); handler.RemoveEffect(activeHandle); - activeHandle = handler.ApplyEffectForTesting(effect, currentTime: -10f).Value; + activeHandle = handler.ApplyEffect(effect, currentTime: -10f).Value; Assert.IsTrue(entity.TryGetRemainingDuration(activeHandle, out float beforeNoRefresh)); Assert.AreEqual(0f, beforeNoRefresh); @@ -646,7 +646,7 @@ public IEnumerator RefreshEffectIgnorePolicyExtensionRefreshesWhenDisallowed() ); EffectHandler handler = entity.GetComponent(); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: -10f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: -10f).Value; Assert.IsTrue(entity.TryGetRemainingDuration(handle, out float beforeRefresh)); Assert.AreEqual(0f, beforeRefresh); diff --git a/Tests/Runtime/Tags/EffectBehaviorTests.cs b/Tests/Runtime/Tags/EffectBehaviorTests.cs index 05781c8a7..eacad6a32 100644 --- a/Tests/Runtime/Tags/EffectBehaviorTests.cs +++ b/Tests/Runtime/Tags/EffectBehaviorTests.cs @@ -66,7 +66,7 @@ public void LifecycleCallbacksProvideContextData() ); effect.behaviors.Add(behavior); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 10f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 10f).Value; Assert.AreEqual(1, RecordingEffectBehavior.ApplyCount); Assert.AreEqual( 1, @@ -74,17 +74,14 @@ public void LifecycleCallbacksProvideContextData() "OnApply should fire immediately." ); - int tickCount = handler.ProcessBehaviorTicksForTesting(deltaTime: 0.02f); + int tickCount = handler.ProcessBehaviorTicks(deltaTime: 0.02f); Assert.AreEqual(1, tickCount); Assert.IsNotEmpty( RecordingEffectBehavior.TickContexts, "OnTick should run when behavior ticks are processed." ); - int periodicTicks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 10f, - deltaTime: 0.02f - ); + int periodicTicks = handler.ProcessPeriodicEffects(currentTime: 10f, deltaTime: 0.02f); Assert.AreEqual(1, periodicTicks); Assert.AreEqual( @@ -157,12 +154,9 @@ public void PeriodicTickContextTracksExecutedTicksAndTime() ); effect.behaviors.Add(behavior); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 20f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 20f).Value; - int ticks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 20.11f, - deltaTime: 0.11f - ); + int ticks = handler.ProcessPeriodicEffects(currentTime: 20.11f, deltaTime: 0.11f); Assert.AreEqual(3, ticks); Assert.AreEqual( diff --git a/Tests/Runtime/Tags/EffectHandlerReentrancyTests.cs b/Tests/Runtime/Tags/EffectHandlerReentrancyTests.cs index 961331f61..cff36f8b9 100644 --- a/Tests/Runtime/Tags/EffectHandlerReentrancyTests.cs +++ b/Tests/Runtime/Tags/EffectHandlerReentrancyTests.cs @@ -67,12 +67,15 @@ private static void AssertHandlerIsIdle(EffectHandler handler) { Assert.AreEqual( 0, - handler.TraversalDepthForTesting, + handler._traversalDepth, "The traversal counter must return to zero once every callback has unwound." ); Assert.AreEqual( 0, - handler.DeferredLeaseCountForTesting, + handler._deferredBehaviorLeases.Count + + handler._deferredPeriodicLeases.Count + + handler._deferredHandleLeases.Count + + handler._deferredCosmeticLeases.Count, "Every deferred pooled lease must be released once the outermost traversal exits." ); } @@ -317,7 +320,7 @@ TagHandler tags RentAndMutateBehaviorBuffer(); }; - int processedTicks = handler.ProcessBehaviorTicksForTesting(deltaTime: 0.1f); + int processedTicks = handler.ProcessBehaviorTicks(deltaTime: 0.1f); Assert.AreEqual(1, processedTicks); Assert.AreEqual(1, ReentrantEffectBehavior.TickCount); @@ -325,7 +328,7 @@ TagHandler tags Assert.AreEqual(1, ReentrantEffectBehavior.RemoveCount); Assert.AreEqual(2, RecordingEffectBehavior.RemoveCount); Assert.IsFalse(handler.IsEffectActive(effect)); - Assert.AreEqual(0, handler.ProcessBehaviorTicksForTesting(deltaTime: 0.1f)); + Assert.AreEqual(0, handler.ProcessBehaviorTicks(deltaTime: 0.1f)); AssertHandlerIsIdle(handler); } @@ -358,7 +361,7 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 600f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 600f).Value; ReentrantEffectBehavior.PeriodicTickHook = (context, _) => { context.handler.RemoveEffect(context.handle); @@ -366,7 +369,7 @@ TagHandler tags RentAndMutatePeriodicBuffer(); }; - int consumedTicks = handler.ProcessPeriodicEffectsForTesting( + int consumedTicks = handler.ProcessPeriodicEffects( currentTime: 600.6f, deltaTime: 0.6f ); @@ -377,11 +380,8 @@ TagHandler tags Assert.AreEqual(1, ReentrantEffectBehavior.RemoveCount); Assert.AreEqual(1, RecordingEffectBehavior.RemoveCount); Assert.IsFalse(handler.IsEffectActive(effect)); - Assert.AreEqual( - 0, - handler.ProcessPeriodicEffectsForTesting(currentTime: 601.6f, deltaTime: 1f) - ); - Assert.AreEqual(0, handler.ProcessBehaviorTicksForTesting(deltaTime: 0.1f)); + Assert.AreEqual(0, handler.ProcessPeriodicEffects(currentTime: 601.6f, deltaTime: 1f)); + Assert.AreEqual(0, handler.ProcessBehaviorTicks(deltaTime: 0.1f)); AssertHandlerIsIdle(handler); } @@ -577,7 +577,7 @@ void FailOnce() Assert.IsFalse(tags.HasTag(LifecycleTag)); Assert.AreEqual(100f, attributes.health.CurrentValue); Assert.AreEqual(1, ReentrantEffectBehavior.RemoveCount); - Assert.AreEqual(0, handler.ProcessBehaviorTicksForTesting(deltaTime: 0.1f)); + Assert.AreEqual(0, handler.ProcessBehaviorTicks(deltaTime: 0.1f)); int entriesAfterFailure = EffectLifecycleLog.Entries.Count; handler.RemoveEffect(handle); @@ -633,7 +633,7 @@ TagHandler tags Assert.AreEqual(100f, attributes.health.CurrentValue); Assert.AreEqual(1, ReentrantEffectBehavior.RemoveCount); Assert.AreEqual(1, ReentrantCosmeticComponent.RemovedCount); - Assert.AreEqual(0, handler.ProcessBehaviorTicksForTesting(deltaTime: 0.1f)); + Assert.AreEqual(0, handler.ProcessBehaviorTicks(deltaTime: 0.1f)); yield return null; @@ -675,7 +675,7 @@ TagHandler tags Assert.AreEqual(0, handler.GetActiveEffects().Count); Assert.IsFalse(tags.HasTag(LifecycleTag)); Assert.AreEqual(100f, attributes.health.CurrentValue); - Assert.AreEqual(0, handler.ProcessBehaviorTicksForTesting(deltaTime: 0.1f)); + Assert.AreEqual(0, handler.ProcessBehaviorTicks(deltaTime: 0.1f)); Assert.AreEqual(initialChildCount, entity.transform.childCount); AssertHandlerIsIdle(handler); } @@ -1066,7 +1066,7 @@ TagHandler tags RentAndMutateBehaviorBuffer(); }; - int processedTicks = handler.ProcessBehaviorTicksForTesting(deltaTime: 0.1f); + int processedTicks = handler.ProcessBehaviorTicks(deltaTime: 0.1f); Assert.AreEqual(1, processedTicks); Assert.AreEqual(1, ReentrantEffectBehavior.TickCount); diff --git a/Tests/Runtime/Tags/EffectHandlerTests.cs b/Tests/Runtime/Tags/EffectHandlerTests.cs index ba66057be..0ab5a6c08 100644 --- a/Tests/Runtime/Tags/EffectHandlerTests.cs +++ b/Tests/Runtime/Tags/EffectHandlerTests.cs @@ -376,7 +376,7 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 10f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 10f).Value; Assert.IsTrue( handler.TryGetRemainingDuration( handle, @@ -386,7 +386,7 @@ TagHandler tags ); Assert.AreEqual(0.25f, beforeReapply, RemainingDurationEpsilon); - EffectHandle? reapplied = handler.ApplyEffectForTesting(effect, currentTime: 10.2f); + EffectHandle? reapplied = handler.ApplyEffect(effect, currentTime: 10.2f); Assert.IsTrue(reapplied.HasValue); Assert.AreEqual(handle, reapplied.Value); @@ -516,6 +516,112 @@ TagHandler tags handler.RemoveEffect(second); } + [TestCase(100f, 0f, 1)] + [TestCase(100f, 1f, 0)] + [TestCase(16777216f, 0f, 1)] + [TestCase(16777216f, 1f, 0)] + [TestCase(float.MaxValue, 0f, 1)] + [TestCase(float.MaxValue, 1f, 0)] + public void PeriodicCadenceDoesNotRepeatAtUnchangedClock( + float startTime, + float initialDelay, + int expectedTicks + ) + { + (_, EffectHandler handler, TestAttributesComponent attributes, _) = CreateEntity(); + AttributeEffect effect = CreateEffect( + nameof(PeriodicCadenceDoesNotRepeatAtUnchangedClock), + e => + { + e.durationType = ModifierDurationType.Infinite; + PeriodicEffectDefinition definition = new() + { + initialDelay = initialDelay, + interval = 1f, + }; + definition.modifications.Add( + new AttributeModification + { + attribute = nameof(TestAttributesComponent.health), + action = ModificationAction.Addition, + value = -1f, + } + ); + e.periodicEffects.Add(definition); + } + ); + EffectHandle handle = handler.ApplyEffect(effect, startTime).Value; + Assert.AreEqual(expectedTicks, handler.ProcessPeriodicEffects(startTime, 0f)); + Assert.AreEqual(0, handler.ProcessPeriodicEffects(startTime, 0f)); + Assert.AreEqual( + 100f - expectedTicks, + attributes.health.CurrentValue, + RemainingDurationEpsilon + ); + handler.RemoveEffect(handle); + } + + [Test] + public void PeriodicCancellationOriginCatchUpRetainsThePerPassCap() + { + (_, EffectHandler handler, _, _) = CreateEntity(); + AttributeEffect effect = CreateEffect( + nameof(PeriodicCancellationOriginCatchUpRetainsThePerPassCap), + e => + { + e.durationType = ModifierDurationType.Infinite; + e.periodicEffects.Add( + new PeriodicEffectDefinition + { + initialDelay = float.MaxValue, + interval = 0.25f, + } + ); + } + ); + EffectHandle handle = handler.ApplyEffect(effect, -float.MaxValue).Value; + Assert.AreEqual(32, handler.ProcessPeriodicEffects(10f, 0f)); + Assert.AreEqual(9, handler.ProcessPeriodicEffects(10f, 0f)); + Assert.AreEqual(0, handler.ProcessPeriodicEffects(10f, 0f)); + handler.RemoveEffect(handle); + } + + [TestCase(false)] + [TestCase(true)] + public void PeriodicPhaseSurvivesDurationRefreshAndReapplication(bool reapply) + { + (_, EffectHandler handler, _, _) = CreateEntity(); + AttributeEffect effect = CreateEffect( + nameof(PeriodicPhaseSurvivesDurationRefreshAndReapplication), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = 10f; + e.stackingMode = EffectStackingMode.Refresh; + e.resetDurationOnReapplication = true; + e.periodicEffects.Add(new PeriodicEffectDefinition { interval = 1f }); + } + ); + EffectHandle handle = handler.ApplyEffect(effect, 100f).Value; + Assert.AreEqual(1, handler.ProcessPeriodicEffects(100f, 0f)); + if (reapply) + { + Assert.AreEqual(handle, handler.ApplyEffect(effect, 100.5f).Value); + } + else + { + Assert.IsTrue(handler.RefreshEffect(handle, false, 100.5f)); + } + Assert.AreEqual(0, handler.ProcessPeriodicEffects(100.5f, 0f)); + Assert.AreEqual(1, handler.ProcessPeriodicEffects(101f, 0f)); + handler.RemoveEffect(handle); + EffectHandle fresh = handler.ApplyEffect(effect, 101f).Value; + Assert.AreNotEqual(handle, fresh); + Assert.AreEqual(1, handler.ProcessPeriodicEffects(101f, 0f)); + Assert.AreEqual(0, handler.ProcessPeriodicEffects(101f, 0f)); + handler.RemoveEffect(fresh); + } + [UnityTest] public IEnumerator PeriodicEffectHonorsInitialDelayAndMaxTicks() { @@ -550,10 +656,10 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 5f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 5f).Value; Assert.AreEqual(100f, attributes.health.CurrentValue, 0.01f); - int beforeDelayTicks = handler.ProcessPeriodicEffectsForTesting( + int beforeDelayTicks = handler.ProcessPeriodicEffects( currentTime: 5.049f, deltaTime: 0.049f ); @@ -563,21 +669,15 @@ TagHandler tags "No periodic ticks should occur before the initial delay elapses." ); - int firstTicks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 5.051f, - deltaTime: 0.05f - ); + int firstTicks = handler.ProcessPeriodicEffects(currentTime: 5.051f, deltaTime: 0.05f); Assert.AreEqual(1, firstTicks); Assert.AreEqual(90f, attributes.health.CurrentValue, 0.01f); - int secondTicks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 5.101f, - deltaTime: 0.05f - ); + int secondTicks = handler.ProcessPeriodicEffects(currentTime: 5.101f, deltaTime: 0.05f); Assert.AreEqual(1, secondTicks); Assert.AreEqual(80f, attributes.health.CurrentValue, 0.01f); - int afterMaxTicks = handler.ProcessPeriodicEffectsForTesting( + int afterMaxTicks = handler.ProcessPeriodicEffects( currentTime: 5.201f, deltaTime: 0.1f ); @@ -617,18 +717,15 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 20f).Value; - int ticks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 20.16f, - deltaTime: 0.16f - ); + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 20f).Value; + int ticks = handler.ProcessPeriodicEffects(currentTime: 20.16f, deltaTime: 0.16f); Assert.Greater(ticks, 0); float afterTicks = attributes.health.CurrentValue; Assert.Less(afterTicks, 100f); handler.RemoveEffect(handle); float afterRemoval = attributes.health.CurrentValue; - int ticksAfterRemoval = handler.ProcessPeriodicEffectsForTesting( + int ticksAfterRemoval = handler.ProcessPeriodicEffects( currentTime: 20.26f, deltaTime: 0.1f ); @@ -659,21 +756,15 @@ public IEnumerator PeriodicEffectCatchUpIsBoundedPerUpdate() } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 30f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 30f).Value; float overdueTime = 40f; - int firstCatchUpTicks = handler.ProcessPeriodicEffectsForTesting( - overdueTime, - deltaTime: 10f - ); + int firstCatchUpTicks = handler.ProcessPeriodicEffects(overdueTime, deltaTime: 10f); Assert.AreEqual(32, firstCatchUpTicks); Assert.AreEqual(32, attributes.notifications.Count); Assert.AreEqual(68f, attributes.health.CurrentValue, 0.01f); - int secondCatchUpTicks = handler.ProcessPeriodicEffectsForTesting( - overdueTime, - deltaTime: 0f - ); + int secondCatchUpTicks = handler.ProcessPeriodicEffects(overdueTime, deltaTime: 0f); Assert.AreEqual(32, secondCatchUpTicks); Assert.AreEqual(64, attributes.notifications.Count); Assert.AreEqual(36f, attributes.health.CurrentValue, 0.01f); @@ -734,24 +825,18 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 50f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 50f).Value; - int firstTicks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 50.021f, - deltaTime: 0.02f - ); + int firstTicks = handler.ProcessPeriodicEffects(currentTime: 50.021f, deltaTime: 0.02f); Assert.AreEqual(1, firstTicks); - int secondTicks = handler.ProcessPeriodicEffectsForTesting( + int secondTicks = handler.ProcessPeriodicEffects( currentTime: 50.051f, deltaTime: 0.03f ); Assert.AreEqual(1, secondTicks); - int finalTicks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 50.221f, - deltaTime: 0.17f - ); + int finalTicks = handler.ProcessPeriodicEffects(currentTime: 50.221f, deltaTime: 0.17f); Assert.AreEqual(3, finalTicks); Assert.AreEqual(5, attributes.notifications.Count); @@ -808,7 +893,7 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 70f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 70f).Value; Assert.IsTrue( handler.TryGetRemainingDuration( handle, @@ -848,7 +933,7 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 100f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 100f).Value; Assert.IsTrue( handler.TryGetRemainingDuration( handle, @@ -927,7 +1012,7 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 200f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 200f).Value; Assert.IsTrue( handler.TryGetRemainingDuration( handle, @@ -981,15 +1066,12 @@ TagHandler tags } ); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 300f).Value; - int ticks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 300.35f, - deltaTime: 0.35f - ); + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 300f).Value; + int ticks = handler.ProcessPeriodicEffects(currentTime: 300.35f, deltaTime: 0.35f); Assert.AreEqual(3, ticks); Assert.AreEqual(70f, attributes.health.CurrentValue, 0.01f); - int afterMaxTicks = handler.ProcessPeriodicEffectsForTesting( + int afterMaxTicks = handler.ProcessPeriodicEffects( currentTime: 300.55f, deltaTime: 0.2f ); @@ -1025,15 +1107,15 @@ TagHandler tags ); effect.behaviors.Add(behavior); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 400f).Value; + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 400f).Value; Assert.AreEqual(1, RecordingEffectBehavior.ApplyCount); - int tickCount = handler.ProcessBehaviorTicksForTesting(deltaTime: 0.033f); + int tickCount = handler.ProcessBehaviorTicks(deltaTime: 0.033f); Assert.AreEqual(1, tickCount); Assert.Greater(RecordingEffectBehavior.TickCount, 0); Assert.AreEqual(0.033f, RecordingEffectBehavior.TickContexts[0].deltaTime); - int periodicTicks = handler.ProcessPeriodicEffectsForTesting( + int periodicTicks = handler.ProcessPeriodicEffects( currentTime: 400.12f, deltaTime: 0.12f ); @@ -1111,8 +1193,8 @@ TagHandler tags ); effect.behaviors.Add(behavior); - EffectHandle handle = handler.ApplyEffectForTesting(effect, currentTime: 500f).Value; - int tickCount = handler.ProcessBehaviorTicksForTesting(deltaTime: 0.1f); + EffectHandle handle = handler.ApplyEffect(effect, currentTime: 500f).Value; + int tickCount = handler.ProcessBehaviorTicks(deltaTime: 0.1f); Assert.AreEqual(1, tickCount); Assert.AreEqual(0, RecordingEffectBehavior.PeriodicTickCount); @@ -1229,10 +1311,7 @@ void CountWarning(string condition, string stackTrace, LogType type) ); } - int ticks = handler.ProcessPeriodicEffectsForTesting( - currentTime: 600f, - deltaTime: 0.1f - ); + int ticks = handler.ProcessPeriodicEffects(currentTime: 600f, deltaTime: 0.1f); Assert.AreEqual(0, ticks); Assert.AreEqual(100f, attributes.health.CurrentValue, 0.01f); } @@ -1313,6 +1392,328 @@ TagHandler tags handler.RemoveAllEffects(); } + [TestCase(100f, 0.5f, TestName = "EffectDuration.OrdinaryClock")] + [TestCase(16777216f, 1f, TestName = "EffectDuration.LargeClock")] + [TestCase(33554432f, 2f, TestName = "EffectDuration.CollapsedDeadline")] + [TestCase(33554432f, 3f, TestName = "EffectDuration.RoundedDeadline")] + [TestCase(float.MaxValue, 1f, TestName = "EffectDuration.ExtremeClock")] + [TestCase(float.MaxValue, float.MaxValue, TestName = "EffectDuration.FiniteOverflow")] + [TestCase(100f, float.PositiveInfinity, TestName = "EffectDuration.InfinityPolicy")] + [TestCase(100f, float.NaN, TestName = "EffectDuration.NaNPolicy")] + public void DurationSurvivesApplyRefreshAndEnsureAtSameClock( + float currentTime, + float duration + ) + { + EffectHandler handler = CreateEntity().handler; + AttributeEffect effect = CreateEffect( + nameof(DurationSurvivesApplyRefreshAndEnsureAtSameClock), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = duration; + e.resetDurationOnReapplication = true; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, currentTime).Value; + Assert.IsTrue( + handler.TryGetRemainingDuration(handle, currentTime, out float remaining) + ); + Assert.That(remaining, Is.EqualTo(duration)); + Assert.IsTrue(handler.RefreshEffect(handle, false, currentTime)); + Assert.IsTrue(handler.TryGetRemainingDuration(handle, currentTime, out remaining)); + Assert.That(remaining, Is.EqualTo(duration)); + EffectHandle ensured = handler.EnsureHandle(effect, true, currentTime).Value; + Assert.AreEqual(handle, ensured); + Assert.IsTrue(handler.TryGetRemainingDuration(ensured, currentTime, out remaining)); + Assert.That(remaining, Is.EqualTo(duration)); + } + + [TestCase(false, TestName = "EffectDuration.Reapply.KeepsStart")] + [TestCase(true, TestName = "EffectDuration.Reapply.ResetsStart")] + public void ReapplicationRetainsDurationPolicyAtLargeClock(bool reset) + { + EffectHandler handler = CreateEntity().handler; + AttributeEffect effect = CreateEffect( + nameof(ReapplicationRetainsDurationPolicyAtLargeClock), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = 3f; + e.resetDurationOnReapplication = reset; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, 33554432f).Value; + EffectHandle reapplied = handler.ApplyEffect(effect, 33554436f).Value; + Assert.AreEqual(handle, reapplied); + Assert.IsTrue(handler.TryGetRemainingDuration(handle, 33554436f, out float remaining)); + Assert.That(remaining, Is.EqualTo(reset ? 3f : 0f)); + } + + [TestCase(float.Epsilon, 1f, -0.000000059604644775390625f, 1.00000011920928955078125f)] + [TestCase( + -7.888609052210118e-31f, + 1f, + 0.0000000298023223876953125f, + 0.999999940395355224609375f + )] + [TestCase(7.888609052210118e-31f, 1f, 0.0000000298023223876953125f, 1f)] + [TestCase(1.0141204801825835e31f, float.MaxValue, float.Epsilon, float.MaxValue)] + [TestCase(1.0141204801825835e31f, float.MaxValue, -float.Epsilon, float.PositiveInfinity)] + [TestCase(1.0141204801825835e31f, float.MaxValue, 0f, float.PositiveInfinity)] + public void RemainingDurationRoundsExactFloatMidpointsAndOverflow( + float startTime, + float duration, + float currentTime, + float expectedRemaining + ) + { + (_, EffectHandler handler, _, _) = CreateEntity(); + AttributeEffect effect = CreateEffect( + nameof(RemainingDurationRoundsExactFloatMidpointsAndOverflow), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = duration; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, startTime).Value; + Assert.IsTrue( + handler.TryGetRemainingDuration(handle, currentTime, out float remaining) + ); + Assert.AreEqual(expectedRemaining, remaining); + } + + [TestCase(1f, 1f, false)] + [TestCase(0f, 0f, true)] + [TestCase(-1f, 0f, true)] + public void LargeDurationRetainsSmallRemainingTimeAndExpiryResidual( + float startTime, + float expectedRemaining, + bool expired + ) + { + (_, EffectHandler handler, _, _) = CreateEntity(); + AttributeEffect effect = CreateEffect( + nameof(LargeDurationRetainsSmallRemainingTimeAndExpiryResidual), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = float.MaxValue; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, startTime).Value; + Assert.IsTrue( + handler.TryGetRemainingDuration(handle, float.MaxValue, out float remaining) + ); + Assert.AreEqual(expectedRemaining, remaining); + handler.ProcessEffectExpirations(float.MaxValue); + Assert.AreEqual( + !expired, + handler.TryGetRemainingDuration(handle, float.MaxValue, out _) + ); + } + + [TestCase(false)] + [TestCase(true)] + public void DurationRefreshPreservesOrReplacesSmallElapsedResidual(bool reset) + { + (_, EffectHandler handler, _, _) = CreateEntity(); + AttributeEffect effect = CreateEffect( + nameof(DurationRefreshPreservesOrReplacesSmallElapsedResidual), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = float.MaxValue; + e.resetDurationOnReapplication = reset; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, 1f).Value; + Assert.AreEqual(reset, handler.RefreshEffect(handle, false, -1f)); + Assert.IsTrue( + handler.TryGetRemainingDuration(handle, float.MaxValue, out float remaining) + ); + Assert.AreEqual(reset ? 0f : 1f, remaining); + handler.ProcessEffectExpirations(float.MaxValue); + Assert.AreEqual(!reset, handler.TryGetRemainingDuration(handle, float.MaxValue, out _)); + } + + [TestCase(10f, 0.5f, 10.25f, false, TestName = "EffectExpiry.Ordinary.BeforeBoundary")] + [TestCase(10f, 0.5f, 10.5f, true, TestName = "EffectExpiry.Ordinary.AtBoundary")] + [TestCase(16777216f, 1f, 16777216f, false, TestName = "EffectExpiry.Large.SameClock")] + [TestCase(16777216f, 1f, 16777218f, true, TestName = "EffectExpiry.Large.NextClock")] + [TestCase(16777216f, 2f, 16777218f, true, TestName = "EffectExpiry.Large.AtBoundary")] + [TestCase( + float.MaxValue, + float.Epsilon, + float.MaxValue, + false, + TestName = "EffectExpiry.TinyDuration" + )] + [TestCase( + float.MaxValue, + float.MaxValue, + float.MaxValue, + false, + TestName = "EffectExpiry.FiniteOverflow" + )] + [TestCase( + -float.MaxValue, + float.MaxValue, + float.MaxValue, + true, + TestName = "EffectExpiry.OppositeExtremes" + )] + [TestCase(10f, 2f, 9f, false, TestName = "EffectExpiry.BackwardClock")] + [TestCase(10f, 0f, 10f, true, TestName = "EffectExpiry.ZeroDuration")] + [TestCase(10f, -1f, 10f, true, TestName = "EffectExpiry.NegativeDuration")] + [TestCase( + 10f, + float.PositiveInfinity, + float.MaxValue, + false, + TestName = "EffectExpiry.InfinityDuration" + )] + [TestCase(10f, float.NaN, float.MaxValue, false, TestName = "EffectExpiry.NaNDuration")] + public void ExpirationPassPreservesInclusiveDurationBoundary( + float start, + float duration, + float currentTime, + bool expires + ) + { + EffectHandler handler = CreateEntity().handler; + AttributeEffect effect = CreateEffect( + nameof(ExpirationPassPreservesInclusiveDurationBoundary), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = duration; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, start).Value; + int removed = 0; + handler.OnEffectRemoved += _ => ++removed; + Assert.IsTrue( + handler.TryGetRemainingDuration(handle, currentTime, out float beforeExpiration) + ); + if (expires) + { + Assert.That(beforeExpiration, Is.EqualTo(0f)); + } + handler.ProcessEffectExpirations(currentTime); + Assert.AreEqual(expires ? 1 : 0, removed); + Assert.AreEqual(expires ? 0 : 1, handler.GetEffectStackCount(effect)); + Assert.AreEqual( + !expires, + handler.TryGetRemainingDuration(handle, currentTime, out float remaining) + ); + if (expires) + { + Assert.That(remaining, Is.EqualTo(0f)); + } + else if (!float.IsNaN(duration)) + { + double expected = (double)duration - ((double)currentTime - start); + Assert.That(remaining, Is.EqualTo((float)expected)); + } + else + { + Assert.IsTrue(float.IsNaN(remaining)); + } + handler.ProcessEffectExpirations(currentTime); + Assert.AreEqual(expires ? 1 : 0, removed); + } + + [Test] + public void DurationSnapshotAndInfinitePolicySurviveAssetChanges() + { + EffectHandler handler = CreateEntity().handler; + AttributeEffect effect = CreateEffect( + nameof(DurationSnapshotAndInfinitePolicySurviveAssetChanges), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = 2f; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, 16777216f).Value; + effect.duration = 100f; + Assert.IsTrue(handler.TryGetRemainingDuration(handle, 16777216f, out float remaining)); + Assert.That(remaining, Is.EqualTo(2f)); + handler.ProcessEffectExpirations(16777218f); + Assert.IsFalse(handler.IsEffectActive(effect)); + effect.durationType = ModifierDurationType.Infinite; + handle = handler.ApplyEffect(effect, float.MaxValue).Value; + handler.ProcessEffectExpirations(float.MaxValue); + Assert.IsTrue(handler.IsEffectActive(effect)); + Assert.IsFalse(handler.TryGetRemainingDuration(handle, float.MaxValue, out remaining)); + Assert.That(remaining, Is.EqualTo(0f)); + Assert.IsFalse(handler.RefreshEffect(handle, true, float.MaxValue)); + } + + [TestCase( + float.MaxValue, + float.MaxValue, + float.PositiveInfinity, + true, + TestName = "EffectClock.Infinity.AfterFiniteOverflow" + )] + [TestCase( + -float.MaxValue, + -float.MaxValue, + float.NegativeInfinity, + true, + TestName = "EffectClock.NegativeInfinity.AfterFiniteOverflow" + )] + [TestCase(10f, 1f, float.NaN, false, TestName = "EffectClock.NaN")] + public void NonfiniteClockRetainsExistingQueryAndExpirationPolicy( + float start, + float duration, + float currentTime, + bool expires + ) + { + EffectHandler handler = CreateEntity().handler; + AttributeEffect effect = CreateEffect( + nameof(NonfiniteClockRetainsExistingQueryAndExpirationPolicy), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = duration; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, start).Value; + Assert.IsTrue( + handler.TryGetRemainingDuration(handle, currentTime, out float remaining) + ); + Assert.IsTrue(float.IsNaN(remaining)); + handler.ProcessEffectExpirations(currentTime); + Assert.AreEqual(!expires, handler.IsEffectActive(effect)); + } + + [TestCase(false, TestName = "EffectExpiry.Refresh.RespectsPolicy")] + [TestCase(true, TestName = "EffectExpiry.Refresh.IgnoresPolicy")] + public void RefreshResetsActualExpirationOnlyWhenAllowed(bool ignorePolicy) + { + EffectHandler handler = CreateEntity().handler; + AttributeEffect effect = CreateEffect( + nameof(RefreshResetsActualExpirationOnlyWhenAllowed), + e => + { + e.durationType = ModifierDurationType.Duration; + e.duration = 4f; + e.resetDurationOnReapplication = false; + } + ); + EffectHandle handle = handler.ApplyEffect(effect, 16777216f).Value; + Assert.AreEqual(ignorePolicy, handler.RefreshEffect(handle, ignorePolicy, 16777218f)); + handler.ProcessEffectExpirations(16777220f); + Assert.AreEqual(ignorePolicy, handler.IsEffectActive(effect)); + handler.ProcessEffectExpirations(16777222f); + Assert.IsFalse(handler.IsEffectActive(effect)); + } + private CosmeticEffectData CreateCosmeticTemplate( string name, bool requiresInstance = false diff --git a/Tests/Runtime/Tags/PeriodicEffectRuntimeStateTests.cs b/Tests/Runtime/Tags/PeriodicEffectRuntimeStateTests.cs index d4dc88385..eef3ff469 100644 --- a/Tests/Runtime/Tags/PeriodicEffectRuntimeStateTests.cs +++ b/Tests/Runtime/Tags/PeriodicEffectRuntimeStateTests.cs @@ -10,6 +10,203 @@ namespace WallstopStudios.UnityHelpers.Tests.Tags [NUnit.Framework.Category("Fast")] public sealed class PeriodicEffectRuntimeStateTests { + [TestCase(100f, 0f, true)] + [TestCase(100f, 1f, false)] + [TestCase(16777216f, 0f, true)] + [TestCase(16777216f, 1f, false)] + [TestCase(float.MaxValue, 0f, true)] + [TestCase(float.MaxValue, 1f, false)] + public void RepeatedClockDoesNotAdvancePeriodicCadence( + float startTime, + float initialDelay, + bool firstTickDue + ) + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { initialDelay = initialDelay, interval = 1f }, + startTime + ); + Assert.AreEqual(firstTickDue, state.TryConsumeTick(startTime)); + Assert.IsFalse(state.TryConsumeTick(startTime)); + Assert.AreEqual(firstTickDue ? 1 : 0, state.ExecutedTicks); + } + + [Test] + public void OppositeFiniteStartAndDelayPreserveSmallElapsedTime() + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { initialDelay = float.MaxValue, interval = 1f }, + -float.MaxValue + ); + Assert.IsTrue(state.TryConsumeTick(1f)); + Assert.IsTrue(state.TryConsumeTick(1f)); + Assert.IsFalse(state.TryConsumeTick(1f)); + } + + [Test] + public void SmallPositiveStartBeforeHugeDelayRemainsNotDue() + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { initialDelay = float.MaxValue, interval = 1f }, + 1f + ); + Assert.IsFalse(state.TryConsumeTick(float.MaxValue)); + } + + [Test] + public void SmallInitialDelaySurvivesHugeElapsedIntervalComparison() + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { initialDelay = 1f, interval = float.MaxValue }, + -float.MaxValue + ); + Assert.IsTrue(state.TryConsumeTick(1f)); + Assert.IsTrue(state.TryConsumeTick(1f)); + Assert.IsFalse(state.TryConsumeTick(1f)); + Assert.IsFalse(state.TryConsumeTick(float.MaxValue)); + } + + [Test] + public void HugeIntervalRetainsSmallPositiveStartAtBoundary() + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { interval = float.MaxValue }, + 1f + ); + Assert.IsTrue(state.TryConsumeTick(1f)); + Assert.IsFalse(state.TryConsumeTick(float.MaxValue)); + } + + [Test] + public void TinyIntervalAfterHugeDelayPreservesCadenceAtUnchangedClock() + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { initialDelay = float.MaxValue, interval = 1f }, + -float.MaxValue + ); + Assert.IsTrue(state.TryConsumeTick(0f)); + Assert.IsFalse(state.TryConsumeTick(0f)); + } + + [Test] + public void DueBoundaryAndBackwardClockPreserveScheduledPhase() + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { initialDelay = 1f, interval = 1f }, + 100f + ); + Assert.IsFalse(state.TryConsumeTick(99f)); + Assert.IsFalse(state.TryConsumeTick(100f)); + Assert.IsTrue(state.TryConsumeTick(101f)); + Assert.IsFalse(state.TryConsumeTick(101f)); + Assert.IsFalse(state.TryConsumeTick(100f)); + Assert.IsTrue(state.TryConsumeTick(102f)); + Assert.IsFalse(state.TryConsumeTick(102f)); + } + + [TestCase(0f)] + [TestCase(-1f)] + [TestCase(float.NegativeInfinity)] + public void NonpositiveIntervalAndDelayKeepMinimumCadence(float authoredInterval) + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { initialDelay = -1f, interval = authoredInterval }, + 0f + ); + Assert.IsTrue(state.TryConsumeTick(0f)); + Assert.IsFalse(state.TryConsumeTick(0f)); + Assert.IsTrue(state.TryConsumeTick(0.01f)); + Assert.IsFalse(state.TryConsumeTick(0.01f)); + } + + [Test] + public void FiniteCadenceContinuesAcrossFloatDeadlineOverflow() + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { interval = float.MaxValue }, + -float.MaxValue + ); + Assert.IsTrue(state.TryConsumeTick(-float.MaxValue)); + Assert.IsFalse(state.TryConsumeTick(-float.MaxValue)); + Assert.IsTrue(state.TryConsumeTick(0f)); + Assert.IsFalse(state.TryConsumeTick(0f)); + Assert.IsTrue(state.TryConsumeTick(float.MaxValue)); + Assert.IsFalse(state.TryConsumeTick(float.MaxValue)); + Assert.AreEqual(3, state.ExecutedTicks); + } + + [TestCase(float.NaN, 0f, 1f, 100f, true, true)] + [TestCase(100f, 0f, 1f, float.NaN, true, true)] + [TestCase(100f, float.NaN, 1f, 100f, true, true)] + [TestCase(100f, float.PositiveInfinity, 1f, 100f, false, false)] + [TestCase(100f, 0f, float.NaN, 100f, true, true)] + [TestCase(100f, 0f, float.PositiveInfinity, 100f, true, false)] + [TestCase(100f, 0f, float.PositiveInfinity, float.PositiveInfinity, true, true)] + [TestCase(float.PositiveInfinity, 0f, 1f, float.PositiveInfinity, true, true)] + [TestCase(float.NegativeInfinity, 0f, 1f, float.NegativeInfinity, true, true)] + public void NonfiniteTimingPreservesTickComparisonPolicy( + float startTime, + float delay, + float authoredInterval, + float now, + bool firstDue, + bool secondDue + ) + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { initialDelay = delay, interval = authoredInterval }, + startTime + ); + Assert.AreEqual(firstDue, state.TryConsumeTick(now)); + Assert.AreEqual(secondDue, state.TryConsumeTick(now)); + } + + [Test] + public void PublicTickCounterWrapDoesNotResetScheduledCadence() + { + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { interval = 1f }, + 0f + ); + state.ExecutedTicks = int.MaxValue; + state._scheduleOrdinal = int.MaxValue; + Assert.IsTrue(state.TryConsumeTick(2147483648f)); + Assert.AreEqual(int.MinValue, state.ExecutedTicks); + Assert.IsTrue(state.TryConsumeTick(2147483648f)); + Assert.AreEqual(int.MinValue + 1, state.ExecutedTicks); + Assert.IsFalse(state.TryConsumeTick(2147483648f)); + } + + [Test] + public void LargeOrdinalProductRemainderKeepsFutureTickNotDue() + { + const float StartTime = 158456325028528675187087900672f; + PeriodicEffectRuntimeState state = new( + new PeriodicEffectDefinition { interval = float.MaxValue / 2147483648f }, + StartTime + ); + state.ExecutedTicks = int.MaxValue; + state._scheduleOrdinal = int.MaxValue; + Assert.IsFalse(state.TryConsumeTick(float.MaxValue)); + Assert.AreEqual(int.MaxValue, state.ExecutedTicks); + } + + [Test] + public void TickLimitChangesRemainLiveWithoutResettingPhase() + { + PeriodicEffectDefinition definition = new() { interval = 1f, maxTicks = 1 }; + PeriodicEffectRuntimeState state = new(definition, 100f); + Assert.IsTrue(state.TryConsumeTick(100f)); + Assert.IsTrue(state.IsComplete); + definition.maxTicks = 2; + Assert.IsFalse(state.IsComplete); + Assert.IsFalse(state.TryConsumeTick(100f)); + Assert.IsTrue(state.TryConsumeTick(101f)); + Assert.IsTrue(state.IsComplete); + definition.maxTicks = 0; + Assert.IsTrue(state.TryConsumeTick(102f)); + } + [Test] public void TryConsumeTickCatchesUpUsingScheduledIntervalsAfterLongFrame() { diff --git a/Tests/Runtime/Utils/BuffersTests.cs b/Tests/Runtime/Utils/BuffersTests.cs index 9433bb115..088498da4 100644 --- a/Tests/Runtime/Utils/BuffersTests.cs +++ b/Tests/Runtime/Utils/BuffersTests.cs @@ -137,7 +137,7 @@ out Exception error public void SetUp() { PoolPurgeSettings.ResetToDefaults(); - _waitInstructionScope = Buffers.BeginWaitInstructionTestScope(); + _waitInstructionScope = new WaitInstructionCacheScope(); } [TearDown] @@ -663,7 +663,7 @@ out int[] array public void WallstopFastArrayPoolLifoOrderingSingleThread() { // Clear the pool first to ensure test isolation - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); const int arraySize = 15; const int arrayCount = 5; @@ -731,7 +731,7 @@ out int[] array public void WallstopFastArrayPoolLifoOrderingParameterized(int arraySize, int arrayCount) { // Clear the pool first to ensure test isolation - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); int[][] allocatedArrays = new int[arrayCount][]; List> pooledArrays = new(); @@ -787,7 +787,7 @@ out int[] array } [Test] - public void WallstopFastArrayPoolClearForTestingClearsAllBuckets() + public void WallstopFastArrayPoolClearAllClearsAllBuckets() { int[] testSizes = { 5, 10, 20, 50, 100 }; Dictionary originalArrays = new(); @@ -800,7 +800,7 @@ public void WallstopFastArrayPoolClearForTestingClearsAllBuckets() pooled.Dispose(); } - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); foreach (int size in testSizes) { @@ -812,7 +812,7 @@ out int[] array Assert.AreNotSame( originalArrays.ValueFor(size), array, - $"After ClearForTesting, size {size} should return a new array, not the original. " + $"After Pool.ClearAll, size {size} should return a new array, not the original. " + $"Original hash: {RuntimeHelpers.GetHashCode(originalArrays.ValueFor(size))}, " + $"New hash: {RuntimeHelpers.GetHashCode(array)}" ); @@ -826,17 +826,17 @@ out int[] array } [Test] - public void WallstopFastArrayPoolClearForTestingOnEmptyPoolDoesNotThrow() + public void WallstopFastArrayPoolClearAllOnEmptyPoolDoesNotThrow() { // Use a type that hasn't been used before to ensure empty pool Assert.DoesNotThrow( - () => WallstopFastArrayPool.ClearForTesting(), - "ClearForTesting should not throw on an empty pool" + () => WallstopFastArrayPool.Pool.ClearAll(), + "Pool.ClearAll should not throw on an empty pool" ); Assert.DoesNotThrow( - () => WallstopFastArrayPool.ClearForTesting(), - "ClearForTesting should be idempotent" + () => WallstopFastArrayPool.Pool.ClearAll(), + "Pool.ClearAll should be idempotent" ); } @@ -2483,11 +2483,11 @@ public void SystemArrayPoolTryWithBufferPublicOverloadsDoNotAcquireDisposalLease DisposalLease control = default; try { - int slot = probe.SlotForTests; + int slot = probe._slot; Assert.That(probe.TryClaim(), Is.True); long beforeControl = DisposalLeases.CurrentGeneration(slot); control = DisposalLeases.Acquire(); - Assert.That(control.SlotForTests, Is.EqualTo(slot)); + Assert.That(control._slot, Is.EqualTo(slot)); Assert.That(control.TryClaim(), Is.True); Assert.That(DisposalLeases.CurrentGeneration(slot), Is.EqualTo(beforeControl + 2)); long before = DisposalLeases.CurrentGeneration(slot); @@ -2529,11 +2529,11 @@ public void SystemArrayPoolPrivateOwnerRentAndReturnDoNotAcquireDisposalLeases() int[] buffer = null; try { - int slot = probe.SlotForTests; + int slot = probe._slot; Assert.That(probe.TryClaim(), Is.True); long beforeControl = DisposalLeases.CurrentGeneration(slot); control = DisposalLeases.Acquire(); - Assert.That(control.SlotForTests, Is.EqualTo(slot)); + Assert.That(control._slot, Is.EqualTo(slot)); Assert.That(control.TryClaim(), Is.True); Assert.That(DisposalLeases.CurrentGeneration(slot), Is.EqualTo(beforeControl + 2)); long before = DisposalLeases.CurrentGeneration(slot); @@ -2778,7 +2778,7 @@ public void SystemArrayPoolVariousSizesWorkCorrectly() [Test] public void WallstopFastArrayPoolDoubleDisposeIsNoOp() { - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); const int size = 10; PooledArray pooled = WallstopFastArrayPool.Get(size, out int[] firstArray); @@ -2812,7 +2812,7 @@ out int[] thirdArray [Test] public void WallstopFastArrayPoolLifoWithInterleavedSizes() { - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); const int sizeA = 10; const int sizeB = 20; @@ -2867,9 +2867,9 @@ public void WallstopFastArrayPoolLifoWithInterleavedSizes() } [Test] - public void WallstopFastArrayPoolClearForTestingDuringActiveRentals() + public void WallstopFastArrayPoolClearAllDuringActiveRentals() { - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); const int size = 15; @@ -2884,7 +2884,7 @@ out int[] activeArray returned1.Dispose(); returned2.Dispose(); - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); Assert.AreEqual( 999, @@ -2896,7 +2896,7 @@ out int[] activeArray Assert.AreNotSame( arr1, newArray, - "After ClearForTesting, should get fresh array, not previously pooled one" + "After Pool.ClearAll, should get fresh array, not previously pooled one" ); Assert.AreNotSame(arr2, newArray); @@ -2909,7 +2909,7 @@ out int[] activeArray [TestCase(100, TestName = "WallstopFastArrayPoolLifoSingleElementSize100")] public void WallstopFastArrayPoolLifoSingleElement(int arraySize) { - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); PooledArray pooled = WallstopFastArrayPool.Get(arraySize, out int[] original); original[0] = 12345; @@ -2927,8 +2927,8 @@ out int[] retrieved [Test] public void WallstopFastArrayPoolIsolationBetweenTypes() { - WallstopFastArrayPool.ClearForTesting(); - WallstopFastArrayPool.ClearForTesting(); + WallstopFastArrayPool.Pool.ClearAll(); + WallstopFastArrayPool.Pool.ClearAll(); const int size = 10; diff --git a/Tests/Runtime/Utils/BuffersWaitInstructionTests.cs b/Tests/Runtime/Utils/BuffersWaitInstructionTests.cs index 06b0f5622..f5b93b5f6 100644 --- a/Tests/Runtime/Utils/BuffersWaitInstructionTests.cs +++ b/Tests/Runtime/Utils/BuffersWaitInstructionTests.cs @@ -17,7 +17,7 @@ public sealed class BuffersWaitInstructionTests [SetUp] public void SetUp() { - waitInstructionScope = Buffers.BeginWaitInstructionTestScope(); + waitInstructionScope = new WaitInstructionCacheScope(); } [TearDown] @@ -1152,7 +1152,7 @@ public void TestScopeRestoresPreviousState() WaitForSeconds outerInstance = Buffers.GetWaitForSeconds(0.5f); Assert.NotNull(outerInstance); - using (IDisposable innerScope = Buffers.BeginWaitInstructionTestScope()) + using (IDisposable innerScope = new WaitInstructionCacheScope()) { Assert.AreEqual(0, Buffers.WaitForSecondsCacheDiagnostics.DistinctEntries); Assert.AreEqual( diff --git a/Tests/Runtime/Utils/ChildSpawnerTests.cs b/Tests/Runtime/Utils/ChildSpawnerTests.cs index d233cebef..1e1bd7599 100644 --- a/Tests/Runtime/Utils/ChildSpawnerTests.cs +++ b/Tests/Runtime/Utils/ChildSpawnerTests.cs @@ -27,13 +27,13 @@ private static void InvokeStartOnce(ChildSpawner childSpawner) public override void BaseSetUp() { base.BaseSetUp(); - ChildSpawner.ClearSpawnedPrefabsForTesting(); + ChildSpawner.ClearSpawnedPrefabs(); } [TearDown] public override void TearDown() { - ChildSpawner.ClearSpawnedPrefabsForTesting(); + ChildSpawner.ClearSpawnedPrefabs(); base.TearDown(); } diff --git a/Tests/Runtime/Utils/ComparerPoolCacheTests.cs b/Tests/Runtime/Utils/ComparerPoolCacheTests.cs index 330a04cd0..83bc9ceba 100644 --- a/Tests/Runtime/Utils/ComparerPoolCacheTests.cs +++ b/Tests/Runtime/Utils/ComparerPoolCacheTests.cs @@ -32,12 +32,18 @@ public sealed class ComparerPoolCacheTests // Reset closed-generic caches so retained domains cannot carry counts into the next run. private static void DropProbePools() { - SetBuffers.ClearPoolsForTesting(); - SetBuffers.ClearPoolsForTesting(); - SetBuffers.ClearPoolsForTesting(); - SetBuffers.ClearPoolsForTesting(); - SetBuffers.ClearPoolsForTesting(); - DictionaryBuffer.ClearPoolsForTesting(); + SetBuffers.HashSetCache.Clear(); + SetBuffers.SortedSetCache.Clear(); + SetBuffers.HashSetCache.Clear(); + SetBuffers.SortedSetCache.Clear(); + SetBuffers.HashSetCache.Clear(); + SetBuffers.SortedSetCache.Clear(); + SetBuffers.HashSetCache.Clear(); + SetBuffers.SortedSetCache.Clear(); + SetBuffers.HashSetCache.Clear(); + SetBuffers.SortedSetCache.Clear(); + DictionaryBuffer.DictionaryCache.Clear(); + DictionaryBuffer.SortedDictionaryCache.Clear(); } [SetUp] diff --git a/Tests/Runtime/Utils/DisposalLeaseTests.cs b/Tests/Runtime/Utils/DisposalLeaseTests.cs index a35b86ba4..0df8e9fab 100644 --- a/Tests/Runtime/Utils/DisposalLeaseTests.cs +++ b/Tests/Runtime/Utils/DisposalLeaseTests.cs @@ -190,7 +190,7 @@ public void ConcurrentClaimsOfOneLeaseElectOneWinner() Thread.SpinWait(1); } - if (lease.TryClaimWithoutRelease()) + if (DisposalLeases.TryClaim(lease._slot, lease._generation)) { Interlocked.Increment(ref winners); } @@ -203,7 +203,7 @@ public void ConcurrentClaimsOfOneLeaseElectOneWinner() Task.WaitAll(racers); totalWinners += Volatile.Read(ref winners); - DisposalLeases.Release(lease.SlotForTests, lease.OwnerThreadIdForTests); + DisposalLeases.Release(lease._slot, lease._ownerThreadId); } Assert.That( @@ -232,7 +232,7 @@ public void ConcurrentAcquireAndClaimNeverHandsOneSlotToTwoOwners() for (int i = 0; i < PerThread; ++i) { DisposalLease lease = DisposalLeases.Acquire(); - int slot = lease.SlotForTests; + int slot = lease._slot; // Claim counts alone cannot detect simultaneous ownership of one slot. if (!liveSlots.TryAdd(slot, id)) @@ -242,17 +242,17 @@ public void ConcurrentAcquireAndClaimNeverHandsOneSlotToTwoOwners() DisposalLease copy = lease; int wins = 0; - if (lease.TryClaimWithoutRelease()) + if (DisposalLeases.TryClaim(lease._slot, lease._generation)) { ++wins; } - if (copy.TryClaimWithoutRelease()) + if (DisposalLeases.TryClaim(copy._slot, copy._generation)) { ++wins; } liveSlots.TryRemove(slot, out int _); - DisposalLeases.Release(slot, lease.OwnerThreadIdForTests); + DisposalLeases.Release(slot, lease._ownerThreadId); if (wins == 1) { diff --git a/Tests/Runtime/Utils/RuntimeSingletonTests.cs b/Tests/Runtime/Utils/RuntimeSingletonTests.cs index 66c976330..899a9e787 100644 --- a/Tests/Runtime/Utils/RuntimeSingletonTests.cs +++ b/Tests/Runtime/Utils/RuntimeSingletonTests.cs @@ -33,7 +33,7 @@ public sealed class RuntimeSingletonTests : CommonTestBase public override void BaseSetUp() { base.BaseSetUp(); - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); DestroyAll(); DestroyAll(); DestroyAll(); @@ -113,7 +113,7 @@ public void InstanceReturnsNonNull() [Test] public void ApplicationShutdownRefusesCreationButKeepsAnExistingInstanceAvailable() { - RuntimeSingletonRegistry.NotifyApplicationQuittingForTesting(); + RuntimeStateTestUtilities.SimulateApplicationQuitting(); try { Assert.IsTrue(TestRuntimeSingleton.Instance == null); @@ -122,16 +122,16 @@ public void ApplicationShutdownRefusesCreationButKeepsAnExistingInstanceAvailabl UnityObjectExtensions.FindObjectsOfTypeShim(true).Length ); - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); TestRuntimeSingleton created = TestRuntimeSingleton.Instance; Track(created.gameObject); - RuntimeSingletonRegistry.NotifyApplicationQuittingForTesting(); + RuntimeStateTestUtilities.SimulateApplicationQuitting(); Assert.AreSame(created, TestRuntimeSingleton.Instance); } finally { - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); } } @@ -157,10 +157,10 @@ public void ASubclassUsingAnotherSingletonsGenericBaseCannotEnterItsCache() [Test] public void ReturningToEditModeClearsTheShutdownGuardWithoutADomainReload() { - RuntimeSingletonRegistry.NotifyApplicationQuittingForTesting(); + RuntimeStateTestUtilities.SimulateApplicationQuitting(); Assert.IsTrue(TestRuntimeSingleton.Instance == null); - RuntimeSingletonRegistry.NotifyReturnedToEditModeForTesting(); + RuntimeStateTestUtilities.ReturnToEditMode(); TestRuntimeSingleton instance = TestRuntimeSingleton.Instance; Track(instance.gameObject); @@ -173,7 +173,7 @@ public void RegistryDescribesLiveSingletonInstancesBeforeClear() TestRuntimeSingleton instance = TestRuntimeSingleton.Instance; Track(instance.gameObject); - string description = RuntimeSingletonRegistry.DescribeLiveInstancesForTesting(); + string description = RuntimeStateTestUtilities.DescribeLiveSingletons(); StringAssert.Contains(nameof(TestRuntimeSingleton), description); StringAssert.Contains(instance.name, description); @@ -1318,7 +1318,7 @@ public void ShutdownAfterCacheResetStillFindsAnAuthoredInstance() TestRuntimeSingleton authored = authoredObject.AddComponent(); RuntimeSingletonRegistry.ResetAllRegisteredCaches(); - RuntimeSingletonRegistry.NotifyApplicationQuittingForTesting(); + RuntimeStateTestUtilities.SimulateApplicationQuitting(); try { TestRuntimeSingleton resolved = TestRuntimeSingleton.Instance; @@ -1331,7 +1331,7 @@ public void ShutdownAfterCacheResetStillFindsAnAuthoredInstance() } finally { - RuntimeSingletonRegistry.PrepareForSceneLoadForTesting(); + RuntimeStateTestUtilities.PrepareForSceneLoad(); } } diff --git a/Tests/Runtime/Utils/TextureScaleTests.cs b/Tests/Runtime/Utils/TextureScaleTests.cs index 6e36decb5..661a25c18 100644 --- a/Tests/Runtime/Utils/TextureScaleTests.cs +++ b/Tests/Runtime/Utils/TextureScaleTests.cs @@ -4,9 +4,10 @@ namespace WallstopStudios.UnityHelpers.Tests.Utils { using System; - using System.Threading; + using System.Text.RegularExpressions; using NUnit.Framework; using UnityEngine; + using UnityEngine.TestTools; using WallstopStudios.UnityHelpers.Tests.Core; using WallstopStudios.UnityHelpers.Utils; @@ -87,7 +88,6 @@ public void SetUp() [TearDown] public override void TearDown() { - TextureScale.SliceStartedForTesting = null; if (_textureHelper != null) { _textureHelper.Dispose(); @@ -96,61 +96,20 @@ public override void TearDown() base.TearDown(); } - [TestCase(true, 8, true)] - [TestCase(true, 1, false)] - [TestCase(false, 8, true)] - [TestCase(false, 1, false)] - public void ScaleReportsASliceFailureWhicheverBranchRanIt( - bool useBilinear, - int newHeight, - bool parallel - ) + [TestCase(true, 2)] + [TestCase(true, 1)] + [TestCase(false, 2)] + [TestCase(false, 1)] + public void ScaleReportsActualSliceFailureWhicheverBranchRanIt(bool useBilinear, int cores) { - Texture2D texture = _textureHelper.CreateTextureWithFactory( - 8, - 8, - (x, y) => new Color(x / 8f, y / 8f, 0f, 1f) - ); - InvalidOperationException injected = new("the slice could not run"); - - /* - Use measured slice counts; single-core execution cannot prove the parallel failure path. Row - zero belongs to a worker when multiple slices run. - */ - int sliceStarts = 0; - TextureScale.SliceStartedForTesting = start => - { - Interlocked.Increment(ref sliceStarts); - if (start == 0) - { - throw injected; - } - }; - - InvalidOperationException thrown = Assert.Throws(() => - InvokeScale(texture, 4, newHeight, useBilinear) - ); - - int observedSlices = Volatile.Read(ref sliceStarts); - if (parallel && observedSlices <= 1) + Color[] source = new Color[64]; + Color[] destination = new Color[4]; + if (1 < cores) { - Assert.Ignore( - "This runner ran one slice, so there is no background slice to fail and a " - + "pass here would be the absence of a measurement." - ); + LogAssert.Expect(LogType.Exception, new Regex(nameof(IndexOutOfRangeException))); } - - Assert.AreEqual( - parallel, - 1 < observedSlices, - $"Expected the {(parallel ? "parallel" : "single-threaded")} branch, and " - + $"{observedSlices} slice(s) ran." - ); - Assert.AreSame( - injected, - thrown, - "a slice that failed on a worker must reach the caller as the failure it was, not " - + "as a silently partial image" + Assert.Throws(() => + TextureScale.ScalePixels(source, destination, 8, 8, 4, 8, useBilinear, cores) ); } diff --git a/Tests/Runtime/Utils/WaitInstructionCacheScope.cs b/Tests/Runtime/Utils/WaitInstructionCacheScope.cs new file mode 100644 index 000000000..047c8a986 --- /dev/null +++ b/Tests/Runtime/Utils/WaitInstructionCacheScope.cs @@ -0,0 +1,181 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Utils +{ + using System; + using System.Collections.Generic; + using System.Threading; + using UnityEngine; + using static WallstopStudios.UnityHelpers.Utils.Buffers; + using Buffers = WallstopStudios.UnityHelpers.Utils.Buffers; + + internal sealed class WaitInstructionCacheScope : IDisposable + { + private readonly WaitInstructionCacheSnapshot _waitForSecondsSnapshot; + private readonly WaitInstructionCacheSnapshot _waitForSecondsRealtimeSnapshot; + private readonly float _quantizationStepSnapshot; + private readonly int _maxDistinctEntriesSnapshot; + private readonly bool _useLruSnapshot; + private readonly int _waitForSecondsLimitHitsSnapshot; + private readonly int _waitForSecondsRealtimeLimitHitsSnapshot; + private readonly int _waitForSecondsEvictionsSnapshot; + private readonly int _waitForSecondsRealtimeEvictionsSnapshot; + private bool _disposed; + + internal WaitInstructionCacheScope() + { + _waitForSecondsSnapshot = SnapshotCache(Buffers.WaitForSeconds, WaitForSecondsOrder); + _waitForSecondsRealtimeSnapshot = SnapshotCache( + Buffers.WaitForSecondsRealtime, + WaitForSecondsRealtimeOrder + ); + _quantizationStepSnapshot = WaitInstructionQuantizationStepSeconds; + _maxDistinctEntriesSnapshot = WaitInstructionMaxDistinctEntries; + _useLruSnapshot = WaitInstructionUseLruEviction; + _waitForSecondsLimitHitsSnapshot = Volatile.Read(ref _waitForSecondsLimitHits); + _waitForSecondsRealtimeLimitHitsSnapshot = Volatile.Read( + ref _waitForSecondsRealtimeLimitHits + ); + _waitForSecondsEvictionsSnapshot = Volatile.Read(ref _waitForSecondsEvictions); + _waitForSecondsRealtimeEvictionsSnapshot = Volatile.Read( + ref _waitForSecondsRealtimeEvictions + ); + + Reset(); + } + + internal static void Reset() + { +#if !SINGLE_THREADED + lock (WaitInstructionCacheLock) + { +#endif + Buffers.WaitForSeconds.Clear(); + Buffers.WaitForSecondsRealtime.Clear(); + WaitForSecondsOrder.Clear(); + WaitForSecondsRealtimeOrder.Clear(); +#if !SINGLE_THREADED + } +#endif + WaitInstructionQuantizationStepSeconds = 0f; + WaitInstructionMaxDistinctEntries = WaitInstructionDefaultMaxDistinctEntries; + WaitInstructionUseLruEviction = false; + Volatile.Write(ref _waitForSecondsLimitHits, 0); + Volatile.Write(ref _waitForSecondsRealtimeLimitHits, 0); + Volatile.Write(ref _waitForSecondsEvictions, 0); + Volatile.Write(ref _waitForSecondsRealtimeEvictions, 0); + } + + private static WaitInstructionCacheSnapshot SnapshotCache( + Dictionary> cache, + LinkedList order + ) + where TInstruction : class + { +#if !SINGLE_THREADED + lock (WaitInstructionCacheLock) + { +#endif + Dictionary entries = new(cache.Count); + foreach (KeyValuePair> pair in cache) + { + entries[pair.Key] = pair.Value._value; + } + + List ordering = new(order); + return new WaitInstructionCacheSnapshot(entries, ordering); +#if !SINGLE_THREADED + } +#endif + } + + private static void RestoreCache( + Dictionary> cache, + LinkedList order, + WaitInstructionCacheSnapshot snapshot + ) + where TInstruction : class + { +#if !SINGLE_THREADED + lock (WaitInstructionCacheLock) + { +#endif + cache.Clear(); + order.Clear(); + + if (snapshot.Order == null || snapshot.Entries == null) + { + return; + } + + Dictionary> nodes = new(snapshot.Order.Count); + foreach (float key in snapshot.Order) + { + LinkedListNode node = order.AddLast(key); + nodes[key] = node; + } + + foreach (KeyValuePair pair in snapshot.Entries) + { + if (!nodes.TryGetValue(pair.Key, out LinkedListNode node)) + { + node = order.AddLast(pair.Key); + nodes[pair.Key] = node; + } + + cache[pair.Key] = new WaitInstructionCacheEntry(pair.Value, node); + } +#if !SINGLE_THREADED + } +#endif + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + WaitInstructionQuantizationStepSeconds = _quantizationStepSnapshot; + WaitInstructionMaxDistinctEntries = _maxDistinctEntriesSnapshot; + WaitInstructionUseLruEviction = _useLruSnapshot; + Volatile.Write(ref _waitForSecondsLimitHits, _waitForSecondsLimitHitsSnapshot); + Volatile.Write( + ref _waitForSecondsRealtimeLimitHits, + _waitForSecondsRealtimeLimitHitsSnapshot + ); + Volatile.Write(ref _waitForSecondsEvictions, _waitForSecondsEvictionsSnapshot); + Volatile.Write( + ref _waitForSecondsRealtimeEvictions, + _waitForSecondsRealtimeEvictionsSnapshot + ); + + RestoreCache(Buffers.WaitForSeconds, WaitForSecondsOrder, _waitForSecondsSnapshot); + RestoreCache( + Buffers.WaitForSecondsRealtime, + WaitForSecondsRealtimeOrder, + _waitForSecondsRealtimeSnapshot + ); + } + + private readonly struct WaitInstructionCacheSnapshot + where TInstruction : class + { + internal Dictionary Entries { get; } + + internal List Order { get; } + + internal WaitInstructionCacheSnapshot( + Dictionary entries, + List order + ) + { + Entries = entries; + Order = order; + } + } + } +} diff --git a/Tests/Runtime/Utils/WaitInstructionCacheScope.cs.meta b/Tests/Runtime/Utils/WaitInstructionCacheScope.cs.meta new file mode 100644 index 000000000..9133a336f --- /dev/null +++ b/Tests/Runtime/Utils/WaitInstructionCacheScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a268e7a0086f8e13748ae90bbbbe5508 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Visuals/EnhancedImageTests.cs b/Tests/Runtime/Visuals/EnhancedImageTests.cs index 511680873..a6894d206 100644 --- a/Tests/Runtime/Visuals/EnhancedImageTests.cs +++ b/Tests/Runtime/Visuals/EnhancedImageTests.cs @@ -55,7 +55,7 @@ public void StartCreatesMaterialInstanceAndAppliesHdrColor() Color expectedHdrColor = new Color(0.2f, 0.4f, 0.6f, 0.8f); image.HdrColor = expectedHdrColor; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -70,7 +70,7 @@ public void StartCreatesMaterialInstanceAndAppliesHdrColor() public void HdrColorAboveSdrOverridesMaterialColor() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color hdr = new(2f, 0.5f, 0.25f, 1f); image.HdrColor = hdr; @@ -88,7 +88,7 @@ public void ShapeMaskAssignmentWritesTextureToMaterial() image._shapeMask = mask; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); /* The support shader may be stripped in players; assert shape-mask writes only when its property @@ -107,14 +107,14 @@ public void ShapeMaskAssignmentWritesTextureToMaterial() public void OnDestroyReleasesCachedMaterialInstance() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cachedBefore = image.CachedMaterialInstanceForTests; + Material cachedBefore = image._cachedMaterialInstance; Assert.IsTrue(cachedBefore != null); - image.InvokeOnDestroyForTests(); + image.CleanupMaterialInstance(); - Material cachedAfter = image.CachedMaterialInstanceForTests; + Material cachedAfter = image._cachedMaterialInstance; Assert.IsTrue(cachedAfter == null); Assert.That(cachedBefore == null, Is.True); } @@ -125,9 +125,9 @@ public void UpdateHandlesNullMaterialGracefully() EnhancedImage image = CreateEnhancedImage(out _); image.material = null; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Assert.IsTrue( cached == null, "Expected no instance to be created when material is null." @@ -142,7 +142,7 @@ public void HdrColorWithinSdrRangeIsStillApplied() Color sdrHdrColor = new Color(0.4f, 0.4f, 0.4f, 0.4f); image.HdrColor = sdrHdrColor; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -157,7 +157,7 @@ public void HdrColorWithinSdrRangeIsStillApplied() public void MaterialInstanceIsReusedAcrossUpdates() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material first = image.material; Assert.IsTrue(first != null); @@ -174,11 +174,11 @@ public void MaterialInstanceIsReusedAcrossUpdates() public void StartDoesNotDuplicateExistingInstance() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material first = image.material; Assert.IsTrue(first != null); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material second = image.material; Assert.That( second, @@ -191,7 +191,7 @@ public void StartDoesNotDuplicateExistingInstance() public void HdrColorUpdateAfterStartActuallyUpdatesTheMaterialColor() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color initialHdr = new(1.5f, 0.8f, 0.4f, 1f); image.HdrColor = initialHdr; @@ -218,7 +218,7 @@ public void HdrColorUpdateAfterStartActuallyUpdatesTheMaterialColor() public void MultipleConsecutiveHdrColorChangesAllReflectInMaterial() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color[] hdrColors = new Color[] { @@ -250,7 +250,7 @@ public void TransitionFromHdrToSdrRangeStillUsesHdrColor() { EnhancedImage image = CreateEnhancedImage(out _); image.color = new Color(0.3f, 0.4f, 0.5f, 1f); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color hdrColor = new(2.0f, 1.5f, 1.2f, 1f); image.HdrColor = hdrColor; @@ -279,7 +279,7 @@ public void TransitionFromSdrToHdrRangeUpdatesHdrColor() image.color = new Color(0.2f, 0.3f, 0.4f, 1f); Color initialSdrColor = new Color(0.5f, 0.5f, 0.5f, 1f); image.HdrColor = initialSdrColor; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -305,7 +305,7 @@ public void HdrColorAtExactlyOneStillUsesHdrColor() image.color = new Color(0.1f, 0.2f, 0.3f, 1f); Color hdrColorExactlyOne = new Color(1f, 1f, 1f, 1f); image.HdrColor = hdrColorExactlyOne; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -324,7 +324,7 @@ public void HdrColorSlightlyAboveOneUsesHdrColor() image.color = new Color(0.1f, 0.2f, 0.3f, 1f); Color hdrColor = new(1.001f, 0.5f, 0.5f, 1f); image.HdrColor = hdrColor; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -340,7 +340,7 @@ public void HdrColorSlightlyAboveOneUsesHdrColor() public void HdrColorWithZeroAlphaStillUpdatesCorrectly() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color hdrWithZeroAlpha = new(2f, 1.5f, 1f, 0f); image.HdrColor = hdrWithZeroAlpha; @@ -359,7 +359,7 @@ public void HdrColorWithZeroAlphaStillUpdatesCorrectly() public void HdrColorWithVeryHighIntensityUpdatesCorrectly() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color extremeHdr = new(100f, 50f, 25f, 1f); image.HdrColor = extremeHdr; @@ -379,7 +379,7 @@ public void HdrColorWithNegativeValuesDoesNotThrowAndStoresValueCorrectly() { EnhancedImage image = CreateEnhancedImage(out _); image.color = new Color(0.5f, 0.5f, 0.5f, 1f); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color negativeColor = new(-0.5f, -0.2f, 0.1f, 1f); @@ -426,7 +426,7 @@ public void HdrColorWithNegativeValuesDoesNotThrowAndStoresValueCorrectly() public void SettingSameHdrColorDoesNotTriggerUpdate() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color hdrColor = new(2f, 1.5f, 1f, 1f); image.HdrColor = hdrColor; @@ -448,9 +448,9 @@ public void SettingSameHdrColorDoesNotTriggerUpdate() public void MaterialInstancePreservedAcrossManyRapidHdrColorChanges() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material originalInstance = image.CachedMaterialInstanceForTests; + Material originalInstance = image._cachedMaterialInstance; Assert.IsTrue(originalInstance != null); for (int i = 0; i < 100; ++i) @@ -459,7 +459,7 @@ public void MaterialInstancePreservedAcrossManyRapidHdrColorChanges() image.HdrColor = new Color(intensity, 0.5f, 0.3f, 1f); } - Material finalInstance = image.CachedMaterialInstanceForTests; + Material finalInstance = image._cachedMaterialInstance; Assert.That( finalInstance, Is.SameAs(originalInstance), @@ -471,7 +471,7 @@ public void MaterialInstancePreservedAcrossManyRapidHdrColorChanges() public void HdrColorPropertyGetReturnsCurrentValue() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color expected = new(3f, 2f, 1f, 0.8f); image.HdrColor = expected; @@ -501,7 +501,7 @@ public void ChangingHdrColorBeforeStartStillAppliesCorrectlyOnStart() Color hdrColor = new(2.5f, 1.8f, 0.9f, 1f); image.HdrColor = hdrColor; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -517,7 +517,7 @@ public void ChangingHdrColorBeforeStartStillAppliesCorrectlyOnStart() public void GraphicColorChangeDoesNotAffectMaterialWhenHdrIsActive() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color hdrColor = new(2f, 1.5f, 1f, 1f); image.HdrColor = hdrColor; @@ -540,11 +540,11 @@ public void MultipleEnhancedImagesHaveIndependentMaterialInstances() EnhancedImage image1 = CreateEnhancedImage(out Material baseMaterial1); EnhancedImage image2 = CreateEnhancedImageWithMaterial(baseMaterial1); - image1.InvokeStartForTests(); - image2.InvokeStartForTests(); + image1.UpdateMaterialInstance(); + image2.UpdateMaterialInstance(); - Material cached1 = image1.CachedMaterialInstanceForTests; - Material cached2 = image2.CachedMaterialInstanceForTests; + Material cached1 = image1._cachedMaterialInstance; + Material cached2 = image2._cachedMaterialInstance; Assert.IsTrue(cached1 != null); Assert.IsTrue(cached2 != null); @@ -575,7 +575,7 @@ public void HdrColorChangeDoesNotAffectBaseMaterial() EnhancedImage image = CreateEnhancedImage(out Material baseMaterial); Color originalBaseColor = baseMaterial.GetColor("_Color"); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); image.HdrColor = new Color(5f, 3f, 2f, 1f); @@ -592,7 +592,7 @@ public void AlternatingBetweenHdrAndSdrMultipleTimes() { EnhancedImage image = CreateEnhancedImage(out _); image.color = new Color(0.2f, 0.3f, 0.4f, 1f); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -621,7 +621,7 @@ public void HdrColorWithOnlyAlphaAboveOneUsesHdrColor() EnhancedImage image = CreateEnhancedImage(out _); Color hdrAlphaOnly = new(0.5f, 0.5f, 0.5f, 1.5f); image.HdrColor = hdrAlphaOnly; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -637,7 +637,7 @@ public void HdrColorWithOnlyAlphaAboveOneUsesHdrColor() public void ShapeMaskCanBeChangedAfterStart() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Texture2D mask1 = Track(new Texture2D(4, 4, TextureFormat.RGBA32, false, false)); image._shapeMask = mask1; @@ -661,27 +661,27 @@ public void ShapeMaskCanBeChangedAfterStart() public void OnDestroyCleansMaterialAndBaseMaterial() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color hdrColor = new(2f, 1.5f, 1f, 1f); image.HdrColor = hdrColor; - Material cachedBefore = image.CachedMaterialInstanceForTests; - Material baseBefore = image.BaseMaterialForTests; + Material cachedBefore = image._cachedMaterialInstance; + Material baseBefore = image._baseMaterial; Assert.IsTrue( cachedBefore != null, "Cached material instance should exist before destroy" ); Assert.IsTrue(baseBefore != null, "Base material should exist before destroy"); - image.InvokeOnDestroyForTests(); + image.CleanupMaterialInstance(); Assert.IsTrue( - image.CachedMaterialInstanceForTests == null, + image._cachedMaterialInstance == null, "Cached material should be null after destroy" ); Assert.IsTrue( - image.BaseMaterialForTests == null, + image._baseMaterial == null, "Base material should be null after destroy" ); @@ -695,18 +695,18 @@ public void OnDestroyCleansMaterialAndBaseMaterial() public void ReassigningMaterialAfterDestroyCreatesNewInstance() { EnhancedImage image = CreateEnhancedImage(out Material originalBase); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color hdrColor = new(2f, 1.5f, 1f, 1f); image.HdrColor = hdrColor; - Material cachedBefore = image.CachedMaterialInstanceForTests; + Material cachedBefore = image._cachedMaterialInstance; Assert.IsTrue(cachedBefore != null, "Cached material should exist before destroy"); - image.InvokeOnDestroyForTests(); + image.CleanupMaterialInstance(); Assert.IsTrue( - image.CachedMaterialInstanceForTests == null, + image._cachedMaterialInstance == null, "Cached material should be null after destroy" ); @@ -715,9 +715,9 @@ public void ReassigningMaterialAfterDestroyCreatesNewInstance() Material newBase = Track(new Material(shader)); image.material = newBase; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cachedAfter = image.CachedMaterialInstanceForTests; + Material cachedAfter = image._cachedMaterialInstance; Assert.IsTrue( cachedAfter != null, "New material instance should be created after reassigning material and calling Start" @@ -739,7 +739,7 @@ public void ReassigningMaterialAfterDestroyCreatesNewInstance() public void MaterialColorPropertyExistsAndIsAccessible() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -753,7 +753,7 @@ public void MaterialColorPropertyExistsAndIsAccessible() public void HdrColorChangesAreImmediatelyReflectedInMaterial() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -782,9 +782,9 @@ public void UpdateWithDefaultUIMaterialDoesNotCreateInstance() GameObject owner = Track(new GameObject("Default Material Test")); EnhancedImage image = owner.AddComponent(); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Assert.IsTrue( cached == null, "Should not create material instance when using default UI material" @@ -795,19 +795,19 @@ public void UpdateWithDefaultUIMaterialDoesNotCreateInstance() public void MaterialInstanceCreatedOnlyOnce() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material instance1 = image.CachedMaterialInstanceForTests; + Material instance1 = image._cachedMaterialInstance; Assert.IsTrue(instance1 != null); image.HdrColor = new Color(1.5f, 0.5f, 0.3f, 1f); - Material instance2 = image.CachedMaterialInstanceForTests; + Material instance2 = image._cachedMaterialInstance; image.HdrColor = new Color(2.5f, 1.5f, 0.8f, 1f); - Material instance3 = image.CachedMaterialInstanceForTests; + Material instance3 = image._cachedMaterialInstance; image.HdrColor = new Color(0.5f, 0.5f, 0.5f, 1f); - Material instance4 = image.CachedMaterialInstanceForTests; + Material instance4 = image._cachedMaterialInstance; Assert.That(instance2, Is.SameAs(instance1)); Assert.That(instance3, Is.SameAs(instance1)); @@ -818,7 +818,7 @@ public void MaterialInstanceCreatedOnlyOnce() public void ColorChannelsUpdateIndependently() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -849,7 +849,7 @@ public void ColorChannelsUpdateIndependently() public void AlphaChannelPreservedInHdrColor() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -874,7 +874,7 @@ public void VerySmallHdrValuesAboveOneAreRecognizedAsHdr() { EnhancedImage image = CreateEnhancedImage(out _); image.color = new Color(0.1f, 0.1f, 0.1f, 1f); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -895,7 +895,7 @@ public void VerySmallHdrValuesBelowOneStillUsesHdrColor() EnhancedImage image = CreateEnhancedImage(out _); Color graphicColor = new(0.3f, 0.4f, 0.5f, 1f); image.color = graphicColor; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -914,9 +914,9 @@ public void VerySmallHdrValuesBelowOneStillUsesHdrColor() public void MaterialInstanceHasHideAndDontSaveFlags() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Assert.IsTrue(cached != null); Assert.AreEqual( HideFlags.HideAndDontSave, @@ -929,9 +929,9 @@ public void MaterialInstanceHasHideAndDontSaveFlags() public void BaseMaterialReferencePreservedAfterStart() { EnhancedImage image = CreateEnhancedImage(out Material baseMaterial); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material storedBaseMaterial = image.BaseMaterialForTests; + Material storedBaseMaterial = image._baseMaterial; Assert.IsTrue(storedBaseMaterial != null); Assert.That( storedBaseMaterial, @@ -944,9 +944,9 @@ public void BaseMaterialReferencePreservedAfterStart() public void ChangingBaseMaterialDestroysOldInstance() { EnhancedImage image = CreateEnhancedImage(out Material baseMaterial1); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cachedInstance1 = image.CachedMaterialInstanceForTests; + Material cachedInstance1 = image._cachedMaterialInstance; Assert.IsTrue(cachedInstance1 != null); Shader shader = Shader.Find("UI/Default"); @@ -954,7 +954,7 @@ public void ChangingBaseMaterialDestroysOldInstance() image.material = baseMaterial2; image.HdrColor = new Color(1.5f, 0.5f, 0.3f, 1f); - Material cachedInstance2 = image.CachedMaterialInstanceForTests; + Material cachedInstance2 = image._cachedMaterialInstance; Assert.IsTrue(cachedInstance2 != null); Assert.AreNotSame( cachedInstance1, @@ -976,9 +976,9 @@ public void SpriteTextureAppliedToMaterialMainTex() Sprite.Create(texture, new Rect(0, 0, 16, 16), new Vector2(0.5f, 0.5f)) ); image.sprite = sprite; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Assert.IsTrue(cached != null); if (cached.HasProperty("_MainTex")) @@ -999,11 +999,11 @@ public void NullBaseMaterialDoesNotCauseErrors() EnhancedImage image = owner.AddComponent(); Assert.DoesNotThrow( - () => image.InvokeStartForTests(), + () => image.UpdateMaterialInstance(), "Start with null material should not throw" ); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Assert.IsTrue(cached == null, "Should not create instance when material is null"); } @@ -1011,17 +1011,17 @@ public void NullBaseMaterialDoesNotCauseErrors() public void SettingMaterialToNullClearsBaseMaterial() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cachedBefore = image.CachedMaterialInstanceForTests; + Material cachedBefore = image._cachedMaterialInstance; Assert.IsTrue(cachedBefore != null); - Assert.IsTrue(image.BaseMaterialForTests != null); + Assert.IsTrue(image._baseMaterial != null); image.material = null; - image.InvokeOnDestroyForTests(); + image.CleanupMaterialInstance(); Assert.IsTrue( - image.BaseMaterialForTests == null, + image._baseMaterial == null, "Base material should be cleared when material is set to null and destroyed" ); } @@ -1031,14 +1031,14 @@ public void MultipleStartCallsDoNotLeakMaterialInstances() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); - Material instance1 = image.CachedMaterialInstanceForTests; + image.UpdateMaterialInstance(); + Material instance1 = image._cachedMaterialInstance; - image.InvokeStartForTests(); - Material instance2 = image.CachedMaterialInstanceForTests; + image.UpdateMaterialInstance(); + Material instance2 = image._cachedMaterialInstance; - image.InvokeStartForTests(); - Material instance3 = image.CachedMaterialInstanceForTests; + image.UpdateMaterialInstance(); + Material instance3 = image._cachedMaterialInstance; Assert.That(instance2, Is.SameAs(instance1)); Assert.That(instance3, Is.SameAs(instance1)); @@ -1050,7 +1050,7 @@ public void GraphicColorChangesIgnoredInFavorOfHdrColor() EnhancedImage image = CreateEnhancedImage(out _); Color hdrColor = new(1.5f, 0.8f, 0.4f, 1f); image.HdrColor = hdrColor; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Material cached = image.material; Assert.IsTrue(cached != null); @@ -1070,16 +1070,16 @@ public void MaterialRestoredWhenCurrentIsDefaultAndBaseMaterialValid() EnhancedImage image = CreateEnhancedImage(out Material baseMaterial); Color hdrColor = new(2f, 1.5f, 1f, 1f); image.HdrColor = hdrColor; - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cachedInstance = image.CachedMaterialInstanceForTests; + Material cachedInstance = image._cachedMaterialInstance; Assert.IsTrue(cachedInstance != null); Object.DestroyImmediate(cachedInstance); // UNH-SUPPRESS: Test verifies material recreation after destruction - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material newCachedInstance = image.CachedMaterialInstanceForTests; + Material newCachedInstance = image._cachedMaterialInstance; Assert.IsTrue( newCachedInstance != null, "Should recreate material instance when cached instance is destroyed but base material is valid" @@ -1094,9 +1094,9 @@ public void MaterialRestoredWhenCurrentIsDefaultAndBaseMaterialValid() public void DestroyedMaterialInstanceIsRecreatedOnUpdate() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cachedInstance = image.CachedMaterialInstanceForTests; + Material cachedInstance = image._cachedMaterialInstance; Assert.IsTrue(cachedInstance != null); Object.DestroyImmediate(cachedInstance); // UNH-SUPPRESS: Test verifies material recreation on update @@ -1104,7 +1104,7 @@ public void DestroyedMaterialInstanceIsRecreatedOnUpdate() Color newHdr = new(3f, 2f, 1f, 1f); image.HdrColor = newHdr; - Material newInstance = image.CachedMaterialInstanceForTests; + Material newInstance = image._cachedMaterialInstance; Assert.IsTrue( newInstance != null, "Should recreate material instance when it was destroyed externally" @@ -1140,7 +1140,7 @@ public void MaterialWithoutColorPropertyHandledGracefully() image.material = baseMaterial; Assert.DoesNotThrow( - () => image.InvokeStartForTests(), + () => image.UpdateMaterialInstance(), "Start should not throw even if material doesn't have _Color property" ); @@ -1154,9 +1154,9 @@ public void MaterialWithoutColorPropertyHandledGracefully() public void RapidMaterialAssignmentsDoNotLeak() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material originalInstance = image.CachedMaterialInstanceForTests; + Material originalInstance = image._cachedMaterialInstance; Assert.IsTrue(originalInstance != null); Shader shader = Shader.Find("UI/Default"); @@ -1167,7 +1167,7 @@ public void RapidMaterialAssignmentsDoNotLeak() image.HdrColor = new Color(1f + (i * 0.1f), 0.5f, 0.3f, 1f); } - Material finalInstance = image.CachedMaterialInstanceForTests; + Material finalInstance = image._cachedMaterialInstance; Assert.IsTrue(finalInstance != null); Assert.AreNotSame( originalInstance, @@ -1182,7 +1182,7 @@ public void RapidMaterialAssignmentsDoNotLeak() public void HdrColorIsPreservedInMaterial(Color hdrColor, string description) { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); image.HdrColor = hdrColor; @@ -1192,7 +1192,7 @@ public void HdrColorIsPreservedInMaterial(Color hdrColor, string description) $"HdrColor property should store {description} exactly as set" ); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Assert.IsTrue(cached != null, $"Material instance should exist for {description}"); Color materialColor = cached.GetColor("_Color"); @@ -1211,7 +1211,7 @@ string description ) { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Assert.DoesNotThrow( () => image.HdrColor = inputColor, @@ -1224,7 +1224,7 @@ string description $"HdrColor property should store {description} exactly as set" ); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Assert.IsTrue(cached != null, $"Material instance should exist for {description}"); // Shader readback differs by Unity version; exact HdrColor storage is the package contract. @@ -1242,17 +1242,17 @@ public void MaterialInstanceLifecycleIsCorrectlyManaged() EnhancedImage image = CreateEnhancedImage(out Material baseMaterial); Assert.IsTrue( - image.CachedMaterialInstanceForTests == null, + image._cachedMaterialInstance == null, "Cached material should be null before Start" ); Assert.IsTrue( - image.BaseMaterialForTests == null, + image._baseMaterial == null, "Base material reference should be null before Start" ); - image.InvokeStartForTests(); - Material firstInstance = image.CachedMaterialInstanceForTests; - Material firstBase = image.BaseMaterialForTests; + image.UpdateMaterialInstance(); + Material firstInstance = image._cachedMaterialInstance; + Material firstBase = image._baseMaterial; Assert.IsTrue(firstInstance != null, "Cached material should be created after Start"); Assert.AreNotSame( @@ -1266,27 +1266,27 @@ public void MaterialInstanceLifecycleIsCorrectlyManaged() "Base material reference should point to original material" ); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Assert.AreSame( firstInstance, - image.CachedMaterialInstanceForTests, + image._cachedMaterialInstance, "Repeated Start should reuse the same instance" ); image.HdrColor = new Color(2f, 1f, 0.5f, 1f); Assert.AreSame( firstInstance, - image.CachedMaterialInstanceForTests, + image._cachedMaterialInstance, "HdrColor change should reuse the same instance" ); - image.InvokeOnDestroyForTests(); + image.CleanupMaterialInstance(); Assert.IsTrue( - image.CachedMaterialInstanceForTests == null, + image._cachedMaterialInstance == null, "Cached material should be null after OnDestroy" ); Assert.IsTrue( - image.BaseMaterialForTests == null, + image._baseMaterial == null, "Base material should be null after OnDestroy" ); Assert.IsTrue(firstInstance == null, "Destroyed material instance should be fake-null"); @@ -1296,7 +1296,7 @@ public void MaterialInstanceLifecycleIsCorrectlyManaged() public void HdrColorPropertyStoresExactValueRegardlessOfMaterialBehavior() { EnhancedImage image = CreateEnhancedImage(out _); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); Color[] testColors = { @@ -1332,9 +1332,9 @@ public void SettingHdrColorBeforeStartIsAppliedCorrectly() "HdrColor should be stored before Start" ); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Assert.IsTrue(cached != null, "Material should be created after Start"); Color materialColor = cached.GetColor("_Color"); @@ -1355,9 +1355,9 @@ public void DiagnosticsRevealMaterialState() $"Base material has _Color: {baseMaterial.HasProperty("_Color")}" ); - image.InvokeStartForTests(); + image.UpdateMaterialInstance(); - Material cached = image.CachedMaterialInstanceForTests; + Material cached = image._cachedMaterialInstance; Material fromProperty = image.material; TestContext.WriteLine($"Cached instance: {(cached != null ? cached.name : "null")}"); diff --git a/Tests/Runtime/Visuals/LayeredImageTests.cs b/Tests/Runtime/Visuals/LayeredImageTests.cs index 946307482..912e01108 100644 --- a/Tests/Runtime/Visuals/LayeredImageTests.cs +++ b/Tests/Runtime/Visuals/LayeredImageTests.cs @@ -168,7 +168,7 @@ public IEnumerator ForceUpdateDoesNotAdvanceBackgroundWhenAttachedAndFpsCannotPl yield return AttachToRuntimePanel(image); - Assert.IsFalse(image.SelfUpdateActiveForTests); + Assert.IsFalse(image._selfUpdateItem != null); Assert.AreSame(computed[0], image.style.backgroundImage.value.texture); Assert.DoesNotThrow(() => image.Update(force: true)); @@ -194,16 +194,16 @@ public IEnumerator SelfUpdateStaysPausedWhenFpsIsZero() updatesSelf: true ); Texture2D[] computed = VisualsTestHelpers.GetComputedTextures(image, _trackedObjects); - Assert.IsFalse(image.SelfUpdateActiveForTests); + Assert.IsFalse(image._selfUpdateItem != null); yield return AttachToRuntimePanel(image); - Assert.IsTrue(image.SelfUpdateActiveForTests); + Assert.IsTrue(image._selfUpdateItem != null); yield return WaitUntilBackgroundIs(image, computed[1], "initial self-update"); image.Fps = 0f; - Assert.IsFalse(image.SelfUpdateActiveForTests); + Assert.IsFalse(image._selfUpdateItem != null); Texture2D pausedFrame = image.style.backgroundImage.value.texture; yield return null; @@ -232,22 +232,22 @@ public IEnumerator SelfUpdateResumesAfterFpsReturnsPositive() updatesSelf: true ); Texture2D[] computed = VisualsTestHelpers.GetComputedTextures(image, _trackedObjects); - Assert.IsFalse(image.SelfUpdateActiveForTests); + Assert.IsFalse(image._selfUpdateItem != null); yield return AttachToRuntimePanel(image); - Assert.IsTrue(image.SelfUpdateActiveForTests); + Assert.IsTrue(image._selfUpdateItem != null); yield return WaitUntilBackgroundIs(image, computed[1], "initial self-update"); image.Fps = 0f; - Assert.IsFalse(image.SelfUpdateActiveForTests); + Assert.IsFalse(image._selfUpdateItem != null); Texture2D pausedFrame = image.style.backgroundImage.value.texture; yield return null; Assert.AreSame(pausedFrame, image.style.backgroundImage.value.texture); image.Fps = 60f; - Assert.IsTrue(image.SelfUpdateActiveForTests); + Assert.IsTrue(image._selfUpdateItem != null); yield return WaitUntilBackgroundIs(image, computed[2], "resumed self-update"); } @@ -272,11 +272,11 @@ public IEnumerator SelfUpdateStopsWhenRemovedFromPanel() yield return AttachToRuntimePanel(image); - Assert.IsTrue(image.SelfUpdateActiveForTests); + Assert.IsTrue(image._selfUpdateItem != null); yield return WaitUntilBackgroundIs(image, computed[1], "attached self-update"); image.RemoveFromHierarchy(); - Assert.IsFalse(image.SelfUpdateActiveForTests); + Assert.IsFalse(image._selfUpdateItem != null); Texture2D detachedFrame = image.style.backgroundImage.value.texture; yield return null; @@ -301,13 +301,13 @@ public void SelfUpdateDoesNotStartBeforePanelAttachment() ); Texture2D[] computed = VisualsTestHelpers.GetComputedTextures(image, _trackedObjects); - Assert.IsFalse(image.SelfUpdateActiveForTests); + Assert.IsFalse(image._selfUpdateItem != null); Assert.AreSame(computed[0], image.style.backgroundImage.value.texture); image.Update(force: true); Assert.AreSame(computed[0], image.style.backgroundImage.value.texture); - Assert.IsFalse(image.SelfUpdateActiveForTests); + Assert.IsFalse(image._selfUpdateItem != null); } [UnityTest] @@ -779,7 +779,7 @@ public void SequentialAndParallelBlendPathsProduceIdenticalPixels() parallel[index] = background; } - LayeredImage.ComposeSpriteOntoBufferForTests( + LayeredImage.ComposeSpriteOntoBuffer( sequential, bufferWidth, bufferHeight, @@ -792,7 +792,7 @@ public void SequentialAndParallelBlendPathsProduceIdenticalPixels() 0.01f, useParallel: false ); - LayeredImage.ComposeSpriteOntoBufferForTests( + LayeredImage.ComposeSpriteOntoBuffer( parallel, bufferWidth, bufferHeight, @@ -827,9 +827,9 @@ string description yield return AttachToRuntimePanel(image); - image.SetElapsedSinceLastFrameForTests( - TimeSpanFromFractionalMilliseconds(elapsedMilliseconds) - ); + image._timer.Stop(); + image._lastTick = + image._timer.Elapsed - TimeSpanFromFractionalMilliseconds(elapsedMilliseconds); image.Update(); Assert.AreSame( diff --git a/Tests/Runtime/Visuals/VisualsTestHelpers.cs b/Tests/Runtime/Visuals/VisualsTestHelpers.cs index 203b105a4..d7fd8db14 100644 --- a/Tests/Runtime/Visuals/VisualsTestHelpers.cs +++ b/Tests/Runtime/Visuals/VisualsTestHelpers.cs @@ -71,7 +71,7 @@ conceptual test input. public static Texture2D[] GetComputedTextures(LayeredImage image, List tracked) { - Texture2D[] computed = image?.ComputedTexturesForTests; + Texture2D[] computed = image?._computed; foreach (Texture2D texture in computed) { diff --git a/docs/contributing/testing-patterns.md b/docs/contributing/testing-patterns.md index 23cf0cc5a..7b8893b5b 100644 --- a/docs/contributing/testing-patterns.md +++ b/docs/contributing/testing-patterns.md @@ -2,19 +2,19 @@ This guide documents patterns for testing states that "should never happen" but could occur in production. These tests catch edge cases that defensive programming must handle gracefully. -## Test-only hooks and production listeners +## Access real implementation internals -The `ForTests` and `TestHooks` members in `Runtime/` and `Editor/` serve different test needs. Keep them internal unless a production caller needs a defined contract. +Production code contains no test-only methods, callbacks, state capture, timing latches, or conditional execution paths. Names such as `ForTest`, `ForTesting`, `ForTests`, and `TestHooks` are prohibited in shipped implementation code and generated production source. Tests use existing operations and state through `internal` visibility and friend assemblies. -| Hook group | Examples | Current purpose | Decision | -| ----------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -| Scheduling callbacks | `DurableFile.BeforeStagedSwapForTests`, `TestRunSummaryFile.AfterBeginClaimForTests` | Hold a file operation at a specific point to prove ownership and race behavior | Keep internal. No production listener needs the staging path or a callback inside the claim or writer lock. | -| Editor action probes | `ManualRecompile.AssetsRefreshedForTests`, `CompilationRequestedForTests` | Observe ordering and avoid a real compilation request during a test | Keep internal. A listener could throw or request compilation again during the action. | -| State inspection and reset | Drawer cache and layout `TestHooks`, singleton retry resets, visual cache accessors | Check eviction, layout, and lifecycle behavior without changing a public API | Keep internal. These expose implementation state rather than a stable product event. | -| Direct algorithm entry points | `ImageBlurTool.BlurredForTests`, `LayeredImage.ComposeSpriteOntoBufferForTests` | Exercise a pure operation without a window or frame loop | Keep internal until a production caller needs the operation itself. | -| Test-assembly-only rebuild | `AttributeMetadataCache.ForceRebuildForTests` | Reset relational metadata after a test changes inputs | Keep behind `UNITY_INCLUDE_TESTS`; it is not present in normal player builds. | +- Promote the actual method or field needed by a fixture; remove forwarding wrappers and duplicate diagnostics. +- Keep fixture setup, state snapshots, reset helpers, and deterministic inputs under `Tests/`. +- Exercise real file ownership, cancellation, layout, and lifecycle operations. Do not add callbacks to pause production execution or suppress editor actions. +- State the limits of phase tests: testing publication after staging does not by itself prove scheduling through the entire public asynchronous operation. +- Keep timing observations in test-owned entry points. A warmed registration call cannot replace a first-registration measurement. -No current `Runtime/` or `Editor/` caller subscribes to the scheduling callbacks or editor action probes. The existing tests set and clear those callbacks around their assertions. In particular, the `DurableFile` callbacks run under its per-path gate and expose temporary file paths. A public listener would need an explicit subscription lifetime, callback thread, ordering, exception policy, reentrancy policy, and path disclosure policy before it could be safe to use. No such listener is proposed now, so the existing deterministic tests and hot paths remain unchanged. +This removes the released test APIs `AttributeMetadataCache.ForceRebuildForTests`, `WProtoDeclaredRootProvider.Unregister`, and `UnityMainThreadDispatcher.CreateTestScope`. Package fixtures use internal implementation state; external consumer tests exercise public operations instead. Relational metadata changes invalidate lookups through `SetMetadata`, and the assigner rebuilds them on first use. + +The production-hook gate checks forbidden identifiers and explicit test-only markers with positive controls. Review still checks suffix-free injection and instrumentation. Legitimate discovery of test assemblies and UI suppression during automated editor runs remain supported. The `ValidationRun` loader overload also remains: automatic validation uses it to resolve live prefab-stage roots before falling back to asset loading. Tests use real asset targets and implementation phases. ## Why Test "Impossible" States diff --git a/docs/features/editor-tools/authored-asset-validation.md b/docs/features/editor-tools/authored-asset-validation.md index 4a2238699..841c9431d 100644 --- a/docs/features/editor-tools/authored-asset-validation.md +++ b/docs/features/editor-tools/authored-asset-validation.md @@ -203,11 +203,13 @@ content lives in sub-objects, and a prefab, leaves a key no field claims in each asserts the outcome and the object count. It pins the prefab finding directly — the same prefab rewritten with assets only comes back byte-identical, stale key and all. -The two branches nothing authorable can trigger — a rewrite that loses content, and a rewrite that -throws — reach production code through one seam each, and everything after the seam is the real -thing: the comparison, the error log, the byte restore, and the forced re-import. A render profile -loses content and a test cannot build one; nothing at all makes `ForceReserializeAssets` throw. The -confirmation dialog stays anyway, and committing first is still the advice. +The completion and recovery phases are exercised directly, without injected production callbacks: +a fixture deletes a real authored sub-asset before completion, and a real filesystem exception +exercises recovery after the asset has been changed. These controls check the object-count guard, +error log, byte restore, and forced re-import. Concurrent edits and unwritable destinations check +that restoration refuses unsafe or failed writes. This phase composition does not make +`ForceReserializeAssets` itself lose content or throw, and does not prove the public entry point's +catch routing. The confirmation dialog remains, and committing first is still the advice. ## The reader underneath diff --git a/docs/features/effects/effects-system.md b/docs/features/effects/effects-system.md index e6945177f..b182ba77d 100644 --- a/docs/features/effects/effects-system.md +++ b/docs/features/effects/effects-system.md @@ -976,6 +976,17 @@ public sealed class PlayerHealth : AttributesComponent - Periodic payloads run only for Duration/Infinite effects; they automatically stop after `maxTicks` or when the effect handle is removed. - Combine multiple definitions for mixed cadences (e.g., fast minor regen + slower burst heals). +Periodic schedules preserve their initial delay and cadence at large finite clocks. Repeated +processing at an unchanged clock does not create new due ticks; an overdue schedule can still +consume its backlog, bounded to 32 ticks per definition per pass. Ticks include the exact scheduled +boundary, and moving the finite clock backward leaves the phase unchanged. Intervals below 0.01 +seconds use that minimum; negative initial delays use zero. + +Refreshing or reapplying an existing handle preserves its periodic phase and tick count. Removing +it and applying a fresh handle starts a new schedule. Initial delay and interval are captured when +the handle is created; changes to `maxTicks` remain live. Nonfinite timing retains its existing +comparison behavior. + ### Effect Behaviours - Attach `EffectBehavior` ScriptableObjects to the `behaviors` list for per-handle runtime logic. @@ -1359,6 +1370,15 @@ if (effectHandler.TryGetRemainingDuration(effectHandle, out float remaining)) } ``` +Duration effects retain the authored duration when they are applied or refreshed. They expire +when elapsed time reaches that duration, including at large finite clock values. Reapplication +keeps the original start unless its reset policy allows a refresh. Small clock offsets remain +significant beside very large lifetimes. Remaining-duration queries retain small positive +countdowns, round to the nearest float, and report zero once the duration has elapsed; `Infinite` +effects have no tracked countdown. Clock sampling +still follows Unity's `Time.time`, so expiry occurs on the first sampled frame at or after the +lifetime, and a clock moving backward extends the countdown. + **Effect Manipulation:** ```csharp diff --git a/docs/features/inspector/inspector-selection-attributes.md b/docs/features/inspector/inspector-selection-attributes.md index a1a01d98c..9b354951e 100644 --- a/docs/features/inspector/inspector-selection-attributes.md +++ b/docs/features/inspector/inspector-selection-attributes.md @@ -229,6 +229,8 @@ public int frameRate = 60; // Shows as toggle buttons instead of dropdown The drawer intentionally filters out composite flag values (e.g., `ReadWrite = Read | Write`). This keeps the UI focused on atomic toggles and avoids ambiguous interactions. Use the "Select All" and "Select None" buttons for bulk operations. +Whitespace-only toggle captions display `(Unnamed)`. Nonblank padded captions and option values retain their exact text, including literal whitespace string choices. + ### Best Practices for WEnumToggleButtons - Keep option counts manageable: toggle groups work best for short lists where designers can see everything without scrolling diff --git a/docs/features/logging/unity-main-thread-dispatcher.md b/docs/features/logging/unity-main-thread-dispatcher.md index ce1f65ca4..bd86c6d11 100644 --- a/docs/features/logging/unity-main-thread-dispatcher.md +++ b/docs/features/logging/unity-main-thread-dispatcher.md @@ -248,7 +248,7 @@ internal static class UnityMainThreadDispatcherEditorTestBootstrap The runtime and editor `CommonTestBase` fixtures demonstrate the intended per-test lifecycle via `UnityMainThreadDispatcher.AutoCreationScope`: -1. At `[SetUp]` it grabs `UnityMainThreadDispatcher.CreateTestScope(destroyImmediate: true)` which internally disables auto-creation, destroys stragglers, and then re-enables auto-creation so the test can access `Instance` normally. +1. At `[SetUp]` a test-owned helper composes the real scoped auto-creation and cleanup operations. Downstream fixtures can use `AutoCreationScope.Enabled(destroyExistingInstanceOnEnter: true, destroyInstancesOnDispose: true, destroyImmediate: true)` to enable access and clean up on exit. 2. Production code can create/destroy the dispatcher freely; the scope tracks everything automatically. 3. During every teardown stage it disposes the scope, restoring the previous auto-creation flag and destroying any dispatcher created while the test runs; no manual try/finally blocks required. diff --git a/docs/features/relational-components/relational-components.md b/docs/features/relational-components/relational-components.md index 0d96893fd..2e6e8abaa 100644 --- a/docs/features/relational-components/relational-components.md +++ b/docs/features/relational-components/relational-components.md @@ -616,8 +616,8 @@ VContainer (1.16.x) ```csharp var cache = ScriptableObject.CreateInstance(); - // populate cache._relationalTypeMetadata with your test component types - cache.ForceRebuildForTests(); // rebuild lookups so the initializer can discover your types + // Populate metadata with cache.SetMetadata(...) in the editor. + // Lookups rebuild on first use after metadata changes. var builder = new ContainerBuilder(); builder.RegisterInstance(cache).AsSelf(); builder.Register(Lifetime.Singleton) diff --git a/docs/features/serialization/serialization.md b/docs/features/serialization/serialization.md index 3c91b2168..16af611d9 100644 --- a/docs/features/serialization/serialization.md +++ b/docs/features/serialization/serialization.md @@ -1206,7 +1206,9 @@ See [Retiring a member](#retiring-a-member) and `WPROTO044` fires when an implicit subclass derives from a contract in **another assembly** whose formatter has no generated extension body. Rebuild that assembly with the current generator; -immutable bases still require composition. See +immutable bases still require composition. An intermediate `[WProtoNotSerialized]` stops this +ancestor lookup, so its unannotated descendants do not receive `WPROTO044`, including across +assembly boundaries. Explicit contradictory declarations still report `WPROTO045`. See [Extending a hierarchy across assemblies](#extending-a-hierarchy-across-assemblies). `WPROTO045` fires when the `[WProtoNotSerialized]` opt-out sits beside a declaration that says the opposite; and `WPROTO047` **warns** when a type inherits its contract and declares `[WProtoMember]` @@ -1573,10 +1575,19 @@ three of them are the opposite of the rule for a plain member: it instead. An **absent** field leaves the constructor's value alone either way; there is nothing for an overwrite to be triggered by. For a `Stack` "appends" means the first decoded element ends up on top, which is what makes writing top-first and pushing back in reverse round-trip. -- **A run of packable scalars is written packed**: one key and one length for the whole run instead - of a key per element, which roughly halves a repeated `int`. protobuf-net writes unpacked and reads - either form, so this is a size win rather than a compatibility break, and payloads in both forms - are accepted here. +- **A run of packable scalars is written packed by default**: one key and one length for the whole + run instead of a key per element, which roughly halves a repeated `int`. Set + `[WProtoMember(2, IsPacked = false)]` on an array or collection to write one field key per element, + matching protobuf-net's default repeated encoding. This also applies to generic scalar runs and + enum runs. Readers accept packed, unpacked, and mixed runs regardless of the write setting, so + existing payloads remain readable. The generated proto3 schema includes `[packed = false]` for + eligible members with the opt-out. Strings, messages, and chars retain their existing unpacked + encoding; scalar members, maps, and the contents of nested collection wrappers are unaffected. + +For example, an `int[]` member at field 2 containing `{3, 1, 4, 0, 2}` writes +`12 05 03 01 04 00 02` by default and `10 03 10 01 10 04 10 00 10 02` with `IsPacked = false`. +The values are identical after reading; byte hashes differ between these encodings. Select the +write policy when matching a legacy payload's repeated-field bytes matters. **Reading a packed run allocates the collection once.** The run's length prefix already says how many elements follow, so the generated reader sizes its destination up front instead of doubling it @@ -1628,11 +1639,17 @@ A key may be any integral type, `bool`, `string`, a floating-point type or an en protobuf-net accepts, which is wider than the protobuf specification's. A `byte[]` or message key is refused, because neither has a stable identity to key on once round-tripped. -**Three behaviors were measured rather than assumed.** The entry obeys the ordinary omission rules, -so `{"a": 0}` encodes as key only. A missing key or value decodes to that type's protobuf default, -and for a string that is `""` rather than a `null` that would throw inside the dictionary. And a -repeated key is last-wins, applied through the indexer rather than `Add`, which would throw on the -second occurrence of a key a hostile payload repeated. +Map entries follow protobuf-net 3.2.56. Zero enum keys and empty string keys are written. +Default numeric keys, including `float` and `double`, and default `bool` keys are omitted. +A zero `int` or `double` value is omitted, so `{"a": 0}` carries only its key. + +A missing key or value decodes to that type's protobuf default. A missing string is `""`, +which can serve as a dictionary key. Historical keyless enum entries and explicit zero numeric +keys remain readable. Repeated keys are last-wins, applied through the indexer rather than `Add`. +Rewriting an older payload can change its bytes and hash while preserving its map contents. + +Both protobuf-net majors retain zero enum keys. For enum-keyed maps with `double` values, +2.4.9 writes a zero value that 3.2.56 omits. WallstopProto writes the 3.2.56 form and reads both. **A dictionary may be a `struct`**, on the same terms a collection may: it is never null-checked, and it is assigned back to its member after reading because everything in between operated on a copy. diff --git a/docs/features/utilities/data-structures.md b/docs/features/utilities/data-structures.md index aab778243..4a58f797f 100644 --- a/docs/features/utilities/data-structures.md +++ b/docs/features/utilities/data-structures.md @@ -416,6 +416,20 @@ Notes on constants - Pros: Multiple eviction strategies; fluent builder API; jitter for thundering herd prevention. - Cons: Memory overhead for tracking access patterns; requires configuration for optimal performance. +Cache expiration compares elapsed time with the stored lifetime, including the exact lifetime +boundary. Positive lifetimes retain entries on repeated reads at the same finite clock value, +including very large custom clocks. Small clock offsets and jitter remain significant beside very +large lifetimes, and finite jitter cannot make a finite lifetime infinite. When no explicit lifetime or custom lifetime function is +supplied, disabling both write and access expiration leaves entries without a deadline, even at +`float.MaxValue`. + +An explicit positive lifetime overrides the custom lifetime function, which overrides write +expiration. Access expiration also bounds the first read; a successful read resets the lifetime to +the sliding access interval. Moving a finite clock backward does not expire an entry with a +positive lifetime. Custom `NaN` and infinite lifetimes retain their existing comparison behavior. +If a custom lifetime function throws, its exception propagates before values, expiry schedules, +storage slots, or capacity evictions change. + ```mermaid flowchart LR subgraph Cache Operations diff --git a/docs/features/utilities/helper-utilities.md b/docs/features/utilities/helper-utilities.md index e7aeab7e1..0519dd595 100644 --- a/docs/features/utilities/helper-utilities.md +++ b/docs/features/utilities/helper-utilities.md @@ -357,7 +357,12 @@ when initial jitter or `waitBefore` is enabled. Jitter is applied only before th For cached computations, `TimedCache` requires a finite nonnegative lifetime and throws `ArgumentException` for a negative or nonfinite lifetime. Negative or nonfinite jitter overrides act as zero jitter. Zero lifetime supports jitter without requesting an empty random range; -an explicit finite positive jitter override still delays the initial expiry. +an explicit finite positive jitter override still delays the initial expiry. Expiry arithmetic +preserves finite lifetimes and jitter when their sum would overflow a float. The supplied float +clock expires the cache only when elapsed time exceeds the lifetime plus initial jitter. Small +clock offsets and jitter remain significant beside very large lifetimes. A factory or time provider +exception leaves the cached value, refresh timer, and initial jitter unchanged; retrying +uses the original expiry schedule. A successful refresh consumes the initial jitter. --- @@ -643,7 +648,7 @@ using (var buffer = Buffers.List.Get()) **Problem it solves:** Unity APIs can only be called from the main thread. Background Tasks/threads can't directly manipulate GameObjects. This marshals callbacks back to the main thread. -See the dedicated [Unity Main Thread Dispatcher guide](../logging/unity-main-thread-dispatcher.md) for details about auto-creation, queue limits, the `AutoCreationScope` helper, and the `CreateTestScope(...)` convenience method that packages can use in their own test fixtures. +See the dedicated [Unity Main Thread Dispatcher guide](../logging/unity-main-thread-dispatcher.md) for details about auto-creation, queue limits, the `AutoCreationScope` helper, and instance cleanup through the public `AutoCreationScope.Enabled(...)` and `Disabled(...)` operations. ```csharp using WallstopStudios.UnityHelpers.Core.Helper; @@ -688,7 +693,9 @@ async Task GetTextFromMainThread() **Disposal discards queued work.** `Dispose()` and `DisposeAsync()` cancel the worker rather than draining it, so anything enqueued but not yet started is dropped; the `await` inside -`DisposeAsync()` waits only for the item already in flight. That is what you want for work that can +`DisposeAsync()` waits only for the item already in flight. Disposal immediately removes pending +work and releases the queue's references to its delegates; concurrent submissions cannot leave new +work in a shut-down queue. `Count` reaches zero once the in-flight item finishes. That is what you want for work that can simply be redone, such as a generation pass, and not what you want for durable work such as a persistence write. @@ -696,7 +703,8 @@ The window is narrow enough to hide in testing: work enqueued a millisecond or m almost always completes, work enqueued immediately before it almost never does. Call `DrainAsync()` when queued items must run. It closes the pool to new work permanently, then -returns once the queue is empty and nothing is executing: +returns once the queue is empty and nothing is executing. If disposal interrupts the drain, +`DrainAsync()` returns `false` even when disposal emptied the pending queue: ```csharp using WallstopStudios.UnityHelpers.Core.Threading; diff --git a/docs/features/utilities/pooling-guide.md b/docs/features/utilities/pooling-guide.md index 61fa6e921..7d1dd3ba3 100644 --- a/docs/features/utilities/pooling-guide.md +++ b/docs/features/utilities/pooling-guide.md @@ -78,6 +78,13 @@ list.Add(1); list.Add(2); ``` +A failed producer or `onGet` callback does not leave an active rental. If `onGet` fails after +an item is created or removed from storage, the pool retires that item through `onDisposal` +and propagates the original failure. Failed prewarming retires every item created before the +failure, including the item whose initialization failed. Acquisition callbacks run outside the +storage lock, so they can query pool state from another thread. Rental counters record attempts; +a failed attempt is removed from the active rental count. + Disposing `PooledResource` runs the configured release callback before parking the item. If that callback disposes the pool, the returning item is sent to the disposal callback exactly once and is never added back to the disposed pool. The same guarantee holds when a lease return races diff --git a/docs/features/utilities/singletons.md b/docs/features/utilities/singletons.md index 022f76f4c..74c858bc0 100644 --- a/docs/features/utilities/singletons.md +++ b/docs/features/utilities/singletons.md @@ -263,7 +263,7 @@ Notes: - Access via `T.Instance` (lazy‑loads from `Resources/` using either a custom path or the type name; warns if multiple assets found and chooses the first by name). - `HasInstance` indicates whether the lazy value exists and is not null. -- Optional `[ScriptableSingletonPath("Sub/Folder")]` to control the `Resources` subfolder. +- Optional `[ScriptableSingletonPath("Sub/Folder")]` to control the `Resources` subfolder. Blank subfolders, including whitespace wrapped in slashes, fall back to the type name in the Resources root. Spaces inside a nonblank folder name are preserved. - Editor utility auto‑creates and relocates assets: see the “ScriptableObject Singleton Creator” in the Editor Tools Guide. Example: Settings asset @@ -309,7 +309,13 @@ not apply to this cache-only reset. Asset management tips: - Place the asset under `Assets/Resources/` (or under the path from `[ScriptableSingletonPath]`). -- The Editor’s “ScriptableObject Singleton Creator” runs on load to create missing assets and move misplaced ones. It also supports a test‑assembly toggle used by our test suite. +- The Editor’s “ScriptableObject Singleton Creator” runs on load to create missing assets and move misplaced ones. Automatic creation respects editor suppression, asset-import workers, and compilation or import activity. + +An unexpected error releases the creation guard so a later attempt can run. The active asset batch +still disposes before the guard releases. Created or moved assets remain in place after a failure. +When an asset body is missing but its GUID remains, stale-artifact cleanup requests a synchronous +import before replacement creation. Creation and synchronous import run with batching temporarily +paused before the created asset is checked; the surrounding batch resumes afterward. Lookup order diagram: @@ -581,8 +587,11 @@ public sealed class CustomSingletonTests [SetUp] public void SetUp() { - // Disable auto-creation, destroy any existing instance, then re-enable - _scope = UnityMainThreadDispatcher.CreateTestScope(destroyImmediate: true); + // Enable scoped auto-creation and clean up instances on entry and exit + _scope = UnityMainThreadDispatcher.AutoCreationScope.Enabled( + destroyExistingInstanceOnEnter: true, + destroyInstancesOnDispose: true, + destroyImmediate: true); } [TearDown] @@ -671,7 +680,7 @@ public void SettingsWithCustomValuesWork() 1. **Inherit from `CommonTestBase`**: This handles most singleton cleanup automatically, including dispatcher scope management. -2. **Use `CreateTestScope` for dispatcher**: The `UnityMainThreadDispatcher.CreateTestScope()` method packages the common test setup pattern: disable auto-creation → destroy existing → re-enable auto-creation. +2. **Use `AutoCreationScope` for dispatcher**: Configure real scoped auto-creation and instance cleanup with `Enabled` or `Disabled`; keep fixture helpers in the test assembly. 3. **Prefer `destroyImmediate: true` in EditMode**: EditMode tests should use `DestroyImmediate` to ensure synchronous cleanup without Unity's delayed destruction. diff --git a/docs/performance/analyzers.md b/docs/performance/analyzers.md index 881b77f22..86281a023 100644 --- a/docs/performance/analyzers.md +++ b/docs/performance/analyzers.md @@ -1064,6 +1064,15 @@ source warning from proving that every possible owner has surrendered its right [compiler restrictions](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct) do not provide move-only ownership. +The host compiler fixture [DisposalOwnershipCompilerTests](https://github.com/Ambiguous-Interactive/unity-helpers/blob/main/Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/DisposalOwnershipCompilerTests.cs) +compiles the actual shipped `SemaphoreLease` and `DisposalLease` sources at C# 9, with and without +`SINGLE_THREADED`. Assignment, by-value calls, closure capture, static-field retention, repeated +`Dispose`, and manual disposal inside `using` compile without either disposal analyzer reporting a +warning. A separate positive control confirms interface boxing produces WUH022, then compiles the +same conversion with a local warning suppression and receives no analyzer diagnostic. These tests +pin the boundary between advisory source checks and runtime ownership; they do not measure Unity +player timing or allocation. + The runtime sweep identified `DisposableScope`, `DisposableScope`, `SemaphoreLease`, `PooledResource`, and `PooledArray` using shared `(slot, generation)` disposal leases. `RestorableGlobal` and editor global scopes use owner-issued borrow identifiers, which also diff --git a/package.json b/package.json index a1674fd47..aa7c0cfef 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,8 @@ "lint:editor-assembly-monobehaviours": "node scripts/lint-editor-assembly-monobehaviours.js --verbose", "lint:changelog": "pwsh -NoProfile -File scripts/lint-changelog.ps1 -VerboseOutput", "lint:duplicate-usings": "pwsh -NoProfile -File scripts/lint-duplicate-usings.ps1 -VerboseOutput", + "lint:production-test-hooks": "node scripts/lint-production-test-hooks.js", + "test:lint-production-test-hooks": "node scripts/tests/test-lint-production-test-hooks.js", "lint:xml-doc-summaries": "node scripts/lint-xml-doc-summaries.js --verbose", "lint:yaml": "pwsh -NoProfile -File scripts/lint-yaml.ps1 -VerboseOutput", "lint:dependabot": "pwsh -NoProfile -File scripts/lint-dependabot.ps1 -VerboseOutput", diff --git a/scripts/agent-preflight.ps1 b/scripts/agent-preflight.ps1 index 8343657ec..14e03b484 100644 --- a/scripts/agent-preflight.ps1 +++ b/scripts/agent-preflight.ps1 @@ -55,16 +55,23 @@ function Invoke-GitPathList { $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo - [void]$process.Start() - $stdout = $process.StandardOutput.ReadToEnd() - [void]$process.StandardError.ReadToEnd() - $process.WaitForExit() + try { + [void]$process.Start() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $stdout = $stdoutTask.GetAwaiter().GetResult() + [void]$stderrTask.GetAwaiter().GetResult() + $process.WaitForExit() - if ($process.ExitCode -ne 0) { - return @() - } + if ($process.ExitCode -ne 0) { + return @() + } - return @(Split-NulPathText -Text $stdout) + return @(Split-NulPathText -Text $stdout) + } + finally { + $process.Dispose() + } } function Get-GitChangedPaths { @@ -214,14 +221,21 @@ function Invoke-GitRawText { $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo - [void]$process.Start() - $stdout = $process.StandardOutput.ReadToEnd() - [void]$process.StandardError.ReadToEnd() - $process.WaitForExit() + try { + [void]$process.Start() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $stdout = $stdoutTask.GetAwaiter().GetResult() + [void]$stderrTask.GetAwaiter().GetResult() + $process.WaitForExit() - return [pscustomobject]@{ - ExitCode = $process.ExitCode - Stdout = $stdout + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Stdout = $stdout + } + } + finally { + $process.Dispose() } } @@ -1446,6 +1460,8 @@ function Resolve-LicenseYearsWithLibrary { $standardOutput = '' try { $process = [System.Diagnostics.Process]::Start($info) + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() $encoding = [System.Text.UTF8Encoding]::new($false) $stream = $process.StandardInput.BaseStream foreach ($path in $Paths) { @@ -1455,8 +1471,8 @@ function Resolve-LicenseYearsWithLibrary { } $stream.Flush() $process.StandardInput.Close() - $standardOutput = $process.StandardOutput.ReadToEnd() - $process.StandardError.ReadToEnd() | Out-Null + $standardOutput = $stdoutTask.GetAwaiter().GetResult() + [void]$stderrTask.GetAwaiter().GetResult() $process.WaitForExit() if ($process.ExitCode -ne 0) { return $null diff --git a/scripts/lint-production-test-hooks.js b/scripts/lint-production-test-hooks.js new file mode 100644 index 000000000..bed65a28c --- /dev/null +++ b/scripts/lint-production-test-hooks.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const repoRoot = path.resolve(__dirname, ".."); +const scanRoots = ["Runtime", "Editor", "Generator~", "Samples~", "Styles"]; +const skipped = new Set(["bin", "obj", ".git", "node_modules"]); +const hostProjects = new Set([ + "ProtobufNetV2Oracle", + "WallstopStudios.UnityHelpers.CountingLoopAudit", + "WallstopStudios.UnityHelpers.DocSamplesCheck", + "WallstopStudios.UnityHelpers.EditorCheck", + "WallstopStudios.UnityHelpers.EditorTestCheck", + "WallstopStudios.UnityHelpers.IntegrationCheck", + "WallstopStudios.UnityHelpers.RandomQuality", + "WallstopStudios.UnityHelpers.SyntaxPolicy", + "WallstopStudios.UnityHelpers.TestCheck", + "WallstopStudios.UnityHelpers.TypeCheck" +]); + +function isProductionPath(relative) { + const parts = relative.replace(/\\/g, "/").split("/"); + if (!scanRoots.includes(parts[0])) return false; + // Only generator project boundaries may exclude test/host projects. A production + // subdirectory named Tests must not bypass the policy. + return parts[0] !== "Generator~" || !(parts[1]?.endsWith(".Tests") || hostProjects.has(parts[1])); +} + +// This naming/marker gate is not a C# parser or semantic proof. Interpolation +// expressions inside strings are masked; ordinary member declarations remain visible. +// Keep comments/strings separate: ordinary messages can describe tests without defining hooks. +// Generator literal contents are checked separately because they become shipped source. +function tokenize(source) { + let code = ""; + const literals = []; + const literalRanges = []; + const comments = []; + for (let i = 0; i < source.length;) { + const start = i; + if (source.startsWith("//", i) || source.startsWith("/*", i)) { + const block = source.startsWith("/*", i); + const end = block ? source.indexOf("*/", i + 2) : source.slice(i).search(/[\r\n]/); + i = end < 0 ? source.length : block ? end + 2 : i + end; + comments.push(source.slice(start, i)); + } else if (source[i] === '"' || source[i] === "'") { + const quote = source[i++]; + const verbatim = quote === '"' && source[start - 1] === "@"; + const rawCount = quote === '"' ? source.slice(start).match(/^"+/)[0].length : 0; + let value = ""; + if (rawCount >= 3) { + const delimiter = '"'.repeat(rawCount); + const end = source.indexOf(delimiter, start + rawCount); + i = end < 0 ? source.length : end + rawCount; + value = source.slice(start + rawCount, end < 0 ? source.length : end); + } else { + while (i < source.length) { + if (source[i] === quote) { + if (verbatim && source[i + 1] === quote) { + value += quote; + i += 2; + continue; + } + i++; + break; + } + if (!verbatim && source[i] === "\\") { + const escape = source + .slice(i) + .match(/^\\(?:u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|x[0-9a-fA-F]{1,4}|.)/); + if (escape) { + const text = escape[0]; + value += /^\\[uUx]/.test(text) + ? String.fromCodePoint(parseInt(text.slice(2), 16)) + : ({ n: "\n", r: "\r", t: "\t" }[text[1]] ?? text[1]); + i += text.length; + continue; + } + } + value += source[i++]; + } + } + if (quote === '"') { + literals.push(value); + literalRanges.push({ start, end: i }); + } + } else { + code += source[i++]; + continue; + } + code += source.slice(start, i).replace(/[^\r\n]/g, " "); + } + return { code, literals, literalRanges, comments }; +} + +function forbiddenIdentifiers(code) { + const decoded = code.replace(/\\(?:u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8}))/g, (_, short, long) => + String.fromCodePoint(parseInt(short || long, 16)) + ); + return [...decoded.matchAll(/\b[A-Za-z_][A-Za-z0-9_]*\b/g)] + .filter(([name]) => /ForTest|TestHook|TestOnly/i.test(name)) + .map(([name]) => name); +} + +function analyzeFile(source, generated = false) { + const { code, literals, literalRanges, comments } = tokenize(source); + const violations = forbiddenIdentifiers(code).map( + (name) => `forbidden production identifier ${name}` + ); + for (const comment of comments) { + if ( + /\btest[- ]only\s+(?:hook|callback|seam|API|member|method|field|instrumentation|code|override|latch)\b|\bexists for tests\b/i.test( + comment + ) + ) { + violations.push("explicit test-only production marker"); + } + } + if (generated) { + const emitted = []; + for (let i = 0; i < literals.length; i++) { + const previous = literalRanges[i - 1]; + if (previous && source.slice(previous.end, literalRanges[i].start).trim() === "+") { + emitted[emitted.length - 1] += literals[i]; + } else emitted.push(literals[i]); + } + for (const literal of emitted) { + violations.push( + ...forbiddenIdentifiers(tokenize(literal).code).map( + (name) => `forbidden emitted identifier ${name}` + ) + ); + if (/^\s*#if\s+UNITY_INCLUDE_TESTS\s*$/m.test(literal)) + violations.push("emitted test-only conditional code"); + } + } + return [...new Set(violations)]; +} + +function main(root = repoRoot) { + const files = []; + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (skipped.has(entry.name)) continue; + const absolute = path.join(directory, entry.name); + const relative = path.relative(root, absolute).replace(/\\/g, "/"); + if (entry.isDirectory()) { + if (isProductionPath(relative)) visit(absolute); + } else if (entry.isFile() && entry.name.endsWith(".cs") && isProductionPath(relative)) + files.push({ absolute, relative }); + } + } + for (const relative of scanRoots) { + const directory = path.join(root, relative); + if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) { + console.error( + `[production-test-hooks] missing production root directory ${relative}; refusing partial acceptance.` + ); + return 1; + } + visit(directory); + } + if (!files.length) { + console.error( + "[production-test-hooks] no production C# files found; refusing empty acceptance." + ); + return 1; + } + let failed = 0; + for (const { absolute, relative } of files) { + for (const finding of analyzeFile( + fs.readFileSync(absolute, "utf8"), + relative.startsWith("Generator~/") + )) { + console.error(`[production-test-hooks] ${relative}: ${finding}`); + failed++; + } + } + console.log( + `[production-test-hooks] ${files.length} production files scanned, ${failed} findings.` + ); + return failed ? 1 : 0; +} +module.exports = { analyzeFile, isProductionPath, tokenize, main }; +if (require.main === module) + process.exitCode = main(process.env.PRODUCTION_TEST_HOOKS_ROOT || repoRoot); diff --git a/scripts/lint-production-test-hooks.js.meta b/scripts/lint-production-test-hooks.js.meta new file mode 100644 index 000000000..fc50e6ac1 --- /dev/null +++ b/scripts/lint-production-test-hooks.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3807dd21ea811dfb6638c291ab772dbe +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/run-contract-tests.js b/scripts/run-contract-tests.js index 6c2c56f43..d55f2d974 100644 --- a/scripts/run-contract-tests.js +++ b/scripts/run-contract-tests.js @@ -193,6 +193,11 @@ const CHECKS = [ name: "Typecheck asmdef-reference linter self-test", run: "npm run test:lint-typecheck-asmdef-references" }, + { + id: "lint-production-test-hooks", + name: "Production test-hook policy controls", + run: "npm run test:lint-production-test-hooks" + }, { id: "lint-xml-doc-summaries", name: "XML doc summary linter self-test", diff --git a/scripts/run-repo-lint.js b/scripts/run-repo-lint.js index ea85a1f59..9ff5f35f7 100644 --- a/scripts/run-repo-lint.js +++ b/scripts/run-repo-lint.js @@ -177,6 +177,11 @@ const CHECKS = [ name: "Nested types at the end of their containing type", run: "npm run lint:nested-type-placement" }, + { + id: "production-test-hooks", + name: "Production code excludes test-only hooks", + run: "npm run lint:production-test-hooks" + }, { id: "xml-doc-summaries", name: "One per doc comment block", diff --git a/scripts/tests/test-agent-preflight.ps1 b/scripts/tests/test-agent-preflight.ps1 index abac7ab8e..6c43b1387 100644 --- a/scripts/tests/test-agent-preflight.ps1 +++ b/scripts/tests/test-agent-preflight.ps1 @@ -1,5 +1,7 @@ Param( - [switch]$VerboseOutput + [switch]$VerboseOutput, + [switch]$LicensePipeOnly, + [switch]$PipePressureOnly ) Set-StrictMode -Version Latest @@ -431,6 +433,186 @@ exit 0 return $scriptPath } +function Invoke-BoundedPipeProcess { + param( + [string]$FileName, + [string[]]$Arguments, + [string]$WorkingDirectory + ) + + $process = $null + try { + $info = [System.Diagnostics.ProcessStartInfo]::new() + $info.FileName = $FileName + foreach ($argument in $Arguments) { + [void]$info.ArgumentList.Add($argument) + } + $info.WorkingDirectory = $WorkingDirectory + $info.UseShellExecute = $false + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $process = [System.Diagnostics.Process]::Start($info) + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $completed = $process.WaitForExit(10000) + if (-not $completed) { + $process.Kill($true) + $process.WaitForExit() + } + return [pscustomobject]@{ + Completed = $completed + ExitCode = $process.ExitCode + Stdout = $stdoutTask.GetAwaiter().GetResult() + Stderr = $stderrTask.GetAwaiter().GetResult() + } + } + finally { + if ($null -ne $process) { + if (-not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } + $process.Dispose() + } + } +} + +function Test-GitStderrPipeDrain { + $repoPath = New-TestRepo -ConfigurePushDefaults + try { + $arguments = @('-c', "alias.pressure=!printf '%100000s' x >&2; exit 37", 'pressure') + $control = Invoke-BoundedPipeProcess -FileName (Get-Command git -ErrorAction Stop).Source -Arguments $arguments -WorkingDirectory $repoPath + $stderrBytes = [System.Text.Encoding]::UTF8.GetByteCount($control.Stderr) + Write-TestResult 'GitStderrPipeDrain_ActualPressure' ($control.Completed -and $control.ExitCode -eq 37 -and $control.Stdout -eq '' -and $stderrBytes -eq 100000) "Expected real Git to emit 100000 stderr bytes and exit 37 within 10 seconds; completed=$($control.Completed), exit=$($control.ExitCode), stderrBytes=$stderrBytes." + + $sourceRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $sourceRoot 'scripts/agent-preflight.ps1'), [ref]$null, [ref]$errors) + if ($errors.Count -gt 0) { + throw "Preflight source parse failed: $($errors -join '; ')" + } + $runner = [System.Collections.Generic.List[string]]::new() + $runner.Add('param([string]$Helper, [string]$RepoRoot)') + $runner.Add("Set-StrictMode -Version Latest`n`$ErrorActionPreference = 'Stop'") + foreach ($name in @('Split-NulPathText', 'Invoke-GitPathList', 'Invoke-GitRawText')) { + $functions = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $false)) + if ($functions.Count -ne 1) { + throw "Expected exactly one actual preflight function named $name." + } + $runner.Add($functions[0].Extent.Text) + } + $runner.Add(@' +$result = & $Helper -RepoRoot $RepoRoot -Arguments @('-c', "alias.pressure=!printf '%100000s' x >&2; exit 37", 'pressure') +if ($Helper -eq 'Invoke-GitPathList') { + if (@($result).Count -ne 0) { + throw 'A failed Git command must return no paths.' + } +} +else { + if ($result.ExitCode -ne 37 -or $result.Stdout -ne '') { + throw 'Raw Git failure exit code or stdout changed.' + } +} +Write-Output 'PIPE_RETURN_OK' +exit 0 +'@) + $runnerPath = Join-Path $repoPath 'scripts/pipe-pressure-helper.ps1' + [System.IO.File]::WriteAllText($runnerPath, ($runner -join "`n")) + foreach ($helper in @('Invoke-GitPathList', 'Invoke-GitRawText')) { + $result = Invoke-BoundedPipeProcess -FileName (Get-Command pwsh -ErrorAction Stop).Source -Arguments @('-NoProfile', '-File', $runnerPath, '-Helper', $helper, '-RepoRoot', $repoPath) -WorkingDirectory $repoPath + Write-TestResult "GitStderrPipeDrain.$helper.CompletesWithinTimeout" $result.Completed "The actual helper did not complete within 10 seconds. Stderr: $($result.Stderr)" + Write-TestResult "GitStderrPipeDrain.$helper.PreservesReturnSemantics" ($result.Completed -and $result.ExitCode -eq 0 -and $result.Stdout.Contains('PIPE_RETURN_OK')) "The actual helper's return semantics failed. Exit=$($result.ExitCode); stderr=$($result.Stderr)" + } + } + finally { + Remove-Item -LiteralPath $repoPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Test-LicensePipeDrain { + $repoPath = New-TestRepo -ConfigurePushDefaults + $process = $null + try { + $fileCount = 512 + $directoryName = 'PipeDrain-' + ('p' * 90) + $directory = Join-Path $repoPath $directoryName + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $paths = [System.Collections.Generic.List[string]]::new() + $currentYear = (Get-Date).Year + for ($index = 0; $index -lt $fileCount; ++$index) { + $className = 'PipeDrain' + $index + $fileName = ('x' * 60) + $index.ToString('D4') + '.cs' + $relativePath = $directoryName + '/' + $fileName + $paths.Add($relativePath) + $year = if ($index -eq $fileCount - 1) { $currentYear - 1 } else { $currentYear } + [System.IO.File]::WriteAllText( + (Join-Path $repoPath $relativePath), + "// MIT License - Copyright (c) $year wallstop`n// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE`n`npublic sealed class $className {}`n" + ) + [System.IO.File]::WriteAllText( + (Join-Path $repoPath ($relativePath + '.meta')), + "fileFormatVersion: 2`nguid: $([Guid]::NewGuid().ToString('N'))`n" + ) + } + $pathList = Join-Path $repoPath 'license-pipe-paths.txt' + [System.IO.File]::WriteAllLines($pathList, $paths) + $inputBytes = [System.Text.Encoding]::UTF8.GetByteCount(($paths -join "`0") + "`0") + Write-TestResult 'LicensePipeDrain_ExceedsPipeCapacity' ($inputBytes -gt 65536) "Expected more than 64 KiB of real path input; got $inputBytes bytes." + + $info = [System.Diagnostics.ProcessStartInfo]::new() + $info.FileName = (Get-Command pwsh -ErrorAction Stop).Source + foreach ($argument in @('-NoProfile', '-File', (Join-Path $repoPath 'scripts/agent-preflight.ps1'), '-PathList', $pathList)) { + [void]$info.ArgumentList.Add($argument) + } + $info.WorkingDirectory = $repoPath + $info.UseShellExecute = $false + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $process = [System.Diagnostics.Process]::Start($info) + $outputTask = $process.StandardOutput.ReadToEndAsync() + $errorTask = $process.StandardError.ReadToEndAsync() + $completed = $process.WaitForExit(30000) + if (-not $completed) { + $process.Kill($true) + $process.WaitForExit() + } + $output = $outputTask.GetAwaiter().GetResult() + $errorTask.GetAwaiter().GetResult() + Write-TestResult 'LicensePipeDrain_CompletesWithinTimeout' $completed "Actual preflight did not complete within 30 seconds. Output: $output" + if ($completed) { + Write-TestResult 'LicensePipeDrain_LastFileChecked' ($process.ExitCode -eq 1 -and $output.Contains($paths[$fileCount - 1]) -and $output.Contains('License year header issues detected')) "Expected the last file's license mismatch, got exit $($process.ExitCode). Output: $output" + Write-TestResult 'LicensePipeDrain_LibraryNotSkipped' (-not $output.Contains('Skipped the license year check')) "The actual license library must execute. Output: $output" + } + } + finally { + if ($null -ne $process) { + if (-not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } + $process.Dispose() + } + Remove-Item -LiteralPath $repoPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ($PipePressureOnly) { + Test-GitStderrPipeDrain + if ($script:TestsFailed -gt 0) { + exit 1 + } + exit 0 +} + +Test-LicensePipeDrain +if ($LicensePipeOnly) { + if ($script:TestsFailed -gt 0) { + exit 1 + } + exit 0 +} + +Test-GitStderrPipeDrain + Write-Host 'Testing agent-preflight.ps1...' -ForegroundColor White Write-Host "`nTest group: isolated runspace harness" -ForegroundColor Magenta diff --git a/scripts/tests/test-lint-production-test-hooks.js b/scripts/tests/test-lint-production-test-hooks.js new file mode 100644 index 000000000..f76ecff52 --- /dev/null +++ b/scripts/tests/test-lint-production-test-hooks.js @@ -0,0 +1,209 @@ +#!/usr/bin/env node +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE +"use strict"; +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const script = path.resolve(__dirname, "..", "lint-production-test-hooks.js"); +const { analyzeFile, isProductionPath } = require(script); +let count = 0; +function check(name, body) { + body(); + count++; + console.log(`[PASS] ${name}`); +} +for (const source of [ + "internal void ApplyForTesting() {}", + "internal int CountForTests => 1;", + "internal class TestHooks {}", + "private Action TestOnlyCallback;", + "internal class TestHook {}", + "internal void runforTesting() {}", + "private Action testOnlyCallback;", + "internal void @RunForTesting() {}", + "internal void RunFor\\u0054esting() {}", + "#if UNITY_INCLUDE_TESTS\ninternal void RunForTests() {}\n#endif", + "internal void RunForTests\r() {}" +]) + check(source, () => assert(analyzeFile(source).length)); +check("generated lowercase singular hook", () => + assert(analyzeFile('writer.Line("internal class testHook {}");', true).length) +); +for (const source of [ + "// RunForTesting is forbidden\ninternal void Run() {}", + "/* internal class TestHooks {} */ internal class Real {}", + 'const string Message = "ForTesting";', + 'const string Message = @"TestHooks "" quoted";', + 'const string Message = """ForTests\nTestOnly""";', + "internal bool IsTestAssembly(Assembly assembly) => true;", + "#if UNITY_INCLUDE_TESTS\n// Runtime scans authoring assemblies.\ninternal bool IsTestAssemblyLoaded() => true;\n#endif", + "internal void Update(float elapsed) {}", + "internal Action Callback;" +]) + check(source, () => assert.deepStrictEqual(analyzeFile(source), [])); +check("explicit test-only injection marker", () => + assert(analyzeFile("// Test-only callback injection\ninternal Action Callback;").length) +); +check("test-only types are legitimate authoring docs", () => + assert.deepStrictEqual( + analyzeFile("/// Excludes test-only singleton types from discovery.\ninternal class Filter {}"), + [] + ) +); +check("generated regular literal", () => + assert(analyzeFile('writer.Line("internal void RunForTesting() {}");', true).length) +); +check("generated verbatim literal", () => + assert(analyzeFile('writer.Line(@"internal class TestHooks {}");', true).length) +); +check("generated escaped identifier", () => + assert(analyzeFile('writer.Line("internal void RunFor\\u0054esting() {}");', true).length) +); +check("generated raw literal", () => + assert(analyzeFile('writer.Line("""internal int CountForTests;""");', true).length) +); +check("generated test-only directive", () => + assert(analyzeFile('writer.Line("#if UNITY_INCLUDE_TESTS");', true).length) +); +check("generated ordinary registration", () => + assert.deepStrictEqual( + analyzeFile('writer.Line("internal static void Register() {}");', true), + [] + ) +); +for (const relative of [ + "Runtime/Helpers.cs", + "Editor/Helpers.cs", + "Generator~/WallstopStudios.UnityHelpers.Analyzers/Foo.cs", + "Generator~/WallstopStudios.UnityHelpers.Proto.Generator/Foo.cs", + "Generator~/NewProduction/Foo.cs", + "Samples~/Demo/Foo.cs", + "Styles/Foo.cs" +]) + check(relative, () => assert(isProductionPath(relative))); +for (const relative of [ + "Tests/Runtime/Foo.cs", + "Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/Foo.cs", + "Generator~/WallstopStudios.UnityHelpers.TestCheck/Foo.cs", + "Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Foo.cs", + "Generator~/ProtobufNetV2Oracle/Foo.cs" +]) + check(relative, () => assert(!isProductionPath(relative))); +check("uppercase Unicode source escape", () => + assert(analyzeFile("internal void RunFor\\U00000054esting() {}").length) +); +check("generated concatenated identifier", () => + assert(analyzeFile('writer.Line("internal void RunFor" + "Testing() {}");', true).length) +); +check("generated uppercase Unicode escape", () => + assert(analyzeFile('writer.Line("internal void RunFor\\U00000054esting() {}");', true).length) +); +check("generated documentation mention is not a member", () => + assert.deepStrictEqual(analyzeFile('writer.Line("/// ForTesting is forbidden.");', true), []) +); +check("generated escaped string value is not a member", () => + assert.deepStrictEqual( + analyzeFile('writer.Line("internal const string Message = \\"ForTesting\\";");', true), + [] + ) +); +check("ordinary character literal is masked", () => + assert.deepStrictEqual(analyzeFile("private char Quote = '\\''; internal void Run() {}"), []) +); +check("hook in otherwise inactive branch is rejected", () => + assert(analyzeFile("#if UNKNOWN_DEFINE\ninternal void RunForTests() {}\n#endif").length) +); +check("production Tests subfolder does not bypass policy", () => + assert(isProductionPath("Runtime/Tests/Hook.cs")) +); +check("production host-looking folder does not bypass policy", () => + assert(isProductionPath("Editor/WallstopStudios.UnityHelpers.TestCheck/Hook.cs")) +); +check("nested generator Tests folder remains production", () => + assert(isProductionPath("Generator~/Emitter/Tests/Hook.cs")) +); +check("interpolation-only reference is explicitly outside naming scan", () => + assert.deepStrictEqual(analyzeFile('string Message => $"{Foreign.RunForTests()}";'), []) +); +check("actual hook declaration remains rejected beside interpolation", () => + assert(analyzeFile('internal void RunForTests() {} string Message => $"{RunForTests()}";').length) +); +check("singular production hook is rejected", () => + assert(analyzeFile("internal bool GetConditionForTest() => true;").length) +); +check("singular escaped production hook is rejected", () => + assert(analyzeFile("internal void RunFor\\u0054est() {}").length) +); +check("singular generated hook is rejected", () => + assert(analyzeFile('writer.Line("internal void RunForTest() {}");', true).length) +); +check("singular escaped generated hook is rejected", () => + assert(analyzeFile('writer.Line("internal void RunFor\\u0054est() {}");', true).length) +); +const root = fs.mkdtempSync(path.join(os.tmpdir(), "production-test-hooks-")); +function write(relative, source) { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, source); +} +function cli() { + return spawnSync(process.execPath, [script], { + env: { ...process.env, PRODUCTION_TEST_HOOKS_ROOT: root }, + encoding: "utf8" + }); +} +try { + write("Runtime/Real.cs", "internal class Real {}"); + check("CLI rejects partial roots despite real subjects", () => { + const result = cli(); + assert.strictEqual(result.status, 1); + assert(result.stderr.includes("missing production root directory Editor")); + }); + fs.unlinkSync(path.join(root, "Runtime/Real.cs")); + for (const directory of ["Runtime", "Editor", "Generator~", "Samples~", "Styles"]) + fs.mkdirSync(path.join(root, directory), { recursive: true }); + check("CLI refuses empty acceptance", () => assert.strictEqual(cli().status, 1)); + write("Runtime/Real.cs", "internal class Real {}"); + write( + "Generator~/WallstopStudios.UnityHelpers.Analyzers.Tests/Excluded.cs", + "internal class TestHooks {}" + ); + check("CLI checks real source while excluding test tooling", () => + assert.strictEqual(cli().status, 0) + ); + write("Samples~/Demo/Hook.cs", "internal void RunForTesting() {}"); + check("CLI rejects sample hook", () => { + const result = cli(); + assert.strictEqual(result.status, 1); + assert(result.stderr.includes("Samples~/Demo/Hook.cs")); + }); + fs.unlinkSync(path.join(root, "Samples~/Demo/Hook.cs")); + fs.renameSync(path.join(root, "Editor"), path.join(root, "RenamedEditor")); + check("CLI rejects renamed root with other valid source", () => { + const result = cli(); + assert.strictEqual(result.status, 1); + assert(result.stderr.includes("missing production root directory Editor")); + }); + fs.renameSync(path.join(root, "RenamedEditor"), path.join(root, "Editor")); + fs.rmSync(path.join(root, "Styles"), { recursive: true }); + fs.writeFileSync(path.join(root, "Styles"), "not a directory"); + check("CLI rejects file replacing expected root", () => { + const result = cli(); + assert.strictEqual(result.status, 1); + assert(result.stderr.includes("missing production root directory Styles")); + }); + fs.unlinkSync(path.join(root, "Styles")); + fs.mkdirSync(path.join(root, "Styles")); + write("Generator~/Emitter/Emitter.cs", 'writer.Line("internal class TestHooks {}");'); + check("CLI rejects generated source hook", () => { + const result = cli(); + assert.strictEqual(result.status, 1); + assert(result.stderr.includes("forbidden emitted identifier")); + }); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} +console.log(`[test-lint-production-test-hooks] ${count} controls passed.`); diff --git a/scripts/tests/test-lint-production-test-hooks.js.meta b/scripts/tests/test-lint-production-test-hooks.js.meta new file mode 100644 index 000000000..97582d092 --- /dev/null +++ b/scripts/tests/test-lint-production-test-hooks.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 33a9876835465b804ab99a43e303ae39 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: