From f2d365a4e31f27f9f71e7c38837a696db007b8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 14:42:21 +0200 Subject: [PATCH 1/2] Add MSTest.AotReflection.SourceGeneration.UnitTests Adds a focused unit-test project for the AotReflection source generator PoC introduced in #8574. The PoC had no test coverage until now. Coverage highlights (13 tests): - Support types emission (TestClassReflectionInfo, TestMethodReflectionInfo, TestPropertyReflectionInfo, TestConstructorReflectionInfo). - Registry emission shape and namespace (MSTest.SourceGenerated). - Empty registry when no [TestClass] is present. - Skipping of static / abstract / open-generic test classes. - Constructor invoker, parameter types / names, async return shape. - Class-level attribute capture; property getter & setter delegate text. - Compile-clean snapshot (catches CS errors the generator may introduce). - Incrementality: support-types step is cached when input is unchanged. Also: - Adds MSTest.AotReflection.SourceGeneration to TestFx.slnx and MSTest.slnf (missing since #8574). - Adds [InternalsVisibleTo] for the new test project (generator class is internal sealed). Part of #1837. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MSTest.slnf | 1 + TestFx.slnx | 2 + ...Test.AotReflection.SourceGeneration.csproj | 4 + ...flection.SourceGeneration.UnitTests.csproj | 24 + .../MSTestReflectionMetadataGeneratorTests.cs | 466 ++++++++++++++++++ .../Program.cs | 19 + 6 files changed, 516 insertions(+) create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs diff --git a/MSTest.slnf b/MSTest.slnf index 3c3f4c624a..f8858c6031 100644 --- a/MSTest.slnf +++ b/MSTest.slnf @@ -11,6 +11,7 @@ "src\\Analyzers\\MSTest.Analyzers.CodeFixes\\MSTest.Analyzers.CodeFixes.csproj", "src\\Analyzers\\MSTest.Analyzers.Package\\MSTest.Analyzers.Package.csproj", "src\\Analyzers\\MSTest.Analyzers\\MSTest.Analyzers.csproj", + "src\\Analyzers\\MSTest.AotReflection.SourceGeneration\\MSTest.AotReflection.SourceGeneration.csproj", "src\\Analyzers\\MSTest.GlobalConfigsGenerator\\MSTest.GlobalConfigsGenerator.csproj", "src\\Analyzers\\MSTest.SourceGeneration\\MSTest.SourceGeneration.csproj", "src\\Package\\MSTest.Sdk\\MSTest.Sdk.csproj", diff --git a/TestFx.slnx b/TestFx.slnx index 991e9dc8db..4f9fc147da 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -69,6 +69,7 @@ + @@ -133,6 +134,7 @@ + diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj index 1369428997..dc9bbade05 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj @@ -29,4 +29,8 @@ + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj new file mode 100644 index 0000000000..b91def2e3a --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + MSTest.AotReflection.SourceGeneration.UnitTests + true + true + Exe + + + + + + + + + + + + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs new file mode 100644 index 0000000000..2bd16432b1 --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +using MSTest.AotReflection.SourceGeneration.Generators; + +namespace MSTest.AotReflection.SourceGeneration.UnitTests; + +/// +/// Behavior tests for . +/// These pin the current PoC output so the upcoming follow-up PRs (#1837) can extend it safely. +/// +[TestClass] +public sealed class MSTestReflectionMetadataGeneratorTests +{ + /// + /// Minimal MSTest attribute stubs so the generator can locate [TestClass] / + /// [TestMethod] in test fixtures without dragging the real TestFramework + /// assemblies into the Roslyn compilation. + /// + private const string MinimalMSTestStub = """ + namespace Microsoft.VisualStudio.TestTools.UnitTesting + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public class TestClassAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class TestMethodAttribute : System.Attribute + { + public TestMethodAttribute() { } + public TestMethodAttribute(string displayName) { DisplayName = displayName; } + public string? DisplayName { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = true)] + public class TestCategoryAttribute : System.Attribute + { + public TestCategoryAttribute(string category) { Category = category; } + public string Category { get; } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class TestContextAttribute : System.Attribute { } + } + """; + + [TestMethod] + public void Generator_EmitsSupportTypes_OnAnyCompilation() + { + const string userCode = """ + // Intentionally empty — no [TestClass] in the consumer. + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + // Support types are emitted via RegisterPostInitializationOutput → always present. + string support = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs") + .SourceText.ToString(); + + support.Should().Contain("namespace MSTest.SourceGenerated"); + support.Should().Contain("internal sealed class TestClassReflectionInfo"); + support.Should().Contain("internal sealed class TestMethodReflectionInfo"); + support.Should().Contain("internal sealed class TestPropertyReflectionInfo"); + support.Should().Contain("internal sealed class TestConstructorReflectionInfo"); + } + + [TestMethod] + public void Generator_EmitsRegistry_WithDiscoveredTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class MyTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + + registry.Should().Contain("internal static class MSTestReflectionMetadata"); + registry.Should().Contain("public const string AssemblyName = \"TestSample\";"); + registry.Should().Contain("Type = typeof(global::Sample.MyTests)"); + registry.Should().Contain("Name = \"Test1\""); + registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return null; },"); + } + + [TestMethod] + public void Generator_EmitsEmptyRegistry_WhenNoTestClasses() + { + const string userCode = """ + namespace Sample + { + // No [TestClass] anywhere. + public class NotATest { public void Foo() { } } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("public static IReadOnlyList TestClasses { get; } = new TestClassReflectionInfo[]"); + // No concrete TestClassReflectionInfo instance is emitted (note the open paren). + registry.Should().NotContain("new TestClassReflectionInfo("); + } + + [TestMethod] + public void Generator_SkipsStaticTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public static class StaticTests + { + [TestMethod] + public static void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Static classes are excluded by the predicate in the generator (cannot be instantiated). + registry.Should().NotContain("StaticTests"); + } + + [TestMethod] + public void Generator_SkipsAbstractTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public abstract class AbstractTests + { + [TestMethod] + public void Test1() { } + } + + [TestClass] + public class ConcreteTests + { + [TestMethod] + public void Test2() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Abstract classes are filtered in BuildModel — they cannot be instantiated. + registry.Should().NotContain("AbstractTests"); + registry.Should().Contain("typeof(global::Sample.ConcreteTests)"); + } + + [TestMethod] + public void Generator_SkipsGenericTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class GenericTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Open-generic test classes are out of scope for this PoC. + registry.Should().NotContain("GenericTests"); + } + + [TestMethod] + public void Generator_EmitsConstructorInvoker() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class CtorTests + { + public CtorTests() { } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Constructors = new TestConstructorReflectionInfo[]"); + registry.Should().Contain("Invoke = static args => new global::Sample.CtorTests(),"); + } + + [TestMethod] + public void Generator_EmitsParameterTypes_ForMethodWithParameters() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class ParamTests + { + [TestMethod] + public void Test1(int x, string y) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("ParameterTypes = new Type[] { typeof(int), typeof(string) }"); + registry.Should().Contain("ParameterNames = new string[] { \"x\", \"y\" }"); + } + + [TestMethod] + public void Generator_FlagsAsyncReturnTypes() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class AsyncTests + { + [TestMethod] + public Task Test1() => Task.CompletedTask; + + [TestMethod] + public ValueTask Test2() => default; + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Name = \"Test1\""); + registry.Should().Contain("ReturnsTask = true"); + registry.Should().Contain("Name = \"Test2\""); + registry.Should().Contain("ReturnsValueTask = true"); + } + + [TestMethod] + public void Generator_CapturesClassLevelAttributes() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + [TestCategory("Smoke")] + public class TaggedTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute"); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute"); + registry.Should().Contain("\"Smoke\""); + } + + [TestMethod] + public void Generator_EmitsPropertyGetterAndSetter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + public class PropTests + { + [TestContext] + public TestContext? Context { get; set; } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Name = \"Context\""); + registry.Should().Contain("HasPublicSetter = true"); + registry.Should().Contain("Get = static instance => instance is null ? null : (object?)((global::Sample.PropTests)instance).Context,"); + registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext?)value!,"); + } + + [TestMethod] + public void Generator_EmittedSource_CompilesCleanly() + { + // NOTE: Scenario intentionally avoids nullable reference type annotations on + // property/parameter types: the current PoC emits `typeof(T?)` verbatim, which + // is invalid C#. That bug is tracked separately for a follow-up PR; this test + // is here to prevent the emitted source from ever introducing OTHER compile + // errors. + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + [TestCategory("Smoke")] + public class FullShape + { + [TestContext] + public TestContext Context { get; set; } = new(); + + public FullShape() { } + + [TestMethod("alias")] + public void Sync(int x) { } + + [TestMethod] + public Task Asynchronous() => Task.CompletedTask; + } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + + IEnumerable diagnostics = outputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error); + + diagnostics.Should().BeEmpty( + "the generated source MUST compile cleanly when consumed in the same compilation as the user code"); + } + + [TestMethod] + public void Generator_IsIncremental_SupportTypesAreCached_WhenInputUnchanged() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class IncTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + CSharpCompilation compilation = CreateCompilation(MinimalMSTestStub, userCode); + GeneratorDriver driver = CSharpGeneratorDriver + .Create(new MSTestReflectionMetadataGenerator()) + .WithUpdatedParseOptions((CSharpParseOptions)compilation.SyntaxTrees.First().Options); + + // Track step output cache reasons. + driver = driver.RunGenerators(compilation); + driver = driver.RunGenerators(compilation); + + GeneratorDriverRunResult result = driver.GetRunResult(); + result.Diagnostics.Should().BeEmpty(); + result.Results.Should().ContainSingle(); + // Two passes against the same compilation must produce identical sources. + result.Results[0].GeneratedSources.Should().HaveCount(2); + } + + private static string GetRegistry(GeneratorRunResult result) + => result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") + .SourceText.ToString() + .Replace("\r\n", "\n"); + + private static GeneratorRunResult RunGenerator(params string[] sources) + { + CSharpCompilation compilation = CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + return driver.GetRunResult().Results[0]; + } + + private static Compilation RunGeneratorAndGetCompilation(params string[] sources) + { + CSharpCompilation compilation = CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); + driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out _); + return outputCompilation; + } + + private static CSharpCompilation CreateCompilation(params string[] sources) + { + IEnumerable trees = sources.Select(s => CSharpSyntaxTree.ParseText(s)); + MetadataReference[] references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Runtime.CompilerServices.ModuleInitializerAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Reflection.Assembly).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Collections.Generic.Dictionary<,>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Reflection.MethodInfo).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Threading.Tasks.Task).Assembly.Location), + }; + + return CSharpCompilation.Create( + "TestSample", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs new file mode 100644 index 0000000000..9fbdc73473 --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions; + +ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); +builder.AddMSTest(() => [Assembly.GetEntryAssembly()!]); + +#if ENABLE_CODECOVERAGE +builder.AddCodeCoverageProvider(); +#endif +builder.AddHangDumpProvider(); +builder.AddCrashDumpProvider(ignoreIfNotSupported: true); +builder.AddTrxReportProvider(); +builder.AddAppInsightsTelemetryProvider(); +builder.AddAzureDevOpsProvider(); + +using ITestApplication app = await builder.BuildAsync(); +return await app.RunAsync(); From 51dfb1cc9726468df15d92d3bf3910d59df2df10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= <11340282+Evangelink@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:13:39 +0200 Subject: [PATCH 2/2] Make incrementality test actually validate caching Two RunGenerators calls were performed but only the final result was inspected, so the test never validated that the second run reused cached output. Capture both run results, assert the generated sources match between runs (deterministic output), and assert TrackedOutputSteps for the second run all report Cached or Unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MSTestReflectionMetadataGeneratorTests.cs | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 2bd16432b1..5918a51b22 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -389,7 +389,7 @@ public void Sync(int x) { } } [TestMethod] - public void Generator_IsIncremental_SupportTypesAreCached_WhenInputUnchanged() + public void Generator_IsIncremental_ProducesStableOutputAndCachesSteps_WhenInputUnchanged() { const string userCode = """ using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -406,19 +406,44 @@ public void Test1() { } """; CSharpCompilation compilation = CreateCompilation(MinimalMSTestStub, userCode); - GeneratorDriver driver = CSharpGeneratorDriver - .Create(new MSTestReflectionMetadataGenerator()) - .WithUpdatedParseOptions((CSharpParseOptions)compilation.SyntaxTrees.First().Options); - - // Track step output cache reasons. - driver = driver.RunGenerators(compilation); - driver = driver.RunGenerators(compilation); - - GeneratorDriverRunResult result = driver.GetRunResult(); - result.Diagnostics.Should().BeEmpty(); - result.Results.Should().ContainSingle(); - // Two passes against the same compilation must produce identical sources. - result.Results[0].GeneratedSources.Should().HaveCount(2); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new ISourceGenerator[] { new MSTestReflectionMetadataGenerator().AsSourceGenerator() }, + additionalTexts: null, + parseOptions: (CSharpParseOptions)compilation.SyntaxTrees.First().Options, + optionsProvider: null, + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + + GeneratorDriver firstDriver = driver.RunGenerators(compilation); + GeneratorDriverRunResult firstResult = firstDriver.GetRunResult(); + + GeneratorDriver secondDriver = firstDriver.RunGenerators(compilation); + GeneratorDriverRunResult secondResult = secondDriver.GetRunResult(); + + firstResult.Diagnostics.Should().BeEmpty(); + secondResult.Diagnostics.Should().BeEmpty(); + firstResult.Results.Should().ContainSingle(); + secondResult.Results.Should().ContainSingle(); + + // Two passes against the same compilation MUST produce identical sources (deterministic output). + secondResult.Results[0].GeneratedSources + .Select(s => (s.HintName, Text: s.SourceText.ToString())) + .Should() + .BeEquivalentTo(firstResult.Results[0].GeneratedSources + .Select(s => (s.HintName, Text: s.SourceText.ToString()))); + + // The implementation-source-output pipeline MUST report cached steps on the second run + // (proves the generator's incremental graph actually short-circuits when inputs are unchanged). + secondResult.Results[0].TrackedOutputSteps + .Should() + .NotBeEmpty("the generator registers RegisterImplementationSourceOutput which produces a tracked output step"); + + secondResult.Results[0].TrackedOutputSteps + .SelectMany(static kv => kv.Value) + .SelectMany(static step => step.Outputs) + .Should() + .OnlyContain( + static o => o.Reason == IncrementalStepRunReason.Cached || o.Reason == IncrementalStepRunReason.Unchanged, + "output steps for unchanged inputs MUST be Cached or Unchanged on subsequent runs"); } private static string GetRegistry(GeneratorRunResult result)