Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
Item1: UsesVerify.g.cs,
Item2:
//-----------------------------------------------------
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior
// and will be lost when the code is regenerated.
// <auto-generated />
//-----------------------------------------------------

namespace Foo
{
partial record Outer
{
partial record struct Middle
{
partial class Bar
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Verify.MSTest.SourceGenerator", "1.0.0.0")]
public global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext
{
get => global::VerifyMSTest.Verifier.CurrentTestContext.Value!.TestContext;
set => global::VerifyMSTest.Verifier.CurrentTestContext.Value = new global::VerifyMSTest.TestExecutionContext(value, GetType());
}
}
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
Item1: UsesVerify.g.cs,
Item2:
//-----------------------------------------------------
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior
// and will be lost when the code is regenerated.
// <auto-generated />
//-----------------------------------------------------

namespace Foo
{
partial record Bar
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Verify.MSTest.SourceGenerator", "1.0.0.0")]
public global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext
{
get => global::VerifyMSTest.Verifier.CurrentTestContext.Value!.TestContext;
set => global::VerifyMSTest.Verifier.CurrentTestContext.Value = new global::VerifyMSTest.TestExecutionContext(value, GetType());
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
Item1: UsesVerify.g.cs,
Item2:
//-----------------------------------------------------
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior
// and will be lost when the code is regenerated.
// <auto-generated />
//-----------------------------------------------------

namespace Foo
{
partial record Bar
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Verify.MSTest.SourceGenerator", "1.0.0.0")]
public global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext
{
get => global::VerifyMSTest.Verifier.CurrentTestContext.Value!.TestContext;
set => global::VerifyMSTest.Verifier.CurrentTestContext.Value = new global::VerifyMSTest.TestExecutionContext(value, GetType());
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
83 changes: 83 additions & 0 deletions src/Verify.MSTest.SourceGenerator.Tests/RecordTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// A record is a class, so it is a legal MSTest test class
[TestClass]
public partial class RecordTests : TestBase
{
[TestMethod]
public Task HasAttributeOnRecord()
{
var source = """
using VerifyMSTest;

namespace Foo;

[UsesVerify]
public partial record Bar
{
}
""";

return VerifyGenerator(TestDriver.Run(source));
}

[TestMethod]
public Task HasAttributeOnRecordClass()
{
var source = """
using VerifyMSTest;

namespace Foo;

[UsesVerify]
public partial record class Bar
{
}
""";

return VerifyGenerator(TestDriver.Run(source));
}

// A record struct is not a class, and [UsesVerify] is only valid on a class,
// so nothing is generated for one
[TestMethod]
public Task HasAttributeOnRecordStruct()
{
var source = """
using VerifyMSTest;

namespace Foo;

[UsesVerify]
public partial record struct Bar
{
}
""";

return VerifyGenerator(
TestDriver.Run(source),
expectedDiagnostics: ["CS0592"]);
}

// The partial declaration has to repeat the kind of every parent too
[TestMethod]
public Task HasAttributeOnClassNestedInRecords()
{
var source = """
using VerifyMSTest;

namespace Foo;

public partial record Outer
{
public partial record struct Middle
{
[UsesVerify]
public partial class Bar
{
}
}
}
""";

return VerifyGenerator(TestDriver.Run(source));
}
}
4 changes: 4 additions & 0 deletions src/Verify.MSTest.SourceGenerator/ClassToGenerate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
readonly record struct ClassToGenerate(
string? Namespace,
string ClassName,
string Keyword,
ClassToGenerate.PropertyFlags TestContextPropertyFlags,
ParentClass[] ParentClasses)
{
Expand All @@ -24,12 +25,14 @@ public enum PropertyFlags

public string? Namespace { get; } = Namespace;
public string ClassName { get; } = ClassName;
public string Keyword { get; } = Keyword;
public ParentClass[] ParentClasses { get; } = ParentClasses;
public PropertyFlags TestContextPropertyFlags { get; } = TestContextPropertyFlags;

public bool Equals(ClassToGenerate other) =>
Namespace == other.Namespace &&
ClassName == other.ClassName &&
Keyword == other.Keyword &&
TestContextPropertyFlags == other.TestContextPropertyFlags &&
ParentClasses.SequenceEqual(other.ParentClasses);

Expand All @@ -41,6 +44,7 @@ public override int GetHashCode()
var hash = 1430287;
hash = hash * 7302013 ^ (Namespace ?? string.Empty).GetHashCode();
hash = hash * 7302013 ^ ClassName.GetHashCode();
hash = hash * 7302013 ^ Keyword.GetHashCode();
hash = hash * 7302013 ^ TestContextPropertyFlags.GetHashCode();

// Include (up to) the last 8 elements in the hash code to balance performance and specificity.
Expand Down
2 changes: 1 addition & 1 deletion src/Verify.MSTest.SourceGenerator/Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ void WriteClass(ClassToGenerate toGenerate)
{
builder.AppendLine(
$$"""
partial class {{toGenerate.ClassName}}
partial {{toGenerate.Keyword}} {{toGenerate.ClassName}}
{
""");
AppendGetter(toGenerate.TestContextPropertyFlags);
Expand Down
15 changes: 15 additions & 0 deletions src/Verify.MSTest.SourceGenerator/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ static class Extensions
public static string GetTypeNameWithGenericParameters(this TypeDeclarationSyntax syntax) =>
syntax.Identifier.ToString() + syntax.TypeParameterList;

/// <summary>
/// The keyword to redeclare the type with. A partial declaration has to repeat the kind,
/// and for a record struct that kind is two tokens: `Keyword` is only the `record` half.
/// </summary>
public static string GetPartialKeyword(this TypeDeclarationSyntax syntax)
{
if (syntax is RecordDeclarationSyntax record &&
record.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword))
{
return "record struct";
}

return syntax.Keyword.ValueText;
}

public static IEnumerable<INamedTypeSymbol> GetBaseTypes(this ITypeSymbol symbol)
{
var baseType = symbol.BaseType;
Expand Down
8 changes: 5 additions & 3 deletions src/Verify.MSTest.SourceGenerator/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static ClassToGenerate Parse(INamedTypeSymbol symbol, TypeDeclarationSynt
return new(
Namespace: ns,
ClassName: name,
Keyword: syntax.GetPartialKeyword(),
TestContextPropertyFlags: propertyFlags,
ParentClasses: parents);
}
Expand Down Expand Up @@ -54,12 +55,13 @@ static IEnumerable<INamedTypeSymbol> BaseClassesOf(INamedTypeSymbol typeSymbol)

static ParentClass[] GetParentClasses(TypeDeclarationSyntax syntax, Cancel cancel)
{
// We can only be nested in class/struct/record
// We can only be nested in class/struct/record/record struct
static bool IsAllowedKind(SyntaxKind kind) =>
kind is
SyntaxKind.ClassDeclaration or
SyntaxKind.StructDeclaration or
SyntaxKind.RecordDeclaration;
SyntaxKind.RecordDeclaration or
SyntaxKind.RecordStructDeclaration;

var parents = new Stack<ParentClass>();

Expand All @@ -71,7 +73,7 @@ SyntaxKind.StructDeclaration or
cancel.ThrowIfCancellationRequested();

parents.Push(new(
Keyword: parent.Keyword.ValueText,
Keyword: parent.GetPartialKeyword(),
Name: parent.GetTypeNameWithGenericParameters()));

parent = parent.Parent as TypeDeclarationSyntax;
Expand Down
14 changes: 11 additions & 3 deletions src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
static bool HasTestClassAttribute(INamedTypeSymbol symbol, INamedTypeSymbol testClassType) =>
!symbol.HasAttributeOfType(testClassType, includeDerived: true);

// Require at least one attribute list: both paths only care about classes
// Require at least one attribute list: both paths only care about types
// carrying [UsesVerify] or [TestClass]. Without this filter the uncached
// CreateSyntaxProvider transform runs semantic work for every class in the
// CreateSyntaxProvider transform runs semantic work for every type in the
// consuming project on every keystroke.
// Records are eligible too: a record class is a class, so it is a legal test class.
// A record struct is not, and [UsesVerify] does not permit one.
static bool IsSyntaxEligibleForGeneration(SyntaxNode node, Cancel _) =>
node is ClassDeclarationSyntax { AttributeLists.Count: > 0 };
node switch
{
ClassDeclarationSyntax syntax => syntax.AttributeLists.Count > 0,
RecordDeclarationSyntax syntax => syntax.AttributeLists.Count > 0 &&
!syntax.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword),
_ => false
};

static bool IsAssemblyEligibleForGeneration(IAssemblySymbol assembly, INamedTypeSymbol markerType) =>
assembly.HasAttributeOfType(markerType, includeDerived: false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RecordValue
9 changes: 9 additions & 0 deletions src/Verify.MSTest.Tests/RecordTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// A record is a class, so it is a legal test class. Without generator support the
// TestContext property is never generated and this fails with "TestContext is null".
[TestClass]
public partial record RecordTests
{
[TestMethod]
public Task ShouldPass() =>
Verify("RecordValue");
}
2 changes: 1 addition & 1 deletion src/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des
- [ ] **Stack-trace scrubber destroys paren-less `at` frames** (NativeAOT: `at MyApp!<BaseAddress>+0x1a2b3c`).
`Verify/Serialization/Scrubbers/ScrubStackTrace.cs:36-58` — `IndexOf('(')`/`')'` return −1, slice keeps zero chars → frame becomes an empty line, or the literal `...)` with `removeParams: true`.

- [ ] **MSTest source generator ignores `record` test classes.**
- [x] **MSTest source generator ignores `record` test classes.**
`Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs:125-126` — only `ClassDeclarationSyntax` is eligible; `[UsesVerify] [TestClass] partial record` compiles then fails at runtime with the misleading "TestContext is null" error. `Parser.GetParentClasses` similarly stops at a `record struct` parent.

- [ ] **Unclosed JSON object for empty `CombinationResults`.**
Expand Down
Loading