diff --git a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs index 8e680cd69d..6c1906756d 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs +++ b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs @@ -133,6 +133,61 @@ void M() return VerifyGeneratorOutput(source); } + [Test] + public Task Interface_With_Nullable_Event_Args() + { + // Regression test for https://github.com/thomhurst/TUnit/issues/5425 + // Nullability of generic event handler type arguments must be preserved + // in the generated mock to avoid CS8604. + 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_Nullable_Event_And_Nullable_Args() + { + // Combined regression for #5424 + #5425: both the delegate itself and + // its generic type argument are nullable (`EventHandler?`). + 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_And_Nullable_Args.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Event_And_Nullable_Args.verified.txt new file mode 100644 index 0000000000..382753abce --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Event_And_Nullable_Args.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.Tests/Snapshots/Interface_With_Nullable_Event_Args.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Event_Args.verified.txt new file mode 100644 index 0000000000..44d26a5fb2 --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Event_Args.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.Tests/Issue5425Tests.cs b/TUnit.Mocks.Tests/Issue5425Tests.cs new file mode 100644 index 0000000000..6dc35e9b8e --- /dev/null +++ b/TUnit.Mocks.Tests/Issue5425Tests.cs @@ -0,0 +1,65 @@ +#nullable enable + +// Promote the nullability warnings that #5424 / #5425 produced into errors so any +// regression in the source generator (where the generated mock loses nullability +// information from the source event) breaks this project's build instead of +// silently emitting warnings. +// +// CS8604 — Possible null reference argument (#5425) +// CS8612 — Nullability mismatch in implemented type (#5424) +// CS8613 — Nullability mismatch in return type +// CS8614 — Nullability mismatch in parameter type +// CS8615 — Nullability mismatch in implemented member +#pragma warning error CS8604 +#pragma warning error CS8612 +#pragma warning error CS8613 +#pragma warning error CS8614 +#pragma warning error CS8615 + +namespace TUnit.Mocks.Tests; + +// Compile-time regression coverage for: +// - https://github.com/thomhurst/TUnit/issues/5424 — nullable delegate type +// - https://github.com/thomhurst/TUnit/issues/5425 — nullable generic type argument +// +// If the source generator stops preserving nullability on event handler types, +// the generated mock will mismatch these source declarations and this file will +// fail to compile. +public class Issue5425Tests +{ + public interface IWithNullableTypeArg + { + event EventHandler Something; + } + + public interface IWithNullableEvent + { + event EventHandler? Something; + } + + public interface IWithBothNullable + { + event EventHandler? Something; + } + + [Test] + public async Task Can_Mock_Event_With_Nullable_Type_Argument() + { + var mock = Mock.Of(); + await Assert.That(mock).IsNotNull(); + } + + [Test] + public async Task Can_Mock_Nullable_Event() + { + var mock = Mock.Of(); + await Assert.That(mock).IsNotNull(); + } + + [Test] + public async Task Can_Mock_Nullable_Event_With_Nullable_Type_Argument() + { + var mock = Mock.Of(); + await Assert.That(mock).IsNotNull(); + } +}