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
@@ -1,4 +1,5 @@
using DesignPatterns.SourceGenerators.Generators;
using Microsoft.CodeAnalysis;

namespace DesignPatterns.SourceGenerators.Tests.Generators;

Expand Down Expand Up @@ -176,4 +177,253 @@ public static partial class OrderStatusMachine;
SourceGeneratorTestContext.AssertCacheHit<StateTransitionGenerator>(
("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.
// ───────────────────────────────────────────────────────────────────

/// <summary>
/// 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).
/// <para>
/// Note: the unmodified file's transform is also Modified because
/// RegisterStrategy's Transform returns List&lt;KeyedRegistration&gt;
/// (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.
/// </para>
/// </summary>
[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<RegisterStrategyGenerator>(
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);
}

/// <summary>
/// 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.
/// </summary>
[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<GenerateSingletonGenerator>(
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);
}

/// <summary>
/// Two state machines in separate files. After modifying one file
/// (adding a new transition), the Collect stage should be Modified.
/// </summary>
[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<StateTransitionGenerator>(
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,85 @@ internal static GeneratorDriverRunResult RunWithReferencedAssembly<TGenerator>(
return driver.GetRunResult();
}

/// <summary>
/// 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).
/// <para>
/// 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.
/// </para>
/// </summary>
/// <param name="initialSources">All source files for the first run.</param>
/// <param name="modifiedPath">The path of the file to replace.</param>
/// <param name="modifiedSource">The new content for the replaced file.</param>
/// <returns>The <see cref="GeneratorDriverRunResult"/> for the second (post-edit) run.</returns>
internal static GeneratorDriverRunResult RunIncrementalEdit<TGenerator>(
(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;
}

/// <summary>
/// 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.
/// </summary>
internal static IReadOnlyList<IncrementalStepRunReason> GetTrackedStepReasons(
GeneratorDriverRunResult runResult,
string trackingName)
{
var reasons = new List<IncrementalStepRunReason>();
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<string, string> GetGeneratedSources(GeneratorDriverRunResult runResult) =>
runResult
.Results
Expand Down
Loading