Skip to content

Commit 517e185

Browse files
Amaury LevéCopilot
andcommitted
A4: Materialize [DataRow] arguments into TestMethodReflectionInfo
Part of #1837. Adds compile-time materialization of `[DataRow]` attribute applications on `[TestMethod]` members. The generator now emits a `DataRows` property on each `TestMethodReflectionInfo` containing a flat `IReadOnlyList<object?[]>` mirroring the runtime shape of `DataRowAttribute.Data`, so a consumer can iterate parameterised cases without re-reading the attributes via reflection. Highlights: - New `DataRowModel(EquatableArray<TypedConstantModel> Arguments)` capturing one row of arguments per attribute application. - `BuildDataRows` detects `Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute` applications and flattens the variadic `params object?[] moreData` tail back into the row so the emitted array matches `DataRowAttribute.Data` rather than preserving a nested array. - Inheritance-aware: reuses the inherited attribute walk introduced in #9006, so `[DataRow]` applied on a base method (when the override is virtual) is still picked up. - Emitter always emits `DataRows` (empty array for non-data-driven tests) for shape parity with the other `TestMethodReflectionInfo` properties. Deferred to a follow-up: `[DynamicData]` materialization. Resolving the data source method/property/field at compile time is materially more complex (handles `Method`/`Property`/`Field`/`AutoDetect` source kinds plus `object[]` / `IEnumerable<object[]>` return shapes) and warrants its own PR. Tests: - `Generator_EmitsEmptyDataRows_WhenMethodHasNoDataRow` - `Generator_CapturesSingleDataRow_WithScalarArgs` - `Generator_CapturesMultipleDataRows_InDeclarationOrder` - `Generator_FlattensParamsArrayInDataRow` - `Generator_HandlesNullValueInDataRow` Total: 32/32 passing. Depends on #9004, #9005, #9006, #9007. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e4d126e commit 517e185

4 files changed

Lines changed: 259 additions & 2 deletions

File tree

src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ public static string EmitSupportTypes()
5757
sb.AppendLine("public Type[] ParameterTypes { get; init; } = Array.Empty<Type>();");
5858
sb.AppendLine("public string[] ParameterNames { get; init; } = Array.Empty<string>();");
5959
sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty<Attribute>();");
60+
sb.AppendLine("/// <summary>Materialized argument tuples from <c>[DataRow]</c> attributes (empty for non-data-driven tests). Each <c>object?[]</c> corresponds to one <c>[DataRow]</c> application.</summary>");
61+
sb.AppendLine("public IReadOnlyList<object?[]> DataRows { get; init; } = Array.Empty<object?[]>();");
6062
sb.AppendLine("/// <summary>Direct invoker — replaces <see cref=\"System.Reflection.MethodInfo.Invoke(object, object[])\" />.</summary>");
6163
sb.AppendLine("public Func<object?, object?[]?, object?> Invoke { get; init; } = static (_, _) => null;");
6264
}
@@ -225,6 +227,8 @@ private static void EmitMethods(IndentedStringBuilder sb, string fqn, TestClassM
225227
sb.AppendLine(",");
226228
EmitAttributesProperty(sb, "Attributes", method.Attributes);
227229
sb.AppendLine(",");
230+
EmitDataRows(sb, method.DataRows);
231+
sb.AppendLine(",");
228232
EmitMethodInvoker(sb, fqn, method);
229233
}
230234

@@ -280,6 +284,44 @@ private static void EmitMethodInvoker(IndentedStringBuilder sb, string classFqn,
280284
sb.AppendLine($"Invoke = static (instance, args) => {body},");
281285
}
282286

287+
private static void EmitDataRows(IndentedStringBuilder sb, EquatableArray<DataRowModel> dataRows)
288+
{
289+
if (dataRows.Length == 0)
290+
{
291+
sb.Append("DataRows = Array.Empty<object?[]>()");
292+
sb.AppendLine();
293+
return;
294+
}
295+
296+
sb.AppendLine("DataRows = new object?[][]");
297+
using (sb.Block(null))
298+
{
299+
for (int i = 0; i < dataRows.Length; i++)
300+
{
301+
EquatableArray<TypedConstantModel> args = dataRows[i].Arguments;
302+
if (args.Length == 0)
303+
{
304+
sb.Append("Array.Empty<object?>()");
305+
}
306+
else
307+
{
308+
string literals = string.Join(", ", args.AsImmutableArray().Select(BuildConstantExpression));
309+
sb.Append($"new object?[] {{ {literals} }}");
310+
}
311+
312+
if (i < dataRows.Length - 1)
313+
{
314+
sb.AppendLine(",");
315+
}
316+
else
317+
{
318+
sb.AppendLine();
319+
}
320+
}
321+
}
322+
}
323+
324+
283325
private static void EmitParameterTypes(IndentedStringBuilder sb, EquatableArray<TestParameterModel> parameters)
284326
{
285327
if (parameters.Length == 0)

src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
using Microsoft.CodeAnalysis;
1111

12+
using MSTest.AotReflection.SourceGeneration.Helpers;
1213
using MSTest.AotReflection.SourceGeneration.Model;
1314

1415
namespace MSTest.AotReflection.SourceGeneration.Generators;
@@ -149,6 +150,8 @@ private static TestMethodModel BuildMethod(IMethodSymbol method)
149150
|| returnTypeFqn.StartsWith("global::System.Threading.Tasks.ValueTask<", System.StringComparison.Ordinal);
150151
bool returnsVoid = returnType.SpecialType == SpecialType.System_Void;
151152

153+
ImmutableArray<AttributeData> inheritedAttributes = CollectInheritedAttributes(method);
154+
152155
return new TestMethodModel(
153156
Name: method.Name,
154157
IsStatic: method.IsStatic,
@@ -157,7 +160,63 @@ private static TestMethodModel BuildMethod(IMethodSymbol method)
157160
ReturnsValueTask: returnsValueTask,
158161
ReturnsVoid: returnsVoid,
159162
Parameters: BuildParameters(method),
160-
Attributes: BuildAttributes(CollectInheritedAttributes(method)));
163+
Attributes: BuildAttributes(inheritedAttributes),
164+
DataRows: BuildDataRows(inheritedAttributes));
165+
}
166+
167+
// Walks the attribute list and reifies each [DataRow(...)] application into a flat
168+
// object?[] row. Mirrors DataRowAttribute's runtime behavior: when the constructor uses
169+
// the variadic overload (object? data1, params object?[] moreData), Roslyn surfaces the
170+
// tail as a single Array TypedConstant, which we flatten back so the consumer sees the
171+
// same shape as DataRowAttribute.Data.
172+
private static EquatableArray<DataRowModel> BuildDataRows(ImmutableArray<AttributeData> attributes)
173+
{
174+
if (attributes.IsDefaultOrEmpty)
175+
{
176+
return EquatableArray<DataRowModel>.Empty;
177+
}
178+
179+
ImmutableArray<DataRowModel>.Builder builder = ImmutableArray.CreateBuilder<DataRowModel>();
180+
foreach (AttributeData attribute in attributes)
181+
{
182+
if (attribute.AttributeClass is not { } attributeClass)
183+
{
184+
continue;
185+
}
186+
187+
if (attributeClass.ToDisplayString(FullyQualifiedFormat) != "global::" + MSTestAttributeNames.DataRow)
188+
{
189+
continue;
190+
}
191+
192+
ImmutableArray<TypedConstant> ctorArgs = attribute.ConstructorArguments;
193+
ImmutableArray<TypedConstantModel>.Builder rowBuilder = ImmutableArray.CreateBuilder<TypedConstantModel>();
194+
195+
bool lastIsParamsArray =
196+
attribute.AttributeConstructor is { Parameters: { IsDefaultOrEmpty: false } parameters }
197+
&& parameters[parameters.Length - 1].IsParams
198+
&& !ctorArgs.IsDefaultOrEmpty
199+
&& ctorArgs[ctorArgs.Length - 1].Kind == TypedConstantKind.Array;
200+
201+
for (int i = 0; i < ctorArgs.Length; i++)
202+
{
203+
if (i == ctorArgs.Length - 1 && lastIsParamsArray)
204+
{
205+
foreach (TypedConstant element in ctorArgs[i].Values)
206+
{
207+
rowBuilder.Add(ToModel(element));
208+
}
209+
}
210+
else
211+
{
212+
rowBuilder.Add(ToModel(ctorArgs[i]));
213+
}
214+
}
215+
216+
builder.Add(new DataRowModel(new EquatableArray<TypedConstantModel>(rowBuilder.ToImmutable())));
217+
}
218+
219+
return new EquatableArray<DataRowModel>(builder.ToImmutable());
161220
}
162221

163222
private static TestPropertyModel BuildProperty(IPropertySymbol property)

src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ internal enum ConstantValueKind
4141

4242
internal sealed record TestParameterModel(string FullyQualifiedType, string Name);
4343

44+
/// <summary>
45+
/// One row of arguments from a <c>[DataRow]</c> attribute, materialized at compile time so
46+
/// the consumer can iterate without re-reading <c>DataRowAttribute.Data</c> via reflection.
47+
/// </summary>
48+
internal sealed record DataRowModel(EquatableArray<TypedConstantModel> Arguments);
49+
4450
internal sealed record TestMethodModel(
4551
string Name,
4652
bool IsStatic,
@@ -49,7 +55,8 @@ internal sealed record TestMethodModel(
4955
bool ReturnsValueTask,
5056
bool ReturnsVoid,
5157
EquatableArray<TestParameterModel> Parameters,
52-
EquatableArray<AttributeApplicationModel> Attributes);
58+
EquatableArray<AttributeApplicationModel> Attributes,
59+
EquatableArray<DataRowModel> DataRows);
5360

5461
internal sealed record TestPropertyModel(
5562
string Name,

test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ public class ParallelizeAttribute : System.Attribute
5858
public int Workers { get; set; }
5959
public string? Scope { get; set; }
6060
}
61+
62+
[System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true)]
63+
public class DataRowAttribute : System.Attribute
64+
{
65+
public DataRowAttribute(object? data1) { }
66+
public DataRowAttribute(object? data1, params object?[] moreData) { }
67+
}
6168
}
6269
""";
6370

@@ -883,6 +890,148 @@ public class NotATest { }
883890
registry.Should().NotContain("new TestClassReflectionInfo(");
884891
}
885892

893+
[TestMethod]
894+
public void Generator_EmitsEmptyDataRows_WhenMethodHasNoDataRow()
895+
{
896+
const string userCode = """
897+
using Microsoft.VisualStudio.TestTools.UnitTesting;
898+
899+
namespace Sample
900+
{
901+
[TestClass]
902+
public class Tests
903+
{
904+
[TestMethod]
905+
public void NoData() { }
906+
}
907+
}
908+
""";
909+
910+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
911+
912+
result.Diagnostics.Should().BeEmpty();
913+
string registry = GetRegistry(result);
914+
registry.Should().Contain("DataRows = Array.Empty<object?[]>()");
915+
registry.Should().NotContain("DataRows = new object?[][]");
916+
}
917+
918+
[TestMethod]
919+
public void Generator_CapturesSingleDataRow_WithScalarArgs()
920+
{
921+
const string userCode = """
922+
using Microsoft.VisualStudio.TestTools.UnitTesting;
923+
924+
namespace Sample
925+
{
926+
[TestClass]
927+
public class Tests
928+
{
929+
[TestMethod]
930+
[DataRow(1, "x")]
931+
public void Test(int a, string b) { }
932+
}
933+
}
934+
""";
935+
936+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
937+
938+
result.Diagnostics.Should().BeEmpty();
939+
string registry = GetRegistry(result);
940+
registry.Should().Contain("DataRows = new object?[][]");
941+
registry.Should().Contain("new object?[] { 1, \"x\" }");
942+
}
943+
944+
[TestMethod]
945+
public void Generator_CapturesMultipleDataRows_InDeclarationOrder()
946+
{
947+
const string userCode = """
948+
using Microsoft.VisualStudio.TestTools.UnitTesting;
949+
950+
namespace Sample
951+
{
952+
[TestClass]
953+
public class Tests
954+
{
955+
[TestMethod]
956+
[DataRow(1, "a")]
957+
[DataRow(2, "b")]
958+
[DataRow(3, "c")]
959+
public void Test(int a, string b) { }
960+
}
961+
}
962+
""";
963+
964+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
965+
966+
result.Diagnostics.Should().BeEmpty();
967+
string registry = GetRegistry(result);
968+
registry.Should().Contain("DataRows = new object?[][]");
969+
970+
int idx1 = registry.IndexOf("new object?[] { 1, \"a\" }", StringComparison.Ordinal);
971+
int idx2 = registry.IndexOf("new object?[] { 2, \"b\" }", StringComparison.Ordinal);
972+
int idx3 = registry.IndexOf("new object?[] { 3, \"c\" }", StringComparison.Ordinal);
973+
974+
idx1.Should().BeGreaterThan(-1);
975+
idx2.Should().BeGreaterThan(idx1);
976+
idx3.Should().BeGreaterThan(idx2);
977+
}
978+
979+
[TestMethod]
980+
public void Generator_FlattensParamsArrayInDataRow()
981+
{
982+
const string userCode = """
983+
using Microsoft.VisualStudio.TestTools.UnitTesting;
984+
985+
namespace Sample
986+
{
987+
[TestClass]
988+
public class Tests
989+
{
990+
[TestMethod]
991+
[DataRow(1, 2, 3, 4)]
992+
public void Test(int a, int b, int c, int d) { }
993+
}
994+
}
995+
""";
996+
997+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
998+
999+
result.Diagnostics.Should().BeEmpty();
1000+
string registry = GetRegistry(result);
1001+
// The variadic `params object?[] moreData` tail must be flattened into a single flat row
1002+
// within the DataRows block — the row contains all four values inline, not nested.
1003+
registry.Should().Contain("new object?[] { 1, 2, 3, 4 }");
1004+
}
1005+
1006+
[TestMethod]
1007+
public void Generator_HandlesNullValueInDataRow()
1008+
{
1009+
const string userCode = """
1010+
using Microsoft.VisualStudio.TestTools.UnitTesting;
1011+
1012+
namespace Sample
1013+
{
1014+
[TestClass]
1015+
public class Tests
1016+
{
1017+
[TestMethod]
1018+
[DataRow(null)]
1019+
public void Test(string? value) { }
1020+
}
1021+
}
1022+
""";
1023+
1024+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
1025+
1026+
result.Diagnostics.Should().BeEmpty();
1027+
string registry = GetRegistry(result);
1028+
registry.Should().Contain("DataRows = new object?[][]");
1029+
// The single-arg DataRowAttribute(object? data1) overload binds null to object,
1030+
// which surfaces as `(object)null!` from BuildConstantExpression (C# keyword form
1031+
// produced by FullyQualifiedFormat for System.Object).
1032+
registry.Should().Contain("new object?[] { (object)null! }");
1033+
}
1034+
8861035
private static string GetRegistry(GeneratorRunResult result)
8871036
=> result.GeneratedSources
8881037
.Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs")

0 commit comments

Comments
 (0)