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
Expand Up @@ -695,4 +695,56 @@ public static partial class PluginModule { }
await That(result.Diagnostics).Contains("*AWT142*Roaster*Singleton*Transient*").AsWildcard()
.Because("two scans of one module registering the same implementation with different lifetimes mirror the container's scan-lifetime conflict");
}

private const string LibraryWithUnexposableMatchSource = """
using Awaiten;

namespace Lib;

public interface IPlugin { }
public interface IRoaster { }
internal interface IGrinder { }

internal sealed class Roaster : IPlugin, IRoaster { }
internal sealed class Grinder : IPlugin, IGrinder { }

[Module]
[Scan<IPlugin>(As = ScanAs.MatchingInterface)]
public static partial class PluginModule { }
""";

[Fact]
public async Task PerMatchScanDiagnosticsLandInTheLibrarysBuildAndNotTheConsumers()
{
(_, Microsoft.CodeAnalysis.GeneratorDriverRunResult libraryRun) =
Generator.RunGenerator(LibraryWithUnexposableMatchSource, [], [], "ReferencedAssembly");

Microsoft.CodeAnalysis.Diagnostic exposure =
libraryRun.Diagnostics.Single(diagnostic => diagnostic.Id == "AWT196");
int scanLine = LibraryWithUnexposableMatchSource
[..LibraryWithUnexposableMatchSource.IndexOf("[Scan<IPlugin>", StringComparison.Ordinal)]
.Count(character => character == '\n');
await That(exposure.Location.GetLineSpan().StartLinePosition.Line).IsEqualTo(scanLine)
.Because("evaluating the scan in the module's own compilation anchors the per-match warning on the library's own [Scan], the only build that can act on it");
await That(exposure.ToString()).Contains("Grinder")
.Because("the warning names the library's own type, not the module the consumer imported");

GeneratorResult consumer = Generator.RunWithGeneratedReferencedAssembly(LibraryWithUnexposableMatchSource, """
using Awaiten;
using Lib;

namespace MyCode;

[Container]
[Import(typeof(PluginModule))]
public static partial class MyContainer
{
}
""");

await That(consumer.Diagnostics).IsEmpty()
.Because("the scan is already resolved by the time the consumer imports the module, so it sees a plain module and no per-match warning about types it neither owns nor can fix");
await That(consumer.Sources["Awaiten.MyCode.MyContainer.g.cs"]).Contains(FactoryName("global::Lib.Roaster"))
.Because("only the unexposable match was dropped; the one with an accessible exposure interface still registers");
}
}
31 changes: 31 additions & 0 deletions Tests/Awaiten.Tests.Support/CrossAssemblyScanLifetimeModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace Awaiten.Tests.Support;

/// <summary>The marker the lifetime module's singleton <c>[Scan]</c> selects on.</summary>
public interface ISingletonPlugin;

/// <summary>The marker the lifetime module's transient <c>[Scan]</c> selects on.</summary>
public interface ITransientPlugin;

/// <summary>The accessible exposure interface the singleton match is registered and resolved under.</summary>
public interface IScanSingleton;

/// <summary>The accessible exposure interface the transient match is registered and resolved under.</summary>
public interface IScanTransient;

/// <summary>An internal match the module registers as a singleton, exposed only through its interface.</summary>
internal sealed class ScanSingleton : ISingletonPlugin, IScanSingleton;

/// <summary>An internal match the module registers as a transient, exposed only through its interface.</summary>
internal sealed class ScanTransient : ITransientPlugin, IScanTransient;

/// <summary>
/// A <c>[Module]</c> in a referenced assembly whose two self-compiled <c>[Scan]</c>s declare different
/// lifetimes. The scan's <c>Lifetime</c> becomes the generated registration's lifetime, which the importing
/// container honors like any other module registration: nothing about instance ownership moves into the
/// library. A consumer imports this to observe that a singleton match is one instance per <c>Root</c> while a
/// transient match is a new instance per resolve, without naming either internal implementation.
/// </summary>
[Module]
[Scan<ISingletonPlugin>(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)]
[Scan<ITransientPlugin>(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Transient)]
public static partial class ScanLifetimeModule;
49 changes: 49 additions & 0 deletions Tests/Awaiten.Tests/ModuleScanLifetimeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Awaiten.Tests.Support;

namespace Awaiten.Tests;

/// <summary>
/// Runtime lifetimes of a <c>[Module]</c>'s self-compiled <c>[Scan]</c>. The module lives in the referenced
/// Awaiten.Tests.Support assembly, where it compiled its scans into ordinary registrations carrying the scans'
/// declared lifetimes. Those registrations are honored here by the importing container, so instance ownership
/// stays container-side even though the implementations are internal to the library.
/// </summary>
public partial class ModuleScanLifetimeTests
{
[Fact]
public async Task SingletonMatch_IsOneInstancePerRoot()
{
using ScanLifetimeContainer.Root first = new();
using ScanLifetimeContainer.Root second = new();

IScanSingleton fromFirst = first.Resolve<IScanSingleton>();

await That(first.Resolve<IScanSingleton>()).IsSameAs(fromFirst)
.Because("the scan declared Singleton, so the importing container shares one instance for the root's life");
await That(second.Resolve<IScanSingleton>()).IsNotSameAs(fromFirst)
.Because("the instance is owned by the root that built it, not cached in the library's module type");
}

[Fact]
public async Task SingletonMatch_IsSharedByEveryScopeOfItsRoot()
{
using ScanLifetimeContainer.Root container = new();
using IAwaitenScope scope = container.CreateScope();

await That(scope.Resolve<IScanSingleton>()).IsSameAs(container.Resolve<IScanSingleton>())
.Because("a scan-declared singleton resolves from the root like any other, rather than being re-created per scope");
}

[Fact]
public async Task TransientMatch_IsANewInstancePerResolve()
{
using ScanLifetimeContainer.Root container = new();

await That(container.Resolve<IScanTransient>()).IsNotSameAs(container.Resolve<IScanTransient>())
.Because("the second scan declared Transient, so the container honors a different lifetime for a match from the same module");
}

[Container]
[Import(typeof(ScanLifetimeModule))]
public static partial class ScanLifetimeContainer;
}
Loading