Skip to content

Commit 49dacb6

Browse files
committed
refactor: extract FrozenParameterInjector and tighten exact-type promotion
1 parent a7ba612 commit 49dacb6

File tree

3 files changed

+103
-83
lines changed

3 files changed

+103
-83
lines changed

src/Atc.Test/ClassAutoNSubstituteDataAttribute.cs

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -39,35 +39,18 @@ public override async ValueTask<IReadOnlyCollection<ITheoryDataRow>> GetData(
3939
{
4040
var baseRows = await base.GetData(testMethod, disposalTracker).ConfigureAwait(false);
4141
var parameters = testMethod.GetParameters();
42-
var frozenValues = parameters
43-
.Select((p, i) => (Index: i, Parameter: p, p.ParameterType))
44-
.Where(x => x.Parameter.GetCustomAttribute<FrozenAttribute>() != null)
45-
.ToArray();
46-
var injectMethod = typeof(FixtureRegistrar).GetMethod(
47-
nameof(FixtureRegistrar.Inject),
48-
BindingFlags.Public | BindingFlags.Static);
4942

50-
void InjectFrozen(object?[] originalData, IFixture f)
51-
{
52-
foreach (var frozenValue in frozenValues)
53-
{
54-
if (originalData.Length > frozenValue.Index)
55-
{
56-
injectMethod?
57-
.MakeGenericMethod(frozenValue.ParameterType)
58-
.Invoke(null, [f, originalData[frozenValue.Index]]);
59-
}
60-
}
61-
}
43+
// Build injector with promotion disabled (class data relies purely on positional alignment).
44+
var frozenInjector = FrozenParameterInjector.Build(parameters, enableExactTypePromotion: false);
6245

6346
var augmented = new List<ITheoryDataRow>(baseRows.Count);
6447
foreach (var row in baseRows)
6548
{
6649
var originalData = row.GetData();
6750
var fixture = FixtureFactory.Create();
6851

69-
// Inject frozen values if present in source data (positional only for class data).
70-
InjectFrozen(originalData, fixture);
52+
// Inject frozen values if present in source data (positional only).
53+
frozenInjector(originalData, fixture);
7154

7255
var extendedData = originalData
7356
.Concat(parameters
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
namespace Atc.Test;
2+
3+
internal static class FrozenParameterInjector
4+
{
5+
private static readonly MethodInfo? InjectMethod = typeof(FixtureRegistrar).GetMethod(
6+
nameof(FixtureRegistrar.Inject),
7+
BindingFlags.Public | BindingFlags.Static);
8+
9+
/// <summary>
10+
/// Builds an injector delegate that performs positional frozen injections and (optionally) exact-type promotions.
11+
/// </summary>
12+
/// <param name="parameters">Ordered method parameters.</param>
13+
/// <param name="enableExactTypePromotion">When true, a later frozen parameter whose index exceeds supplied row length
14+
/// will re-use an earlier supplied value only if the earlier parameter type is exactly the same (no interface/base widening).</param>
15+
/// <returns>An injector delegate accepting (suppliedRowValues, fixture) that performs injections/promotions.</returns>
16+
internal static Action<object?[], IFixture> Build(
17+
ParameterInfo[] parameters,
18+
bool enableExactTypePromotion)
19+
{
20+
var frozenParameters = parameters
21+
.Select((p, i) => new FrozenDescriptor(i, p, p.GetCustomAttribute<FrozenAttribute>() != null))
22+
.Where(x => x.IsFrozen)
23+
.ToArray();
24+
25+
if (frozenParameters.Length == 0)
26+
{
27+
return static (_, _) => { };
28+
}
29+
30+
// Pre-compute mapping from type to earliest supplied parameter index for fast exact promotion lookup.
31+
var earliestIndexByType = parameters
32+
.Select((p, i) => (p.ParameterType, Index: i))
33+
.GroupBy(x => x.ParameterType)
34+
.ToDictionary(g => g.Key, g => g.Min(x => x.Index));
35+
36+
return (suppliedData, fixture) =>
37+
{
38+
// Phase 1: direct positional injection where row already supplies the frozen parameter value.
39+
foreach (var frozen in frozenParameters)
40+
{
41+
if (suppliedData.Length > frozen.Index)
42+
{
43+
InjectGeneric(fixture, frozen.Parameter.ParameterType, suppliedData[frozen.Index]);
44+
}
45+
}
46+
47+
if (!enableExactTypePromotion)
48+
{
49+
return; // Class data does not promote.
50+
}
51+
52+
// Phase 2: exact-type promotion only (no interface/base assignability) to avoid cross-interface bleed.
53+
foreach (var frozen in frozenParameters)
54+
{
55+
if (suppliedData.Length > frozen.Index)
56+
{
57+
continue; // Already handled by direct positional reuse.
58+
}
59+
60+
if (!earliestIndexByType.TryGetValue(frozen.Parameter.ParameterType, out var earliestIndex))
61+
{
62+
continue;
63+
}
64+
65+
if (earliestIndex >= suppliedData.Length)
66+
{
67+
continue; // Earliest instance not actually supplied in this row.
68+
}
69+
70+
// Reuse supplied instance at earliest index for this exact type.
71+
var instance = suppliedData[earliestIndex];
72+
InjectGeneric(fixture, frozen.Parameter.ParameterType, instance);
73+
}
74+
};
75+
}
76+
77+
private static void InjectGeneric(
78+
IFixture fixture,
79+
Type type,
80+
object? instance)
81+
=> InjectMethod?
82+
.MakeGenericMethod(type)
83+
.Invoke(null, [fixture, instance]);
84+
85+
private readonly struct FrozenDescriptor(
86+
int index,
87+
ParameterInfo parameter,
88+
bool isFrozen)
89+
{
90+
public int Index { get; } = index;
91+
92+
public ParameterInfo Parameter { get; } = parameter;
93+
94+
public bool IsFrozen { get; } = isFrozen;
95+
}
96+
}

src/Atc.Test/MemberAutoNSubstituteDataAttribute.cs

Lines changed: 3 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ public override async ValueTask<IReadOnlyCollection<ITheoryDataRow>> GetData(
5555
var parameters = testMethod.GetParameters();
5656
var augmented = new List<ITheoryDataRow>(baseRows.Count);
5757

58-
// Pre-compute an injector tailored to the frozen parameters of this method.
59-
var frozenInjector = BuildFrozenInjector(parameters);
58+
// Pre-compute an injector tailored to the frozen parameters of this method (with exact-type promotion enabled).
59+
var frozenInjector = FrozenParameterInjector.Build(parameters, enableExactTypePromotion: true);
6060

6161
foreach (var row in baseRows)
6262
{
@@ -86,66 +86,7 @@ public override async ValueTask<IReadOnlyCollection<ITheoryDataRow>> GetData(
8686
return augmented;
8787
}
8888

89-
/// <summary>
90-
/// Builds a delegate that performs two-phase frozen value handling for the supplied test method parameters.
91-
/// </summary>
92-
/// <param name="parameters">Ordered parameter list from the test method.</param>
93-
/// <returns>An action taking (suppliedRowValues, fixture) which injects any frozen instances.</returns>
94-
/// <remarks>
95-
/// Phase 1 (Direct): For each parameter marked with <see cref="FrozenAttribute"/>, if the member row already
96-
/// supplies a value at the same index, that instance is injected (frozen) into the fixture.
97-
/// Phase 2 (Promotion): For frozen parameters whose index exceeds the supplied row length, the earliest
98-
/// previously supplied compatible instance (assignable type) is promoted and injected. This enables scenarios
99-
/// where the developer supplies a value earlier and later annotates a parameter of the same type with [Frozen].
100-
/// </remarks>
101-
private static Action<object?[], IFixture> BuildFrozenInjector(ParameterInfo[] parameters)
102-
{
103-
// Identify parameters decorated with [Frozen]; capture index + type for later injection/promotion.
104-
var frozenParameters = parameters
105-
.Select((p, i) => (Index: i, Type: p.ParameterType, Frozen: p.GetCustomAttribute<FrozenAttribute>()))
106-
.Where(x => x.Frozen is not null)
107-
.ToArray();
108-
109-
if (frozenParameters.Length == 0)
110-
{
111-
// Fast path: no frozen parameters -> no-op.
112-
return static (_, _) => { };
113-
}
114-
115-
var injectMethod = typeof(FixtureRegistrar).GetMethod(
116-
nameof(FixtureRegistrar.Inject),
117-
BindingFlags.Public | BindingFlags.Static);
118-
119-
return (suppliedData, fixture) =>
120-
{
121-
// Phase 1: Direct positional injections for frozen parameters already covered by supplied row data.
122-
foreach (var frozen in frozenParameters)
123-
{
124-
if (suppliedData.Length > frozen.Index)
125-
{
126-
injectMethod?
127-
.MakeGenericMethod(frozen.Type)
128-
.Invoke(null, [fixture, suppliedData[frozen.Index]]);
129-
}
130-
}
131-
132-
// Phase 2: Promotions – for frozen parameters whose index is beyond supplied data length,
133-
// attempt to reuse an earlier compatible supplied argument (interface / base type friendly).
134-
foreach (var frozen in frozenParameters)
135-
{
136-
if (suppliedData.Length <= frozen.Index)
137-
{
138-
var promoted = suppliedData.FirstOrDefault(d => d is not null && frozen.Type.IsInstanceOfType(d));
139-
if (promoted is not null)
140-
{
141-
injectMethod?
142-
.MakeGenericMethod(frozen.Type)
143-
.Invoke(null, [fixture, promoted]);
144-
}
145-
}
146-
}
147-
};
148-
}
89+
// Frozen injector logic has been moved into FrozenParameterInjector to share behavior with class-based data.
14990

15091
/// <summary>
15192
/// Resolves a specimen for a single parameter after applying any parameter-level customizations.

0 commit comments

Comments
 (0)