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
28 changes: 28 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>? Something;
}

public class TestUsage
{
void M()
{
var mock = Mock.Of<IFoo>();
}
}
""";

return VerifyGeneratorOutput(source);
}

[Test]
public Task Interface_With_Multiple_Multi_Parameter_Events()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// <auto-generated/>
#nullable enable

namespace TUnit.Mocks.Generated
{
public sealed class IFooMock : global::TUnit.Mocks.Mock<global::IFoo>, global::IFoo
{
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
internal IFooMock(global::IFoo mockObject, global::TUnit.Mocks.MockEngine<global::IFoo> engine)
: base(mockObject, engine) { }

event global::System.EventHandler<string>? global::IFoo.Something { add => Object.Something += value; remove => Object.Something -= value; }
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#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<global::IFoo> Engine;

internal IFoo_MockEvents(global::TUnit.Mocks.MockEngine<global::IFoo> engine) => Engine = engine;
}

public static class IFoo_MockEventsExtensions
{
extension(global::TUnit.Mocks.Mock<global::IFoo> 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 =====

// <auto-generated/>
#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<global::IFoo> _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<global::IFoo> engine)
{
_engine = engine;
}

private global::System.EventHandler<string>? _backing_Something;

public event global::System.EventHandler<string>? 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<global::IFoo>(Create);
}

internal static global::TUnit.Mocks.Mock<global::IFoo> 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<global::IFoo>(behavior);
var impl = new IFooMockImpl(engine);
engine.Raisable = impl;
var mock = new IFooMock(impl, engine);
return mock;
}
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#nullable enable

namespace TUnit.Mocks.Generated
{
public static class IFoo_MockMemberExtensions
{
public static void RaiseSomething(this global::TUnit.Mocks.Mock<global::IFoo> mock, string e)
{
((global::TUnit.Mocks.IRaisable)global::TUnit.Mocks.MockRegistry.GetEngine(mock).Raisable!).RaiseEvent("Something", (object?)e);
}
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#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<global::IFoo>(behavior);
}
}
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#nullable enable

namespace TUnit.Mocks.Generated;
8 changes: 4 additions & 4 deletions TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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); }}");
Expand Down Expand Up @@ -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); }}");
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@ namespace TUnit.Mocks.SourceGenerator.Models;
internal sealed record MockEventModel : IEquatable<MockEventModel>
{
public string Name { get; init; } = "";
/// <summary>
/// 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
/// <see cref="EventHandlerTypeNonNullable"/> and append <c>?</c>.
/// </summary>
public string EventHandlerType { get; init; } = "";

/// <summary>The handler type with any trailing nullable annotation removed.</summary>
public string EventHandlerTypeNonNullable => EventHandlerType.TrimEnd('?');

/// <summary>
/// The argument expression for invoking the backing delegate.
/// E.g., "this, args" for EventHandler&lt;string&gt;, or "arg1, arg2" for Action&lt;string, int&gt;.
Expand Down
Loading