diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs index 92016534..166ad7df 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,26 @@ internal void AddProperty(Property property) int getId = AllocateId("MemberId_" + identifierGet); PropertyGetIds[property] = getId; + // PropertyGetterAccess is identified solely by Name, so the generator emits one + // 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)); + 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 +258,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..4180acd0 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,7 +6,15 @@ namespace Mockolate.Interactions; /// -/// Per-member buffer for property getters. Records only the property name + sequence number. +/// 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 @@ -15,26 +24,40 @@ 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) + /// 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)} 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.Name = name; - r.Boxed = null; _storage.Publish(); if (_owner.HasInteractionAddedSubscribers) @@ -43,6 +66,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 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 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. + _access ??= new PropertyGetterAccess(name); + Append(); + } + /// public void Clear() => _storage.Clear(); @@ -51,11 +90,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 +109,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 +123,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 +152,6 @@ public int ConsumeMatching() internal struct Record { public long Seq; - public string Name; - public IInteraction? Boxed; } } @@ -234,6 +281,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..89b35934 100644 --- a/Source/Mockolate/MockRegistry.Interactions.cs +++ b/Source/Mockolate/MockRegistry.Interactions.cs @@ -421,6 +421,34 @@ public TResult GetProperty(string propertyName, Func defaultVa return ResolveGetterInternal(propertyName, defaultValueGenerator, baseValueAccessor, interaction); } + /// + /// 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 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) + { + 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 +472,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 access + /// in the matching pointing at the same singleton. + /// + /// The property's value type. + /// The generator-emitted member id for the property getter. + /// 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(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 +517,39 @@ 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 + /// 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 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. + /// 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 +596,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/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.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.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs b/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs index b06b2e6e..360416d0 100644 --- a/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs +++ b/Tests/Mockolate.Internal.Tests/Interactions/FastBufferBoxingAndUnverifiedTests.cs @@ -238,7 +238,21 @@ public async Task FastPropertyGetterBuffer_Append_ShouldRaiseInteractionAdded() }); [Fact] - public async Task FastPropertyGetterBuffer_AppendBoxed_CachesAndReusesAlreadyBoxedRecord() + public async Task FastPropertyGetterBuffer_Append_WithoutInstalledSingleton_ShouldThrow() + { + FastMockInteractions store = new(1); + FastPropertyGetterBuffer buffer = store.InstallPropertyGetter(0); + + void Act() + { + buffer.Append(); + } + + await That(Act).Throws(); + } + + [Fact] + public async Task FastPropertyGetterBuffer_AppendBoxed_RepeatedCallsReturnSameSingleton() { FastMockInteractions store = new(1); FastPropertyGetterBuffer buffer = store.InstallPropertyGetter(0); @@ -255,6 +269,28 @@ public async Task FastPropertyGetterBuffer_AppendBoxed_CachesAndReusesAlreadyBox await That(second[0].Interaction).IsSameAs(first[0].Interaction); } + [Fact] + 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); + + 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).IsSameAs(dest[1].Interaction); + } + [Fact] public async Task FastPropertySetterBuffer_Append_ShouldRaiseInteractionAdded() => await VerifyRaisesInteractionAdded(store => 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 { 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)]