diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/IncrementalCacheTests.cs index a30d2a2..1a03b5e 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,253 @@ 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 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_CombineReflectsChange() + { + 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); + + // 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); + } + + /// + /// 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); + + // 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 5e5a033..c6a433a 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/SourceGeneratorTestContext.cs @@ -118,6 +118,85 @@ 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. + /// 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; + } + internal static IReadOnlyDictionary GetGeneratedSources(GeneratorDriverRunResult runResult) => runResult .Results