diff --git a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs index c2f3f33926..8e680cd69d 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs +++ b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs @@ -105,6 +105,34 @@ void M() return VerifyGeneratorOutput(source); } + [Test] + public Task Interface_With_Nullable_Event() + { + // Regression test for #5424: nullable event handler types must + // preserve nullability in generated explicit interface implementation, + // otherwise CS8615 (nullability mismatch) is emitted. + var source = """ + #nullable enable + using System; + using TUnit.Mocks; + + public interface IFoo + { + event EventHandler? Something; + } + + public class TestUsage + { + void M() + { + var mock = Mock.Of(); + } + } + """; + + return VerifyGeneratorOutput(source); + } + [Test] public Task Interface_With_Multiple_Multi_Parameter_Events() { diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Event.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Event.verified.txt new file mode 100644 index 0000000000..7700682253 --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Event.verified.txt @@ -0,0 +1,160 @@ +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + public sealed class IFooMock : global::TUnit.Mocks.Mock, global::IFoo + { + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal IFooMock(global::IFoo mockObject, global::TUnit.Mocks.MockEngine engine) + : base(mockObject, engine) { } + + event global::System.EventHandler? global::IFoo.Something { add => Object.Something += value; remove => Object.Something -= value; } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public readonly struct IFoo_MockEvents + { + internal readonly global::TUnit.Mocks.MockEngine Engine; + + internal IFoo_MockEvents(global::TUnit.Mocks.MockEngine engine) => Engine = engine; + } + + public static class IFoo_MockEventsExtensions + { + extension(global::TUnit.Mocks.Mock mock) + { + public IFoo_MockEvents Events => new(global::TUnit.Mocks.MockRegistry.GetEngine(mock)); + } + + extension(IFoo_MockEvents events) + { + public global::TUnit.Mocks.EventSubscriptionAccessor Something + => new(events.Engine, "Something"); + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + file sealed class IFooMockImpl : global::IFoo, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject + { + private readonly global::TUnit.Mocks.MockEngine _engine; + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + global::TUnit.Mocks.IMock? global::TUnit.Mocks.IMockObject.MockWrapper { get; set; } + + internal IFooMockImpl(global::TUnit.Mocks.MockEngine engine) + { + _engine = engine; + } + + private global::System.EventHandler? _backing_Something; + + public event global::System.EventHandler? Something + { + add { _backing_Something += value; _engine.RecordEventSubscription("Something", true); } + remove { _backing_Something -= value; _engine.RecordEventSubscription("Something", false); } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal void Raise_Something(string e) + { + _backing_Something?.Invoke(this, e); + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public void RaiseEvent(string eventName, object? args) + { + switch (eventName) + { + case "Something": + { + Raise_Something((string)args!); + break; + } + default: + throw new global::System.InvalidOperationException($"No event named '{eventName}' exists on this mock."); + } + } + } + + file static class IFooMockFactory + { + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterFactory(Create); + } + + internal static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + if (constructorArgs.Length > 0) throw new global::System.ArgumentException($"Interface mock 'global::IFoo' does not support constructor arguments, but {constructorArgs.Length} were provided."); + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new IFooMockImpl(engine); + engine.Raisable = impl; + var mock = new IFooMock(impl, engine); + return mock; + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + public static class IFoo_MockMemberExtensions + { + public static void RaiseSomething(this global::TUnit.Mocks.Mock mock, string e) + { + ((global::TUnit.Mocks.IRaisable)global::TUnit.Mocks.MockRegistry.GetEngine(mock).Raisable!).RaiseEvent("Something", (object?)e); + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks +{ + public static class IFoo_MockStaticExtension + { + extension(global::IFoo) + { + public static global::TUnit.Mocks.Generated.IFooMock Mock(global::TUnit.Mocks.MockBehavior behavior = global::TUnit.Mocks.MockBehavior.Loose) + { + return (global::TUnit.Mocks.Generated.IFooMock)global::TUnit.Mocks.Mock.Of(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index ab5d931182..d3469de74e 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -946,11 +946,11 @@ private static void GeneratePartialProperty(CodeWriter writer, MockMemberModel p private static void GenerateEvent(CodeWriter writer, MockEventModel evt) { // Backing delegate field - writer.AppendLine($"private {evt.EventHandlerType}? _backing_{evt.Name};"); + writer.AppendLine($"private {evt.EventHandlerTypeNonNullable}? _backing_{evt.Name};"); writer.AppendLine(); // Event add/remove accessors - writer.AppendLine($"public event {evt.EventHandlerType}? {evt.Name}"); + writer.AppendLine($"public event {evt.EventHandlerTypeNonNullable}? {evt.Name}"); writer.OpenBrace(); writer.AppendLine($"add {{ _backing_{evt.Name} += value; _engine.RecordEventSubscription(\"{evt.Name}\", true); }}"); writer.AppendLine($"remove {{ _backing_{evt.Name} -= value; _engine.RecordEventSubscription(\"{evt.Name}\", false); }}"); @@ -979,11 +979,11 @@ private static void GenerateEvent(CodeWriter writer, MockEventModel evt) private static void GeneratePartialEvent(CodeWriter writer, MockEventModel evt) { // Backing delegate field - writer.AppendLine($"private {evt.EventHandlerType}? _backing_{evt.Name};"); + writer.AppendLine($"private {evt.EventHandlerTypeNonNullable}? _backing_{evt.Name};"); writer.AppendLine(); // Event add/remove accessors with override - writer.AppendLine($"public override event {evt.EventHandlerType}? {evt.Name}"); + writer.AppendLine($"public override event {evt.EventHandlerTypeNonNullable}? {evt.Name}"); writer.OpenBrace(); writer.AppendLine($"add {{ _backing_{evt.Name} += value; _engine.RecordEventSubscription(\"{evt.Name}\", true); }}"); writer.AppendLine($"remove {{ _backing_{evt.Name} -= value; _engine.RecordEventSubscription(\"{evt.Name}\", false); }}"); diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index 5580e2c5d1..68125b6fe1 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -649,7 +649,7 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i private static MockEventModel CreateEventModel(IEventSymbol evt, string? explicitInterfaceName, string? declaringInterfaceName = null) { - var eventHandlerType = evt.Type.GetFullyQualifiedName(); + var eventHandlerType = evt.Type.GetFullyQualifiedNameWithNullability(); // Determine if this is an EventHandler pattern (sender + args) var isEventHandlerPattern = IsEventHandlerType(evt.Type); diff --git a/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs index 639bb71c63..ac5fb74e32 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs @@ -5,8 +5,19 @@ namespace TUnit.Mocks.SourceGenerator.Models; internal sealed record MockEventModel : IEquatable { public string Name { get; init; } = ""; + /// + /// The fully qualified event handler type, with nullable annotations + /// preserved from the declaring interface. Used when emitting explicit + /// interface event implementations so that nullability matches the + /// interface declaration (otherwise CS8615 is emitted, see issue #5424). + /// For the always-nullable backing delegate field, use + /// and append ?. + /// public string EventHandlerType { get; init; } = ""; + /// The handler type with any trailing nullable annotation removed. + public string EventHandlerTypeNonNullable => EventHandlerType.TrimEnd('?'); + /// /// The argument expression for invoking the backing delegate. /// E.g., "this, args" for EventHandler<string>, or "arg1, arg2" for Action<string, int>.