From 3caf77f607abda4002a58272536389b94562c41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 12:31:40 +0200 Subject: [PATCH 1/5] perf: skip default-scope dictionary registration for generator-emitted method setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fluent `Setup.MethodName(...)` pipeline routed every call through the generator-emitted `SetupMethod(int memberId, MethodSetup)` overload, which ran two parallel registrations: the lock-free `_setupsByMemberId` snapshot that backs the proxy fast-path (`GetMethodSetupSnapshot`), and the legacy string-keyed `MockScenarioSetup.Methods` list (`GetMethodSetups(name)`) inherited from the pre-snapshot dispatch. The dict path is only useful for hand-written `SetupMethod(MethodSetup)` callers (the `HttpClientExtensions` pipeline) and for scenario-scoped setups; generator-emitted default-scope setups were paying the full lazy `MethodSetups` wrapper + `List` allocation, the per-setup lock + `List.Add`, and the per-dispatch iterator state-machine on unmatched calls. Drop the dual registration: `SetupMethod(int, MethodSetup)` now writes only to the snapshot, and `SetupMethod(int, string, MethodSetup)` writes to the snapshot in the default scope and to the scenario bucket otherwise. `GetMethodSetups(name)` is rewritten to walk the snapshot table directly (filtered by `Name` for ref-struct dispatch and scenario fallback callers) followed by the dict, and `MockSetups.MethodSetups.EnumerateByName` returns `Array.Empty` instead of an empty-yielding state machine when its storage is null — letting the unmatched-dispatch hot path skip an iterator allocation per call when no hand-written entries exist. `GetUnusedSetups` now also walks the snapshot table so generator-emitted setups remain visible to the diagnostic enumeration even though they bypass the dict. Measured on `CombinedWorkflowBenchmarks` (2 mocks, 3 setups, 4 invokes, 4 verifies — mirrors TUnit's CombinedWorkflow): Pre 9600 B/iter Post 9320 B/iter (-280 B/iter) Time impact is below this benchmark's noise floor (~9.3 KB/iter, GC variance dominates the per-call savings). Indexer and event paths share the same dual-registration pattern; those are intentionally left for follow-up commits to keep this change focused. --- Source/Mockolate/MockRegistry.Interactions.cs | 76 ++++++++++++++++++- Source/Mockolate/MockRegistry.Setup.cs | 19 +++-- Source/Mockolate/MockRegistry.Verify.cs | 43 +++++++++++ Source/Mockolate/Setup/MockSetups.Methods.cs | 13 +++- 4 files changed, 138 insertions(+), 13 deletions(-) diff --git a/Source/Mockolate/MockRegistry.Interactions.cs b/Source/Mockolate/MockRegistry.Interactions.cs index 1457cb30..687d7137 100644 --- a/Source/Mockolate/MockRegistry.Interactions.cs +++ b/Source/Mockolate/MockRegistry.Interactions.cs @@ -118,8 +118,13 @@ public void ClearAllInteractions() /// /// The caller iterates and evaluates each setup's matcher on the stack (passing ref-struct /// values where applicable), so no predicate closure is captured. - /// Scenario-scoped results come first; the caller is expected to stop on the first match so - /// scenarios override the default scope. + /// Order: scenario-scoped dict (newest first), then default-scope memberId-keyed snapshot + /// entries (newest first per bucket, but bucket order is registration order), then default-scope + /// hand-written dict entries. The caller is expected to stop on the first match so scenarios + /// override the default scope. + /// When the call stays entirely on the default scope and neither the snapshot table nor the + /// dict has any entries, the empty-storage fast path returns + /// directly so the dispatch hot path skips an iterator state-machine allocation per call. /// /// The concrete subtype to return. /// The simple method name. @@ -129,7 +134,30 @@ public IEnumerable GetMethodSetups(string methodName) where T : MethodSetu if (!string.IsNullOrEmpty(Scenario) && Setup.TryGetScenario(Scenario, out MockScenarioSetup? scopedBucket)) { - foreach (T setup in scopedBucket.Methods.EnumerateByName(methodName)) + return EnumerateScopedAndGlobalMethodSetups(methodName, scopedBucket); + } + + MethodSetup[]?[]? snapshot = Volatile.Read(ref _setupsByMemberId); + if (snapshot is null) + { + return Setup.Methods.EnumerateByName(methodName); + } + + return EnumerateGlobalMethodSetups(methodName, snapshot); + } + + private IEnumerable EnumerateScopedAndGlobalMethodSetups(string methodName, + MockScenarioSetup scopedBucket) where T : MethodSetup + { + foreach (T setup in scopedBucket.Methods.EnumerateByName(methodName)) + { + yield return setup; + } + + MethodSetup[]?[]? snapshot = Volatile.Read(ref _setupsByMemberId); + if (snapshot is not null) + { + foreach (T setup in EnumerateSnapshotByName(methodName, snapshot)) { yield return setup; } @@ -141,6 +169,48 @@ public IEnumerable GetMethodSetups(string methodName) where T : MethodSetu } } + private IEnumerable EnumerateGlobalMethodSetups(string methodName, + MethodSetup[]?[] snapshot) where T : MethodSetup + { + foreach (T setup in EnumerateSnapshotByName(methodName, snapshot)) + { + yield return setup; + } + + // Hand-written SetupMethod(MethodSetup) entries (e.g. the HttpClientExtensions pipeline) live + // only in the root dict; the empty-storage fast path returns Array.Empty so the loop + // allocates nothing further when no such entry exists. + foreach (T setup in Setup.Methods.EnumerateByName(methodName)) + { + yield return setup; + } + } + + private static IEnumerable EnumerateSnapshotByName(string methodName, + MethodSetup[]?[] snapshot) where T : MethodSetup + { + // Walk every memberId bucket: the snapshot is keyed by member id, but a name-based caller + // (scenario-active dispatch, ref-struct dispatch) doesn't know the id, so we filter by Name + // after the type-test. Bucket count grows linearly with registered setups, so the scan is + // proportional to setup count, not interaction count. + for (int b = 0; b < snapshot.Length; b++) + { + MethodSetup[]? bucket = snapshot[b]; + if (bucket is null) + { + continue; + } + + for (int i = bucket.Length - 1; i >= 0; i--) + { + if (bucket[i] is T typed && typed.Name.Equals(methodName)) + { + yield return typed; + } + } + } + } + /// /// Returns the latest registered indexer setup of type that satisfies /// , or when no setup matches. Scenario-scoped setups diff --git a/Source/Mockolate/MockRegistry.Setup.cs b/Source/Mockolate/MockRegistry.Setup.cs index 7b99aedd..c4a741dd 100644 --- a/Source/Mockolate/MockRegistry.Setup.cs +++ b/Source/Mockolate/MockRegistry.Setup.cs @@ -131,37 +131,40 @@ public void SetupMethod(string scenario, MethodSetup methodSetup) => Setup.GetOrCreateScenario(scenario).Methods.Add(methodSetup); /// - /// Registers for the default scenario and additionally indexes it by the + /// Registers for the default scenario by indexing it under the /// generator-emitted for fast dispatch from the proxy method body. /// /// /// The is a compile-time constant emitted by the source generator, one per /// mocked member. Reads via are lock-free; writes take an /// internal lock and publish a new snapshot via . + /// The default-scope string-keyed list () is intentionally + /// bypassed — the snapshot is authoritative for default-scope dispatch from generator-emitted code. /// /// The generator-emitted member id for the setup's target member. /// The method setup produced by the fluent Setup.MethodName(...) API. public void SetupMethod(int memberId, MethodSetup methodSetup) - { - SetupMethod(methodSetup); - AppendToMemberIdBucket(memberId, methodSetup); - } + => AppendToMemberIdBucket(memberId, methodSetup); /// /// Registers for the given . When - /// is the default scope, the setup is additionally indexed by - /// for fast dispatch. + /// is the default scope, the setup is indexed by + /// for fast dispatch instead of the string-keyed list; scenario-scoped setups continue to land in the + /// scenario bucket only. /// /// The generator-emitted member id for the setup's target member. /// The scenario name the setup applies to. /// The method setup produced by the fluent Setup.MethodName(...) API. public void SetupMethod(int memberId, string scenario, MethodSetup methodSetup) { - SetupMethod(scenario, methodSetup); if (string.IsNullOrEmpty(scenario)) { AppendToMemberIdBucket(memberId, methodSetup); } + else + { + SetupMethod(scenario, methodSetup); + } } private void AppendToMemberIdBucket(int memberId, MethodSetup methodSetup) diff --git a/Source/Mockolate/MockRegistry.Verify.cs b/Source/Mockolate/MockRegistry.Verify.cs index 4eb0e5d0..cb42b791 100644 --- a/Source/Mockolate/MockRegistry.Verify.cs +++ b/Source/Mockolate/MockRegistry.Verify.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; +using System.Threading; using Mockolate.Exceptions; using Mockolate.Interactions; using Mockolate.Internals; @@ -754,6 +756,11 @@ public VerificationResult UnsubscribedFromTyped(T subject, int memberId, s /// Returns every registered setup (indexer, property, method) that was not hit by any of the given /// . /// + /// + /// The default-scope method snapshot table populated by the + /// overloads is walked alongside the string-keyed list, so generator-emitted setups are visible to the + /// diagnostic enumeration even though they bypass the dictionary. + /// /// The interactions to check against. /// The unused setups; empty when every setup was exercised. public IReadOnlyCollection GetUnusedSetups(IMockInteractions interactions) @@ -763,7 +770,43 @@ public IReadOnlyCollection GetUnusedSetups(IMockInteractions interaction ..Setup.Indexers.EnumerateUnusedSetupsBy(interactions), ..Setup.Properties.EnumerateUnusedSetupsBy(interactions), ..Setup.Methods.EnumerateUnusedSetupsBy(interactions), + ..EnumerateUnusedMethodSnapshotSetups(interactions), ]; return unusedSetups; } + + private IEnumerable EnumerateUnusedMethodSnapshotSetups(IMockInteractions interactions) + { + MethodSetup[]?[]? table = Volatile.Read(ref _setupsByMemberId); + if (table is null) + { + yield break; + } + + foreach (MethodSetup[]? bucket in table) + { + if (bucket is null) + { + continue; + } + + foreach (MethodSetup setup in bucket) + { + bool matched = false; + foreach (IMethodInteraction interaction in interactions.OfType()) + { + if (((IVerifiableMethodSetup)setup).Matches(interaction)) + { + matched = true; + break; + } + } + + if (!matched) + { + yield return setup; + } + } + } + } } diff --git a/Source/Mockolate/Setup/MockSetups.Methods.cs b/Source/Mockolate/Setup/MockSetups.Methods.cs index 0dc2a451..0e0a581f 100644 --- a/Source/Mockolate/Setup/MockSetups.Methods.cs +++ b/Source/Mockolate/Setup/MockSetups.Methods.cs @@ -85,16 +85,25 @@ public void Add(MethodSetup setup) /// Used by the generated code for methods with ref-struct parameters, where the usual /// GetMatching predicate cannot capture the ref-struct value. Callers iterate this /// sequence and evaluate Matches synchronously on the stack, then invoke the first - /// matching setup. + /// matching setup. The empty-storage path returns instead of a + /// yielding state machine, so the dispatch hot path skips an iterator allocation per call when + /// no string-keyed setups have been registered (the common case after generator-emitted + /// SetupMethod(int, ...) bypasses this list). /// public IEnumerable EnumerateByName(string methodName) where T : MethodSetup { List? storage = _storage; if (storage is null) { - yield break; + return Array.Empty(); } + return EnumerateByNameCore(methodName, storage); + } + + private static IEnumerable EnumerateByNameCore(string methodName, List storage) + where T : MethodSetup + { // Snapshot the matching entries under lock; yield them without holding the lock so the // caller's Matches/Invoke can run user code (including throws) without risk of re-entering // the storage lock on the same thread. From 921c91823ac3a653c1f538a2625c4d1dc53ef963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 13:36:03 +0200 Subject: [PATCH 2/5] perf: skip default-scope dictionary registration for generator-emitted indexer and event setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the methods-side optimization to indexers and events. The fluent `Setup[...]` and `Setup.EventName` pipelines routed every generator-emitted call through the `SetupIndexer(int, ...)` / `SetupEvent(int, ...)` overloads, which dual-registered to both the lock-free snapshot table and the legacy string-keyed `MockScenarioSetup.Indexers` / `.Events` lists. The dict path is only useful for legacy `SetupIndexer(IndexerSetup)` / `SetupEvent(EventSetup)` callers and for scenario-scoped setups; the generator-emitted default-scope case was paying the full lazy wrapper + `List` allocation per mock and the lock + `List.Add` per setup. Drop the dual registration: - `SetupIndexer(int, IndexerSetup)` / `SetupEvent(int, EventSetup)` now write only to the snapshot. - `SetupIndexer(int, string, IndexerSetup)` / `SetupEvent(int, string, EventSetup)` write to the snapshot in the default scope and to the scenario bucket otherwise. - `GetIndexerSetup(predicate)` and `GetIndexerSetup(IndexerAccess)` walk the indexer snapshot table after the scenario bucket and before the root dict, so scenario-fallback callers (when `IsNullOrEmpty(Scenario)` is false in the generated dispatch) and ref-struct indexers still find default-scope memberId-registered setups. - `GetEventSetupsByName(name)` walks the event snapshot table filtered by `Name`, which gives the unsubscribe-side dispatch (snapshot keyed only by the subscribe id) a path to find its setup, and lets scenario-fallback reach default-scope events. - `GetUnusedSetups` now also walks the indexer snapshot table for diagnostic visibility, mirroring the methods change. Measured with the existing per-feature benchmarks (Mockolate baseline only, .NET 10, MediumRun) — comparing pre-perf-work (HEAD~1, no opts) to methods+indexers+events: Indexer_Mockolate (N=1) 1.385 us / 3.90 KB -> 1.202 us / 3.81 KB (-13.2% time, -90 B) Indexer_Mockolate (N=10) 3.663 us / 4.95 KB -> 3.393 us / 4.87 KB (-7.4% time, -80 B) Method_Mockolate (N=1) 617.1 ns / 2.15 KB -> 541.7 ns / 2.04 KB (-12.2% time, -110 B) Method_Mockolate (N=10) 998.3 ns / 2.36 KB -> 912.4 ns / 2.25 KB (-8.6% time, -110 B) Workflow_Mockolate 3.377 us / 9.35 KB -> 3.306 us / 9.08 KB (-2.1% time, -270 B) Event_Mockolate 446.4 ns / 1.83 KB -> 455.4 ns / 1.85 KB (within noise; CI margin ±28 ns) The indexer and method per-feature workloads measure cleaner than the combined workflow because they spend a larger fraction of time on the setup + dispatch paths the optimization touches. The event benchmark already hit the snapshot fast path on subscribe before the change, so the saved work on registration shows up only in cold paths the benchmark doesn't exercise. No public API surface changes; only private helpers added. --- Source/Mockolate/MockRegistry.Interactions.cs | 99 ++++++++++++++++++- Source/Mockolate/MockRegistry.Setup.cs | 45 +++++---- Source/Mockolate/MockRegistry.Verify.cs | 41 +++++++- 3 files changed, 165 insertions(+), 20 deletions(-) diff --git a/Source/Mockolate/MockRegistry.Interactions.cs b/Source/Mockolate/MockRegistry.Interactions.cs index 687d7137..949a9371 100644 --- a/Source/Mockolate/MockRegistry.Interactions.cs +++ b/Source/Mockolate/MockRegistry.Interactions.cs @@ -94,7 +94,7 @@ public void ClearAllInteractions() /// , or when no setup has been registered. /// /// - /// Property dispatch reads the snapshot via + /// Property dispatch reads the snapshot via /// and falls back to the cold path when the snapshot is empty, so this accessor is intended for /// diagnostics and tests that need to verify the fast-path table directly. /// @@ -232,6 +232,12 @@ private static IEnumerable EnumerateSnapshotByName(string methodName, } } + T? snapshot = GetMatchingIndexerSetupFromSnapshot(predicate); + if (snapshot is not null) + { + return snapshot; + } + return Setup.Indexers.GetMatching(predicate); } @@ -255,9 +261,75 @@ private static IEnumerable EnumerateSnapshotByName(string methodName, } } + T? snapshot = GetMatchingIndexerSetupFromSnapshot(access); + if (snapshot is not null) + { + return snapshot; + } + return Setup.Indexers.GetMatching(access); } + private T? GetMatchingIndexerSetupFromSnapshot(Func predicate) where T : IndexerSetup + { + IndexerSetup[]?[]? table = Volatile.Read(ref _indexerSetupsByMemberId); + if (table is null) + { + return null; + } + + // Walk every memberId bucket: the snapshot is keyed by member id, but predicate-based callers + // don't know which id to consult, so we filter by type then by predicate. Within each bucket + // the latest registration wins (reverse iteration); buckets are walked in registration order. + for (int b = 0; b < table.Length; b++) + { + IndexerSetup[]? bucket = table[b]; + if (bucket is null) + { + continue; + } + + for (int i = bucket.Length - 1; i >= 0; i--) + { + if (bucket[i] is T typed && predicate(typed)) + { + return typed; + } + } + } + + return null; + } + + private T? GetMatchingIndexerSetupFromSnapshot(IndexerAccess access) where T : IndexerSetup + { + IndexerSetup[]?[]? table = Volatile.Read(ref _indexerSetupsByMemberId); + if (table is null) + { + return null; + } + + for (int b = 0; b < table.Length; b++) + { + IndexerSetup[]? bucket = table[b]; + if (bucket is null) + { + continue; + } + + for (int i = bucket.Length - 1; i >= 0; i--) + { + if (bucket[i] is T typed && + ((IInteractiveIndexerSetup)typed).Matches(access)) + { + return typed; + } + } + } + + return null; + } + /// /// Handles the no-matching-setup fallback for an indexer getter: returns any previously stored value, throws /// when is , or otherwise stores and @@ -431,6 +503,31 @@ private IEnumerable GetEventSetupsByName(string name) } } + // Default-scope: walk the memberId-keyed snapshot table for generator-emitted setups (the + // SetupEvent(int, ...) overloads bypass the dict). The unsubscribe-side dispatch always falls + // here because the snapshot is keyed by subscribe id only — the bucket walk reunites it with + // its setup. Then walk the dict for legacy SetupEvent(EventSetup) entries. + EventSetup[]?[]? table = Volatile.Read(ref _eventSetupsByMemberId); + if (table is not null) + { + for (int b = 0; b < table.Length; b++) + { + EventSetup[]? bucket = table[b]; + if (bucket is null) + { + continue; + } + + for (int i = 0; i < bucket.Length; i++) + { + if (bucket[i].Name.Equals(name)) + { + yield return bucket[i]; + } + } + } + } + foreach (EventSetup setup in Setup.Events.GetByName(name)) { yield return setup; diff --git a/Source/Mockolate/MockRegistry.Setup.cs b/Source/Mockolate/MockRegistry.Setup.cs index c4a741dd..758b4ff2 100644 --- a/Source/Mockolate/MockRegistry.Setup.cs +++ b/Source/Mockolate/MockRegistry.Setup.cs @@ -43,32 +43,38 @@ public void SetupIndexer(string scenario, IndexerSetup indexerSetup) => Setup.GetOrCreateScenario(scenario).Indexers.Add(indexerSetup); /// - /// Registers for the default scenario and additionally indexes it by the + /// Registers for the default scenario by indexing it under the /// generator-emitted for fast dispatch from the proxy indexer body. /// + /// + /// The default-scope string-keyed list is intentionally bypassed — the snapshot is authoritative + /// for default-scope dispatch from generator-emitted code, and + /// consults the snapshot table directly for scenario-active fallback callers. + /// /// The generator-emitted member id for the setup's target indexer accessor. /// The indexer setup produced by the fluent Setup[...] API. public void SetupIndexer(int memberId, IndexerSetup indexerSetup) - { - SetupIndexer(indexerSetup); - AppendToIndexerMemberIdBucket(memberId, indexerSetup); - } + => AppendToIndexerMemberIdBucket(memberId, indexerSetup); /// /// Registers for the given . When - /// is the default scope, the setup is additionally indexed by - /// for fast dispatch. + /// is the default scope, the setup is indexed by + /// for fast dispatch instead of the string-keyed list; scenario-scoped setups continue to land in the + /// scenario bucket only. /// /// The generator-emitted member id for the setup's target indexer accessor. /// The scenario name the setup applies to. /// The indexer setup produced by the fluent Setup[...] API. public void SetupIndexer(int memberId, string scenario, IndexerSetup indexerSetup) { - SetupIndexer(scenario, indexerSetup); if (string.IsNullOrEmpty(scenario)) { AppendToIndexerMemberIdBucket(memberId, indexerSetup); } + else + { + SetupIndexer(scenario, indexerSetup); + } } private void AppendToIndexerMemberIdBucket(int memberId, IndexerSetup indexerSetup) @@ -313,38 +319,43 @@ public void SetupEvent(string scenario, EventSetup eventSetup) => Setup.GetOrCreateScenario(scenario).Events.Add(eventSetup); /// - /// Registers for the default scenario and additionally indexes it by the - /// generator-emitted for fast dispatch from the proxy event subscribe/unsubscribe body. + /// Registers for the default scenario by indexing it under the + /// generator-emitted subscribe-side for fast dispatch from the proxy event + /// subscribe/unsubscribe body. /// /// /// The is the subscribe-side member id emitted by the source generator. A /// single typically wires both subscribe and unsubscribe behavior, so the bucket /// is keyed off the subscribe id. Reads via are lock-free; writes /// take an internal lock and publish a new snapshot via . + /// The default-scope string-keyed list is intentionally bypassed — the snapshot is authoritative for + /// default-scope subscribe dispatch. The unsubscribe path falls through to a name-filtered scan of the + /// snapshot table inside GetEventSetupsByName (see ). /// /// The generator-emitted subscribe-side member id for the setup's target event. /// The event setup produced by the fluent Setup.EventName API. public void SetupEvent(int memberId, EventSetup eventSetup) - { - SetupEvent(eventSetup); - AppendToEventMemberIdBucket(memberId, eventSetup); - } + => AppendToEventMemberIdBucket(memberId, eventSetup); /// /// Registers for the given . When - /// is the default scope, the setup is additionally indexed by - /// for fast dispatch. + /// is the default scope, the setup is indexed by + /// for fast dispatch instead of the string-keyed list; scenario-scoped setups continue to land in the + /// scenario bucket only. /// /// The generator-emitted subscribe-side member id for the setup's target event. /// The scenario name the setup applies to. /// The event setup produced by the fluent Setup.EventName API. public void SetupEvent(int memberId, string scenario, EventSetup eventSetup) { - SetupEvent(scenario, eventSetup); if (string.IsNullOrEmpty(scenario)) { AppendToEventMemberIdBucket(memberId, eventSetup); } + else + { + SetupEvent(scenario, eventSetup); + } } private void AppendToEventMemberIdBucket(int memberId, EventSetup eventSetup) diff --git a/Source/Mockolate/MockRegistry.Verify.cs b/Source/Mockolate/MockRegistry.Verify.cs index cb42b791..d788f14e 100644 --- a/Source/Mockolate/MockRegistry.Verify.cs +++ b/Source/Mockolate/MockRegistry.Verify.cs @@ -757,8 +757,9 @@ public VerificationResult UnsubscribedFromTyped(T subject, int memberId, s /// . /// /// - /// The default-scope method snapshot table populated by the - /// overloads is walked alongside the string-keyed list, so generator-emitted setups are visible to the + /// The default-scope method and indexer snapshot tables populated by the + /// / overloads + /// are walked alongside the string-keyed lists, so generator-emitted setups are visible to the /// diagnostic enumeration even though they bypass the dictionary. /// /// The interactions to check against. @@ -771,6 +772,7 @@ public IReadOnlyCollection GetUnusedSetups(IMockInteractions interaction ..Setup.Properties.EnumerateUnusedSetupsBy(interactions), ..Setup.Methods.EnumerateUnusedSetupsBy(interactions), ..EnumerateUnusedMethodSnapshotSetups(interactions), + ..EnumerateUnusedIndexerSnapshotSetups(interactions), ]; return unusedSetups; } @@ -809,4 +811,39 @@ private IEnumerable EnumerateUnusedMethodSnapshotSetups(IMockIntera } } } + + private IEnumerable EnumerateUnusedIndexerSnapshotSetups(IMockInteractions interactions) + { + IndexerSetup[]?[]? table = Volatile.Read(ref _indexerSetupsByMemberId); + if (table is null) + { + yield break; + } + + foreach (IndexerSetup[]? bucket in table) + { + if (bucket is null) + { + continue; + } + + foreach (IndexerSetup setup in bucket) + { + bool matched = false; + foreach (IndexerAccess access in interactions.OfType()) + { + if (((IInteractiveIndexerSetup)setup).Matches(access)) + { + matched = true; + break; + } + } + + if (!matched) + { + yield return setup; + } + } + } + } } From e370907c4d886232dfd7d116dc1f203aabaefc02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 14:06:07 +0200 Subject: [PATCH 3/5] refactor: split GetEventSetupsByName for lower cognitive complexity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method had three responsibilities tangled together: scenario-scoped override detection (with a hasScoped flag and yield break), the memberId snapshot walk (two nested loops with a null check), and the root dict walk. Split into three methods: - GetEventSetupsByName decides scoped-vs-default. The scoped path now uses a Count check on the List that EventSetups.GetByName already returns, dropping the lazy "did we yield anything?" flag-and-yield-break pattern. - EnumerateDefaultScopeEventSetupsByName chains the snapshot walk and the root dict walk — no branching of its own. - EnumerateEventSnapshotByName is the bucket-walk loop in isolation. Each method now has a single, easily-named responsibility. No behavioral or allocation-shape change: callers (AddEvent / RemoveEvent dispatch) iterate fully so eager materialization of the scoped list is safe, and GetByName was already allocating a List per call. --- Source/Mockolate/MockRegistry.Interactions.cs | 72 +++++++++++-------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/Source/Mockolate/MockRegistry.Interactions.cs b/Source/Mockolate/MockRegistry.Interactions.cs index 949a9371..2a50bca1 100644 --- a/Source/Mockolate/MockRegistry.Interactions.cs +++ b/Source/Mockolate/MockRegistry.Interactions.cs @@ -487,51 +487,65 @@ public void RegisterInteraction(IInteraction interaction) private IEnumerable GetEventSetupsByName(string name) { + // Scenario-scoped setups override default scope when at least one matches the name. The + // underlying GetByName already returns a materialized List, so a Count check is cheaper than + // the lazy "did we yield anything?" flag pattern this used to track. if (!string.IsNullOrEmpty(Scenario) && Setup.TryGetScenario(Scenario, out MockScenarioSetup? scopedBucket)) { - bool hasScoped = false; - foreach (EventSetup setup in scopedBucket.Events.GetByName(name)) + List scoped = scopedBucket.Events.GetByName(name); + if (scoped.Count > 0) { - hasScoped = true; - yield return setup; + return scoped; } + } - if (hasScoped) - { - yield break; - } + return EnumerateDefaultScopeEventSetupsByName(name); + } + + /// + /// Walks the memberId-keyed snapshot table for generator-emitted setups (the + /// SetupEvent(int, ...) overloads bypass the dict), then the root dict for legacy + /// SetupEvent(EventSetup) entries. The unsubscribe-side dispatch always lands here + /// because the snapshot is keyed by subscribe id only — the bucket walk reunites it with its + /// setup. + /// + private IEnumerable EnumerateDefaultScopeEventSetupsByName(string name) + { + foreach (EventSetup setup in EnumerateEventSnapshotByName(name)) + { + yield return setup; } - // Default-scope: walk the memberId-keyed snapshot table for generator-emitted setups (the - // SetupEvent(int, ...) overloads bypass the dict). The unsubscribe-side dispatch always falls - // here because the snapshot is keyed by subscribe id only — the bucket walk reunites it with - // its setup. Then walk the dict for legacy SetupEvent(EventSetup) entries. + foreach (EventSetup setup in Setup.Events.GetByName(name)) + { + yield return setup; + } + } + + private IEnumerable EnumerateEventSnapshotByName(string name) + { EventSetup[]?[]? table = Volatile.Read(ref _eventSetupsByMemberId); - if (table is not null) + if (table is null) + { + yield break; + } + + foreach (EventSetup[]? bucket in table) { - for (int b = 0; b < table.Length; b++) + if (bucket is null) { - EventSetup[]? bucket = table[b]; - if (bucket is null) - { - continue; - } + continue; + } - for (int i = 0; i < bucket.Length; i++) + foreach (EventSetup setup in bucket) + { + if (setup.Name.Equals(name)) { - if (bucket[i].Name.Equals(name)) - { - yield return bucket[i]; - } + yield return setup; } } } - - foreach (EventSetup setup in Setup.Events.GetByName(name)) - { - yield return setup; - } } /// From 1a9a22290f228d4fef0e79b4993cf90c186f4802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 14:19:21 +0200 Subject: [PATCH 4/5] Apply suggestions from code review --- Source/Mockolate/MockRegistry.Interactions.cs | 15 ++++++------ Source/Mockolate/MockRegistry.Verify.cs | 23 ++++++++++++++++--- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Source/Mockolate/MockRegistry.Interactions.cs b/Source/Mockolate/MockRegistry.Interactions.cs index 2a50bca1..37f3f7ea 100644 --- a/Source/Mockolate/MockRegistry.Interactions.cs +++ b/Source/Mockolate/MockRegistry.Interactions.cs @@ -119,9 +119,9 @@ public void ClearAllInteractions() /// The caller iterates and evaluates each setup's matcher on the stack (passing ref-struct /// values where applicable), so no predicate closure is captured. /// Order: scenario-scoped dict (newest first), then default-scope memberId-keyed snapshot - /// entries (newest first per bucket, but bucket order is registration order), then default-scope - /// hand-written dict entries. The caller is expected to stop on the first match so scenarios - /// override the default scope. + /// entries (newest first within each bucket, with buckets visited in memberId/index order), + /// then default-scope hand-written dict entries. The caller is expected to stop on the first + /// match so scenarios override the default scope. /// When the call stays entirely on the default scope and neither the snapshot table nor the /// dict has any entries, the empty-storage fast path returns /// directly so the dispatch hot path skips an iterator state-machine allocation per call. @@ -191,8 +191,8 @@ private static IEnumerable EnumerateSnapshotByName(string methodName, { // Walk every memberId bucket: the snapshot is keyed by member id, but a name-based caller // (scenario-active dispatch, ref-struct dispatch) doesn't know the id, so we filter by Name - // after the type-test. Bucket count grows linearly with registered setups, so the scan is - // proportional to setup count, not interaction count. + // after the type-test. The outer scan is proportional to snapshot.Length (the memberId table + // size / mocked member count), not interaction count; the table may be sparse for a method. for (int b = 0; b < snapshot.Length; b++) { MethodSetup[]? bucket = snapshot[b]; @@ -279,8 +279,9 @@ private static IEnumerable EnumerateSnapshotByName(string methodName, } // Walk every memberId bucket: the snapshot is keyed by member id, but predicate-based callers - // don't know which id to consult, so we filter by type then by predicate. Within each bucket - // the latest registration wins (reverse iteration); buckets are walked in registration order. + // don't know which id to consult, so we filter by type then by predicate. Buckets are walked + // by ascending memberId/index order; within each bucket, reverse iteration means the latest + // registration wins. for (int b = 0; b < table.Length; b++) { IndexerSetup[]? bucket = table[b]; diff --git a/Source/Mockolate/MockRegistry.Verify.cs b/Source/Mockolate/MockRegistry.Verify.cs index d788f14e..56dd20ec 100644 --- a/Source/Mockolate/MockRegistry.Verify.cs +++ b/Source/Mockolate/MockRegistry.Verify.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading; using Mockolate.Exceptions; using Mockolate.Interactions; @@ -785,6 +784,15 @@ private IEnumerable EnumerateUnusedMethodSnapshotSetups(IMockIntera yield break; } + List methodInteractions = []; + foreach (IInteraction interaction in interactions) + { + if (interaction is IMethodInteraction method) + { + methodInteractions.Add(method); + } + } + foreach (MethodSetup[]? bucket in table) { if (bucket is null) @@ -795,7 +803,7 @@ private IEnumerable EnumerateUnusedMethodSnapshotSetups(IMockIntera foreach (MethodSetup setup in bucket) { bool matched = false; - foreach (IMethodInteraction interaction in interactions.OfType()) + foreach (IMethodInteraction interaction in methodInteractions) { if (((IVerifiableMethodSetup)setup).Matches(interaction)) { @@ -820,6 +828,15 @@ private IEnumerable EnumerateUnusedIndexerSnapshotSetups(IMockInte yield break; } + List indexerAccesses = []; + foreach (IInteraction interaction in interactions) + { + if (interaction is IndexerAccess access) + { + indexerAccesses.Add(access); + } + } + foreach (IndexerSetup[]? bucket in table) { if (bucket is null) @@ -830,7 +847,7 @@ private IEnumerable EnumerateUnusedIndexerSnapshotSetups(IMockInte foreach (IndexerSetup setup in bucket) { bool matched = false; - foreach (IndexerAccess access in interactions.OfType()) + foreach (IndexerAccess access in indexerAccesses) { if (((IInteractiveIndexerSetup)setup).Matches(access)) { From 2deaeab100a69ff1106073f83c01ac794546d4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 17:30:40 +0200 Subject: [PATCH 5/5] Fix review issues --- .../MockRegistrySnapshotRetrievalTests.cs | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 Tests/Mockolate.Internal.Tests/Registry/MockRegistrySnapshotRetrievalTests.cs diff --git a/Tests/Mockolate.Internal.Tests/Registry/MockRegistrySnapshotRetrievalTests.cs b/Tests/Mockolate.Internal.Tests/Registry/MockRegistrySnapshotRetrievalTests.cs new file mode 100644 index 00000000..20df9ed7 --- /dev/null +++ b/Tests/Mockolate.Internal.Tests/Registry/MockRegistrySnapshotRetrievalTests.cs @@ -0,0 +1,196 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Mockolate.Interactions; +using Mockolate.Internal.Tests.TestHelpers; +using Mockolate.Setup; + +namespace Mockolate.Internal.Tests.Registry; + +public sealed class MockRegistrySnapshotRetrievalTests +{ + private static MethodInfo GetMethodInfo() + => typeof(MockRegistrySnapshotRetrievalTests).GetMethod(nameof(GetMethodInfo), + BindingFlags.Static | BindingFlags.NonPublic)!; + + public sealed class GetMethodSetupsTests + { + [Fact] + public async Task WithActiveScenarioAndScopedSetup_YieldsScopedThenSnapshot() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeMethodSetup snapshotSetup = new(); + FakeMethodSetup scopedSetup = new(); + registry.SetupMethod(2, snapshotSetup); + registry.Setup.GetOrCreateScenario("a").Methods.Add(scopedSetup); + + registry.TransitionTo("a"); + + List setups = registry.GetMethodSetups("foo").ToList(); + + await That(setups).HasCount(2); + await That(setups[0]).IsSameAs(scopedSetup); + await That(setups[1]).IsSameAs(snapshotSetup); + } + + [Fact] + public async Task WithActiveScenarioButNoScopedBucket_FallsBackToSnapshot() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeMethodSetup snapshotSetup = new(); + + registry.SetupMethod(2, snapshotSetup); + registry.TransitionTo("never-registered"); + + List setups = registry.GetMethodSetups("foo").ToList(); + + await That(setups).HasCount(1); + await That(setups[0]).IsSameAs(snapshotSetup); + } + + [Fact] + public async Task WithSnapshotAndGlobalDictSetups_YieldsSnapshotBeforeGlobalDict() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeMethodSetup snapshotSetup = new(); + FakeMethodSetup globalDictSetup = new(); + + registry.SetupMethod(2, snapshotSetup); + registry.Setup.Methods.Add(globalDictSetup); + + List setups = registry.GetMethodSetups("foo").ToList(); + + await That(setups).HasCount(2); + await That(setups[0]).IsSameAs(snapshotSetup); + await That(setups[1]).IsSameAs(globalDictSetup); + } + + [Fact] + public async Task WithSnapshotSetupOnly_YieldsSnapshotSetup() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeMethodSetup setup = new(); + + registry.SetupMethod(2, setup); + + List setups = registry.GetMethodSetups("foo").ToList(); + + await That(setups).HasCount(1); + await That(setups[0]).IsSameAs(setup); + } + } + + public sealed class GetIndexerSetupTests + { + [Fact] + public async Task ByAccess_WithActiveScenarioAndScopedDoesNotMatch_FallsBackToSnapshot() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeIndexerSetup snapshotSetup = new(true); + FakeIndexerSetup scopedNonMatching = new(false); + registry.SetupIndexer(0, snapshotSetup); + registry.Setup.GetOrCreateScenario("a").Indexers.Add(scopedNonMatching); + + registry.TransitionTo("a"); + + IndexerSetup? result = registry.GetIndexerSetup(new IndexerGetterAccess(1)); + + await That(result).IsSameAs(snapshotSetup); + } + + [Fact] + public async Task ByAccess_WithSnapshotSetupOnly_ReturnsSnapshotSetup() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeIndexerSetup setup = new(true); + + registry.SetupIndexer(0, setup); + + IndexerSetup? result = registry.GetIndexerSetup(new IndexerGetterAccess(1)); + + await That(result).IsSameAs(setup); + } + + [Fact] + public async Task ByPredicate_WithActiveScenarioAndPredicateRejectsScoped_FallsBackToSnapshot() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeIndexerSetup snapshotSetup = new(true); + FakeIndexerSetup scopedSetup = new(true); + registry.SetupIndexer(0, snapshotSetup); + registry.Setup.GetOrCreateScenario("a").Indexers.Add(scopedSetup); + + registry.TransitionTo("a"); + + IndexerSetup? result = registry.GetIndexerSetup(s => ReferenceEquals(s, snapshotSetup)); + + await That(result).IsSameAs(snapshotSetup); + } + + [Fact] + public async Task ByPredicate_WithSnapshotSetupOnly_ReturnsSnapshotSetup() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeIndexerSetup setup = new(true); + + registry.SetupIndexer(0, setup); + + IndexerSetup? result = registry.GetIndexerSetup(_ => true); + + await That(result).IsSameAs(setup); + } + } + + public sealed class RemoveEventSnapshotByNameTests + { + [Fact] + public async Task RemoveEvent_WhenUnsubscribeMemberIdDiffersFromSubscribe_FiresUnsubscribedCallbackViaNameScan() + { + // The source generator mints separate subscribe and unsubscribe member ids and registers the + // event setup under the subscribe id only. The unsubscribe path therefore always misses the + // snapshot lookup-by-id and must fall through to the name-based snapshot scan inside + // EnumerateDefaultScopeEventSetupsByName for unsubscribed callbacks to fire. + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + int unsubscribedCount = 0; + EventSetup setup = new(registry, "OnFoo"); + setup.OnUnsubscribed.Do(() => unsubscribedCount++); + + const int subscribeMemberId = 5; + const int unsubscribeMemberId = 6; + registry.SetupEvent(subscribeMemberId, setup); + + registry.RemoveEvent(unsubscribeMemberId, "OnFoo", this, GetMethodInfo()); + + await That(unsubscribedCount).IsEqualTo(1); + } + } + + public sealed class GetUnusedSetupsTests + { + [Fact] + public async Task IncludesIndexerSnapshotSetup_WhenNoInteractionsMatch() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeIndexerSetup setup = new(false); + + registry.SetupIndexer(2, setup); + + IReadOnlyCollection unused = registry.GetUnusedSetups(registry.Interactions); + + await That(unused).Contains(setup); + } + + [Fact] + public async Task IncludesMethodSnapshotSetup_WhenNoInteractionsMatch() + { + MockRegistry registry = new(MockBehavior.Default, new FastMockInteractions(0)); + FakeMethodSetup setup = new(); + + registry.SetupMethod(2, setup); + + IReadOnlyCollection unused = registry.GetUnusedSetups(registry.Interactions); + + await That(unused).Contains(setup); + } + } +}