From 779ff5e57aebc65e95388532d921f82d3baa97cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Breu=C3=9F=20Valentin?= Date: Mon, 27 Apr 2026 10:36:54 +0200 Subject: [PATCH 1/3] perf: optimize property getter access handling in FastPropertyBuffer --- .../Sources/Sources.MemberIds.cs | 23 +++++ .../Sources/Sources.MockClass.cs | 18 ++-- .../Interactions/FastPropertyBuffer.cs | 69 +++++++++++--- Source/Mockolate/MockRegistry.Interactions.cs | 95 +++++++++++++++++++ .../Expected/Mockolate_net10.0.txt | 5 + .../Expected/Mockolate_net8.0.txt | 5 + .../Expected/Mockolate_netstandard2.0.txt | 5 + .../MockTests.ClassTests.PropertiesTests.cs | 26 ++--- 8 files changed, 215 insertions(+), 31 deletions(-) diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs index 92016534..88a19cc3 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs @@ -132,10 +132,12 @@ internal sealed class MemberIdTable { private readonly List _declarations = new(); private readonly Dictionary _usedIdentifiers = new(); + private readonly List<(Property Property, string FieldName)> _propertyGetterAccessFields = new(); internal Dictionary MethodIds { get; } = new(); internal Dictionary PropertyGetIds { get; } = new(); internal Dictionary PropertySetIds { get; } = new(); + internal Dictionary PropertyGetterAccessFieldNames { get; } = new(); internal Dictionary IndexerGetIds { get; } = new(); internal Dictionary IndexerSetIds { get; } = new(); internal Dictionary EventSubscribeIds { get; } = new(); @@ -190,11 +192,22 @@ internal void AddProperty(Property property) int getId = AllocateId("MemberId_" + identifierGet); PropertyGetIds[property] = getId; + // PropertyGetterAccess is parameterless and identified solely by Name, so we can + // share one instance across every recorded access for a given property. The + // generator emits a static readonly field next to MemberId__Get; both the + // FastPropertyGetterBuffer and the legacy RecordPropertyGetter path use it. + string accessFieldName = "PropertyAccess_" + identifierGet; + PropertyGetterAccessFieldNames[property] = accessFieldName; + _propertyGetterAccessFields.Add((property, accessFieldName)); + string identifierSet = UniqueIdentifier(property.Name + "_Set"); int setId = AllocateId("MemberId_" + identifierSet); PropertySetIds[property] = setId; } + internal string GetPropertyGetterAccessFieldName(Property property) + => PropertyGetterAccessFieldNames[property]; + internal void AddIndexer(Property indexer) { string keySignature = BuildIndexerSignatureSuffix(indexer); @@ -241,6 +254,16 @@ internal void Emit(StringBuilder sb, string indent) sb.Append(indent).Append("internal const int MemberCount = ").Append(_declarations.Count) .Append(';').AppendLine(); + + foreach ((Property property, string fieldName) in _propertyGetterAccessFields) + { + sb.Append(indent) + .Append( + "internal static readonly global::Mockolate.Interactions.PropertyGetterAccess ") + .Append(fieldName) + .Append(" = new global::Mockolate.Interactions.PropertyGetterAccess(") + .Append(property.GetUniqueNameString()).Append(");").AppendLine(); + } } private static string BuildIndexerSignatureSuffix(Property indexer) diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index 1181fbca..dc34e313 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -1331,9 +1331,10 @@ private static void AppendCreateFastInteractions(StringBuilder sb, string indent } string getMemberIdRef = memberIdPrefix + memberIds.GetPropertyGetIdentifier(property); + string accessFieldRef = memberIdPrefix + memberIds.GetPropertyGetterAccessFieldName(property); sb.Append(indent) .Append("\tglobal::Mockolate.Interactions.FastPropertyBufferFactory.InstallPropertyGetter(fast, ") - .Append(getMemberIdRef).Append(");").AppendLine(); + .Append(getMemberIdRef).Append(", ").Append(accessFieldRef).Append(");").AppendLine(); string setMemberIdRef = memberIdPrefix + memberIds.GetPropertySetIdentifier(property); string propertyType = property.Type.ToTypeOrWrapper(); @@ -2334,12 +2335,13 @@ property.IndexerParameters is not null } else { + string accessFieldRef = memberIdPrefix + memberIds.GetPropertyGetterAccessFieldName(property); if (useFastForProperty) { sb.Append("\t\t\t\treturn ").Append(mockRegistry).Append(".GetPropertyFast<") .AppendTypeOrWrapper(property.Type).Append(">(") .Append(memberIdPrefix).Append(memberIds.GetPropertyGetIdentifier(property)) - .Append(", ").Append(property.GetUniqueNameString()).Append(", static b => ") + .Append(", ").Append(accessFieldRef).Append(", static b => ") .AppendDefaultValueGeneratorFor(property.Type, "b.DefaultValue"); if (!property.IsStatic) { @@ -2353,7 +2355,7 @@ property.IndexerParameters is not null { sb.Append("\t\t\t\treturn ").Append(mockRegistry).Append(".GetProperty<") .AppendTypeOrWrapper(property.Type).Append(">(") - .Append(propertyGetMemberArg).Append(property.GetUniqueNameString()).Append(", () => ") + .Append(propertyGetMemberArg).Append(accessFieldRef).Append(", () => ") .AppendDefaultValueGeneratorFor(property.Type, $"{mockRegistry}.Behavior.DefaultValue"); if (!property.IsStatic) { @@ -2423,12 +2425,13 @@ property.IndexerParameters is not null } else { + string accessFieldRef = memberIdPrefix + memberIds.GetPropertyGetterAccessFieldName(property); if (useFastForProperty) { sb.Append("\t\t\t\treturn ").Append(mockRegistry).Append(".GetPropertyFast<") .AppendTypeOrWrapper(property.Type).Append(">(") .Append(memberIdPrefix).Append(memberIds.GetPropertyGetIdentifier(property)) - .Append(", ").Append(property.GetUniqueNameString()).Append(", static b => ") + .Append(", ").Append(accessFieldRef).Append(", static b => ") .AppendDefaultValueGeneratorFor(property.Type, "b.DefaultValue"); if (property is { IsStatic: false, } && property.Getter?.IsProtected != true) { @@ -2447,7 +2450,7 @@ property.IndexerParameters is not null { sb.Append("\t\t\t\treturn ").Append(mockRegistry).Append(".GetProperty<") .AppendTypeOrWrapper(property.Type).Append(">(") - .Append(propertyGetMemberArg).Append(property.GetUniqueNameString()).Append(", () => ") + .Append(propertyGetMemberArg).Append(accessFieldRef).Append(", () => ") .AppendDefaultValueGeneratorFor(property.Type, $"{mockRegistry}.Behavior.DefaultValue"); if (property is { IsStatic: false, } && property.Getter?.IsProtected != true) { @@ -2485,12 +2488,13 @@ property.IndexerParameters is not null } else { + string accessFieldRef = memberIdPrefix + memberIds.GetPropertyGetterAccessFieldName(property); if (useFastForProperty) { sb.Append("\t\t\t\treturn ").Append(mockRegistry).Append(".GetPropertyFast<") .AppendTypeOrWrapper(property.Type).Append(">(") .Append(memberIdPrefix).Append(memberIds.GetPropertyGetIdentifier(property)) - .Append(", ").Append(property.GetUniqueNameString()) + .Append(", ").Append(accessFieldRef) .Append(", static b => ") .AppendDefaultValueGeneratorFor(property.Type, "b.DefaultValue") .Append(");").AppendLine(); @@ -2499,7 +2503,7 @@ property.IndexerParameters is not null { sb.Append("\t\t\t\treturn ").Append(mockRegistry).Append(".GetProperty<") .AppendTypeOrWrapper(property.Type).Append(">(") - .Append(propertyGetMemberArg).Append(property.GetUniqueNameString()) + .Append(propertyGetMemberArg).Append(accessFieldRef) .Append(", () => ") .AppendDefaultValueGeneratorFor(property.Type, $"{mockRegistry}.Behavior.DefaultValue") .Append(", null);").AppendLine(); diff --git a/Source/Mockolate/Interactions/FastPropertyBuffer.cs b/Source/Mockolate/Interactions/FastPropertyBuffer.cs index 37a56a4b..fb4544a3 100644 --- a/Source/Mockolate/Interactions/FastPropertyBuffer.cs +++ b/Source/Mockolate/Interactions/FastPropertyBuffer.cs @@ -5,7 +5,9 @@ namespace Mockolate.Interactions; /// -/// Per-member buffer for property getters. Records only the property name + sequence number. +/// Per-member buffer for property getters. Records only a sequence number per call; the +/// property identity is captured once via a shared that +/// every record points at when boxed for verification. /// [DebuggerDisplay("{Count} property gets")] #if !DEBUG @@ -15,26 +17,33 @@ public sealed class FastPropertyGetterBuffer : IFastMemberBuffer { private readonly FastMockInteractions _owner; private readonly ChunkedSlotStorage _storage = new(); + private PropertyGetterAccess? _access; internal FastPropertyGetterBuffer(FastMockInteractions owner) { _owner = owner; } + internal FastPropertyGetterBuffer(FastMockInteractions owner, PropertyGetterAccess access) + { + _owner = owner; + _access = access; + } + /// public int Count => _storage.Count; /// - /// Records a property getter access. + /// Records a property getter access using the buffer's pre-seeded + /// singleton. Throws when the singleton has not been + /// installed — callers must use in that case. /// - public void Append(string name) + public void Append() { long seq = _owner.NextSequence(); int slot = _storage.Reserve(); ref Record r = ref _storage.SlotForWrite(slot); r.Seq = seq; - r.Name = name; - r.Boxed = null; _storage.Publish(); if (_owner.HasInteractionAddedSubscribers) @@ -43,6 +52,22 @@ public void Append(string name) } } + /// + /// Records a property getter access. Lazily installs the buffer's + /// singleton from on first + /// call so legacy callers (generated code that does not pass a pre-built access) keep + /// working without allocating one access object per record. + /// + public void Append(string name) + { + // Lazy init: every record in this buffer addresses the same property, so a single + // PropertyGetterAccess covers all of them. The benign race here is acceptable — both + // instances are equivalent because PropertyGetterAccess is immutable and identified + // solely by Name; whichever assignment wins still satisfies the contract. + _access ??= new PropertyGetterAccess(name); + Append(); + } + /// public void Clear() => _storage.Clear(); @@ -51,11 +76,16 @@ void IFastMemberBuffer.AppendBoxed(List<(long Seq, IInteraction Interaction)> de lock (_storage.Lock) { int n = _storage.PublishedUnderLock; + if (n == 0) + { + return; + } + + PropertyGetterAccess access = _access!; for (int slot = 0; slot < n; slot++) { ref Record r = ref _storage.SlotUnderLock(slot); - r.Boxed ??= new PropertyGetterAccess(r.Name); - dest.Add((r.Seq, r.Boxed)); + dest.Add((r.Seq, access)); } } } @@ -65,6 +95,12 @@ void IFastMemberBuffer.AppendBoxedUnverified(List<(long Seq, IInteraction Intera lock (_storage.Lock) { int n = _storage.PublishedUnderLock; + if (n == 0) + { + return; + } + + PropertyGetterAccess access = _access!; for (int slot = 0; slot < n; slot++) { if (_storage.VerifiedUnderLock(slot)) @@ -73,8 +109,7 @@ void IFastMemberBuffer.AppendBoxedUnverified(List<(long Seq, IInteraction Intera } ref Record r = ref _storage.SlotUnderLock(slot); - r.Boxed ??= new PropertyGetterAccess(r.Name); - dest.Add((r.Seq, r.Boxed)); + dest.Add((r.Seq, access)); } } } @@ -103,8 +138,6 @@ public int ConsumeMatching() internal struct Record { public long Seq; - public string Name; - public IInteraction? Boxed; } } @@ -234,6 +267,20 @@ public static FastPropertyGetterBuffer InstallPropertyGetter(this FastMockIntera return buffer; } + /// + /// Creates and installs a property getter buffer at the given with + /// a pre-built shared singleton. Used by the source generator so the + /// buffer never has to allocate a on the first record or on + /// verification. + /// + public static FastPropertyGetterBuffer InstallPropertyGetter(this FastMockInteractions interactions, + int memberId, PropertyGetterAccess access) + { + FastPropertyGetterBuffer buffer = new(interactions, access); + interactions.InstallBuffer(memberId, buffer); + return buffer; + } + /// /// Creates and installs a property setter buffer at the given . /// diff --git a/Source/Mockolate/MockRegistry.Interactions.cs b/Source/Mockolate/MockRegistry.Interactions.cs index 40c5734e..d895f5ab 100644 --- a/Source/Mockolate/MockRegistry.Interactions.cs +++ b/Source/Mockolate/MockRegistry.Interactions.cs @@ -421,6 +421,31 @@ public TResult GetProperty(string propertyName, Func defaultVa return ResolveGetterInternal(propertyName, defaultValueGenerator, baseValueAccessor, interaction); } + /// + /// Singleton-aware overload of . + /// Records directly so the per-call allocation of a fresh + /// is eliminated. The source generator emits one shared + /// per non-indexer property and routes both the fast and + /// legacy paths through the singleton. + /// + /// The property's value type. + /// The shared for the property. + /// Producer of the default value when no setup supplies one. + /// Optional accessor for the base-class getter; when only the default/initial value is considered. + /// The resolved getter value. + /// No setup exists for the property and is . + public TResult GetProperty(PropertyGetterAccess access, Func defaultValueGenerator, + Func? baseValueAccessor) + { + IInteraction? interaction = null; + if (!Behavior.SkipInteractionRecording) + { + interaction = Interactions.RegisterInteraction(access); + } + + return ResolveGetterInternal(access.Name, defaultValueGenerator, baseValueAccessor, interaction); + } + /// /// Member-id-keyed overload of that /// records via the typed when the mock is wired to a @@ -444,6 +469,30 @@ public TResult GetProperty(int memberId, string propertyName, Func + /// Singleton-aware overload of + /// that records the supplied directly. The source generator emits one shared + /// per non-indexer property; reusing it keeps every recorded record + /// in the matching pointing at the same access object. + /// + /// The property's value type. + /// The generator-emitted member id for the property getter. + /// The shared for the property. + /// Producer of the default value when no setup supplies one. + /// Optional accessor for the base-class getter; when only the default/initial value is considered. + /// The resolved getter value. + /// No setup exists for the property and is . + public TResult GetProperty(int memberId, PropertyGetterAccess access, + Func defaultValueGenerator, Func? baseValueAccessor) + { + if (!Behavior.SkipInteractionRecording) + { + RecordPropertyGetter(memberId, access); + } + + return ResolveGetterInternal(access.Name, defaultValueGenerator, baseValueAccessor, null); + } + /// /// Allocation-free fast-path overload of . /// Avoids the per-call closure allocation by accepting a static that takes the @@ -465,6 +514,36 @@ public TResult GetPropertyFast(int memberId, string propertyName, RecordPropertyGetter(memberId, propertyName); } + return ResolvePropertyFast(memberId, propertyName, defaultValueGenerator, baseValueAccessor); + } + + /// + /// Singleton-aware overload of + /// + /// that records the shared singleton emitted by the source generator, + /// avoiding the per-call allocation. + /// + /// The property's value type. + /// The generator-emitted member id for the property getter. + /// The shared for the property. + /// Cached factory invoked with the active when a default value is needed. + /// Optional accessor for the base-class getter; when only the default/initial value is considered. Pass for the no-wrapping fast path. + /// The resolved getter value. + /// No setup exists for the property and is . + public TResult GetPropertyFast(int memberId, PropertyGetterAccess access, + Func defaultValueGenerator, Func? baseValueAccessor = null) + { + if (!Behavior.SkipInteractionRecording) + { + RecordPropertyGetter(memberId, access); + } + + return ResolvePropertyFast(memberId, access.Name, defaultValueGenerator, baseValueAccessor); + } + + private TResult ResolvePropertyFast(int memberId, string propertyName, + Func defaultValueGenerator, Func? baseValueAccessor) + { // Hot path: setup registered via SetupProperty(int, ...), no scenario active, no base accessor. if (baseValueAccessor is null && string.IsNullOrEmpty(Scenario)) { @@ -511,6 +590,22 @@ private void RecordPropertyGetter(int memberId, string propertyName) Interactions.RegisterInteraction(new PropertyGetterAccess(propertyName)); } + private void RecordPropertyGetter(int memberId, PropertyGetterAccess access) + { + if (Interactions is FastMockInteractions fast) + { + IFastMemberBuffer?[] buffers = fast.Buffers; + if ((uint)memberId < (uint)buffers.Length && + buffers[memberId] is FastPropertyGetterBuffer buffer) + { + buffer.Append(); + return; + } + } + + Interactions.RegisterInteraction(access); + } + private TResult ResolveGetterInternal(string propertyName, Func defaultValueGenerator, Func? baseValueAccessor, IInteraction? interaction) { diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt index 87ad122e..f8a3086f 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt @@ -218,8 +218,11 @@ namespace Mockolate public Mockolate.Setup.MethodSetup[]? GetMethodSetupSnapshot(int memberId) { } public System.Collections.Generic.IEnumerable GetMethodSetups(string methodName) where T : Mockolate.Setup.MethodSetup { } + public TResult GetProperty(Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } public TResult GetProperty(string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } + public TResult GetProperty(int memberId, Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } public TResult GetProperty(int memberId, string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } + public TResult GetPropertyFast(int memberId, Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor = null) { } public TResult GetPropertyFast(int memberId, string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor = null) { } public Mockolate.Setup.PropertySetup? GetPropertySetupSnapshot(int memberId) { } public System.Collections.Generic.IReadOnlyCollection GetUnusedSetups(Mockolate.Interactions.IMockInteractions interactions) { } @@ -820,12 +823,14 @@ namespace Mockolate.Interactions public static class FastPropertyBufferFactory { public static Mockolate.Interactions.FastPropertyGetterBuffer InstallPropertyGetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId) { } + public static Mockolate.Interactions.FastPropertyGetterBuffer InstallPropertyGetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId, Mockolate.Interactions.PropertyGetterAccess access) { } public static Mockolate.Interactions.FastPropertySetterBuffer InstallPropertySetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId) { } } [System.Diagnostics.DebuggerDisplay("{Count} property gets")] public sealed class FastPropertyGetterBuffer : Mockolate.Interactions.IFastMemberBuffer { public int Count { get; } + public void Append() { } public void Append(string name) { } public void Clear() { } public int ConsumeMatching() { } diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt index 6f2f092c..554400d8 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt @@ -211,8 +211,11 @@ namespace Mockolate public Mockolate.Setup.MethodSetup[]? GetMethodSetupSnapshot(int memberId) { } public System.Collections.Generic.IEnumerable GetMethodSetups(string methodName) where T : Mockolate.Setup.MethodSetup { } + public TResult GetProperty(Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } public TResult GetProperty(string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } + public TResult GetProperty(int memberId, Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } public TResult GetProperty(int memberId, string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } + public TResult GetPropertyFast(int memberId, Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor = null) { } public TResult GetPropertyFast(int memberId, string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor = null) { } public Mockolate.Setup.PropertySetup? GetPropertySetupSnapshot(int memberId) { } public System.Collections.Generic.IReadOnlyCollection GetUnusedSetups(Mockolate.Interactions.IMockInteractions interactions) { } @@ -811,12 +814,14 @@ namespace Mockolate.Interactions public static class FastPropertyBufferFactory { public static Mockolate.Interactions.FastPropertyGetterBuffer InstallPropertyGetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId) { } + public static Mockolate.Interactions.FastPropertyGetterBuffer InstallPropertyGetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId, Mockolate.Interactions.PropertyGetterAccess access) { } public static Mockolate.Interactions.FastPropertySetterBuffer InstallPropertySetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId) { } } [System.Diagnostics.DebuggerDisplay("{Count} property gets")] public sealed class FastPropertyGetterBuffer : Mockolate.Interactions.IFastMemberBuffer { public int Count { get; } + public void Append() { } public void Append(string name) { } public void Clear() { } public int ConsumeMatching() { } diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt index 233adbb9..e970c242 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt @@ -198,8 +198,11 @@ namespace Mockolate public Mockolate.Setup.MethodSetup[]? GetMethodSetupSnapshot(int memberId) { } public System.Collections.Generic.IEnumerable GetMethodSetups(string methodName) where T : Mockolate.Setup.MethodSetup { } + public TResult GetProperty(Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } public TResult GetProperty(string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } + public TResult GetProperty(int memberId, Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } public TResult GetProperty(int memberId, string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor) { } + public TResult GetPropertyFast(int memberId, Mockolate.Interactions.PropertyGetterAccess access, System.Func defaultValueGenerator, System.Func? baseValueAccessor = null) { } public TResult GetPropertyFast(int memberId, string propertyName, System.Func defaultValueGenerator, System.Func? baseValueAccessor = null) { } public Mockolate.Setup.PropertySetup? GetPropertySetupSnapshot(int memberId) { } public System.Collections.Generic.IReadOnlyCollection GetUnusedSetups(Mockolate.Interactions.IMockInteractions interactions) { } @@ -770,12 +773,14 @@ namespace Mockolate.Interactions public static class FastPropertyBufferFactory { public static Mockolate.Interactions.FastPropertyGetterBuffer InstallPropertyGetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId) { } + public static Mockolate.Interactions.FastPropertyGetterBuffer InstallPropertyGetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId, Mockolate.Interactions.PropertyGetterAccess access) { } public static Mockolate.Interactions.FastPropertySetterBuffer InstallPropertySetter(this Mockolate.Interactions.FastMockInteractions interactions, int memberId) { } } [System.Diagnostics.DebuggerDisplay("{Count} property gets")] public sealed class FastPropertyGetterBuffer : Mockolate.Interactions.IFastMemberBuffer { public int Count { get; } + public void Append() { } public void Append(string name) { } public void Clear() { } public int ConsumeMatching() { } diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.ClassTests.PropertiesTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.ClassTests.PropertiesTests.cs index af8a470f..cf68ff70 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.ClassTests.PropertiesTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.ClassTests.PropertiesTests.cs @@ -80,7 +80,7 @@ public int SomeProperty { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomeProperty_Get, "global::MyCode.IMyService.SomeProperty", static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomeProperty); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomeProperty_Get, global::Mockolate.Mock.IMyService.PropertyAccess_SomeProperty_Get, static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomeProperty); } set { @@ -98,7 +98,7 @@ public bool? SomeReadOnlyProperty { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomeReadOnlyProperty_Get, "global::MyCode.IMyService.SomeReadOnlyProperty", static b => b.DefaultValue.Generate(default(bool?)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomeReadOnlyProperty); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomeReadOnlyProperty_Get, global::Mockolate.Mock.IMyService.PropertyAccess_SomeReadOnlyProperty_Get, static b => b.DefaultValue.Generate(default(bool?)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomeReadOnlyProperty); } } """).IgnoringNewlineStyle().And @@ -122,7 +122,7 @@ internal int SomeInternalProperty { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomeInternalProperty_Get, "global::MyCode.IMyService.SomeInternalProperty", static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomeInternalProperty); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomeInternalProperty_Get, global::Mockolate.Mock.IMyService.PropertyAccess_SomeInternalProperty_Get, static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomeInternalProperty); } set { @@ -140,7 +140,7 @@ private int SomePrivateProperty { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomePrivateProperty_Get, "global::MyCode.IMyService.SomePrivateProperty", static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomePrivateProperty); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomePrivateProperty_Get, global::Mockolate.Mock.IMyService.PropertyAccess_SomePrivateProperty_Get, static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomePrivateProperty); } set { @@ -158,7 +158,7 @@ private protected int SomePrivateProtectedProperty { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomePrivateProtectedProperty_Get, "global::MyCode.IMyService.SomePrivateProtectedProperty", static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomePrivateProtectedProperty); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_SomePrivateProtectedProperty_Get, global::Mockolate.Mock.IMyService.PropertyAccess_SomePrivateProtectedProperty_Get, static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.SomePrivateProtectedProperty); } set { @@ -217,7 +217,7 @@ public int MyDirectProperty { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_MyDirectProperty_Get, "global::MyCode.IMyService.MyDirectProperty", static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.MyDirectProperty); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_MyDirectProperty_Get, global::Mockolate.Mock.IMyService.PropertyAccess_MyDirectProperty_Get, static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.MyDirectProperty); } set { @@ -235,7 +235,7 @@ public int MyBaseProperty1 { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_MyBaseProperty1_Get, "global::MyCode.IMyServiceBase1.MyBaseProperty1", static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.MyBaseProperty1); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_MyBaseProperty1_Get, global::Mockolate.Mock.IMyService.PropertyAccess_MyBaseProperty1_Get, static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.MyBaseProperty1); } set { @@ -253,7 +253,7 @@ public int MyBaseProperty2 { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_MyBaseProperty2_Get, "global::MyCode.IMyServiceBase2.MyBaseProperty2", static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.MyBaseProperty2); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_MyBaseProperty2_Get, global::Mockolate.Mock.IMyService.PropertyAccess_MyBaseProperty2_Get, static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.MyBaseProperty2); } set { @@ -271,7 +271,7 @@ public int MyBaseProperty3 { get { - return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_MyBaseProperty3_Get, "global::MyCode.IMyServiceBase3.MyBaseProperty3", static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.MyBaseProperty3); + return this.MockRegistry.GetPropertyFast(global::Mockolate.Mock.IMyService.MemberId_MyBaseProperty3_Get, global::Mockolate.Mock.IMyService.PropertyAccess_MyBaseProperty3_Get, static b => b.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is not global::MyCode.IMyService wraps ? null : () => wraps.MyBaseProperty3); } set { @@ -331,7 +331,7 @@ public override int SomeProperty1 { protected get { - return this.MockRegistry.GetProperty("global::MyCode.MyService.SomeProperty1", () => this.MockRegistry.Behavior.DefaultValue.Generate(default(int)!), () => base.SomeProperty1); + return this.MockRegistry.GetProperty(global::Mockolate.Mock.MyService__IMyOtherService.PropertyAccess_SomeProperty1_Get, () => this.MockRegistry.Behavior.DefaultValue.Generate(default(int)!), () => base.SomeProperty1); } set { @@ -355,7 +355,7 @@ public override int SomeProperty2 { get { - return this.MockRegistry.GetProperty("global::MyCode.MyService.SomeProperty2", () => this.MockRegistry.Behavior.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is global::MyCode.MyService wraps ? () => wraps.SomeProperty2 : () => base.SomeProperty2); + return this.MockRegistry.GetProperty(global::Mockolate.Mock.MyService__IMyOtherService.PropertyAccess_SomeProperty2_Get, () => this.MockRegistry.Behavior.DefaultValue.Generate(default(int)!), this.MockRegistry.Wraps is global::MyCode.MyService wraps ? () => wraps.SomeProperty2 : () => base.SomeProperty2); } protected set { @@ -372,7 +372,7 @@ protected override bool? SomeReadOnlyProperty { get { - return this.MockRegistry.GetProperty("global::MyCode.MyService.SomeReadOnlyProperty", () => this.MockRegistry.Behavior.DefaultValue.Generate(default(bool?)!), () => base.SomeReadOnlyProperty); + return this.MockRegistry.GetProperty(global::Mockolate.Mock.MyService__IMyOtherService.PropertyAccess_SomeReadOnlyProperty_Get, () => this.MockRegistry.Behavior.DefaultValue.Generate(default(bool?)!), () => base.SomeReadOnlyProperty); } } """).IgnoringNewlineStyle().And @@ -396,7 +396,7 @@ protected override bool? SomeWriteOnlyProperty { get { - return this.MockRegistry.GetProperty("global::MyCode.IMyOtherService.SomeAdditionalProperty", () => this.MockRegistry.Behavior.DefaultValue.Generate(default(int)!), null); + return this.MockRegistry.GetProperty(global::Mockolate.Mock.MyService__IMyOtherService.PropertyAccess_SomeAdditionalProperty_Get, () => this.MockRegistry.Behavior.DefaultValue.Generate(default(int)!), null); } set { From f91c9717dac9e7845edacbcbbf0946ccae86cf14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Breu=C3=9F=20Valentin?= Date: Mon, 27 Apr 2026 11:12:25 +0200 Subject: [PATCH 2/3] Fix review issues --- .../Sources/Sources.MemberIds.cs | 12 +++-- .../Interactions/FastPropertyBuffer.cs | 46 ++++++++++------ Source/Mockolate/MockRegistry.Interactions.cs | 54 ++++++++++--------- .../FastBufferBoxingAndUnverifiedTests.cs | 30 +++++++++++ .../VerificationResultExtensionsTests.cs | 12 +++++ 5 files changed, 110 insertions(+), 44 deletions(-) diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs index 88a19cc3..8fa3f6ea 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs @@ -192,10 +192,14 @@ internal void AddProperty(Property property) int getId = AllocateId("MemberId_" + identifierGet); PropertyGetIds[property] = getId; - // PropertyGetterAccess is parameterless and identified solely by Name, so we can - // share one instance across every recorded access for a given property. The - // generator emits a static readonly field next to MemberId__Get; both the - // FastPropertyGetterBuffer and the legacy RecordPropertyGetter path use it. + // PropertyGetterAccess is identified solely by Name, so the generator emits one + // static readonly per-property template next to MemberId__Get. The template is a + // cheap name source: FastPropertyGetterBuffer caches it once instead of storing the + // name on every record, and the cold-path RecordPropertyGetter constructs fresh + // PropertyGetterAccess instances from the template's Name. Per-record identity is + // preserved (every recorded interaction is still a distinct object) — reference- + // keyed bookkeeping like Then ordering and FastMockInteractions._verified depends + // on that. string accessFieldName = "PropertyAccess_" + identifierGet; PropertyGetterAccessFieldNames[property] = accessFieldName; _propertyGetterAccessFields.Add((property, accessFieldName)); diff --git a/Source/Mockolate/Interactions/FastPropertyBuffer.cs b/Source/Mockolate/Interactions/FastPropertyBuffer.cs index fb4544a3..4ee253c8 100644 --- a/Source/Mockolate/Interactions/FastPropertyBuffer.cs +++ b/Source/Mockolate/Interactions/FastPropertyBuffer.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Diagnostics; using Mockolate.Parameters; @@ -5,9 +6,12 @@ namespace Mockolate.Interactions; /// -/// Per-member buffer for property getters. Records only a sequence number per call; the -/// property identity is captured once via a shared that -/// every record points at when boxed for verification. +/// Per-member buffer for property getters. The buffer is bound to a single property and +/// keeps the property name on a per-buffer template, so +/// the per-call hot path only stores the sequence number. Verification still emits a fresh +/// per recorded slot — the template is only used as a +/// name source — because reference-keyed bookkeeping (Then ordering, the verified set) +/// requires each recorded interaction to be a distinct object. /// [DebuggerDisplay("{Count} property gets")] #if !DEBUG @@ -35,15 +39,23 @@ internal FastPropertyGetterBuffer(FastMockInteractions owner, PropertyGetterAcce /// /// Records a property getter access using the buffer's pre-seeded - /// singleton. Throws when the singleton has not been + /// template. Throws when the template has not been /// installed — callers must use in that case. /// + /// No template was supplied at install time. public void Append() { + if (_access is null) + { + throw new InvalidOperationException( + $"{nameof(Append)}() requires the buffer to be installed with a {nameof(PropertyGetterAccess)} template via {nameof(FastPropertyBufferFactory)}.{nameof(FastPropertyBufferFactory.InstallPropertyGetter)}(memberId, access). Use {nameof(Append)}(string) when no template is available."); + } + long seq = _owner.NextSequence(); int slot = _storage.Reserve(); ref Record r = ref _storage.SlotForWrite(slot); r.Seq = seq; + r.Boxed = null; _storage.Publish(); if (_owner.HasInteractionAddedSubscribers) @@ -54,16 +66,17 @@ public void Append() /// /// Records a property getter access. Lazily installs the buffer's - /// singleton from on first - /// call so legacy callers (generated code that does not pass a pre-built access) keep - /// working without allocating one access object per record. + /// template from on first + /// call so legacy callers (generated code that does not pass a pre-built template) keep + /// working without allocating one template object per record. /// public void Append(string name) { - // Lazy init: every record in this buffer addresses the same property, so a single - // PropertyGetterAccess covers all of them. The benign race here is acceptable — both - // instances are equivalent because PropertyGetterAccess is immutable and identified - // solely by Name; whichever assignment wins still satisfies the contract. + // Lazy init: the buffer is bound to a single property, so one template covers every + // record. The benign race here is acceptable — both instances are equivalent because + // PropertyGetterAccess is identified solely by Name; whichever assignment wins still + // satisfies the contract. The template is only a Name source; per-record identity is + // preserved by allocating a fresh PropertyGetterAccess in AppendBoxed. _access ??= new PropertyGetterAccess(name); Append(); } @@ -81,11 +94,12 @@ void IFastMemberBuffer.AppendBoxed(List<(long Seq, IInteraction Interaction)> de return; } - PropertyGetterAccess access = _access!; + string name = _access!.Name; for (int slot = 0; slot < n; slot++) { ref Record r = ref _storage.SlotUnderLock(slot); - dest.Add((r.Seq, access)); + r.Boxed ??= new PropertyGetterAccess(name); + dest.Add((r.Seq, r.Boxed)); } } } @@ -100,7 +114,7 @@ void IFastMemberBuffer.AppendBoxedUnverified(List<(long Seq, IInteraction Intera return; } - PropertyGetterAccess access = _access!; + string name = _access!.Name; for (int slot = 0; slot < n; slot++) { if (_storage.VerifiedUnderLock(slot)) @@ -109,7 +123,8 @@ void IFastMemberBuffer.AppendBoxedUnverified(List<(long Seq, IInteraction Intera } ref Record r = ref _storage.SlotUnderLock(slot); - dest.Add((r.Seq, access)); + r.Boxed ??= new PropertyGetterAccess(name); + dest.Add((r.Seq, r.Boxed)); } } } @@ -138,6 +153,7 @@ public int ConsumeMatching() internal struct Record { public long Seq; + public IInteraction? Boxed; } } diff --git a/Source/Mockolate/MockRegistry.Interactions.cs b/Source/Mockolate/MockRegistry.Interactions.cs index d895f5ab..ee593c87 100644 --- a/Source/Mockolate/MockRegistry.Interactions.cs +++ b/Source/Mockolate/MockRegistry.Interactions.cs @@ -422,29 +422,23 @@ public TResult GetProperty(string propertyName, Func defaultVa } /// - /// Singleton-aware overload of . - /// Records directly so the per-call allocation of a fresh - /// is eliminated. The source generator emits one shared - /// per non-indexer property and routes both the fast and - /// legacy paths through the singleton. + /// Template-aware overload of . + /// Reads the property name from and delegates to the string-keyed + /// overload so each recorded interaction stays a unique reference — + /// reference-keyed bookkeeping such as Then ordering and the verified set requires + /// distinct objects per call. The source generator emits one static + /// per non-indexer property and passes it here so the cold + /// path can derive the property name without an extra string literal. /// /// The property's value type. - /// The shared for the property. + /// The per-property template. /// Producer of the default value when no setup supplies one. /// Optional accessor for the base-class getter; when only the default/initial value is considered. /// The resolved getter value. /// No setup exists for the property and is . public TResult GetProperty(PropertyGetterAccess access, Func defaultValueGenerator, Func? baseValueAccessor) - { - IInteraction? interaction = null; - if (!Behavior.SkipInteractionRecording) - { - interaction = Interactions.RegisterInteraction(access); - } - - return ResolveGetterInternal(access.Name, defaultValueGenerator, baseValueAccessor, interaction); - } + => GetProperty(access.Name, defaultValueGenerator, baseValueAccessor); /// /// Member-id-keyed overload of that @@ -470,14 +464,17 @@ public TResult GetProperty(int memberId, string propertyName, Func - /// Singleton-aware overload of - /// that records the supplied directly. The source generator emits one shared - /// per non-indexer property; reusing it keeps every recorded record - /// in the matching pointing at the same access object. + /// Template-aware overload of + /// that uses as a per-property name source and dispatches through the + /// fast buffer when available. The fast buffer's + /// stores only a sequence number; verification still emits a fresh + /// per slot so reference-keyed bookkeeping stays correct. + /// When no fast buffer is installed, the cold path allocates a fresh + /// per call. /// /// The property's value type. /// The generator-emitted member id for the property getter. - /// The shared for the property. + /// The per-property template. /// Producer of the default value when no setup supplies one. /// Optional accessor for the base-class getter; when only the default/initial value is considered. /// The resolved getter value. @@ -518,14 +515,17 @@ public TResult GetPropertyFast(int memberId, string propertyName, } /// - /// Singleton-aware overload of + /// Template-aware overload of /// - /// that records the shared singleton emitted by the source generator, - /// avoiding the per-call allocation. + /// that uses as a per-property name source. Recording goes through + /// (sequence-only) when a fast buffer is wired + /// up; verification still allocates a fresh per slot so + /// reference-keyed bookkeeping stays correct. The cold-path fall-through allocates a fresh + /// per call from 's name. /// /// The property's value type. /// The generator-emitted member id for the property getter. - /// The shared for the property. + /// The per-property template. /// Cached factory invoked with the active when a default value is needed. /// Optional accessor for the base-class getter; when only the default/initial value is considered. Pass for the no-wrapping fast path. /// The resolved getter value. @@ -603,7 +603,11 @@ private void RecordPropertyGetter(int memberId, PropertyGetterAccess access) } } - Interactions.RegisterInteraction(access); + // Cold path: each recorded interaction must be a unique IInteraction reference so + // reference-keyed bookkeeping (Then ordering, FastMockInteractions._verified) stays + // correct, so allocate a fresh PropertyGetterAccess per call rather than registering + // the per-property template. + Interactions.RegisterInteraction(new PropertyGetterAccess(access.Name)); } private TResult ResolveGetterInternal(string propertyName, Func defaultValueGenerator, diff --git a/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs b/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs index b06b2e6e..a6ff97e4 100644 --- a/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs +++ b/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs @@ -237,6 +237,20 @@ public async Task FastPropertyGetterBuffer_Append_ShouldRaiseInteractionAdded() return () => buffer.Append("P"); }); + [Fact] + public async Task FastPropertyGetterBuffer_Append_WithoutInstalledTemplate_ShouldThrow() + { + FastMockInteractions store = new(1); + FastPropertyGetterBuffer buffer = store.InstallPropertyGetter(0); + + void Act() + { + buffer.Append(); + } + + await That(Act).Throws(); + } + [Fact] public async Task FastPropertyGetterBuffer_AppendBoxed_CachesAndReusesAlreadyBoxedRecord() { @@ -255,6 +269,22 @@ public async Task FastPropertyGetterBuffer_AppendBoxed_CachesAndReusesAlreadyBox await That(second[0].Interaction).IsSameAs(first[0].Interaction); } + [Fact] + public async Task FastPropertyGetterBuffer_AppendBoxed_DistinctSlotsProduceDistinctInteractions() + { + FastMockInteractions store = new(1); + FastPropertyGetterBuffer buffer = store.InstallPropertyGetter(0); + + buffer.Append("P"); + buffer.Append("P"); + + List<(long Seq, IInteraction Interaction)> dest = []; + ((IFastMemberBuffer)buffer).AppendBoxed(dest); + + await That(dest).HasCount(2); + await That(dest[0].Interaction).IsNotSameAs(dest[1].Interaction); + } + [Fact] public async Task FastPropertySetterBuffer_Append_ShouldRaiseInteractionAdded() => await VerifyRaisesInteractionAdded(store => diff --git a/Tests/Mockolate.Tests/Verify/VerificationResultExtensionsTests.cs b/Tests/Mockolate.Tests/Verify/VerificationResultExtensionsTests.cs index e900fffe..77a299f4 100644 --- a/Tests/Mockolate.Tests/Verify/VerificationResultExtensionsTests.cs +++ b/Tests/Mockolate.Tests/Verify/VerificationResultExtensionsTests.cs @@ -437,6 +437,18 @@ await That(Act).Throws() .Then(m => m.Dispense(It.IsAny(), It.Is(2))); } + [Fact] + public void Then_RepeatedPropertyGetter_ShouldNotCollapseIntoOnePosition() + { + IChocolateDispenser sut = IChocolateDispenser.CreateMock(); + _ = sut.TotalDispensed; + sut.Dispense("Dark", 1); + _ = sut.TotalDispensed; + + sut.Mock.Verify.TotalDispensed.Got() + .Then(m => m.TotalDispensed.Got()); + } + [Theory] [InlineData(false, 1, 2, 3, 4)] [InlineData(true, 1, 2, 2, 4)] From dc4bcfb3d1a6cd0d5241adfc7ed3fd6a9e155202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Breu=C3=9F=20Valentin?= Date: Mon, 27 Apr 2026 12:55:52 +0200 Subject: [PATCH 3/3] refactor: update FastPropertyBuffer to use singleton for PropertyGetterAccess --- .../Sources/Sources.MemberIds.cs | 14 ++--- .../Interactions/FastPropertyBuffer.cs | 44 +++++++------- Source/Mockolate/MockRegistry.Interactions.cs | 60 ++++++++++--------- .../Verify/VerificationResultExtensions.cs | 37 ++++++------ .../FastBufferBoxingAndUnverifiedTests.cs | 14 +++-- 5 files changed, 89 insertions(+), 80 deletions(-) diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs index 8fa3f6ea..166ad7df 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs @@ -193,13 +193,13 @@ internal void AddProperty(Property property) PropertyGetIds[property] = getId; // PropertyGetterAccess is identified solely by Name, so the generator emits one - // static readonly per-property template next to MemberId__Get. The template is a - // cheap name source: FastPropertyGetterBuffer caches it once instead of storing the - // name on every record, and the cold-path RecordPropertyGetter constructs fresh - // PropertyGetterAccess instances from the template's Name. Per-record identity is - // preserved (every recorded interaction is still a distinct object) — reference- - // keyed bookkeeping like Then ordering and FastMockInteractions._verified depends - // on that. + // static readonly per-property singleton next to MemberId__Get and reuses it for + // every recorded access. Sharing one reference is safe because the only two + // reference-keyed verification paths in the codebase tolerate it: the + // FastMockInteractions._verified filter is all-or-nothing per matched property + // (every recording of a getter shares the same predicate result), and + // VerificationResultExtensions.Then walks the snapshot positionally so repeated + // occurrences of the same reference still resolve to distinct positions. string accessFieldName = "PropertyAccess_" + identifierGet; PropertyGetterAccessFieldNames[property] = accessFieldName; _propertyGetterAccessFields.Add((property, accessFieldName)); diff --git a/Source/Mockolate/Interactions/FastPropertyBuffer.cs b/Source/Mockolate/Interactions/FastPropertyBuffer.cs index 4ee253c8..4180acd0 100644 --- a/Source/Mockolate/Interactions/FastPropertyBuffer.cs +++ b/Source/Mockolate/Interactions/FastPropertyBuffer.cs @@ -6,12 +6,15 @@ namespace Mockolate.Interactions; /// -/// Per-member buffer for property getters. The buffer is bound to a single property and -/// keeps the property name on a per-buffer template, so -/// the per-call hot path only stores the sequence number. Verification still emits a fresh -/// per recorded slot — the template is only used as a -/// name source — because reference-keyed bookkeeping (Then ordering, the verified set) -/// requires each recorded interaction to be a distinct object. +/// Per-member buffer for property getters. The buffer is bound to a single property; every +/// recorded record stores only a sequence number and shares a single +/// when boxed for verification. Sharing one reference +/// across records is safe because property getters carry no parameters — every recorded +/// access is semantically identical — and the two reference-keyed verification paths in +/// this codebase tolerate it: FastMockInteractions._verified filters all-or-nothing +/// per matched property, and VerificationResultExtensions.Then walks the snapshot +/// positionally so repeated occurrences of the same reference still resolve to distinct +/// positions. /// [DebuggerDisplay("{Count} property gets")] #if !DEBUG @@ -39,23 +42,22 @@ internal FastPropertyGetterBuffer(FastMockInteractions owner, PropertyGetterAcce /// /// Records a property getter access using the buffer's pre-seeded - /// template. Throws when the template has not been + /// singleton. Throws when the singleton has not been /// installed — callers must use in that case. /// - /// No template was supplied at install time. + /// No singleton was supplied at install time. public void Append() { if (_access is null) { throw new InvalidOperationException( - $"{nameof(Append)}() requires the buffer to be installed with a {nameof(PropertyGetterAccess)} template via {nameof(FastPropertyBufferFactory)}.{nameof(FastPropertyBufferFactory.InstallPropertyGetter)}(memberId, access). Use {nameof(Append)}(string) when no template is available."); + $"{nameof(Append)}() requires the buffer to be installed with a {nameof(PropertyGetterAccess)} singleton via {nameof(FastPropertyBufferFactory)}.{nameof(FastPropertyBufferFactory.InstallPropertyGetter)}(memberId, access). Use {nameof(Append)}(string) when no singleton is available."); } long seq = _owner.NextSequence(); int slot = _storage.Reserve(); ref Record r = ref _storage.SlotForWrite(slot); r.Seq = seq; - r.Boxed = null; _storage.Publish(); if (_owner.HasInteractionAddedSubscribers) @@ -66,17 +68,16 @@ public void Append() /// /// Records a property getter access. Lazily installs the buffer's - /// template from on first - /// call so legacy callers (generated code that does not pass a pre-built template) keep - /// working without allocating one template object per record. + /// singleton from on first + /// call so legacy callers (generated code that does not pass a pre-built singleton) keep + /// working without allocating one access object per record. /// public void Append(string name) { - // Lazy init: the buffer is bound to a single property, so one template covers every + // Lazy init: the buffer is bound to a single property, so one singleton covers every // record. The benign race here is acceptable — both instances are equivalent because // PropertyGetterAccess is identified solely by Name; whichever assignment wins still - // satisfies the contract. The template is only a Name source; per-record identity is - // preserved by allocating a fresh PropertyGetterAccess in AppendBoxed. + // satisfies the contract. _access ??= new PropertyGetterAccess(name); Append(); } @@ -94,12 +95,11 @@ void IFastMemberBuffer.AppendBoxed(List<(long Seq, IInteraction Interaction)> de return; } - string name = _access!.Name; + PropertyGetterAccess access = _access!; for (int slot = 0; slot < n; slot++) { ref Record r = ref _storage.SlotUnderLock(slot); - r.Boxed ??= new PropertyGetterAccess(name); - dest.Add((r.Seq, r.Boxed)); + dest.Add((r.Seq, access)); } } } @@ -114,7 +114,7 @@ void IFastMemberBuffer.AppendBoxedUnverified(List<(long Seq, IInteraction Intera return; } - string name = _access!.Name; + PropertyGetterAccess access = _access!; for (int slot = 0; slot < n; slot++) { if (_storage.VerifiedUnderLock(slot)) @@ -123,8 +123,7 @@ void IFastMemberBuffer.AppendBoxedUnverified(List<(long Seq, IInteraction Intera } ref Record r = ref _storage.SlotUnderLock(slot); - r.Boxed ??= new PropertyGetterAccess(name); - dest.Add((r.Seq, r.Boxed)); + dest.Add((r.Seq, access)); } } } @@ -153,7 +152,6 @@ public int ConsumeMatching() internal struct Record { public long Seq; - public IInteraction? Boxed; } } diff --git a/Source/Mockolate/MockRegistry.Interactions.cs b/Source/Mockolate/MockRegistry.Interactions.cs index ee593c87..89b35934 100644 --- a/Source/Mockolate/MockRegistry.Interactions.cs +++ b/Source/Mockolate/MockRegistry.Interactions.cs @@ -422,23 +422,32 @@ public TResult GetProperty(string propertyName, Func defaultVa } /// - /// Template-aware overload of . - /// Reads the property name from and delegates to the string-keyed - /// overload so each recorded interaction stays a unique reference — - /// reference-keyed bookkeeping such as Then ordering and the verified set requires - /// distinct objects per call. The source generator emits one static - /// per non-indexer property and passes it here so the cold - /// path can derive the property name without an extra string literal. + /// Singleton-aware overload of . + /// Records the shared singleton emitted by the source generator + /// (one static instance per non-indexer property), avoiding the per-call + /// allocation. Sharing one reference across recorded + /// accesses is safe because the only reference-keyed bookkeeping in the codebase tolerates + /// it — the _verified filter is all-or-nothing per matched property, and + /// Then walks the snapshot positionally rather than mapping interactions to a + /// position via a dictionary. /// /// The property's value type. - /// The per-property template. + /// The shared singleton for the property. /// Producer of the default value when no setup supplies one. /// Optional accessor for the base-class getter; when only the default/initial value is considered. /// The resolved getter value. /// No setup exists for the property and is . public TResult GetProperty(PropertyGetterAccess access, Func defaultValueGenerator, Func? baseValueAccessor) - => GetProperty(access.Name, defaultValueGenerator, baseValueAccessor); + { + IInteraction? interaction = null; + if (!Behavior.SkipInteractionRecording) + { + interaction = Interactions.RegisterInteraction(access); + } + + return ResolveGetterInternal(access.Name, defaultValueGenerator, baseValueAccessor, interaction); + } /// /// Member-id-keyed overload of that @@ -464,17 +473,14 @@ public TResult GetProperty(int memberId, string propertyName, Func - /// Template-aware overload of - /// that uses as a per-property name source and dispatches through the - /// fast buffer when available. The fast buffer's - /// stores only a sequence number; verification still emits a fresh - /// per slot so reference-keyed bookkeeping stays correct. - /// When no fast buffer is installed, the cold path allocates a fresh - /// per call. + /// Singleton-aware overload of + /// that records the supplied directly. The source generator emits one shared + /// per non-indexer property; reusing it keeps every recorded access + /// in the matching pointing at the same singleton. /// /// The property's value type. /// The generator-emitted member id for the property getter. - /// The per-property template. + /// The shared singleton for the property. /// Producer of the default value when no setup supplies one. /// Optional accessor for the base-class getter; when only the default/initial value is considered. /// The resolved getter value. @@ -515,17 +521,17 @@ public TResult GetPropertyFast(int memberId, string propertyName, } /// - /// Template-aware overload of + /// Singleton-aware overload of /// - /// that uses as a per-property name source. Recording goes through - /// (sequence-only) when a fast buffer is wired - /// up; verification still allocates a fresh per slot so - /// reference-keyed bookkeeping stays correct. The cold-path fall-through allocates a fresh - /// per call from 's name. + /// that records the shared singleton emitted by the source generator, + /// avoiding the per-call allocation. The + /// stores only a sequence number per call and emits the + /// same singleton for every recorded record on verification; the cold-path fall-through likewise + /// registers the singleton. /// /// The property's value type. /// The generator-emitted member id for the property getter. - /// The per-property template. + /// The shared singleton for the property. /// Cached factory invoked with the active when a default value is needed. /// Optional accessor for the base-class getter; when only the default/initial value is considered. Pass for the no-wrapping fast path. /// The resolved getter value. @@ -603,11 +609,7 @@ private void RecordPropertyGetter(int memberId, PropertyGetterAccess access) } } - // Cold path: each recorded interaction must be a unique IInteraction reference so - // reference-keyed bookkeeping (Then ordering, FastMockInteractions._verified) stays - // correct, so allocate a fresh PropertyGetterAccess per call rather than registering - // the per-property template. - Interactions.RegisterInteraction(new PropertyGetterAccess(access.Name)); + Interactions.RegisterInteraction(access); } private TResult ResolveGetterInternal(string propertyName, Func defaultValueGenerator, diff --git a/Source/Mockolate/Verify/VerificationResultExtensions.cs b/Source/Mockolate/Verify/VerificationResultExtensions.cs index 8cd55049..1f88167b 100644 --- a/Source/Mockolate/Verify/VerificationResultExtensions.cs +++ b/Source/Mockolate/Verify/VerificationResultExtensions.cs @@ -441,11 +441,6 @@ public void Then(params Func>[] orderedChecks) IVerificationResult result = verificationResult; TMock mockVerify = ((IVerificationResult)verificationResult).Object; IInteraction[] snapshot = result.Interactions.ToArray(); - Dictionary positions = new(snapshot.Length); - for (int i = 0; i < snapshot.Length; i++) - { - positions[snapshot[i]] = i; - } int after = -1; foreach (Func> check in orderedChecks) @@ -467,23 +462,31 @@ public void Then(params Func>[] orderedChecks) bool VerifyInteractions(IInteraction[] interactions, IVerificationResult currentResult) { - int bestPosition = int.MaxValue; - IInteraction? firstInteraction = null; - foreach (IInteraction candidate in interactions) + // Walk the snapshot from `after + 1` and stop on the first slot whose interaction + // is in the verification's matched set. The membership check uses reference + // equality, but the search is positional — repeated entries with the same + // reference (e.g. shared property-getter access singletons) still resolve to + // distinct positions because each call to this lambda picks up where the + // previous one stopped. + int firstPos = -1; + // `after == int.MaxValue` is the "earlier step already failed" signal — skip the + // walk so subsequent steps cascade to failure without going through the loop + // (and without triggering int overflow on `after + 1`). + if (after < snapshot.Length) { - // Stryker disable once Equality : positions are unique per interaction, so position <= bestPosition can only differ from position < bestPosition on exact equality, which never occurs. - if (positions.TryGetValue(candidate, out int position) && - position > after && - position < bestPosition) + HashSet matched = new(interactions); + for (int i = after + 1; i < snapshot.Length; i++) { - bestPosition = position; - firstInteraction = candidate; + if (matched.Contains(snapshot[i])) + { + firstPos = i; + break; + } } } - bool hasInteractionAfter = firstInteraction is not null; - // Stryker disable once Conditional : when hasInteractionAfter is false, bestPosition is still the int.MaxValue seed, so the ternary branches produce identical values. - after = hasInteractionAfter ? bestPosition : int.MaxValue; + bool hasInteractionAfter = firstPos >= 0; + after = hasInteractionAfter ? firstPos : int.MaxValue; if (!hasInteractionAfter && error is null) { error = interactions.Length > 0 diff --git a/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs b/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs index a6ff97e4..360416d0 100644 --- a/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs +++ b/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs @@ -238,7 +238,7 @@ public async Task FastPropertyGetterBuffer_Append_ShouldRaiseInteractionAdded() }); [Fact] - public async Task FastPropertyGetterBuffer_Append_WithoutInstalledTemplate_ShouldThrow() + public async Task FastPropertyGetterBuffer_Append_WithoutInstalledSingleton_ShouldThrow() { FastMockInteractions store = new(1); FastPropertyGetterBuffer buffer = store.InstallPropertyGetter(0); @@ -252,7 +252,7 @@ void Act() } [Fact] - public async Task FastPropertyGetterBuffer_AppendBoxed_CachesAndReusesAlreadyBoxedRecord() + public async Task FastPropertyGetterBuffer_AppendBoxed_RepeatedCallsReturnSameSingleton() { FastMockInteractions store = new(1); FastPropertyGetterBuffer buffer = store.InstallPropertyGetter(0); @@ -270,8 +270,14 @@ public async Task FastPropertyGetterBuffer_AppendBoxed_CachesAndReusesAlreadyBox } [Fact] - public async Task FastPropertyGetterBuffer_AppendBoxed_DistinctSlotsProduceDistinctInteractions() + public async Task FastPropertyGetterBuffer_AppendBoxed_SharesSingletonAcrossRecords() { + // All recorded getter accesses for the same property surface as one PropertyGetterAccess + // reference. This is intentional — getters carry no parameters, so every record is + // semantically identical, and the two reference-keyed verification paths + // (FastMockInteractions._verified and VerificationResultExtensions.Then) tolerate + // shared identity (the Then walker is positional, the verified filter is + // all-or-nothing per matched property). FastMockInteractions store = new(1); FastPropertyGetterBuffer buffer = store.InstallPropertyGetter(0); @@ -282,7 +288,7 @@ public async Task FastPropertyGetterBuffer_AppendBoxed_DistinctSlotsProduceDisti ((IFastMemberBuffer)buffer).AppendBoxed(dest); await That(dest).HasCount(2); - await That(dest[0].Interaction).IsNotSameAs(dest[1].Interaction); + await That(dest[0].Interaction).IsSameAs(dest[1].Interaction); } [Fact]