From 080432ba56090ab561011b535a8c134f2193e920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=BD=E7=AC=94?= <46271592+Skymly@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:58:27 +0800 Subject: [PATCH 1/2] Add incremental edit tests for RegisterStrategy, GenerateSingleton, StateTransition Add RunIncrementalEdit helper to SourceGeneratorTestContext that runs a generator on an initial compilation, replaces one syntax tree with a modified version, and re-runs. This enables testing the core incremental value: editing one file should only regenerate affected contracts. Three new test methods in IncrementalCacheTests: - RegisterStrategy_EditOneFile_UnmodifiedContractStaysCached: two strategy contracts in separate files, add a strategy to one file, assert Combine is Modified and transform reflects the new strategy. - GenerateSingleton_EditOneFile_TransformReflectsChange: two singleton targets in separate files, add a singleton to one file, assert transform has at least one Modified/New output. - StateTransition_EditOneFile_CollectIsModified: two state machines in separate files, add a transition to one file, assert Combine is Modified and transform reflects the change. Also add GetTrackedStepReasons and GetAllTrackedStepNames helpers for inspecting tracked step reasons from a GeneratorDriverRunResult. Closes #213 --- .../Generators/IncrementalCacheTests.cs | 228 ++++++++++++++++++ .../SourceGeneratorTestContext.cs | 96 ++++++++ 2 files changed, 324 insertions(+) diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs index a30d2a2..4fe96a2 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs @@ -1,4 +1,5 @@ using DesignPatterns.SourceGenerators.Generators; +using Microsoft.CodeAnalysis; namespace DesignPatterns.SourceGenerators.Tests.Generators; @@ -176,4 +177,231 @@ public static partial class OrderStatusMachine; SourceGeneratorTestContext.AssertCacheHit( ("OrderMachine.cs", source)); } + + // ─────────────────────────────────────────────────────────────────── + // Incremental edit tests — verify that editing one file only + // regenerates the contracts in that file, while other contracts + // remain cached. This is the core incremental value of source + // generators in large projects. + // ─────────────────────────────────────────────────────────────────── + + /// + /// Two strategy contracts in separate files. After adding a new + /// strategy to one file, the transform for the unmodified file + /// should be Cached, and the Collect/Combine stages should be + /// Modified (because one input changed). + /// + [Fact] + public void RegisterStrategy_EditOneFile_UnmodifiedContractStaysCached() + { + var shippingSource = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public interface IShippingStrategy + { + decimal Calculate(decimal weight); + } + + [RegisterStrategy("express", typeof(IShippingStrategy))] + public partial class ExpressShipping : IShippingStrategy + { + public decimal Calculate(decimal weight) => weight * 2m; + } + """; + + var paymentSource = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public interface IPaymentStrategy + { + void Pay(decimal amount); + } + + [RegisterStrategy("credit", typeof(IPaymentStrategy))] + public partial class CreditPayment : IPaymentStrategy + { + public void Pay(decimal amount) { } + } + """; + + // Modified shipping source: add a second strategy. + var modifiedShippingSource = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public interface IShippingStrategy + { + decimal Calculate(decimal weight); + } + + [RegisterStrategy("express", typeof(IShippingStrategy))] + public partial class ExpressShipping : IShippingStrategy + { + public decimal Calculate(decimal weight) => weight * 2m; + } + + [RegisterStrategy("standard", typeof(IShippingStrategy))] + public partial class StandardShipping : IShippingStrategy + { + public decimal Calculate(decimal weight) => weight * 1m; + } + """; + + var secondResult = SourceGeneratorTestContext.RunIncrementalEdit( + new[] { ("Shipping.cs", shippingSource), ("Payment.cs", paymentSource) }, + "Shipping.cs", + modifiedShippingSource); + + // The Combine stage should be Modified (one input changed). + // StrategyCollect is not tracked (no WithTrackingName on Collect), + // but StrategyCombine covers the Collect+Combine aggregation. + var combineReasons = SourceGeneratorTestContext.GetTrackedStepReasons( + secondResult, TrackingNames.StrategyCombine); + Assert.NotEmpty(combineReasons); + Assert.All(combineReasons, reason => + Assert.True( + reason is IncrementalStepRunReason.Modified or IncrementalStepRunReason.New, + $"StrategyCombine expected Modified or New, but was {reason}.")); + + // The transform stage should reflect the edit: at least one output + // should be Modified or New (the added StandardShipping strategy). + var transformReasons = SourceGeneratorTestContext.GetTrackedStepReasons( + secondResult, TrackingNames.StrategyNonGenericTransform); + Assert.NotEmpty(transformReasons); + Assert.Contains(transformReasons, reason => + reason is IncrementalStepRunReason.Modified or IncrementalStepRunReason.New); + } + + /// + /// Two singleton targets in separate files. After modifying one file + /// (adding a second singleton), the transform stage should reflect + /// the change — at least one output should be Modified or Added. + /// GenerateSingleton has no Collect stage (per-item output), so we + /// check the transform tracking name directly. + /// + [Fact] + public void GenerateSingleton_EditOneFile_TransformReflectsChange() + { + var serviceSource = """ + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateSingleton] + public partial class MyService; + """; + + var repoSource = """ + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateSingleton] + public partial class MyRepository; + """; + + // Modified service source: add a second singleton. + var modifiedServiceSource = """ + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateSingleton] + public partial class MyService; + + [GenerateSingleton] + public partial class MyCache; + """; + + var secondResult = SourceGeneratorTestContext.RunIncrementalEdit( + new[] { ("Service.cs", serviceSource), ("Repo.cs", repoSource) }, + "Service.cs", + modifiedServiceSource); + + // The transform stage should have at least one Modified or Added + // output (the new MyCache singleton). The existing MyService and + // MyRepository may be Cached if their syntax nodes are unchanged. + var transformReasons = SourceGeneratorTestContext.GetTrackedStepReasons( + secondResult, TrackingNames.SingletonTransform); + Assert.NotEmpty(transformReasons); + Assert.Contains(transformReasons, reason => + reason is IncrementalStepRunReason.Modified or IncrementalStepRunReason.New); + } + + /// + /// Two state machines in separate files. After modifying one file + /// (adding a new transition), the Collect stage should be Modified. + /// + [Fact] + public void StateTransition_EditOneFile_CollectIsModified() + { + var orderSource = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public enum OrderStatus { Draft, Submitted } + public enum OrderTrigger { Submit } + + [StateMachine(typeof(OrderStatus), typeof(OrderTrigger), Initial = OrderStatus.Draft)] + [Transition(OrderStatus.Draft, OrderTrigger.Submit, OrderStatus.Submitted)] + public static partial class OrderStatusMachine; + """; + + var paymentSource = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public enum PaymentStatus { Pending, Paid } + public enum PaymentTrigger { Confirm } + + [StateMachine(typeof(PaymentStatus), typeof(PaymentTrigger), Initial = PaymentStatus.Pending)] + [Transition(PaymentStatus.Pending, PaymentTrigger.Confirm, PaymentStatus.Paid)] + public static partial class PaymentStatusMachine; + """; + + // Modified order source: add a new transition. + var modifiedOrderSource = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public enum OrderStatus { Draft, Submitted, Cancelled } + public enum OrderTrigger { Submit, Cancel } + + [StateMachine(typeof(OrderStatus), typeof(OrderTrigger), Initial = OrderStatus.Draft)] + [Transition(OrderStatus.Draft, OrderTrigger.Submit, OrderStatus.Submitted)] + [Transition(OrderStatus.Draft, OrderTrigger.Cancel, OrderStatus.Cancelled)] + public static partial class OrderStatusMachine; + """; + + var secondResult = SourceGeneratorTestContext.RunIncrementalEdit( + new[] { ("OrderMachine.cs", orderSource), ("PaymentMachine.cs", paymentSource) }, + "OrderMachine.cs", + modifiedOrderSource); + + // The Combine stage should be Modified (one input changed). + // StateMachineCollect is not tracked (no WithTrackingName on Collect), + // but StateMachineCombine covers the Collect+Combine aggregation. + var combineReasons = SourceGeneratorTestContext.GetTrackedStepReasons( + secondResult, TrackingNames.StateMachineCombine); + Assert.NotEmpty(combineReasons); + Assert.All(combineReasons, reason => + Assert.True( + reason is IncrementalStepRunReason.Modified or IncrementalStepRunReason.New, + $"StateMachineCombine expected Modified or New, but was {reason}.")); + + // The transform stage should reflect the edit. + var transformReasons = SourceGeneratorTestContext.GetTrackedStepReasons( + secondResult, TrackingNames.StateMachineTransform); + Assert.NotEmpty(transformReasons); + Assert.Contains(transformReasons, reason => + reason is IncrementalStepRunReason.Modified or IncrementalStepRunReason.New); + } } diff --git a/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs b/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs index 5e5a033..2498542 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs @@ -118,6 +118,102 @@ internal static GeneratorDriverRunResult RunWithReferencedAssembly( return driver.GetRunResult(); } + /// + /// Runs the generator on an initial compilation, then re-runs it after replacing + /// one syntax tree with a modified version. Returns the second run's result so + /// the caller can inspect tracked step reasons (Cached vs Modified). + /// + /// This is the core incremental edit test: it proves that editing one file + /// only regenerates the contracts in that file, while other contracts remain + /// cached. + /// + /// + /// All source files for the first run. + /// The path of the file to replace. + /// The new content for the replaced file. + /// Source files that are not modified (must match initialSources except for modifiedPath). + /// The for the second (post-edit) run. + internal static GeneratorDriverRunResult RunIncrementalEdit( + (string Path, string Source)[] initialSources, + string modifiedPath, + string modifiedSource) + where TGenerator : IIncrementalGenerator, new() + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Latest); + var driverOptions = new GeneratorDriverOptions( + disabledOutputs: IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true); + + var initialTrees = initialSources + .Select(s => CSharpSyntaxTree.ParseText(s.Source, parseOptions, path: s.Path)) + .ToArray(); + + var compilation = CSharpCompilation.Create( + assemblyName: "DesignPatterns.SourceGenerators.Tests.IncrementalEdit", + syntaxTrees: initialTrees, + references: References, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new TGenerator().AsSourceGenerator() }, + driverOptions: driverOptions, + parseOptions: parseOptions); + + // First run — populates the cache. + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + + // Replace one syntax tree with the modified version. + var oldTree = initialTrees.First(t => t.FilePath == modifiedPath); + var modifiedTree = CSharpSyntaxTree.ParseText(modifiedSource, parseOptions, path: modifiedPath); + var updatedCompilation = compilation.ReplaceSyntaxTree(oldTree, modifiedTree); + + // Second run — should only re-execute stages for the modified file. + var secondResult = driver.RunGenerators(updatedCompilation).GetRunResult(); + + return secondResult; + } + + /// + /// Gets the tracked step reasons for a specific tracking name from a run result. + /// Returns the list of (reason) for each output of the tracked step. + /// + internal static IReadOnlyList GetTrackedStepReasons( + GeneratorDriverRunResult runResult, + string trackingName) + { + var reasons = new List(); + foreach (var generatorResult in runResult.Results) + { + if (generatorResult.TrackedSteps.TryGetValue(trackingName, out var steps)) + { + foreach (var step in steps) + { + foreach (var output in step.Outputs) + { + reasons.Add(output.Reason); + } + } + } + } + return reasons; + } + + /// + /// Gets all tracked step names from a run result for diagnostic purposes. + /// + internal static IReadOnlyList GetAllTrackedStepNames(GeneratorDriverRunResult runResult) + { + var names = new HashSet(); + foreach (var generatorResult in runResult.Results) + { + foreach (var key in generatorResult.TrackedSteps.Keys) + { + names.Add(key); + } + } + return names.OrderBy(n => n).ToList(); + } + internal static IReadOnlyDictionary GetGeneratedSources(GeneratorDriverRunResult runResult) => runResult .Results From 1e4a6c20e223be338dc9d95046366063d4d58225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=BD=E7=AC=94?= <46271592+Skymly@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:32:44 +0800 Subject: [PATCH 2/2] Fix review issues: stale doc, dead code, caching assertions - Remove stale from RunIncrementalEdit XML doc comment (the parameter does not exist in the method signature). - Remove dead code GetAllTrackedStepNames (defined but never called). - Add Cached/Unchanged assertions to GenerateSingleton and StateTransition edit tests: the unmodified file's transform should be Cached or Unchanged, verifying the core incremental guarantee. - Rename RegisterStrategy_EditOneFile_UnmodifiedContractStaysCached to RegisterStrategy_EditOneFile_CombineReflectsChange: the unmodified file's transform is Modified (not Cached) because Transform returns List (reference equality). This will be fixed by PR #216 (LocationInfo + EquatableArray), but until then the test correctly verifies only the Combine stage behavior. Added XML doc explaining the limitation. --- .../Generators/IncrementalCacheTests.cs | 30 ++++++++++++++++--- .../SourceGeneratorTestContext.cs | 17 ----------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs index 4fe96a2..1a03b5e 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs @@ -187,12 +187,20 @@ public static partial class OrderStatusMachine; /// /// Two strategy contracts in separate files. After adding a new - /// strategy to one file, the transform for the unmodified file - /// should be Cached, and the Collect/Combine stages should be - /// Modified (because one input changed). + /// strategy to one file, the transform for the modified file should + /// be Modified/New, and the Collect/Combine stages should be Modified + /// (because one input changed). + /// + /// Note: the unmodified file's transform is also Modified because + /// RegisterStrategy's Transform returns List<KeyedRegistration> + /// (reference equality). Once LocationInfo + EquatableArray are applied + /// (PR #216), the unmodified file's transform will be Unchanged. Until + /// then, this test verifies that the Combine stage correctly reflects + /// the edit. + /// /// [Fact] - public void RegisterStrategy_EditOneFile_UnmodifiedContractStaysCached() + public void RegisterStrategy_EditOneFile_CombineReflectsChange() { var shippingSource = """ using DesignPatterns.Behavioral; @@ -331,6 +339,13 @@ public partial class MyCache; Assert.NotEmpty(transformReasons); Assert.Contains(transformReasons, reason => reason is IncrementalStepRunReason.Modified or IncrementalStepRunReason.New); + + // The unmodified file's transform should be Cached or Unchanged — + // Repo.cs was not edited, so MyRepository's transform output should + // be unchanged. (Cached = not re-executed; Unchanged = re-executed + // but produced identical output.) + Assert.Contains(transformReasons, reason => + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged); } /// @@ -403,5 +418,12 @@ public static partial class OrderStatusMachine; Assert.NotEmpty(transformReasons); Assert.Contains(transformReasons, reason => reason is IncrementalStepRunReason.Modified or IncrementalStepRunReason.New); + + // The unmodified file's transform should be Cached or Unchanged — + // PaymentMachine.cs was not edited, so PaymentStatusMachine's + // transform should be unchanged. (Cached = not re-executed; + // Unchanged = re-executed but produced identical output.) + Assert.Contains(transformReasons, reason => + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged); } } diff --git a/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs b/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs index 2498542..c6a433a 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs @@ -131,7 +131,6 @@ internal static GeneratorDriverRunResult RunWithReferencedAssembly( /// All source files for the first run. /// The path of the file to replace. /// The new content for the replaced file. - /// Source files that are not modified (must match initialSources except for modifiedPath). /// The for the second (post-edit) run. internal static GeneratorDriverRunResult RunIncrementalEdit( (string Path, string Source)[] initialSources, @@ -198,22 +197,6 @@ internal static IReadOnlyList GetTrackedStepReasons( return reasons; } - /// - /// Gets all tracked step names from a run result for diagnostic purposes. - /// - internal static IReadOnlyList GetAllTrackedStepNames(GeneratorDriverRunResult runResult) - { - var names = new HashSet(); - foreach (var generatorResult in runResult.Results) - { - foreach (var key in generatorResult.TrackedSteps.Keys) - { - names.Add(key); - } - } - return names.OrderBy(n => n).ToList(); - } - internal static IReadOnlyDictionary GetGeneratedSources(GeneratorDriverRunResult runResult) => runResult .Results