Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
fix(mocks): preserve [Obsolete] named args and obsolete regression gu…
…ard (#5626)
  • Loading branch information
JohnVerheij committed Apr 19, 2026
commit 1b27fda38e7620efee0bd5d8432e82b8b55c8ed3
8 changes: 8 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,13 @@ public interface IDialogService
// must round-trip through the generated attribute literal verbatim.
[Obsolete("Use \"NewMethod\" in C:\\New\\Path")]
string? WithTrickyChars();

// C# 10+ named arguments on [Obsolete] (DiagnosticId, UrlFormat) must be
// preserved on the generated forward so consumers using
// `#pragma warning disable CUSTOM001` continue to suppress the mock's
// call site, not just the source interface's.
[Obsolete("Replaced", DiagnosticId = "CUSTOM001", UrlFormat = "https://example.test/{0}")]
string? WithDiagnosticId();
}

public abstract class BaseDialog
Expand All @@ -1258,6 +1265,7 @@ void M()
}
""";

AssertGeneratedCodeHasNoObsoleteWarnings(source);
AssertGeneratedCodeHasNoNullableWarnings(source);
return VerifyGeneratorOutput(source);
}
Expand Down
27 changes: 27 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,33 @@ protected static void AssertGeneratedCodeHasNoNullableWarnings(
}
}

/// <summary>
/// Compiles the generator output and asserts no CS0612 (obsolete, no message), CS0618
/// (obsolete with message), or CS0672 ('override missing Obsolete') warnings are emitted.
/// Direct compile-time guard for the [Obsolete]-propagation portion of #5626. Proves
/// the snapshot's [Obsolete] attribute placement actually suppresses the warnings, not
/// just appears in the generated text.
/// </summary>
protected static void AssertGeneratedCodeHasNoObsoleteWarnings(
string source,
IEnumerable<MetadataReference>? additionalReferences = null,
CSharpParseOptions? parseOptions = null)
{
var (_, diagnostics) = RunGeneratorWithCompilationDiagnostics(source, additionalReferences, parseOptions);
var obsoleteWarnings = diagnostics
.Where(d => d.Severity >= DiagnosticSeverity.Warning
&& (string.Equals(d.Id, "CS0612", StringComparison.Ordinal)
|| string.Equals(d.Id, "CS0618", StringComparison.Ordinal)
|| string.Equals(d.Id, "CS0672", StringComparison.Ordinal)))
.ToList();
if (obsoleteWarnings.Count > 0)
{
var messages = string.Join(Environment.NewLine, obsoleteWarnings.Select(d => d.ToString()));
throw new InvalidOperationException(
$"Generated code emits {obsoleteWarnings.Count} obsolete warning(s):{Environment.NewLine}{messages}");
}
}

private static (Compilation Compilation, IReadOnlyList<Diagnostic> Diagnostics) RunGeneratorWithCompilationDiagnostics(
string source,
IEnumerable<MetadataReference>? additionalReferences,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ namespace TUnit.Mocks.Generated
[global::System.Obsolete("Use \"NewMethod\" in C:\\New\\Path")]
string? global::IDialogService.WithTrickyChars() => Object.WithTrickyChars();

[global::System.Obsolete("Replaced", DiagnosticId = "CUSTOM001", UrlFormat = "https://example.test/{0}")]
string? global::IDialogService.WithDiagnosticId() => Object.WithDiagnosticId();

string? global::IDialogService.Greeting { [global::System.Obsolete] get => Object.Greeting; [global::System.Obsolete] set => Object.Greeting = value; }

string? global::IDialogService.Headline { [global::System.Obsolete] get => Object.Headline; set => Object.Headline = value; }
Expand Down Expand Up @@ -292,6 +295,12 @@ namespace TUnit.Mocks.Generated
return _engine.HandleCallWithReturn<string?>(8, "WithTrickyChars", global::System.Array.Empty<object?>(), default);
}

[global::System.Obsolete("Replaced", DiagnosticId = "CUSTOM001", UrlFormat = "https://example.test/{0}")]
public string? WithDiagnosticId()
{
return _engine.HandleCallWithReturn<string?>(9, "WithDiagnosticId", global::System.Array.Empty<object?>(), default);
}

public string? Greeting
{
[global::System.Obsolete]
Expand Down Expand Up @@ -431,6 +440,12 @@ namespace TUnit.Mocks.Generated
return new IDialogService_WithTrickyChars_M8_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 8, "WithTrickyChars", matchers);
}

public static IDialogService_WithDiagnosticId_M9_MockCall WithDiagnosticId(this global::TUnit.Mocks.Mock<global::IDialogService> mock)
{
var matchers = global::System.Array.Empty<global::TUnit.Mocks.Arguments.IArgumentMatcher>();
return new IDialogService_WithDiagnosticId_M9_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 9, "WithDiagnosticId", matchers);
}

extension(global::TUnit.Mocks.Mock<global::IDialogService> mock)
{
public global::TUnit.Mocks.PropertyMockCall<string?> Greeting
Expand Down Expand Up @@ -593,6 +608,68 @@ namespace TUnit.Mocks.Generated
/// <inheritdoc />
public void WasNeverCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled(message);
}

[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
public sealed class IDialogService_WithDiagnosticId_M9_MockCall : global::TUnit.Mocks.Verification.ICallVerification
{
private readonly global::TUnit.Mocks.IMockEngineAccess _engine;
private readonly int _memberId;
private readonly string _memberName;
private readonly global::TUnit.Mocks.Arguments.IArgumentMatcher[] _matchers;
private global::TUnit.Mocks.Setup.MethodSetupBuilder<string?>? _builder;
private bool _builderInitialized;
private object? _builderLock;

internal IDialogService_WithDiagnosticId_M9_MockCall(global::TUnit.Mocks.IMockEngineAccess engine, int memberId, string memberName, global::TUnit.Mocks.Arguments.IArgumentMatcher[] matchers)
{
_engine = engine;
_memberId = memberId;
_memberName = memberName;
_matchers = matchers;
}

private global::TUnit.Mocks.Setup.MethodSetupBuilder<string?> EnsureSetup() =>
global::System.Threading.LazyInitializer.EnsureInitialized(ref _builder, ref _builderInitialized, ref _builderLock, () =>
{
var setup = new global::TUnit.Mocks.Setup.MethodSetup(_memberId, _matchers, _memberName);
_engine.AddSetup(setup);
return new global::TUnit.Mocks.Setup.MethodSetupBuilder<string?>(setup);
})!;

/// <inheritdoc />
public IDialogService_WithDiagnosticId_M9_MockCall Returns(string? value) { EnsureSetup().Returns(value); return this; }
/// <inheritdoc />
public IDialogService_WithDiagnosticId_M9_MockCall Returns(global::System.Func<string?> factory) { EnsureSetup().Returns(factory); return this; }
/// <inheritdoc />
public IDialogService_WithDiagnosticId_M9_MockCall ReturnsSequentially(params string?[] values) { EnsureSetup().ReturnsSequentially(values); return this; }
/// <inheritdoc />
public IDialogService_WithDiagnosticId_M9_MockCall Throws<TException>() where TException : global::System.Exception, new() { EnsureSetup().Throws<TException>(); return this; }
/// <inheritdoc />
public IDialogService_WithDiagnosticId_M9_MockCall Throws(global::System.Exception exception) { EnsureSetup().Throws(exception); return this; }
/// <inheritdoc />
public IDialogService_WithDiagnosticId_M9_MockCall Callback(global::System.Action callback) { EnsureSetup().Callback(callback); return this; }
/// <inheritdoc />
public IDialogService_WithDiagnosticId_M9_MockCall TransitionsTo(string stateName) { EnsureSetup().TransitionsTo(stateName); return this; }
/// <inheritdoc />
public IDialogService_WithDiagnosticId_M9_MockCall Then() { EnsureSetup().Then(); return this; }

/// <summary>Auto-raise the Opened event when this method is called.</summary>
public IDialogService_WithDiagnosticId_M9_MockCall RaisesOpened(string? e) { EnsureSetup().Raises("Opened", (object?)e); return this; }

// ICallVerification
/// <inheritdoc />
public void WasCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled();
/// <inheritdoc />
public void WasCalled(global::TUnit.Mocks.Times times) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times);
/// <inheritdoc />
public void WasCalled(global::TUnit.Mocks.Times times, string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times, message);
/// <inheritdoc />
public void WasCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(message);
/// <inheritdoc />
public void WasNeverCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled();
/// <inheritdoc />
public void WasNeverCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasNeverCalled(message);
}
}


Expand Down
45 changes: 35 additions & 10 deletions TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -932,8 +932,9 @@ private static bool CanDelegateReturnType(ITypeSymbol publicReturnType, ITypeSym
/// <summary>
/// If <paramref name="symbol"/> carries <see cref="System.ObsoleteAttribute"/>, returns
/// the C# attribute syntax to copy onto a generated forward/override (preserving the
/// message and IsError flag). Returns empty string when the member is not obsolete.
/// Suppresses CS0612/CS0618 inside the generated body and resolves CS0672 on overrides.
/// message, IsError flag, and the C# 10+ <c>DiagnosticId</c> / <c>UrlFormat</c> named
/// arguments). Returns empty string when the member is not obsolete. Suppresses
/// CS0612/CS0618 inside the generated body and resolves CS0672 on overrides.
/// </summary>
private static string GetObsoleteAttributeSyntax(ISymbol symbol)
{
Expand All @@ -944,23 +945,47 @@ private static string GetObsoleteAttributeSyntax(ISymbol symbol)
if (!string.Equals(attrClass.Name, "ObsoleteAttribute", StringComparison.Ordinal)) continue;
if (!string.Equals(attrClass.ContainingNamespace?.ToDisplayString(), "System", StringComparison.Ordinal)) continue;

var positional = new List<string>();
var args = attr.ConstructorArguments;
if (args.Length == 0)
if (args.Length >= 1)
{
return "[global::System.Obsolete]";
var message = args[0].Value as string;
positional.Add(message is null ? "null" : EscapeStringLiteral(message));
}
if (args.Length >= 2)
{
var isError = args[1].Value is bool b && b;
positional.Add(isError ? "true" : "false");
}
var message = args[0].Value as string;
var messageLiteral = message is null ? "null" : "\"" + message.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
if (args.Length == 1)

// C# 10+ named arguments: DiagnosticId enables consumers to suppress the
// generated obsolete warning by their custom diagnostic ID rather than CS0618;
// UrlFormat surfaces a documentation link in IDE tooltips.
var named = new List<string>();
foreach (var na in attr.NamedArguments)
{
return $"[global::System.Obsolete({messageLiteral})]";
if (na.Value.Value is not string strValue) continue;
if (string.Equals(na.Key, "DiagnosticId", StringComparison.Ordinal)
|| string.Equals(na.Key, "UrlFormat", StringComparison.Ordinal))
{
named.Add($"{na.Key} = {EscapeStringLiteral(strValue)}");
}
}
var isError = args[1].Value is bool b && b;
return $"[global::System.Obsolete({messageLiteral}, {(isError ? "true" : "false")})]";

if (positional.Count == 0 && named.Count == 0)
{
return "[global::System.Obsolete]";
}
var allArgs = string.Join(", ", positional.Concat(named));
return $"[global::System.Obsolete({allArgs})]";
}
return "";
}

/// <summary>Wraps a string in C# double-quoted literal syntax, escaping backslashes and quotes.</summary>
private static string EscapeStringLiteral(string value)
=> "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";

/// <summary>
/// Escapes a parameter name that is a C# reserved keyword by prepending '@'.
/// E.g., "event" → "@event", "class" → "@class", "return" → "@return".
Expand Down