diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs
index b124236..603bf1f 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs
@@ -21,6 +21,24 @@ public class EfModelAnalyzer(
IFileSystem fileSystem,
IEntityFileDiscovery entityFileDiscovery) : IEfModelAnalyzer
{
+ ///
+ /// The state threaded through owned-navigation resolution: the caches augmented in place as owned
+ /// types are resolved, plus the search scope used to locate an owned type's file.
+ ///
+ /// Maps an owner key (a bare CLR type name, or a resolved {Owner}.{Nav} owned key) to its CLR type name; augmented in place.
+ /// Cache of CLR type name to its type declaration, loaded lazily; augmented in place.
+ /// The entity files discovered so far.
+ /// The owned type files newly discovered; augmented in place.
+ /// The directories to search for an owned type's file.
+ /// The context file path, excluded from the search.
+ private sealed record OwnedTypeResolutionContext(
+ Dictionary KeyToClrTypeName,
+ Dictionary ClassDeclsByName,
+ Dictionary EntityFiles,
+ Dictionary Discovered,
+ IReadOnlyList SearchDirectories,
+ string ContextPath);
+
///
/// Discovers all DbContext classes in the provided syntax tree.
///
@@ -284,12 +302,12 @@ private async Task> DiscoverOwnedNavigationFilesAsync
}
var discovered = new Dictionary(StringComparer.Ordinal);
+ var resolutionContext = new OwnedTypeResolutionContext(
+ keyToClrTypeName, classDeclsByName, entityFiles, discovered, searchDirectories, contextPath);
foreach (var (scope, ambientEntity) in scopes)
{
- await ResolveOwnedNavigationTypesAsync(
- scope, ambientEntity, keyToClrTypeName, classDeclsByName, entityFiles, discovered,
- searchDirectories, contextPath);
+ await ResolveOwnedNavigationTypesAsync(scope, ambientEntity, resolutionContext);
}
return discovered;
@@ -297,26 +315,18 @@ await ResolveOwnedNavigationTypesAsync(
///
/// Resolves every OwnsOne/OwnsMany navigation found in , owner-
- /// before-owned, updating and in place.
+ /// before-owned, updating the key-to-CLR-type and discovered-file caches on in place.
///
/// The configuring method (OnModelCreating or a config class's Configure).
/// The owning entity to fall back to when the chain has no Entity<T>() call.
- /// Maps an owner key (a bare CLR type name, or a resolved {Owner}.{Nav} owned key) to its CLR type name; augmented in place.
- /// Cache of CLR type name to its type declaration, loaded lazily; augmented in place.
- /// The entity files discovered so far.
- /// The owned type files newly discovered; augmented in place.
- /// The directories to search for an owned type's file.
- /// The context file path, excluded from the search.
+ /// The resolution state shared across every scope, augmented in place.
private async Task ResolveOwnedNavigationTypesAsync(
SyntaxNode scope,
string? ambientEntity,
- Dictionary keyToClrTypeName,
- Dictionary classDeclsByName,
- Dictionary entityFiles,
- Dictionary discovered,
- IReadOnlyList searchDirectories,
- string contextPath)
+ OwnedTypeResolutionContext context)
{
+ var (keyToClrTypeName, classDeclsByName, entityFiles, discovered, searchDirectories, contextPath) = context;
+
var ownsOneRoots = FluentSyntax.FindConfigRoots(scope, EfAnalysisConstants.EfMethods.OwnsOne)
.Select(inv => (Invocation: inv, IsCollection: false));
var ownsManyRoots = FluentSyntax.FindConfigRoots(scope, EfAnalysisConstants.EfMethods.OwnsMany)
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentOwnedTypeWalker.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentOwnedTypeWalker.cs
index a977658..198a604 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentOwnedTypeWalker.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentOwnedTypeWalker.cs
@@ -16,6 +16,15 @@ namespace ProjGraph.Lib.EntityFramework.Infrastructure;
///
internal static class FluentOwnedTypeWalker
{
+ ///
+ /// Identifies the owned navigation an OwnsOne/OwnsMany call configures.
+ ///
+ /// The owned entity's dictionary key ({Owner}.{Nav}).
+ /// The owning entity.
+ /// The owner's navigation property name for the owned type.
+ /// Whether the owned type is a collection (OwnsMany).
+ private sealed record OwnedTarget(string Key, EfEntity Owner, string Navigation, bool IsCollection);
+
///
/// Captures every owned type configured within .
///
@@ -78,7 +87,7 @@ private static void Capture(
}
var key = $"{ownerKey}.{navigation}";
- GetOrCreateOwned(key, owns, owner, navigation, isCollection, entities, model, compilation);
+ GetOrCreateOwned(new OwnedTarget(key, owner, navigation, isCollection), owns, entities, model, compilation);
// The owned builder's own lambda configuration (e.g. OwnsOne(o => o.Address, a => a.Property(...))).
// Calls chained onto the OwnsOne invocation instead (the form with no builder lambda) are resolved
@@ -101,29 +110,25 @@ private static void Capture(
}
///
- /// Creates the owned entity for on first sight, adding it to
+ /// Creates the owned entity for on first sight, adding it to
/// and . Repeated calls targeting the same
/// navigation (as in the chained form spread across statements) are no-ops, so they merge into one
/// entity rather than duplicating it.
///
- /// The owned entity's dictionary key ({Owner}.{Nav}).
+ /// The owned navigation being created.
/// The OwnsOne/OwnsMany invocation.
- /// The owning entity.
- /// The owner's navigation property name for the owned type.
- /// Whether the owned type is a collection (OwnsMany).
/// The known entities, augmented in place.
/// The model whose collection is augmented.
/// The compilation for owned-type symbol resolution.
private static void GetOrCreateOwned(
- string key,
+ OwnedTarget target,
InvocationExpressionSyntax owns,
- EfEntity owner,
- string navigation,
- bool isCollection,
Dictionary entities,
EfModel model,
Compilation compilation)
{
+ var (key, owner, navigation, isCollection) = target;
+
if (entities.ContainsKey(key))
{
return;
@@ -271,6 +276,19 @@ private static IEnumerable ForeignKeyPropertyNames(InvocationExpressionS
var navProperty = ownerSymbol?.GetMembers(navigation).OfType().FirstOrDefault();
+ // An OwnsMany collection can be an array (Address[], an IArrayTypeSymbol) rather than a generic
+ // List/ICollection. Its element type carries the members to seed, so unwrap it up front —
+ // matching the DbContext-path file discovery, which likewise unwraps T[] (see
+ // EfModelAnalyzer.OwnedClrTypeName). Without this the `is not INamedTypeSymbol` guard below would
+ // reject the array and the owned entity would seed with zero columns.
+ if (isCollection && navProperty?.Type is IArrayTypeSymbol arrayType)
+ {
+ return arrayType.ElementType as INamedTypeSymbol is { } arrayElement
+ && arrayElement is not IErrorTypeSymbol
+ ? arrayElement
+ : null;
+ }
+
// An IErrorTypeSymbol still satisfies `is INamedTypeSymbol` — Roslyn synthesizes one whenever the
// navigation's CLR type could not be resolved (e.g. its file never made it into the compilation).
// Treating it as resolved would seed the owned entity from a symbol with no members, capturing it
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentSyntax.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentSyntax.cs
index 53724b8..160c828 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentSyntax.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentSyntax.cs
@@ -100,6 +100,28 @@ private static bool IsNestedBuilderFence(InvocationExpressionSyntax invocation,
/// The owning entity to fall back to when no enclosing Entity<T>() is found.
public static string? ResolveOwningEntity(InvocationExpressionSyntax configInvocation, string? ambientEntity)
{
+ return ResolveFromReceiverChain(configInvocation, ambientEntity, out var resolved)
+ ? resolved
+ : ResolveFromAncestors(configInvocation, ambientEntity);
+ }
+
+ ///
+ /// Walks the receiver chain of looking for the builder call that
+ /// determines the owning entity. Returns when the chain settles the question —
+ /// with holding the answer, which is for an
+ /// out-of-scope (join-entity) or unresolvable owned builder — and when the
+ /// chain runs out without a verdict, leaving the ancestor search to decide.
+ ///
+ /// The configuration invocation whose receiver chain to walk.
+ /// The owning entity to fall back to when resolving a nested owned builder.
+ /// The entity key the chain resolved to, when this returns .
+ private static bool ResolveFromReceiverChain(
+ InvocationExpressionSyntax configInvocation,
+ string? ambientEntity,
+ out string? resolved)
+ {
+ resolved = null;
+
for (var receiver = ChainReceiver(configInvocation);
receiver is not null;
receiver = ChainReceiver(receiver))
@@ -116,24 +138,50 @@ private static bool IsNestedBuilderFence(InvocationExpressionSyntax invocation,
// configuration lands there — and never on the outer entity the receiver chain reaches.
if (callName is EfAnalysisConstants.EfMethods.OwnsOne or EfAnalysisConstants.EfMethods.OwnsMany)
{
- var ownerKey = ResolveOwningEntity(receiver, ambientEntity);
- var navigation = OwnedNavigationName(receiver);
- return ownerKey is not null && navigation is not null ? $"{ownerKey}.{navigation}" : null;
+ resolved = ComposeOwnedKey(receiver, ambientEntity);
+ return true;
}
// A join-entity builder (UsingEntity) is out of scope: stop rather than leak onto the owner.
if (callName == EfAnalysisConstants.EfMethods.UsingEntity)
{
- return null;
+ return true;
}
if (callName == EfAnalysisConstants.EfMethods.Entity)
{
- return EntityNameFromInvocation(receiver);
+ resolved = EntityNameFromInvocation(receiver);
+ return true;
}
}
- InvocationExpressionSyntax? enclosingEntity = null;
+ return false;
+ }
+
+ ///
+ /// Composes the {Owner}.{Nav} key an OwnsOne/OwnsMany builder resolves to, or
+ /// when either half is unresolvable.
+ ///
+ /// The OwnsOne/OwnsMany invocation.
+ /// The owning entity to fall back to when resolving the owner.
+ private static string? ComposeOwnedKey(InvocationExpressionSyntax owns, string? ambientEntity)
+ {
+ var ownerKey = ResolveOwningEntity(owns, ambientEntity);
+ var navigation = OwnedNavigationName(owns);
+ return ownerKey is not null && navigation is not null ? $"{ownerKey}.{navigation}" : null;
+ }
+
+ ///
+ /// Finds the enclosing Entity<T>(e => ...) configuration lambda for
+ /// , falling back to when the
+ /// search stops at a nested-builder fence or finds none.
+ ///
+ /// The configuration invocation whose ancestors to search.
+ /// The owning entity to fall back to when no enclosing Entity<T>() is found.
+ private static string? ResolveFromAncestors(
+ InvocationExpressionSyntax configInvocation,
+ string? ambientEntity)
+ {
foreach (var ancestor in configInvocation.Ancestors().OfType())
{
// Climbing past an owned-type / join-entity builder fence (e.g. the OwnsOne call whose
@@ -148,12 +196,11 @@ private static bool IsNestedBuilderFence(InvocationExpressionSyntax invocation,
if (ancestor.Expression is MemberAccessExpressionSyntax ma
&& SimpleName(ma.Name) == EfAnalysisConstants.EfMethods.Entity)
{
- enclosingEntity = ancestor;
- break;
+ return EntityNameFromInvocation(ancestor);
}
}
- return enclosingEntity is null ? ambientEntity : EntityNameFromInvocation(enclosingEntity);
+ return ambientEntity;
}
/// Returns the invocation on the receiver side of a member-access invocation, or .
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/SqlColumnTypeMapper.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/SqlColumnTypeMapper.cs
index 6b3e553..8336051 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/SqlColumnTypeMapper.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/SqlColumnTypeMapper.cs
@@ -8,13 +8,15 @@ namespace ProjGraph.Lib.EntityFramework.Infrastructure;
///
internal static class SqlColumnTypeMapper
{
+ private const string Decimal = "decimal";
+
private static readonly IReadOnlyDictionary SqlToClr =
new Dictionary(StringComparer.OrdinalIgnoreCase)
{
- ["decimal"] = "decimal",
- ["numeric"] = "decimal",
- ["money"] = "decimal",
- ["smallmoney"] = "decimal",
+ [Decimal] = Decimal,
+ ["numeric"] = Decimal,
+ ["money"] = Decimal,
+ ["smallmoney"] = Decimal,
["int"] = "int",
["integer"] = "int",
["bigint"] = "long",
diff --git a/tests/ProjGraph.Tests.Integration.Cli/CommandOptionsCoverageTests.cs b/tests/ProjGraph.Tests.Integration.Cli/CommandOptionsCoverageTests.cs
new file mode 100644
index 0000000..8becc73
--- /dev/null
+++ b/tests/ProjGraph.Tests.Integration.Cli/CommandOptionsCoverageTests.cs
@@ -0,0 +1,347 @@
+using Microsoft.Extensions.DependencyInjection;
+using ProjGraph.Cli.Infrastructure;
+using ProjGraph.Tests.Integration.Cli.Helpers;
+using ProjGraph.Tests.Shared.Helpers;
+using Spectre.Console.Cli;
+
+namespace ProjGraph.Tests.Integration.Cli;
+
+///
+/// Integration tests for command option validation, error handling and reporting branches that the
+/// main per-command suites do not exercise, plus the Spectre.Console DI adapter used to construct
+/// the commands.
+///
+[Collection("CLI Tests")]
+public sealed class CommandOptionsCoverageTests
+{
+ private const string GarbageSolutionContent = "this is not a solution file";
+
+ private static string CsprojReferencing(string relativeReference)
+ {
+ return $"""
+
+
+ net10.0
+
+
+
+
+
+ """;
+ }
+
+ // ── visualize ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void VisualizeCommand_NoPath_ShouldFailValidation()
+ {
+ // Arrange — the path argument is declared optional so Spectre can show help, which means
+ // the "required" rule lives in Settings.Validate and must actually fire.
+ var app = CliTestHelpers.CreateApp();
+
+ // Act & Assert
+ var exception = Assert.Throws(() => app.Run(["visualize"]));
+
+ exception.Message.Should().Contain("Path is required");
+ }
+
+ [Fact]
+ public void VisualizeCommand_UnsupportedExtension_ShouldReportErrorAndExitOne()
+ {
+ // Arrange — an existing file with an extension the command does not handle.
+ var app = CliTestHelpers.CreateApp();
+ var readmePath = CliTestHelpers.GetRootPath("README.md");
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() => exitCode = app.Run(["visualize", readmePath]));
+
+ // Assert
+ exitCode.Should().Be(1);
+ output.Should().Contain("File must be a .sln, .slnx, or .csproj file.");
+ output.Should().NotContain("flowchart", "no diagram should be produced for a rejected input");
+ }
+
+ [Fact]
+ public void VisualizeCommand_ClassicSlnFile_ShouldBeAccepted()
+ {
+ // Arrange — the legacy .sln format is an accepted extension alongside .slnx and .csproj.
+ using var temp = new TestDirectory();
+ temp.CreateFile(Path.Combine("A", "A.csproj"), StandaloneCsproj);
+ var slnPath = temp.CreateFile("Legacy.sln", LegacySlnContent);
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() =>
+ exitCode = app.Run(["visualize", slnPath, "--format", "flat"]));
+
+ // Assert
+ exitCode.Should().Be(0);
+ output.Should().Contain("A", "the project declared in the .sln should be listed");
+ }
+
+ [Fact]
+ public void VisualizeCommand_UnparsableSolutionFile_ShouldReportErrorAndExitOne()
+ {
+ // Arrange — a .slnx that passes the extension check but cannot be parsed, so the failure
+ // surfaces from the graph service and must be turned into a user-facing message.
+ using var temp = new TestDirectory();
+ var slnxPath = temp.CreateFile("Broken.slnx", GarbageSolutionContent);
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() =>
+ exitCode = app.Run(["visualize", slnxPath, "--format", "mermaid"]));
+
+ // Assert
+ exitCode.Should().Be(1);
+ output.Should().Contain("Failed to read or parse .slnx file");
+ output.Should().Contain("Broken.slnx", "the error should name the offending file");
+ }
+
+ // ── classdiagram ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ClassDiagramCommand_NoPath_ShouldFailValidation()
+ {
+ // Arrange
+ var app = CliTestHelpers.CreateApp();
+
+ // Act & Assert
+ var exception = Assert.Throws(() => app.Run(["classdiagram"]));
+
+ exception.Message.Should().Contain("Path is required");
+ }
+
+ [Fact]
+ public void ClassDiagramCommand_DirectoryWithManyFiles_ShouldWarnAboutDiagramSize()
+ {
+ // Arrange — the command warns once a directory scan exceeds 50 files, because the resulting
+ // diagram is generally unreadable.
+ using var temp = new TestDirectory();
+ const int fileCount = 51;
+ for (var i = 0; i < fileCount; i++)
+ {
+ temp.CreateFile($"Type{i}.cs", $"namespace Coverage;\n\npublic class Type{i}\n{{\n}}\n");
+ }
+
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() =>
+ exitCode = app.Run(["classdiagram", temp.DirectoryPath, "--show-title", "false"]));
+
+ // Assert — the warning names the actual count, and the diagram is still produced.
+ exitCode.Should().Be(0);
+ output.Should().Contain($"Scanning {fileCount} files");
+ output.Should().Contain("Large diagrams may be hard to read.");
+ output.Should().Contain("classDiagram");
+ }
+
+ [Fact]
+ public void ClassDiagramCommand_SmallDirectory_ShouldNotWarnAboutDiagramSize()
+ {
+ // Arrange — the counterpart of the test above: a small scan must stay quiet.
+ using var temp = new TestDirectory();
+ temp.CreateFile("Only.cs", "namespace Coverage;\n\npublic class Only\n{\n}\n");
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() =>
+ exitCode = app.Run(["classdiagram", temp.DirectoryPath, "--show-title", "false"]));
+
+ // Assert
+ exitCode.Should().Be(0);
+ output.Should().NotContain("Large diagrams may be hard to read.");
+ output.Should().Contain("classDiagram");
+ }
+
+ [Fact]
+ public void ClassDiagramCommand_UnwritableOutputPath_ShouldReportErrorAndExitOne()
+ {
+ // Arrange — an output path nested underneath an existing *file* cannot be created, so the
+ // write fails after a successful analysis and must be reported instead of crashing.
+ using var temp = new TestDirectory();
+ var sourcePath = temp.CreateFile("Only.cs", "namespace Coverage;\n\npublic class Only\n{\n}\n");
+ var outputPath = Path.Combine(sourcePath, "nested", "diagram.md");
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() =>
+ exitCode = app.Run(["classdiagram", sourcePath, "--output", outputPath]));
+
+ // Assert
+ exitCode.Should().Be(1);
+ output.Should().Contain("Error:", "the failure must be surfaced as an error message");
+ File.Exists(outputPath).Should().BeFalse();
+ }
+
+ // ── stats ─────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void StatsCommand_NoPath_ShouldFailValidation()
+ {
+ // Arrange
+ var app = CliTestHelpers.CreateApp();
+
+ // Act & Assert
+ var exception = Assert.Throws(() => app.Run(["stats"]));
+
+ exception.Message.Should().Contain("Path is required");
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("-3")]
+ public void StatsCommand_NonPositiveTop_ShouldFailValidation(string top)
+ {
+ // Arrange — --top drives a "take N" projection, so anything below 1 is meaningless.
+ var app = CliTestHelpers.CreateApp();
+ var slnxPath = CliTestHelpers.GetSamplePath(@"visualize\simple-dependencies\simple-dependencies.slnx");
+
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ app.Run(["stats", slnxPath, "--top", top]));
+
+ exception.Message.Should().Contain("--top must be at least 1");
+ }
+
+ [Fact]
+ public void StatsCommand_SolutionWithCycles_ShouldReportCyclesAndSuppressDepth()
+ {
+ // Arrange — two projects referencing each other. Dependency depth is undefined on a cyclic
+ // graph, so the depth rows must be replaced by an explicit "N/A" row.
+ using var temp = new TestDirectory();
+ temp.CreateFile(Path.Combine("A", "A.csproj"), CsprojReferencing(@"..\B\B.csproj"));
+ temp.CreateFile(Path.Combine("B", "B.csproj"), CsprojReferencing(@"..\A\A.csproj"));
+ var slnxPath = temp.CreateFile("Cyclic.slnx", """
+
+
+
+
+ """);
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() => exitCode = app.Run(["stats", slnxPath]));
+
+ // Assert
+ exitCode.Should().Be(0);
+ output.Should().Contain("N/A (cycles detected)");
+ output.Should().Contain("Cycles detected");
+ output.Should().Contain("Yes");
+ output.Should().NotContain("Average depth",
+ "depth statistics are meaningless once a cycle is present and must not be shown");
+ }
+
+ [Fact]
+ public void StatsCommand_UnparsableSolutionFile_ShouldReportErrorAndExitOne()
+ {
+ // Arrange
+ using var temp = new TestDirectory();
+ var slnxPath = temp.CreateFile("Broken.slnx", GarbageSolutionContent);
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() => exitCode = app.Run(["stats", slnxPath]));
+
+ // Assert
+ exitCode.Should().Be(1);
+ output.Should().Contain("Failed to read or parse .slnx file");
+ output.Should().NotContain("Total projects", "no metrics table should be rendered on failure");
+ }
+
+ // ── DI adapter ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TypeResolver_ResolveNullType_ShouldReturnNull()
+ {
+ // Spectre.Console.Cli asks the resolver for a null type when a command has no settings
+ // dependency to satisfy; that must not throw.
+ using var provider = new ServiceCollection().BuildServiceProvider();
+ var resolver = new TypeResolver(provider);
+
+ resolver.Resolve(null).Should().BeNull();
+ }
+
+ [Fact]
+ public void TypeResolver_ResolveRegisteredType_ShouldReturnTheRegisteredInstance()
+ {
+ var instance = new SampleDependency();
+ var services = new ServiceCollection();
+ services.AddSingleton(instance);
+ using var provider = services.BuildServiceProvider();
+ var resolver = new TypeResolver(provider);
+
+ resolver.Resolve(typeof(SampleDependency)).Should().BeSameAs(instance);
+ }
+
+ [Fact]
+ public void TypeResolver_Dispose_NonDisposableProvider_ShouldNotThrow()
+ {
+ // The resolver owns whatever provider it is handed; a provider that is not IDisposable must
+ // simply be skipped rather than cast-crashing on dispose.
+ var resolver = new TypeResolver(new NonDisposableServiceProvider());
+
+ var act = resolver.Dispose;
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void TypeRegistrar_BuildAfterRegistrations_ShouldResolveEachRegistrationStyle()
+ {
+ // All three registration styles used by Spectre.Console.Cli must be honoured by the built
+ // resolver.
+ var services = new ServiceCollection();
+ var registrar = new TypeRegistrar(services);
+ var lazyInstance = new SampleDependency();
+
+ registrar.Register(typeof(ISampleDependency), typeof(SampleDependency));
+ registrar.RegisterInstance(typeof(SampleDependency), new SampleDependency());
+ registrar.RegisterLazy(typeof(SampleDependency[]), () => new[] { lazyInstance });
+
+ var resolver = registrar.Build();
+
+ resolver.Resolve(typeof(ISampleDependency)).Should().BeOfType();
+ resolver.Resolve(typeof(SampleDependency)).Should().NotBeNull();
+ resolver.Resolve(typeof(SampleDependency[])).Should().BeEquivalentTo(new[] { lazyInstance });
+ }
+
+ private const string StandaloneCsproj = """
+
+
+ net10.0
+
+
+ """;
+
+ private const string LegacySlnContent =
+ """
+ Microsoft Visual Studio Solution File, Format Version 12.00
+ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "A", "A\A.csproj", "{11111111-1111-1111-1111-111111111111}"
+ EndProject
+ Global
+ EndGlobal
+ """;
+
+ private interface ISampleDependency;
+
+ private sealed class SampleDependency : ISampleDependency;
+
+ /// An that deliberately does not implement .
+ private sealed class NonDisposableServiceProvider : IServiceProvider
+ {
+ public object? GetService(Type serviceType)
+ {
+ return null;
+ }
+ }
+}
diff --git a/tests/ProjGraph.Tests.Integration.Cli/ErdCommandCoverageTests.cs b/tests/ProjGraph.Tests.Integration.Cli/ErdCommandCoverageTests.cs
new file mode 100644
index 0000000..92913b8
--- /dev/null
+++ b/tests/ProjGraph.Tests.Integration.Cli/ErdCommandCoverageTests.cs
@@ -0,0 +1,308 @@
+using ProjGraph.Tests.Integration.Cli.Helpers;
+using ProjGraph.Tests.Shared.Helpers;
+using Spectre.Console;
+using Spectre.Console.Testing;
+using System.Text;
+
+namespace ProjGraph.Tests.Integration.Cli;
+
+///
+/// Integration tests for the erd command paths that the main suite never reaches: automatic
+/// discovery of a DbContext/ModelSnapshot when no path argument is supplied, the interactive
+/// selection prompts shown when the discovery is ambiguous, and the ModelSnapshot analysis branch.
+///
+[Collection("CLI Tests")]
+public sealed class ErdCommandCoverageTests
+{
+ [Fact]
+ public void ErdCommand_NoPathAndNoCandidateFiles_ShouldReportNoFileFoundAndExitOne()
+ {
+ // Arrange — an empty working directory, so auto-discovery finds nothing.
+ using var temp = new TestDirectory();
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = 0;
+ var output = RunInDirectory(temp.DirectoryPath,
+ () => CliTestHelpers.CaptureConsoleOutput(() => exitCode = app.Run(["erd"])));
+
+ // Assert — an omitted path is valid (discovery mode), but finding nothing must fail loudly
+ // with usage guidance rather than rendering an empty diagram.
+ exitCode.Should().Be(1);
+ output.Should().Contain("No DbContext or ModelSnapshot .cs file found.");
+ output.Should().Contain("projgraph erd path/to/YourDbContext.cs",
+ "the error should tell the user how to pass an explicit path");
+ }
+
+ [Fact]
+ public void ErdCommand_NoPathAndSingleCandidate_ShouldDiscoverItAndRenderDiagram()
+ {
+ // Arrange — exactly one *DbContext.cs below the working directory.
+ using var temp = new TestDirectory();
+ temp.CreateFile(Path.Combine("Data", "WidgetDbContext.cs"), WidgetContextSource("WidgetDbContext"));
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = RunInDirectory(temp.DirectoryPath,
+ () => CliTestHelpers.CaptureConsoleOutput(() => exitCode = app.Run(["erd", "--show-title", "false"])));
+
+ // Assert — the single candidate is used without prompting, and the user is told which file
+ // was picked so an implicit choice is never silent.
+ exitCode.Should().Be(0);
+ output.Should().Contain("Using ", "the auto-selected file name should be announced");
+ output.Should().Contain("WidgetDbContext.cs");
+ output.Should().Contain("erDiagram");
+ output.Should().Contain("Widget {");
+ }
+
+ [Fact]
+ public void ErdCommand_NoPath_ShouldIgnoreCandidatesUnderExcludedDirectories()
+ {
+ // Arrange — a build-output copy under bin/ plus one real source file. If the excluded
+ // directory were not filtered out, discovery would see two files and prompt instead.
+ using var temp = new TestDirectory();
+ temp.CreateFile(Path.Combine("src", "WidgetDbContext.cs"), WidgetContextSource("WidgetDbContext"));
+ temp.CreateFile(Path.Combine("bin", "Debug", "WidgetDbContext.cs"), WidgetContextSource("WidgetDbContext"));
+ temp.CreateFile(Path.Combine("obj", "StaleDbContext.cs"), WidgetContextSource("StaleDbContext"));
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = RunInDirectory(temp.DirectoryPath,
+ () => CliTestHelpers.CaptureConsoleOutput(() => exitCode = app.Run(["erd", "--show-title", "false"])));
+
+ // Assert — the single non-excluded candidate is selected outright.
+ exitCode.Should().Be(0);
+ output.Should().Contain("Using ");
+ output.Should().Contain("WidgetDbContext.cs");
+ output.Should().NotContain("Multiple files found",
+ "generated copies under bin/ and obj/ must not make the discovery ambiguous");
+ output.Should().NotContain("StaleDbContext",
+ "a DbContext under obj/ must never be offered");
+ output.Should().Contain("Widget {");
+ }
+
+ [Fact]
+ public void ErdCommand_NoPathAndMultipleCandidates_ShouldPromptAndUseTheSelectedFile()
+ {
+ // Arrange — two discoverable contexts in different directories. Both describe the same
+ // Widget entity, so the rendered diagram is deterministic regardless of enumeration order,
+ // while the prompt itself must list both distinct paths.
+ using var temp = new TestDirectory();
+ temp.CreateFile(Path.Combine("Billing", "BillingDbContext.cs"), WidgetContextSource("BillingDbContext"));
+ temp.CreateFile(Path.Combine("Shipping", "ShippingDbContext.cs"), WidgetContextSource("ShippingDbContext"));
+ var app = CliTestHelpers.CreateApp();
+
+ // Act — accept the highlighted (first) choice.
+ var exitCode = -1;
+ var output = RunInDirectory(temp.DirectoryPath,
+ () => CaptureInteractiveOutput(
+ input => input.PushKey(ConsoleKey.Enter),
+ () => exitCode = app.Run(["erd", "--show-title", "false"])));
+
+ // Assert — the prompt is shown with both candidates, and the selection resolves back to a
+ // real file that is then analysed.
+ exitCode.Should().Be(0);
+ output.Should().Contain("Multiple files found. Please select one:");
+ output.Should().Contain("BillingDbContext.cs");
+ output.Should().Contain("ShippingDbContext.cs");
+ output.Should().Contain("erDiagram");
+ output.Should().Contain("Widget {");
+ }
+
+ [Fact]
+ public void ErdCommand_NoPathAndMultipleCandidates_NonInteractiveTerminal_ShouldExitOne()
+ {
+ // Arrange — the same ambiguous discovery, but on a terminal that cannot prompt (CI, pipes).
+ using var temp = new TestDirectory();
+ temp.CreateFile(Path.Combine("Billing", "BillingDbContext.cs"), WidgetContextSource("BillingDbContext"));
+ temp.CreateFile(Path.Combine("Shipping", "ShippingDbContext.cs"), WidgetContextSource("ShippingDbContext"));
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = RunInDirectory(temp.DirectoryPath,
+ () => CliTestHelpers.CaptureConsoleOutput(() => exitCode = app.Run(["erd"])));
+
+ // Assert — it must fail with a readable message rather than hanging or throwing raw.
+ exitCode.Should().Be(1);
+ output.Should().Contain("interactive",
+ "the failure should explain that a selection prompt cannot be shown");
+ }
+
+ [Fact]
+ public void ErdCommand_ModelSnapshotFile_ShouldRenderDiagramFromTheSnapshot()
+ {
+ // Arrange — a file whose name ends in ModelSnapshot.cs must take the snapshot analysis
+ // branch (EF-generated fluent builder) rather than the DbContext branch.
+ using var temp = new TestDirectory();
+ var fixturePath = CliTestHelpers.GetRootPath(Path.Combine(
+ "tests", "ProjGraph.Tests.Unit.EntityFramework", "Golden", "fixtures", "JournalSnapshot.cs"));
+ var snapshotPath = temp.CreateFile("JournalContextModelSnapshot.cs", File.ReadAllText(fixturePath));
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() =>
+ exitCode = app.Run(["erd", snapshotPath]));
+
+ // Assert — entities and the relationship declared in the snapshot are rendered, and the
+ // title comes from the snapshot's context rather than the file name.
+ exitCode.Should().Be(0);
+ output.Should().Contain("title: JournalContext");
+ output.Should().Contain("erDiagram");
+ output.Should().Contain("Entry {");
+ output.Should().Contain("Journal {");
+ output.Should().Contain("Journal ||--o{ Entry");
+ }
+
+ [Fact]
+ public void ErdCommand_MultipleContextsInOneFile_ShouldPromptForTheContextToRender()
+ {
+ // Arrange — one file declaring two DbContext types and no --context option, which is the
+ // only way the per-context selection prompt is reached.
+ using var temp = new TestDirectory();
+ var contextPath = temp.CreateFile("TwoContexts.cs", TwoContextSource);
+ var app = CliTestHelpers.CreateApp();
+
+ // Act — accept the highlighted (first) context.
+ var exitCode = -1;
+ var output = CaptureInteractiveOutput(
+ input => input.PushKey(ConsoleKey.Enter),
+ () => exitCode = app.Run(["erd", contextPath, "--show-title", "false"]));
+
+ // Assert
+ exitCode.Should().Be(0);
+ output.Should().Contain("Multiple DbContexts found. Please select one:");
+ output.Should().Contain("BillingDbContext");
+ output.Should().Contain("ShippingDbContext");
+ output.Should().Contain("erDiagram");
+ output.Should().Contain("Widget {");
+ }
+
+ [Fact]
+ public void ErdCommand_MultipleContextsInOneFile_WithContextOption_ShouldSkipThePrompt()
+ {
+ // Arrange — naming the context explicitly must bypass the prompt entirely, so the command
+ // stays usable on a non-interactive terminal.
+ using var temp = new TestDirectory();
+ var contextPath = temp.CreateFile("TwoContexts.cs", TwoContextSource);
+ var app = CliTestHelpers.CreateApp();
+
+ // Act
+ var exitCode = -1;
+ var output = CliTestHelpers.CaptureConsoleOutput(() =>
+ exitCode = app.Run(["erd", contextPath, "--context", "ShippingDbContext"]));
+
+ // Assert
+ exitCode.Should().Be(0);
+ output.Should().NotContain("Multiple DbContexts found");
+ output.Should().Contain("title: ShippingDbContext");
+ output.Should().Contain("Widget {");
+ }
+
+ private static string WidgetContextSource(string contextName)
+ {
+ return $$"""
+ using Microsoft.EntityFrameworkCore;
+
+ namespace CoverageFixtures;
+
+ public class {{contextName}} : DbContext
+ {
+ public DbSet Widgets { get; set; }
+ }
+
+ public class Widget
+ {
+ public int Id { get; set; }
+ public string Name { get; set; }
+ }
+ """;
+ }
+
+ private const string TwoContextSource = """
+ using Microsoft.EntityFrameworkCore;
+
+ namespace CoverageFixtures;
+
+ public class BillingDbContext : DbContext
+ {
+ public DbSet Widgets { get; set; }
+ }
+
+ public class ShippingDbContext : DbContext
+ {
+ public DbSet Widgets { get; set; }
+ }
+
+ public class Widget
+ {
+ public int Id { get; set; }
+ public string Name { get; set; }
+ }
+ """;
+
+ ///
+ /// Runs with the process working directory temporarily switched to
+ /// , which is what the erd command searches when no path
+ /// argument is given. Safe because every class in this assembly shares one xUnit collection and
+ /// therefore never runs concurrently.
+ ///
+ /// The directory to make current for the duration of the call.
+ /// The action to run; its return value is passed through.
+ /// Whatever returned.
+ private static string RunInDirectory(string directory, Func action)
+ {
+ var original = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(directory);
+ try
+ {
+ return action();
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(original);
+ }
+ }
+
+ ///
+ /// Captures command output while presenting an interactive terminal, so selection prompts can
+ /// be driven by pushed key presses instead of failing as "not interactive".
+ ///
+ /// Queues the key presses the prompt should consume.
+ /// The command invocation to capture.
+ /// The combined standard output and standard error produced by .
+ private static string CaptureInteractiveOutput(Action pushInput, Action action)
+ {
+ var stderr = new StringBuilder();
+ var originalOut = Console.Out;
+ var originalError = Console.Error;
+
+ using var errorWriter = new StringWriter(stderr);
+ using var testConsole = new TestConsole();
+ testConsole.Interactive();
+ testConsole.Profile.Capabilities.Unicode = true;
+ testConsole.Profile.Width = 200;
+ pushInput(testConsole.Input);
+
+ try
+ {
+ Console.SetOut(errorWriter);
+ Console.SetError(errorWriter);
+ AnsiConsole.Console = testConsole;
+
+ action();
+
+ errorWriter.Flush();
+ return testConsole.Output + stderr;
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ Console.SetError(originalError);
+ AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings());
+ }
+ }
+}
diff --git a/tests/ProjGraph.Tests.Integration.Mcp/CollectingOutputConsoleTests.cs b/tests/ProjGraph.Tests.Integration.Mcp/CollectingOutputConsoleTests.cs
new file mode 100644
index 0000000..8f77c12
--- /dev/null
+++ b/tests/ProjGraph.Tests.Integration.Mcp/CollectingOutputConsoleTests.cs
@@ -0,0 +1,191 @@
+using ProjGraph.Mcp;
+
+namespace ProjGraph.Tests.Integration.Mcp;
+
+///
+/// Covers , the MCP server's stdout-safe console. Every write
+/// is discarded — stdout is reserved for the JSON-RPC transport — except warnings, which are
+/// buffered per async flow so a tool can surface them in its own result.
+///
+public sealed class CollectingOutputConsoleTests
+{
+ [Fact]
+ public void DrainWarnings_WithoutAnActiveScope_ShouldReturnEmpty()
+ {
+ var console = new CollectingOutputConsole();
+
+ console.WriteWarning("emitted outside a tool invocation");
+
+ // No collection scope was opened, so the warning is dropped instead of leaking into the
+ // result of whichever tool happens to drain next.
+ console.DrainWarnings().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void DrainWarnings_AfterClearWarnings_ShouldReturnWarningsInOrder()
+ {
+ var console = new CollectingOutputConsole();
+ console.ClearWarnings();
+
+ console.WriteWarning("skipped Foo.csproj");
+ console.WriteWarning("skipped Bar.csproj");
+
+ console.DrainWarnings().Should().Equal("skipped Foo.csproj", "skipped Bar.csproj");
+ }
+
+ [Fact]
+ public void DrainWarnings_CalledTwice_ShouldReturnEmptyOnTheSecondCall()
+ {
+ var console = new CollectingOutputConsole();
+ console.ClearWarnings();
+ console.WriteWarning("partial analysis");
+
+ console.DrainWarnings().Should().ContainSingle();
+
+ // Draining ends the collection, so the same warning is not reported by the next tool call.
+ console.DrainWarnings().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void DrainWarnings_ShouldReturnASnapshotDetachedFromTheBuffer()
+ {
+ var console = new CollectingOutputConsole();
+ console.ClearWarnings();
+ console.WriteWarning("first");
+
+ var drained = console.DrainWarnings();
+
+ console.ClearWarnings();
+ console.WriteWarning("second");
+
+ // An already-returned result is a snapshot: warnings collected afterwards must not appear
+ // in it, or a tool's result could be mutated after it was built.
+ drained.Should().Equal("first");
+ }
+
+ [Fact]
+ public void ClearWarnings_ShouldDiscardWarningsFromThePreviousScope()
+ {
+ var console = new CollectingOutputConsole();
+ console.ClearWarnings();
+ console.WriteWarning("stale");
+
+ console.ClearWarnings();
+ console.WriteWarning("fresh");
+
+ console.DrainWarnings().Should().Equal("fresh");
+ }
+
+ [Fact]
+ public void NonWarningWrites_ShouldNotBeCollected()
+ {
+ var console = new CollectingOutputConsole();
+ console.ClearWarnings();
+
+ console.Write("write");
+ console.WriteLine("write line");
+ console.WriteInfo("info");
+ console.WriteError("error");
+ console.WriteSuccess("success");
+ console.WriteMarkup("[red]markup[/]");
+
+ // Only warnings are surfaced to the client; every other channel is discarded so nothing
+ // (least of all Spectre markup) can reach the JSON-RPC stdio transport.
+ console.DrainWarnings().Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task WriteWarning_ConcurrentFlows_ShouldNotSeeEachOthersWarnings()
+ {
+ var console = new CollectingOutputConsole();
+
+ var first = Task.Run(async () =>
+ {
+ console.ClearWarnings();
+ await Task.Yield();
+ console.WriteWarning("from-first");
+ return console.DrainWarnings();
+ });
+
+ var second = Task.Run(async () =>
+ {
+ console.ClearWarnings();
+ await Task.Yield();
+ console.WriteWarning("from-second");
+ return console.DrainWarnings();
+ });
+
+ var results = await Task.WhenAll(first, second);
+
+ // The buffer lives in an AsyncLocal, so concurrent tool invocations stay isolated even
+ // though the console is registered as a singleton.
+ results[0].Should().Equal("from-first");
+ results[1].Should().Equal("from-second");
+ }
+
+ [Fact]
+ public async Task PromptSelectionAsync_WithChoices_ShouldReturnTheFirstChoice()
+ {
+ var console = new CollectingOutputConsole();
+
+ var selection = await console.PromptSelectionAsync("Select a DbContext", ["Alpha", "Beta"]);
+
+ // The MCP server is non-interactive, so a prompt resolves to the first choice rather than
+ // blocking forever waiting for input that can never arrive.
+ selection.Should().Be("Alpha");
+ }
+
+ [Fact]
+ public async Task PromptSelectionAsync_WithNoChoices_ShouldThrowNamingThePrompt()
+ {
+ var console = new CollectingOutputConsole();
+
+ var act = async () => await console.PromptSelectionAsync("Select a DbContext", []);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*Select a DbContext*");
+ }
+
+ [Fact]
+ public async Task RunWithStatusAsync_ShouldInvokeTheAction()
+ {
+ var console = new CollectingOutputConsole();
+ var invoked = false;
+
+ await console.RunWithStatusAsync("Analyzing", () =>
+ {
+ invoked = true;
+ return Task.CompletedTask;
+ });
+
+ invoked.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task RunWithStatusAsync_ShouldPropagateTheActionFailure()
+ {
+ var console = new CollectingOutputConsole();
+
+ var act = async () => await console.RunWithStatusAsync("Analyzing",
+ () => Task.FromException(new InvalidOperationException("boom")));
+
+ // The status wrapper is transparent: it must not swallow the operation's failure.
+ await act.Should().ThrowAsync().WithMessage("boom");
+ }
+
+ [Fact]
+ public async Task RunWithStatusAsync_ShouldCollectWarningsRaisedByTheAction()
+ {
+ var console = new CollectingOutputConsole();
+ console.ClearWarnings();
+
+ await console.RunWithStatusAsync("Analyzing", async () =>
+ {
+ await Task.Yield();
+ console.WriteWarning("skipped a project");
+ });
+
+ // The action runs on the caller's async flow, so its warnings land in the caller's scope.
+ console.DrainWarnings().Should().Equal("skipped a project");
+ }
+}
diff --git a/tests/ProjGraph.Tests.Integration.Mcp/DiagramResourceCacheTests.cs b/tests/ProjGraph.Tests.Integration.Mcp/DiagramResourceCacheTests.cs
new file mode 100644
index 0000000..1d64279
--- /dev/null
+++ b/tests/ProjGraph.Tests.Integration.Mcp/DiagramResourceCacheTests.cs
@@ -0,0 +1,197 @@
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using ProjGraph.Mcp;
+using ProjGraph.Tests.Integration.Mcp.Helpers;
+using System.Reflection;
+
+namespace ProjGraph.Tests.Integration.Mcp;
+
+///
+/// Covers the half of that only runs when a live
+/// is attached: publishing each cached diagram
+/// as a real MCP resource, un-publishing it on eviction, and notifying the client when the content
+/// behind an unchanged URI is regenerated. McpResourcesTests covers the server-less paths.
+///
+public sealed class DiagramResourceCacheTests
+{
+ private const string SolutionPath = "/src/App.slnx";
+
+ private static string UriFor(string type, string sourcePath)
+ {
+ return $"projgraph://diagrams/{type}/{Uri.EscapeDataString(sourcePath)}";
+ }
+
+ [Fact]
+ public async Task StoreAsync_WithServer_ShouldPublishTheDiagramAsAnMcpResource()
+ {
+ await using var session = await InProcessMcpSession.StartAsync();
+ var cache = new DiagramResourceCache();
+
+ await cache.StoreAsync("graph", SolutionPath, "text/plain", "graph TD\n A --> B",
+ "Dependency graph for App.slnx", session.Server, CancellationToken.None);
+
+ var resources = await session.Client.ListResourcesAsync();
+
+ // Without a server the entry is cache-only; with one it must show up in resources/list so
+ // the client can discover the diagram without re-running the tool.
+ var published = resources.Should().ContainSingle().Subject;
+ published.Uri.Should().Be(UriFor("graph", SolutionPath));
+ published.Name.Should().Be("graph — App.slnx");
+ published.Description.Should().Be("Dependency graph for App.slnx");
+ published.MimeType.Should().Be("text/plain");
+ }
+
+ [Fact]
+ public async Task StoreAsync_WithServer_ShouldServeTheContentThroughResourcesRead()
+ {
+ await using var session = await InProcessMcpSession.StartAsync();
+ var cache = new DiagramResourceCache();
+
+ await cache.StoreAsync("class", "/src/Models/Order.cs", "text/plain",
+ "classDiagram\n class Order", "Class diagram", session.Server, CancellationToken.None);
+
+ var published = (await session.Client.ListResourcesAsync())
+ .Single(resource => resource.Uri == UriFor("class", "/src/Models/Order.cs"));
+ var result = await published.ReadAsync();
+
+ var contents = result.Contents.Should().ContainSingle().Subject
+ .Should().BeOfType().Subject;
+ contents.Text.Should().Be("classDiagram\n class Order");
+ contents.MimeType.Should().Be("text/plain");
+ }
+
+ [Fact]
+ public async Task StoreAsync_WithServer_UpdatingAnExistingEntry_ShouldServeTheNewContent()
+ {
+ await using var session = await InProcessMcpSession.StartAsync();
+ var cache = new DiagramResourceCache();
+
+ await cache.StoreAsync("graph", SolutionPath, "text/plain", "v1", "desc",
+ session.Server, CancellationToken.None);
+ await cache.StoreAsync("graph", SolutionPath, "text/plain", "v2", "desc",
+ session.Server, CancellationToken.None);
+
+ // The URI is stable across regenerations, so the client must not end up with two entries.
+ var published = (await session.Client.ListResourcesAsync()).Should().ContainSingle().Subject;
+
+ var result = await published.ReadAsync();
+ result.Contents.Should().ContainSingle().Subject
+ .Should().BeOfType().Subject
+ .Text.Should().Be("v2");
+ }
+
+ [Fact]
+ public async Task StoreAsync_WithServer_UpdatingAnExistingEntry_ShouldNotifyTheClient()
+ {
+ await using var session = await InProcessMcpSession.StartAsync();
+ var cache = new DiagramResourceCache();
+
+ var notified = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var registration = session.Client.RegisterNotificationHandler(
+ NotificationMethods.ResourceUpdatedNotification,
+ (notification, _) =>
+ {
+ notified.TrySetResult(notification.Params?.ToString() ?? string.Empty);
+ return default;
+ });
+
+ await cache.StoreAsync("graph", SolutionPath, "text/plain", "v1", "desc",
+ session.Server, CancellationToken.None);
+ await cache.StoreAsync("graph", SolutionPath, "text/plain", "v2", "desc",
+ session.Server, CancellationToken.None);
+
+ // Adding a resource raises resources/list_changed on its own, but a regeneration keeps the
+ // same URI — only an explicit resources/updated tells the client the content is stale.
+ var payload = await notified.Task.WaitAsync(TimeSpan.FromSeconds(30));
+ payload.Should().Contain(UriFor("graph", SolutionPath));
+ }
+
+ [Fact]
+ public async Task StoreAsync_WithServer_AtCapacity_ShouldUnpublishTheEvictedResource()
+ {
+ await using var session = await InProcessMcpSession.StartAsync();
+ var cache = new DiagramResourceCache();
+
+ for (var i = 0; i < DiagramResourceCache.MaxCachedResources; i++)
+ {
+ await cache.StoreAsync("graph", $"/src/file{i}.slnx", "text/plain", $"content{i}",
+ $"desc{i}", session.Server, CancellationToken.None);
+ }
+
+ await cache.StoreAsync("graph", "/src/newest.slnx", "text/plain", "newest", "desc",
+ session.Server, CancellationToken.None);
+
+ var uris = (await session.Client.ListResourcesAsync()).Select(resource => resource.Uri).ToList();
+
+ // Eviction must reach the server's resource collection too, otherwise resources/list would
+ // keep advertising diagrams the cache can no longer serve.
+ uris.Should().HaveCount(DiagramResourceCache.MaxCachedResources);
+ uris.Should().NotContain(UriFor("graph", "/src/file0.slnx"));
+ uris.Should().Contain(UriFor("graph", "/src/newest.slnx"));
+ }
+
+ [Fact]
+ public async Task StoreAsync_WithServer_DifferentTypesForOnePath_ShouldPublishBoth()
+ {
+ await using var session = await InProcessMcpSession.StartAsync();
+ var cache = new DiagramResourceCache();
+
+ await cache.StoreAsync("graph", SolutionPath, "text/plain", "graph content", "Graph",
+ session.Server, CancellationToken.None);
+ await cache.StoreAsync("class", SolutionPath, "text/plain", "class content", "Class",
+ session.Server, CancellationToken.None);
+
+ var uris = (await session.Client.ListResourcesAsync()).Select(resource => resource.Uri).ToList();
+
+ uris.Should().HaveCount(2);
+ uris.Should().Contain(UriFor("graph", SolutionPath));
+ uris.Should().Contain(UriFor("class", SolutionPath));
+ }
+
+ [Fact]
+ public async Task ReadForResource_WhenTheCacheEntryIsGone_ShouldThrowMcpException()
+ {
+ // A published McpServerResource is removed from the server collection outside the cache
+ // lock, so a read can still arrive after its entry was evicted. The reader must fail with a
+ // clear not-found error rather than serving another entry's content.
+ var cache = new DiagramResourceCache();
+ await cache.StoreAsync("graph", SolutionPath, "text/plain", "content", "desc",
+ null, CancellationToken.None);
+
+ var readForResource = typeof(DiagramResourceCache)
+ .GetMethod("ReadForResource", BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ var act = () => _ = readForResource.Invoke(cache, [UriFor("graph", "/src/evicted.slnx")]);
+
+ act.Should().Throw()
+ .WithInnerException()
+ .WithMessage("*Resource not found*");
+ }
+
+ [Fact]
+ public async Task ReadForResource_ForALiveEntry_ShouldPromoteItInTheLruOrder()
+ {
+ // resources/read goes through ReadForResource rather than TryRead, so it must apply the
+ // same LRU promotion — otherwise reading a diagram would not protect it from eviction.
+ await using var session = await InProcessMcpSession.StartAsync();
+ var cache = new DiagramResourceCache();
+
+ for (var i = 0; i < DiagramResourceCache.MaxCachedResources; i++)
+ {
+ await cache.StoreAsync("graph", $"/src/file{i}.slnx", "text/plain", $"content{i}",
+ $"desc{i}", session.Server, CancellationToken.None);
+ }
+
+ var file0 = (await session.Client.ListResourcesAsync())
+ .Single(resource => resource.Uri == UriFor("graph", "/src/file0.slnx"));
+ await file0.ReadAsync();
+
+ await cache.StoreAsync("graph", "/src/newest.slnx", "text/plain", "newest", "desc",
+ session.Server, CancellationToken.None);
+
+ cache.TryRead(UriFor("graph", "/src/file0.slnx")).Should()
+ .Be("content0", "file0 was just read and must not be the eviction victim");
+ cache.TryRead(UriFor("graph", "/src/file1.slnx")).Should()
+ .BeNull("file1 became the least recently used entry once file0 was promoted");
+ }
+}
diff --git a/tests/ProjGraph.Tests.Integration.Mcp/Helpers/InProcessMcpSession.cs b/tests/ProjGraph.Tests.Integration.Mcp/Helpers/InProcessMcpSession.cs
new file mode 100644
index 0000000..82f6472
--- /dev/null
+++ b/tests/ProjGraph.Tests.Integration.Mcp/Helpers/InProcessMcpSession.cs
@@ -0,0 +1,140 @@
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.IO.Pipelines;
+
+namespace ProjGraph.Tests.Integration.Mcp.Helpers;
+
+///
+/// Hosts a real and a real connected to each other
+/// over an in-memory duplex pipe pair. Unlike McpTestHelper (which hand-wires the tools with
+/// a server), this gives tests a live — with a
+/// resource collection, client capabilities established by the initialize handshake, and working
+/// server→client requests/notifications — without spawning the server process.
+///
+internal sealed class InProcessMcpSession : IAsyncDisposable
+{
+ private readonly Task _serverLoop;
+
+ private InProcessMcpSession(McpServer server, McpClient client, Task serverLoop)
+ {
+ Server = server;
+ Client = client;
+ _serverLoop = serverLoop;
+ }
+
+ public McpServer Server { get; }
+
+ public McpClient Client { get; }
+
+ public static async Task StartAsync(
+ McpServerOptions? serverOptions = null,
+ McpClientOptions? clientOptions = null)
+ {
+ var clientToServer = new Pipe();
+ var serverToClient = new Pipe();
+
+ var serverTransport = new StreamServerTransport(
+ clientToServer.Reader.AsStream(),
+ serverToClient.Writer.AsStream(),
+ "in-process");
+
+ var server = McpServer.Create(serverTransport, serverOptions ?? CreateServerOptions());
+ var serverLoop = server.RunAsync(CancellationToken.None);
+
+ var clientTransport = new StreamClientTransport(
+ clientToServer.Writer.AsStream(),
+ serverToClient.Reader.AsStream());
+
+ var client = await McpClient.CreateAsync(clientTransport, clientOptions);
+ return new InProcessMcpSession(server, client, serverLoop);
+ }
+
+ ///
+ /// Server options mirroring how Program configures the real server: a mutable resource
+ /// collection plus the resource capabilities the diagram cache relies on.
+ ///
+ public static McpServerOptions CreateServerOptions()
+ {
+ return new McpServerOptions
+ {
+ ServerInfo = new Implementation
+ {
+ Name = "ProjGraph.Tests",
+ Version = "1.0.0"
+ },
+ ResourceCollection = [],
+ Capabilities = new ServerCapabilities
+ {
+ Resources = new ResourcesCapability
+ {
+ ListChanged = true,
+ Subscribe = true
+ }
+ }
+ };
+ }
+
+ ///
+ /// Client options advertising the workspace-roots capability and serving the roots produced by
+ /// , which is re-invoked on every roots/list request so a
+ /// test can change the roots mid-session.
+ ///
+ /// Supplies the workspace root directories for each request.
+ /// Client options that answer roots/list from .
+ public static McpClientOptions CreateClientOptionsWithRoots(Func> rootProvider)
+ {
+ return new McpClientOptions
+ {
+ ClientInfo = new Implementation
+ {
+ Name = "ProjGraph.Tests",
+ Version = "1.0.0"
+ },
+ Capabilities = new ClientCapabilities
+ {
+ Roots = new RootsCapability
+ {
+ ListChanged = true
+ }
+ },
+ Handlers = new McpClientHandlers
+ {
+ RootsHandler = (_, _) => ValueTask.FromResult(new ListRootsResult
+ {
+ Roots = [.. rootProvider().Select(directory => new Root
+ {
+ Uri = new Uri(directory).AbsoluteUri
+ })]
+ })
+ }
+ };
+ }
+
+ ///
+ /// Client options that deliberately advertise no capabilities at all — in particular no
+ /// workspace roots, so relative paths cannot be resolved.
+ ///
+ public static McpClientOptions CreateClientOptionsWithoutRoots()
+ {
+ return new McpClientOptions
+ {
+ ClientInfo = new Implementation
+ {
+ Name = "ProjGraph.Tests",
+ Version = "1.0.0"
+ },
+ Capabilities = new ClientCapabilities()
+ };
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Client.DisposeAsync();
+ await Server.DisposeAsync();
+
+ // The loop ends once the transport is torn down. Observe its outcome without awaiting it,
+ // so an expected teardown fault is not raised later as an unobserved task exception.
+ _ = _serverLoop.ContinueWith(static loop => _ = loop.Exception, TaskScheduler.Default);
+ }
+}
diff --git a/tests/ProjGraph.Tests.Integration.Mcp/WorkspaceRootServiceTests.cs b/tests/ProjGraph.Tests.Integration.Mcp/WorkspaceRootServiceTests.cs
new file mode 100644
index 0000000..f90d8ea
--- /dev/null
+++ b/tests/ProjGraph.Tests.Integration.Mcp/WorkspaceRootServiceTests.cs
@@ -0,0 +1,262 @@
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using ProjGraph.Lib.Core.Abstractions;
+using ProjGraph.Lib.Core.Infrastructure;
+using ProjGraph.Mcp;
+using ProjGraph.Tests.Integration.Mcp.Helpers;
+using ProjGraph.Tests.Shared.Helpers;
+using System.Reflection;
+
+namespace ProjGraph.Tests.Integration.Mcp;
+
+///
+/// Covers the parts of that need a live
+/// : negotiating the client's roots capability,
+/// fetching the roots over roots/list, and invalidating them when the client reports a
+/// change. McpRootsTests covers the pure path-matching logic.
+///
+public sealed class WorkspaceRootServiceTests : IDisposable
+{
+ private readonly TestDirectory _temp = new();
+
+ [Fact]
+ public async Task TryResolveAsync_ClientWithoutRootsCapability_ShouldAskForAnAbsolutePath()
+ {
+ await using var session = await InProcessMcpSession.StartAsync(
+ clientOptions: InProcessMcpSession.CreateClientOptionsWithoutRoots());
+ await using var service = new WorkspaceRootService(new PhysicalFileSystem());
+
+ var act = async () => await service.TryResolveAsync("App.slnx", session.Server, CancellationToken.None);
+
+ // McpException so the guidance survives the SDK's tool boundary instead of being replaced
+ // by a generic "An error occurred invoking …".
+ (await act.Should().ThrowAsync())
+ .WithMessage("*does not support workspace roots*absolute path*");
+ }
+
+ [Fact]
+ public async Task TryResolveAsync_ClientWithRoots_ShouldResolveARelativePathAgainstTheRoot()
+ {
+ var filePath = _temp.CreateFile("App.slnx", "");
+ await using var session = await InProcessMcpSession.StartAsync(
+ clientOptions: InProcessMcpSession.CreateClientOptionsWithRoots(() => [_temp.DirectoryPath]));
+ await using var service = new WorkspaceRootService(new PhysicalFileSystem());
+
+ var resolved = await service.TryResolveAsync("App.slnx", session.Server, CancellationToken.None);
+
+ resolved.Should().Be(filePath);
+ }
+
+ [Fact]
+ public async Task TryResolveAsync_ClientWithMultipleRoots_ShouldSearchAllOfThem()
+ {
+ using var secondRoot = new TestDirectory();
+ var filePath = secondRoot.CreateFile(Path.Combine("nested", "Deep.slnx"), "");
+
+ await using var session = await InProcessMcpSession.StartAsync(
+ clientOptions: InProcessMcpSession.CreateClientOptionsWithRoots(
+ () => [_temp.DirectoryPath, secondRoot.DirectoryPath]));
+ await using var service = new WorkspaceRootService(new PhysicalFileSystem());
+
+ var resolved = await service.TryResolveAsync("Deep.slnx", session.Server, CancellationToken.None);
+
+ resolved.Should().Be(filePath);
+ }
+
+ [Fact]
+ public async Task TryResolveAsync_ConcurrentFirstCalls_ShouldFetchTheRootsOnlyOnce()
+ {
+ var filePath = _temp.CreateFile("App.slnx", "");
+ var rootsRequests = 0;
+
+ await using var session = await InProcessMcpSession.StartAsync(
+ clientOptions: InProcessMcpSession.CreateClientOptionsWithRoots(() =>
+ {
+ Interlocked.Increment(ref rootsRequests);
+ return [_temp.DirectoryPath];
+ }));
+ await using var service = new WorkspaceRootService(new PhysicalFileSystem());
+
+ var resolved = await Task.WhenAll(Enumerable.Range(0, 8)
+ .Select(_ => service.TryResolveAsync("App.slnx", session.Server, CancellationToken.None)));
+
+ // Initialization is guarded by a semaphore plus a re-check, so concurrent tool invocations
+ // must not each issue their own roots/list round trip.
+ resolved.Should().AllBe(filePath);
+ Volatile.Read(ref rootsRequests).Should().Be(1);
+ }
+
+ [Fact]
+ public async Task TryResolveAsync_AfterInvalidateRoots_ShouldRefetchWithoutReRegisteringTheHandler()
+ {
+ var filePath = _temp.CreateFile("App.slnx", "");
+ var rootsRequests = 0;
+
+ await using var session = await InProcessMcpSession.StartAsync(
+ clientOptions: InProcessMcpSession.CreateClientOptionsWithRoots(() =>
+ {
+ Interlocked.Increment(ref rootsRequests);
+ return [_temp.DirectoryPath];
+ }));
+ await using var service = new WorkspaceRootService(new PhysicalFileSystem());
+
+ await service.TryResolveAsync("App.slnx", session.Server, CancellationToken.None);
+ var firstRegistration = GetRootsChangedRegistration(service);
+
+ service.InvalidateRoots();
+ var resolved = await service.TryResolveAsync("App.slnx", session.Server, CancellationToken.None);
+
+ resolved.Should().Be(filePath);
+ Volatile.Read(ref rootsRequests).Should().Be(2, "invalidation must force a fresh roots/list");
+ GetRootsChangedRegistration(service).Should().BeSameAs(firstRegistration,
+ "the roots/list_changed handler is registered once per session, not once per refresh");
+ }
+
+ [Fact]
+ public async Task RootsListChangedNotification_ShouldInvalidateTheCachedRoots()
+ {
+ using var secondRoot = new TestDirectory();
+ var movedFile = secondRoot.CreateFile("Moved.slnx", "");
+
+ var currentRoots = new List { _temp.DirectoryPath };
+ await using var session = await InProcessMcpSession.StartAsync(
+ clientOptions: InProcessMcpSession.CreateClientOptionsWithRoots(
+ () => [.. Volatile.Read(ref currentRoots)]));
+ await using var service = new WorkspaceRootService(new PhysicalFileSystem());
+
+ _temp.CreateFile("App.slnx", "");
+ await service.TryResolveAsync("App.slnx", session.Server, CancellationToken.None);
+
+ // The workspace switches to a different folder and the client announces it.
+ Volatile.Write(ref currentRoots, [secondRoot.DirectoryPath]);
+ await session.Client.SendNotificationAsync(NotificationMethods.RootsListChangedNotification);
+
+ await WaitForRootsInvalidationAsync(service);
+ var resolved = await service.TryResolveAsync("Moved.slnx", session.Server, CancellationToken.None);
+
+ resolved.Should().Be(movedFile, "the notification must drop the stale roots so the new one is used");
+ }
+
+ [Fact]
+ public async Task DisposeAsync_AfterTheRootsHandlerWasRegistered_ShouldNotThrow()
+ {
+ _temp.CreateFile("App.slnx", "");
+ await using var session = await InProcessMcpSession.StartAsync(
+ clientOptions: InProcessMcpSession.CreateClientOptionsWithRoots(() => [_temp.DirectoryPath]));
+ var service = new WorkspaceRootService(new PhysicalFileSystem());
+
+ await service.TryResolveAsync("App.slnx", session.Server, CancellationToken.None);
+ GetRootsChangedRegistration(service).Should().NotBeNull();
+
+ var act = async () => await service.DisposeAsync();
+
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public void ResolveMatches_BareDirectoryNameInASubdirectory_ShouldResolveRecursively()
+ {
+ // The direct root+relative combine cannot find it, so this exercises the recursive search's
+ // directory branch (as opposed to its file branch).
+ var service = new WorkspaceRootService(new PhysicalFileSystem());
+ var nested = Directory.CreateDirectory(
+ Path.Combine(_temp.DirectoryPath, "src", "Domain", "Models")).FullName;
+
+ var matches = service.ResolveMatches([_temp.DirectoryPath], "Models");
+
+ matches.Should().ContainSingle().Which.Should().Be(nested);
+ }
+
+ [Fact]
+ public void ResolveMatches_InaccessibleDirectory_ShouldBeSkippedRatherThanFailTheSearch()
+ {
+ using var accessibleRoot = new TestDirectory();
+ var target = accessibleRoot.CreateFile(Path.Combine("nested", "Target.slnx"), "");
+
+ // The first root's only subdirectory cannot be listed; the search must keep going instead
+ // of surfacing the access failure to the client.
+ var blocked = Directory.CreateDirectory(Path.Combine(_temp.DirectoryPath, "restricted")).FullName;
+ var fileSystem = new BlockedDirectoryFileSystem(new PhysicalFileSystem(), blocked);
+ var service = new WorkspaceRootService(fileSystem);
+
+ var matches = service.ResolveMatches([_temp.DirectoryPath, accessibleRoot.DirectoryPath], "Target.slnx");
+
+ matches.Should().ContainSingle().Which.Should().Be(target);
+ }
+
+ public void Dispose()
+ {
+ _temp.Dispose();
+ }
+
+ private static object? GetRootsChangedRegistration(WorkspaceRootService service)
+ {
+ return typeof(WorkspaceRootService)
+ .GetField("_rootsChangedRegistration", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .GetValue(service);
+ }
+
+ private static async Task WaitForRootsInvalidationAsync(WorkspaceRootService service)
+ {
+ var statusField = typeof(WorkspaceRootService)
+ .GetField("_status", BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ while (statusField.GetValue(service)!.ToString() != "Unknown")
+ {
+ timeout.Token.ThrowIfCancellationRequested();
+ await Task.Delay(20, timeout.Token);
+ }
+ }
+
+ ///
+ /// A file system that behaves like the real one except that listing files in one specific
+ /// directory fails the way an unreadable directory would.
+ ///
+ /// The real file system every other operation delegates to.
+ /// The directory whose file listing throws.
+ private sealed class BlockedDirectoryFileSystem(IFileSystem inner, string blockedDirectory) : IFileSystem
+ {
+ public string[] GetFiles(string path, string searchPattern)
+ {
+ if (string.Equals(path, blockedDirectory, StringComparison.Ordinal))
+ {
+ throw new UnauthorizedAccessException($"Access to '{path}' is denied.");
+ }
+
+ return inner.GetFiles(path, searchPattern);
+ }
+
+ public bool FileExists(string path) => inner.FileExists(path);
+
+ public string ReadAllText(string path) => inner.ReadAllText(path);
+
+ public string GetFullPath(string path) => inner.GetFullPath(path);
+
+ public string? GetDirectoryName(string path) => inner.GetDirectoryName(path);
+
+ public string GetExtension(string path) => inner.GetExtension(path);
+
+ public string Combine(params string[] paths) => inner.Combine(paths);
+
+ public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default)
+ => inner.ReadAllTextAsync(path, cancellationToken);
+
+ public Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default)
+ => inner.WriteAllTextAsync(path, contents, cancellationToken);
+
+ public void CreateDirectory(string path) => inner.CreateDirectory(path);
+
+ public bool DirectoryExists(string path) => inner.DirectoryExists(path);
+
+ public string[] GetDirectories(string path) => inner.GetDirectories(path);
+
+ public IEnumerable EnumerateFiles(string path, string searchPattern, EnumerationOptions options)
+ => inner.EnumerateFiles(path, searchPattern, options);
+
+ public IEnumerable EnumerateDirectories(string path, string searchPattern, EnumerationOptions options)
+ => inner.EnumerateDirectories(path, searchPattern, options);
+
+ public string GetCurrentDirectory() => inner.GetCurrentDirectory();
+ }
+}
diff --git a/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassDiagramCoverageTests.cs b/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassDiagramCoverageTests.cs
new file mode 100644
index 0000000..ed12832
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassDiagramCoverageTests.cs
@@ -0,0 +1,487 @@
+using Microsoft.CodeAnalysis;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using ProjGraph.Core.Models;
+using ProjGraph.Lib.ClassDiagram.Application;
+using ProjGraph.Lib.ClassDiagram.Application.UseCases;
+using ProjGraph.Lib.ClassDiagram.Infrastructure;
+using ProjGraph.Lib.ClassDiagram.Rendering;
+using ProjGraph.Lib.Core.Abstractions;
+using ProjGraph.Tests.Shared.Helpers;
+using TypeKind = ProjGraph.Core.Models.TypeKind;
+
+namespace ProjGraph.Tests.Unit.ClassDiagram;
+
+///
+/// Error-path and fallback coverage across the class-diagram pipeline: unreadable directories and
+/// files during discovery, symbols that cannot be resolved to a declaration, system types that must
+/// never become diagram nodes, and renderer defaults for out-of-range enum values.
+///
+[Trait("Category", "Unit")]
+public sealed class ClassDiagramCoverageTests
+{
+ private readonly IFileSystem _fileSystem = Substitute.For();
+ private readonly IWorkspaceTypeDiscovery _discovery = Substitute.For();
+
+ private static AnalysisContext CreateContext(Microsoft.CodeAnalysis.CSharp.CSharpCompilation compilation)
+ {
+ return new AnalysisContext
+ {
+ AnalyzedTypeFullNames = [],
+ Types = [],
+ Relationships = [],
+ Compilation = compilation,
+ StartDirectory = "/nonexistent"
+ };
+ }
+
+ // ---------------------------------------------------------------------
+ // DiscoverCsFilesUseCase — unreadable directories
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [MemberData(nameof(DirectoryReadFailures))]
+ public void DiscoverCsFiles_UnreadableSubdirectory_ShouldBeSkippedWithoutFailingTheScan(Exception failure)
+ {
+ // A single permission-denied or transient IO failure deep in a tree must not abort the
+ // whole discovery pass; the readable files found so far still have to be returned.
+ const string root = "/root";
+ const string denied = "/root/denied";
+ _fileSystem.DirectoryExists(root).Returns(true);
+ _fileSystem.GetFullPath(root).Returns(root);
+ _fileSystem.GetFiles(root, "*.cs").Returns(["/root/Ok.cs"]);
+ _fileSystem.GetDirectories(root).Returns([denied]);
+ _fileSystem.GetFiles(denied, "*.cs").Throws(failure);
+ var sut = new DiscoverCsFilesUseCase(_fileSystem);
+
+ var result = sut.Execute(root);
+
+ result.Should().BeEquivalentTo("/root/Ok.cs");
+ }
+
+ public static TheoryData DirectoryReadFailures => new()
+ {
+ new UnauthorizedAccessException("denied"),
+ new IOException("device not ready")
+ };
+
+ [Fact]
+ public void DiscoverCsFiles_UnreadableSubdirectory_ShouldNotStopSiblingDirectories()
+ {
+ // The failing directory must not shadow its siblings that are still enumerable.
+ const string root = "/root";
+ const string denied = "/root/denied";
+ const string ok = "/root/ok";
+ _fileSystem.DirectoryExists(root).Returns(true);
+ _fileSystem.GetFullPath(root).Returns(root);
+ _fileSystem.GetFiles(root, "*.cs").Returns([]);
+ _fileSystem.GetDirectories(root).Returns([denied, ok]);
+ _fileSystem.GetFiles(denied, "*.cs").Throws(new UnauthorizedAccessException("denied"));
+ _fileSystem.GetFiles(ok, "*.cs").Returns(["/root/ok/Sibling.cs"]);
+ _fileSystem.GetDirectories(ok).Returns([]);
+ var sut = new DiscoverCsFilesUseCase(_fileSystem);
+
+ var result = sut.Execute(root);
+
+ result.Should().BeEquivalentTo("/root/ok/Sibling.cs");
+ }
+
+ // ---------------------------------------------------------------------
+ // SymbolResolver — declaration lookup fallbacks
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task ResolveRelatedSymbol_DiscoveredFileAlreadyInCompilation_ShouldNotReReadItFromDisk()
+ {
+ // When workspace discovery points at a file whose tree is already in the compilation, the
+ // resolver must reuse that tree instead of re-reading and re-parsing the same source.
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace MyApp; public class Service : Ghost { }");
+ var ghost = RoslynTestHelper.GetTypeSymbol(compilation, "Service")!.BaseType!;
+ ghost.TypeKind.Should().Be(Microsoft.CodeAnalysis.TypeKind.Error);
+ _discovery.FindTypeDefinitionFileAsync("Ghost", Arg.Any())
+ .Returns(Task.FromResult("Test0.cs"));
+ var sut = new SymbolResolver(_discovery, _fileSystem);
+ var context = CreateContext(compilation);
+
+ var resolved = await sut.ResolveRelatedSymbolAsync(ghost, context);
+
+ await _fileSystem.DidNotReceive().ReadAllTextAsync(Arg.Any(), Arg.Any());
+ // The reused tree does not actually declare Ghost, so the original symbol is handed back.
+ resolved.Should().NotBeNull();
+ resolved!.Name.Should().Be("Ghost");
+ }
+
+ [Fact]
+ public async Task ResolveRelatedSymbol_DiscoveredFileWithoutMatchingDeclaration_ShouldReturnOriginalSymbol()
+ {
+ // Discovery can point at a false positive (the name appears in the file but no type with
+ // that name is declared). The resolver must degrade to the original symbol, not crash.
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace MyApp; public class Service : Ghost { }");
+ var ghost = RoslynTestHelper.GetTypeSymbol(compilation, "Service")!.BaseType!;
+ _discovery.FindTypeDefinitionFileAsync("Ghost", Arg.Any())
+ .Returns(Task.FromResult("/workspace/Decoy.cs"));
+ _fileSystem.ReadAllTextAsync("/workspace/Decoy.cs", Arg.Any())
+ .Returns(Task.FromResult("namespace MyApp; public class Unrelated { }"));
+ var sut = new SymbolResolver(_discovery, _fileSystem);
+ var context = CreateContext(compilation);
+
+ var resolved = await sut.ResolveRelatedSymbolAsync(ghost, context);
+
+ resolved.Should().NotBeNull();
+ resolved!.Name.Should().Be("Ghost");
+ }
+
+ [Fact]
+ public async Task ResolveRelatedSymbol_DiscoveredFileDeclaringTheType_ShouldResolveToThatDeclaration()
+ {
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace MyApp; public class Service : Ghost { }");
+ var ghost = RoslynTestHelper.GetTypeSymbol(compilation, "Service")!.BaseType!;
+ _discovery.FindTypeDefinitionFileAsync("Ghost", Arg.Any())
+ .Returns(Task.FromResult("/workspace/Ghost.cs"));
+ _fileSystem.ReadAllTextAsync("/workspace/Ghost.cs", Arg.Any())
+ .Returns(Task.FromResult("namespace MyApp; public class Ghost { }"));
+ var sut = new SymbolResolver(_discovery, _fileSystem);
+ var context = CreateContext(compilation);
+
+ var resolved = await sut.ResolveRelatedSymbolAsync(ghost, context);
+
+ resolved.Should().NotBeNull();
+ resolved!.Name.Should().Be("Ghost");
+ resolved.TypeKind.Should().Be(Microsoft.CodeAnalysis.TypeKind.Class);
+ }
+
+ [Fact]
+ public async Task ResolveRelatedSymbol_UnresolvedWellKnownSystemType_ShouldNotBecomeAnExternalNode()
+ {
+ // 'Task' used without its using directive binds to an error symbol. It is still a BCL type
+ // and must not be drawn as an external class node in the diagram.
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace MyApp; public class Service { public Task Work { get; set; } }");
+ var service = RoslynTestHelper.GetTypeSymbol(compilation, "Service")!;
+ var taskSymbol = (INamedTypeSymbol)service.GetMembers().OfType()
+ .First(p => p.Name == "Work").Type;
+ taskSymbol.TypeKind.Should().Be(Microsoft.CodeAnalysis.TypeKind.Error);
+ _discovery.FindTypeDefinitionFileAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(null));
+ var sut = new SymbolResolver(_discovery, _fileSystem);
+ var context = CreateContext(compilation);
+
+ var resolved = await sut.ResolveRelatedSymbolAsync(taskSymbol, context);
+
+ resolved.Should().BeNull();
+ context.Types.Should().BeEmpty();
+ }
+
+ // ---------------------------------------------------------------------
+ // TypeProcessor — system-type and unresolved-symbol handling
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task ProcessTypeQueue_SystemTypeEnqueued_ShouldNotProduceANode()
+ {
+ // System types reaching the queue (e.g. via a base-type walk) must be dropped rather than
+ // rendered as classes in the user's diagram.
+ var compilation = RoslynTestHelper.CreateCompilation("namespace MyApp; public class Service { }");
+ var resolver = Substitute.For();
+ var sut = new TypeProcessor(resolver);
+ var context = CreateContext(compilation);
+ var queue = new Queue<(INamedTypeSymbol Symbol, int Depth)>();
+ queue.Enqueue((compilation.GetSpecialType(SpecialType.System_String), 0));
+
+ await sut.ProcessTypeQueueAsync(queue, context, new AnalysisOptions(MaxDepth: 2));
+
+ context.Types.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ProcessTypeQueue_MemberTypedAsSystemType_ShouldNotCreateARelationship()
+ {
+ // Every class has string/int members; drawing an edge to each BCL type would swamp the
+ // diagram.
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace MyApp; public class Service { public string Name { get; set; } = string.Empty; }");
+ var resolver = Substitute.For();
+ resolver.ResolveRelatedSymbolAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(null));
+ var sut = new TypeProcessor(resolver);
+ var context = CreateContext(compilation);
+ var queue = new Queue<(INamedTypeSymbol Symbol, int Depth)>();
+ queue.Enqueue((RoslynTestHelper.GetTypeSymbol(compilation, "Service")!, 0));
+
+ await sut.ProcessTypeQueueAsync(queue, context,
+ new AnalysisOptions(MaxDepth: 2, IncludeDependencies: true));
+
+ context.Types.Should().ContainSingle(t => t.Name == "Service");
+ context.Relationships.Should().NotContain(r => r.To.Contains("String", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task ProcessTypeQueue_UnresolvedRelatedType_ShouldRecordEdgeButNotRecurseIntoIt()
+ {
+ // An unresolved type has no declaration to walk into. The edge is still meaningful, but
+ // enqueuing the error symbol would re-analyze a type that cannot yield members.
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace MyApp; public class Service : Ghost { }");
+ var resolver = Substitute.For();
+ resolver.ResolveRelatedSymbolAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(null));
+ var sut = new TypeProcessor(resolver);
+ var context = CreateContext(compilation);
+ var queue = new Queue<(INamedTypeSymbol Symbol, int Depth)>();
+ queue.Enqueue((RoslynTestHelper.GetTypeSymbol(compilation, "Service")!, 0));
+
+ await sut.ProcessTypeQueueAsync(queue, context,
+ new AnalysisOptions(MaxDepth: 3, IncludeInheritance: true));
+
+ context.Relationships.Should().ContainSingle(r => r.To.EndsWith("Ghost", StringComparison.Ordinal));
+ context.Types.Should().ContainSingle(t => t.Name == "Service");
+ }
+
+ // ---------------------------------------------------------------------
+ // TypeFilter — namespace attribution rules
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void IsSystemType_ErrorSymbolNamedLikeABclType_ShouldReturnTrue()
+ {
+ // An unresolved symbol has no namespace to attribute it to, so the well-known-name list is
+ // the only signal available.
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace MyApp; public class Service { public Dictionary Lookup { get; set; } }");
+ var service = RoslynTestHelper.GetTypeSymbol(compilation, "Service")!;
+ var symbol = (INamedTypeSymbol)service.GetMembers().OfType()
+ .First(p => p.Name == "Lookup").Type;
+
+ TypeFilter.IsSystemType(symbol).Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsSystemType_ErrorSymbolWithUserDefinedName_ShouldReturnFalse()
+ {
+ // Unresolved user types are exactly what workspace discovery exists to find; filtering
+ // them as "system" would silently drop them from the diagram.
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace MyApp; public class Service : Ghost { }");
+ var ghost = RoslynTestHelper.GetTypeSymbol(compilation, "Service")!.BaseType!;
+
+ TypeFilter.IsSystemType(ghost).Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsSystemType_GlobalNamespaceTypeNamedLikeABclType_ShouldReturnTrue()
+ {
+ var compilation = RoslynTestHelper.CreateCompilation("public class Exception { }");
+ var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Exception")!;
+
+ TypeFilter.IsSystemType(symbol).Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsSystemType_GlobalNamespaceUserType_ShouldReturnFalse()
+ {
+ var compilation = RoslynTestHelper.CreateCompilation("public class Widget { }");
+ var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Widget")!;
+
+ TypeFilter.IsSystemType(symbol).Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsSystemType_MicrosoftExtensionsNamespace_ShouldReturnTrue()
+ {
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace Microsoft.Extensions.Hosting; public class HostBuilder { }");
+ var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Microsoft.Extensions.Hosting.HostBuilder")!;
+
+ TypeFilter.IsSystemType(symbol).Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsSystemType_MicrosoftNamespaceOutsideExtensions_ShouldReturnFalse()
+ {
+ // Only 'Microsoft.Extensions' is treated as framework noise; other Microsoft namespaces
+ // (and user namespaces under 'Microsoft') stay in the diagram.
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "namespace Microsoft.Playground; public class Sample { }");
+ var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Microsoft.Playground.Sample")!;
+
+ TypeFilter.IsSystemType(symbol).Should().BeFalse();
+ }
+
+ // ---------------------------------------------------------------------
+ // WorkspaceTypeDiscovery — unreadable files and excluded directories
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [MemberData(nameof(DirectoryReadFailures))]
+ public async Task FindTypeDefinitionFile_UnreadableFile_ShouldBeSkippedAndScanContinue(Exception failure)
+ {
+ // One locked or permission-denied file must not abort the workspace scan, otherwise a
+ // single bad file makes every type in the workspace unresolvable.
+ // The start directory must really exist because the workspace-root walk uses the physical file
+ // system; the scan itself runs entirely against the mocked file system below.
+ using var dir = new TestDirectory();
+ var root = dir.DirectoryPath;
+ _fileSystem.EnumerateFiles(root, "*.cs", Arg.Any())
+ .Returns(["/workspace/Locked.cs", "/workspace/Ghost.cs"]);
+ _fileSystem.EnumerateDirectories(root, "*", Arg.Any()).Returns([]);
+ _fileSystem.ReadAllTextAsync("/workspace/Locked.cs", Arg.Any()).Throws(failure);
+ _fileSystem.ReadAllTextAsync("/workspace/Ghost.cs", Arg.Any())
+ .Returns(Task.FromResult("namespace MyApp; public class Ghost { }"));
+ var sut = new WorkspaceTypeDiscovery(_fileSystem);
+
+ var result = await sut.FindTypeDefinitionFileAsync("Ghost", root);
+
+ result.Should().Be("/workspace/Ghost.cs");
+ }
+
+ [Fact]
+ public async Task FindTypeDefinitionFile_ExcludedSubdirectory_ShouldNotBeScanned()
+ {
+ // Build-output directories contain generated copies of source types; scanning them would
+ // both waste time and risk resolving a type to its obj/ copy.
+ // Real start directory for the physical workspace-root walk; the scan uses the mock below.
+ using var dir = new TestDirectory();
+ var root = dir.DirectoryPath;
+ _fileSystem.EnumerateFiles(root, "*.cs", Arg.Any()).Returns([]);
+ _fileSystem.EnumerateDirectories(root, "*", Arg.Any())
+ .Returns(["/workspace/obj", "/workspace/src"]);
+ _fileSystem.EnumerateFiles("/workspace/src", "*.cs", Arg.Any())
+ .Returns(["/workspace/src/Ghost.cs"]);
+ _fileSystem.EnumerateDirectories("/workspace/src", "*", Arg.Any()).Returns([]);
+ _fileSystem.ReadAllTextAsync("/workspace/src/Ghost.cs", Arg.Any())
+ .Returns(Task.FromResult("namespace MyApp; public class Ghost { }"));
+ var sut = new WorkspaceTypeDiscovery(_fileSystem);
+
+ var result = await sut.FindTypeDefinitionFileAsync("Ghost", root);
+
+ result.Should().Be("/workspace/src/Ghost.cs");
+ _fileSystem.DidNotReceive().EnumerateFiles("/workspace/obj", Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task FindTypeDefinitionFile_NameAppearsButTypeIsNotDeclared_ShouldReturnNull()
+ {
+ // The fast substring pre-filter can match a comment or a usage; Roslyn verification must
+ // reject the file so the type is treated as external instead of bound to the wrong file.
+ // Real start directory for the physical workspace-root walk; the scan uses the mock below.
+ using var dir = new TestDirectory();
+ var root = dir.DirectoryPath;
+ _fileSystem.EnumerateFiles(root, "*.cs", Arg.Any())
+ .Returns(["/workspace/Decoy.cs"]);
+ _fileSystem.EnumerateDirectories(root, "*", Arg.Any()).Returns([]);
+ _fileSystem.ReadAllTextAsync("/workspace/Decoy.cs", Arg.Any())
+ .Returns(Task.FromResult("namespace MyApp; /* class Ghost lives elsewhere */ public class Other { }"));
+ var sut = new WorkspaceTypeDiscovery(_fileSystem);
+
+ var result = await sut.FindTypeDefinitionFileAsync("Ghost", root);
+
+ result.Should().BeNull();
+ }
+
+ // ---------------------------------------------------------------------
+ // MermaidClassDiagramRenderer — identity and enum fallbacks
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void MermaidRenderer_Format_ShouldBeMermaid()
+ {
+ new MermaidClassDiagramRenderer().Format.Should().Be("mermaid");
+ }
+
+ [Fact]
+ public void MermaidRenderer_DuplicateTypeFullName_ShouldEmitOneNodeNotACollisionSuffix()
+ {
+ // The same type can be added twice (e.g. as both a source type and a discovered relative).
+ // It must map to a single node id rather than getting a spurious '_2' suffix.
+ var model = new ClassModel(
+ "dup",
+ [
+ new TypeDefinition("User", "Models", "Models.User", TypeKind.Class, []),
+ new TypeDefinition("User", "Models", "Models.User", TypeKind.Class, [])
+ ],
+ []);
+
+ var result = new MermaidClassDiagramRenderer().Render(model);
+
+ result.Should().NotContain("Models_User_2");
+ }
+
+ [Fact]
+ public void MermaidRenderer_InternalMember_ShouldUseTildeVisibilityMarker()
+ {
+ var model = new ClassModel(
+ "vis",
+ [
+ new TypeDefinition("Cfg", "App", "App.Cfg", TypeKind.Class,
+ [
+ new MemberDefinition("Secret", "string", Visibility.Internal, MemberKind.Field)
+ ])
+ ],
+ []);
+
+ var result = new MermaidClassDiagramRenderer().Render(model);
+
+ result.Should().Contain("~string Secret");
+ }
+
+ [Fact]
+ public void MermaidRenderer_OutOfRangeVisibility_ShouldFallBackToPublicMarker()
+ {
+ // Defensive default: an unmapped visibility must still render valid Mermaid rather than
+ // emitting a stray or empty marker that breaks the diagram.
+ var model = new ClassModel(
+ "vis",
+ [
+ new TypeDefinition("Cfg", "App", "App.Cfg", TypeKind.Class,
+ [
+ new MemberDefinition("Value", "int", (Visibility)99, MemberKind.Property)
+ ])
+ ],
+ []);
+
+ var result = new MermaidClassDiagramRenderer().Render(model);
+
+ result.Should().Contain("+int Value");
+ }
+
+ [Fact]
+ public void MermaidRenderer_OutOfRangeRelationshipKind_ShouldFallBackToAssociationArrow()
+ {
+ var model = new ClassModel(
+ "rel",
+ [
+ new TypeDefinition("A", "App", "App.A", TypeKind.Class, []),
+ new TypeDefinition("B", "App", "App.B", TypeKind.Class, [])
+ ],
+ [
+ new Relationship("App.A", "App.B", (RelationshipKind)99)
+ ]);
+
+ var result = new MermaidClassDiagramRenderer().Render(model);
+
+ result.Should().Contain("App_A --> App_B");
+ }
+
+ [Fact]
+ public void MermaidRenderer_RelationshipToTypeOutsideTheModel_ShouldStillEmitASanitizedEndpoint()
+ {
+ // Relationship endpoints are not guaranteed to be present in Types — a filtered node, say — so
+ // the renderer must fall back to plain sanitization instead of dropping the edge.
+ var model = new ClassModel(
+ "rel",
+ [
+ new TypeDefinition("A", "App", "App.A", TypeKind.Class, [])
+ ],
+ [
+ new Relationship("App.A", "External.Thing", RelationshipKind.Dependency)
+ ]);
+
+ var result = new MermaidClassDiagramRenderer().Render(model);
+
+ result.Should().Contain("App_A ..> External_Thing");
+ }
+}
diff --git a/tests/ProjGraph.Tests.Unit.Core/CompilationFactoryCoverageTests.cs b/tests/ProjGraph.Tests.Unit.Core/CompilationFactoryCoverageTests.cs
new file mode 100644
index 0000000..c4ae4fd
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.Core/CompilationFactoryCoverageTests.cs
@@ -0,0 +1,114 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using ProjGraph.Lib.Core.Infrastructure;
+
+namespace ProjGraph.Tests.Unit.Core;
+
+///
+/// Coverage for the compilation options and metadata-reference set produced by
+/// . The analyzers downstream depend on the nullable context being
+/// enabled, on collection interfaces binding, and on the reference set being shared across calls.
+///
+[Trait("Category", "Unit")]
+public sealed class CompilationFactoryCoverageTests
+{
+ private readonly CompilationFactory _sut = new();
+
+ [Fact]
+ public void CreateCompilation_ShouldEnableNullableContextWithoutPerFileDirective()
+ {
+ // The factory enables the nullable context globally. Without it, source that omits
+ // '#nullable enable' would bind '?' annotations with warnings and the analyzers would see
+ // different nullability than the real project build.
+ var tree = CSharpSyntaxTree.ParseText(
+ """
+ namespace Test;
+ public class Holder
+ {
+ public string? Maybe { get; set; }
+ }
+ """);
+
+ var compilation = _sut.CreateCompilation([tree]);
+ var symbol = compilation.GetTypeByMetadataName("Test.Holder")!;
+ var property = symbol.GetMembers("Maybe").OfType().Single();
+
+ property.Type.NullableAnnotation.Should().Be(NullableAnnotation.Annotated);
+ }
+
+ [Fact]
+ public void CreateCompilation_ShouldProduceDynamicallyLinkedLibraryWithoutEntryPoint()
+ {
+ // A library output kind means no entry point is required; a console output kind would make
+ // every entry-point-less analysis target report CS5001.
+ var tree = CSharpSyntaxTree.ParseText("namespace Test; public class NoMain { }");
+
+ var compilation = _sut.CreateCompilation([tree]);
+
+ compilation.Options.OutputKind.Should().Be(OutputKind.DynamicallyLinkedLibrary);
+ compilation.GetDiagnostics().Should().NotContain(d => d.Id == "CS5001");
+ }
+
+ [Fact]
+ public void CreateCompilation_ShouldResolveCollectionInterfaces()
+ {
+ // Relationship analysis classifies collection-typed members by binding ICollection and
+ // IEnumerable; those references must be present in the reference set.
+ var tree = CSharpSyntaxTree.ParseText(
+ """
+ using System.Collections.Generic;
+ namespace Test;
+ public class Bag
+ {
+ public ICollection Items { get; set; } = [];
+ public IEnumerable Numbers { get; set; } = [];
+ public List Names { get; set; } = [];
+ }
+ """);
+
+ var compilation = _sut.CreateCompilation([tree]);
+
+ compilation.GetDiagnostics().Should().NotContain(d => d.Severity == DiagnosticSeverity.Error);
+ }
+
+ [Fact]
+ public void CreateCompilation_ShouldBindTypesAcrossSeparateSyntaxTrees()
+ {
+ // Directory analysis feeds one tree per file; a type in one file must resolve a base type
+ // declared in another.
+ var baseTree = CSharpSyntaxTree.ParseText("namespace Test; public class Animal { }");
+ var derivedTree = CSharpSyntaxTree.ParseText("namespace Test; public class Dog : Animal { }");
+
+ var compilation = _sut.CreateCompilation([baseTree, derivedTree]);
+ var dog = compilation.GetTypeByMetadataName("Test.Dog")!;
+
+ dog.BaseType!.Name.Should().Be("Animal");
+ dog.BaseType.TypeKind.Should().NotBe(TypeKind.Error);
+ }
+
+ [Fact]
+ public void CreateCompilation_CalledTwice_ShouldReuseTheSameCachedReferenceSet()
+ {
+ // The reference set is built once per process and reused; rebuilding it would re-read
+ // several assemblies from disk on every analysis.
+ var first = _sut.CreateCompilation([]);
+ var second = _sut.CreateCompilation([]);
+
+ second.References.Should().BeEquivalentTo(first.References);
+ second.References.Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public void CreateCompilation_UnresolvableType_ShouldYieldErrorSymbolRatherThanThrow()
+ {
+ // Unresolved types are the normal case for workspace discovery: the factory must surface
+ // them as error symbols so the resolver can search for their definition.
+ var tree = CSharpSyntaxTree.ParseText("namespace Test; public class Service : Ghost { }");
+
+ var compilation = _sut.CreateCompilation([tree]);
+ var service = compilation.GetTypeByMetadataName("Test.Service")!;
+
+ service.BaseType!.TypeKind.Should().Be(TypeKind.Error);
+ service.BaseType.Name.Should().Be("Ghost");
+ }
+}
diff --git a/tests/ProjGraph.Tests.Unit.Core/OutputConsoleCoverageTests.cs b/tests/ProjGraph.Tests.Unit.Core/OutputConsoleCoverageTests.cs
new file mode 100644
index 0000000..9d2d1bd
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.Core/OutputConsoleCoverageTests.cs
@@ -0,0 +1,230 @@
+using ProjGraph.Lib.Core.Infrastructure;
+using Spectre.Console;
+
+namespace ProjGraph.Tests.Unit.Core;
+
+///
+/// Coverage for write paths and status handling.
+/// Diagnostic messages (info/error/warning/success) must go to standard error so that piped
+/// standard output stays machine-readable, and markup metacharacters in user-supplied text must
+/// be escaped rather than interpreted.
+///
+[Trait("Category", "Unit")]
+public sealed class OutputConsoleCoverageTests
+{
+ private readonly SpectreOutputConsole _sut = new();
+
+ ///
+ /// Runs an action with redirected to an in-memory,
+ /// colour-free console and returns everything written to it.
+ ///
+ /// The action to run.
+ /// The captured standard-output text.
+ private static string CaptureStandardOutput(Action action)
+ {
+ var original = AnsiConsole.Console;
+ var writer = new StringWriter();
+ try
+ {
+ AnsiConsole.Console = CreatePlainConsole(writer);
+ action();
+ return writer.ToString();
+ }
+ finally
+ {
+ AnsiConsole.Console = original;
+ }
+ }
+
+ ///
+ /// Runs an action with redirected and returns everything written
+ /// to it. is redirected too so no output escapes to the real
+ /// terminal and the stderr console inherits colour-free capabilities.
+ ///
+ /// The action to run.
+ /// The captured standard-error text.
+ private static string CaptureStandardError(Action action)
+ {
+ var originalError = Console.Error;
+ var originalConsole = AnsiConsole.Console;
+ var errorWriter = new StringWriter();
+ var outWriter = new StringWriter();
+ try
+ {
+ Console.SetError(errorWriter);
+ AnsiConsole.Console = CreatePlainConsole(outWriter);
+ action();
+ return errorWriter.ToString();
+ }
+ finally
+ {
+ AnsiConsole.Console = originalConsole;
+ Console.SetError(originalError);
+ }
+ }
+
+ private static IAnsiConsole CreatePlainConsole(TextWriter writer)
+ {
+ return AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Ansi = AnsiSupport.No,
+ ColorSystem = ColorSystemSupport.NoColors,
+ Interactive = InteractionSupport.No,
+ Out = new AnsiConsoleOutput(writer)
+ });
+ }
+
+ [Fact]
+ public void Write_ShouldWriteMessageToStandardOutputWithoutNewline()
+ {
+ var output = CaptureStandardOutput(() => _sut.Write("payload"));
+
+ output.Should().Be("payload");
+ }
+
+ [Fact]
+ public void WriteLine_ShouldWriteMessageToStandardOutputWithNewline()
+ {
+ var output = CaptureStandardOutput(() => _sut.WriteLine("payload"));
+
+ output.Should().Contain("payload");
+ output.Should().EndWith(Environment.NewLine);
+ }
+
+ [Fact]
+ public void WriteMarkup_ShouldInterpretMarkupTags()
+ {
+ // WriteMarkup is the explicit opt-in for pre-formatted text: the tags must be consumed as
+ // styling rather than emitted literally.
+ var output = CaptureStandardOutput(() => _sut.WriteMarkup("[bold]styled[/]"));
+
+ output.Should().Contain("styled");
+ output.Should().NotContain("[bold]");
+ }
+
+ [Fact]
+ public void WriteInfo_ShouldWriteToStandardErrorNotStandardOutput()
+ {
+ var error = CaptureStandardError(() => _sut.WriteInfo("informational"));
+
+ error.Should().Contain("informational");
+ }
+
+ [Fact]
+ public void WriteError_ShouldWriteToStandardErrorWithErrorPrefix()
+ {
+ var error = CaptureStandardError(() => _sut.WriteError("boom"));
+
+ error.Should().Contain("Error: boom");
+ }
+
+ [Fact]
+ public void WriteWarning_ShouldWriteToStandardErrorWithWarningPrefix()
+ {
+ var error = CaptureStandardError(() => _sut.WriteWarning("careful"));
+
+ error.Should().Contain("Warning: careful");
+ }
+
+ [Fact]
+ public void WriteSuccess_ShouldWriteToStandardError()
+ {
+ var error = CaptureStandardError(() => _sut.WriteSuccess("all good"));
+
+ error.Should().Contain("all good");
+ }
+
+ [Theory]
+ [InlineData("[not markup]")]
+ [InlineData("value [/] end")]
+ public void WriteError_MessageContainingMarkupMetacharacters_ShouldBeEscapedNotInterpreted(string message)
+ {
+ // Failure messages routinely contain paths and generic type names with square brackets.
+ // They must survive verbatim instead of being parsed as markup (or throwing).
+ var error = CaptureStandardError(() => _sut.WriteError(message));
+
+ error.Should().Contain(message);
+ }
+
+ [Fact]
+ public void WriteWarning_MessageContainingMarkupMetacharacters_ShouldBeEscapedNotInterpreted()
+ {
+ const string message = "skipped Foo[T].csproj";
+
+ var error = CaptureStandardError(() => _sut.WriteWarning(message));
+
+ error.Should().Contain(message);
+ }
+
+ [Fact]
+ public void WriteSuccess_MessageContainingMarkupMetacharacters_ShouldBeEscapedNotInterpreted()
+ {
+ const string message = "wrote [output].md";
+
+ var error = CaptureStandardError(() => _sut.WriteSuccess(message));
+
+ error.Should().Contain(message);
+ }
+
+ [Fact]
+ public async Task RunWithStatusAsync_ShouldExecuteAction()
+ {
+ var executed = false;
+ var original = AnsiConsole.Console;
+ var writer = new StringWriter();
+ try
+ {
+ AnsiConsole.Console = CreatePlainConsole(writer);
+
+ await _sut.RunWithStatusAsync("Working...", () =>
+ {
+ executed = true;
+ return Task.CompletedTask;
+ });
+ }
+ finally
+ {
+ AnsiConsole.Console = original;
+ }
+
+ executed.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task RunWithStatusAsync_AlreadyCancelledToken_ShouldThrowWithoutRunningAction()
+ {
+ var executed = false;
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ var act = () => _sut.RunWithStatusAsync("Working...", () =>
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }, cts.Token);
+
+ await act.Should().ThrowAsync();
+ executed.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task RunWithStatusAsync_ActionThrows_ShouldPropagateException()
+ {
+ var original = AnsiConsole.Console;
+ var writer = new StringWriter();
+ try
+ {
+ AnsiConsole.Console = CreatePlainConsole(writer);
+
+ var act = () => _sut.RunWithStatusAsync("Working...",
+ () => throw new InvalidOperationException("inner failure"));
+
+ await act.Should().ThrowAsync()
+ .WithMessage("inner failure");
+ }
+ finally
+ {
+ AnsiConsole.Console = original;
+ }
+ }
+}
diff --git a/tests/ProjGraph.Tests.Unit.Core/ProjectParserCoverageTests.cs b/tests/ProjGraph.Tests.Unit.Core/ProjectParserCoverageTests.cs
new file mode 100644
index 0000000..b6ee318
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.Core/ProjectParserCoverageTests.cs
@@ -0,0 +1,355 @@
+using ProjGraph.Core.Exceptions;
+using ProjGraph.Core.Models;
+using ProjGraph.Lib.Core.Infrastructure;
+using ProjGraph.Lib.Core.Parsers;
+using ProjGraph.Tests.Shared.Helpers;
+
+namespace ProjGraph.Tests.Unit.Core;
+
+///
+/// Error-path and fallback coverage for : malformed project and props
+/// files, MSBuild property inheritance from Directory.Build.props, and Central Package
+/// Management version resolution from Directory.Packages.props.
+///
+[Trait("Category", "Unit")]
+public sealed class ProjectParserCoverageTests : IDisposable
+{
+ private readonly TestDirectory _testDirectory = new();
+ private readonly ProjectParser _sut = new(new PhysicalFileSystem());
+
+ public void Dispose()
+ {
+ _testDirectory.Dispose();
+ }
+
+ ///
+ /// Writes a project file inside a uniquely named subdirectory so that MSBuild's global
+ /// ProjectRootElement cache never serves a sibling test's file for the same path.
+ ///
+ /// The path of the project file, relative to the test directory.
+ /// The project file XML.
+ /// The full path of the written project file.
+ private string WriteFile(string relativePath, string content)
+ {
+ return _testDirectory.CreateFile(relativePath, content);
+ }
+
+ [Fact]
+ public void Parse_MalformedProjectFile_ShouldThrowParsingException()
+ {
+ var path = WriteFile("malformed/App.csproj", "");
+
+ var act = () => _sut.Parse(path);
+
+ act.Should().Throw()
+ .WithMessage($"*{path}*");
+ }
+
+ [Fact]
+ public void Parse_MissingProjectFile_ShouldThrowParsingException()
+ {
+ var path = Path.Combine(_testDirectory.DirectoryPath, "missing", "Gone.csproj");
+
+ var act = () => _sut.Parse(path);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Parse_TargetFrameworksPlural_ShouldBeUsedWhenSingularIsAbsent()
+ {
+ var path = WriteFile("plural/App.csproj",
+ """
+
+
+ net8.0;net10.0
+
+
+ """);
+
+ var (project, _, _) = _sut.Parse(path);
+
+ project.Framework.Should().Be("net8.0;net10.0");
+ }
+
+ [Fact]
+ public void Parse_WhitespaceTargetFramework_ShouldBeTreatedAsUndefined()
+ {
+ // A whitespace-only property value counts as "not defined": it must not win over the
+ // inheritance/unknown fallback chain, otherwise the graph shows a blank framework.
+ var path = WriteFile("blank/App.csproj",
+ """
+
+
+
+
+
+ """);
+
+ var (project, _, _) = _sut.Parse(path);
+
+ project.Framework.Should().Be("unknown");
+ }
+
+ [Fact]
+ public void Parse_TargetFrameworkInDirectoryBuildProps_ShouldBeInherited()
+ {
+ WriteFile("inherit/Directory.Build.props",
+ """
+
+
+ net10.0
+
+
+ """);
+ var path = WriteFile("inherit/App.csproj", "");
+
+ var (project, _, _) = _sut.Parse(path);
+
+ project.Framework.Should().Be("net10.0");
+ }
+
+ [Fact]
+ public void Parse_MalformedDirectoryBuildProps_ShouldBeIgnoredAndFallBackToUnknown()
+ {
+ // An unreadable props file must not abort the parse; the walk continues upward and the
+ // framework degrades to "unknown" rather than throwing.
+ WriteFile("badprops/Directory.Build.props", "");
+ var path = WriteFile("badprops/App.csproj", "");
+
+ var (project, _, _) = _sut.Parse(path);
+
+ project.Framework.Should().Be("unknown");
+ }
+
+ [Fact]
+ public void Parse_NestedDirectoryBuildProps_NearestValueShouldWin()
+ {
+ // Both levels define TargetFramework. The walk is nearest-first, so the outer value must
+ // not overwrite the inner one already recorded.
+ WriteFile("nested/Directory.Build.props",
+ """
+
+
+ net6.0
+
+
+ """);
+ WriteFile("nested/inner/Directory.Build.props",
+ """
+
+
+ net10.0
+
+
+ """);
+ var path = WriteFile("nested/inner/App.csproj", "");
+
+ var (project, _, _) = _sut.Parse(path);
+
+ project.Framework.Should().Be("net10.0");
+ }
+
+ [Fact]
+ public void Parse_IsTestProjectInDirectoryBuildProps_ShouldClassifyAsTest()
+ {
+ // The project name deliberately avoids the word "Test" so the classification can only come
+ // from the inherited IsTestProject property.
+ WriteFile("inheritedtest/Directory.Build.props",
+ """
+
+
+ true
+
+
+ """);
+ var path = WriteFile("inheritedtest/Specs.csproj", "");
+
+ var (project, _, _) = _sut.Parse(path);
+
+ project.Type.Should().Be(ProjectType.Test);
+ }
+
+ [Fact]
+ public void Parse_OutputTypeExeInDirectoryBuildProps_ShouldClassifyAsExecutable()
+ {
+ WriteFile("inheritedexe/Directory.Build.props",
+ """
+
+
+ Exe
+
+
+ """);
+ var path = WriteFile("inheritedexe/App.csproj", "");
+
+ var (project, _, _) = _sut.Parse(path);
+
+ project.Type.Should().Be(ProjectType.Executable);
+ }
+
+ [Fact]
+ public void Parse_LocalPropertiesShouldOverrideDirectoryBuildProps()
+ {
+ WriteFile("override/Directory.Build.props",
+ """
+
+
+ net6.0
+
+
+ """);
+ var path = WriteFile("override/App.csproj",
+ """
+
+
+ net10.0
+
+
+ """);
+
+ var (project, _, _) = _sut.Parse(path);
+
+ project.Framework.Should().Be("net10.0");
+ }
+
+ [Fact]
+ public void Parse_CentralPackageManagement_ShouldResolveVersionFromPackagesProps()
+ {
+ WriteFile("cpm/Directory.Packages.props",
+ """
+
+
+
+
+
+ """);
+ var path = WriteFile("cpm/App.csproj",
+ """
+
+
+
+
+
+ """);
+
+ var (_, _, packages) = _sut.Parse(path);
+
+ packages.Should().ContainSingle()
+ .Which.Should().BeEquivalentTo(new PackageReference("Serilog", "4.1.0"));
+ }
+
+ [Fact]
+ public void Parse_MalformedDirectoryPackagesProps_ShouldYieldUnknownVersion()
+ {
+ WriteFile("badcpm/Directory.Packages.props", "");
+ var path = WriteFile("badcpm/App.csproj",
+ """
+
+
+
+
+
+ """);
+
+ var (_, _, packages) = _sut.Parse(path);
+
+ packages.Should().ContainSingle().Which.Version.Should().Be("unknown");
+ }
+
+ [Fact]
+ public void Parse_NoDirectoryPackagesProps_ShouldYieldUnknownVersion()
+ {
+ var path = WriteFile("nocpm/App.csproj",
+ """
+
+
+
+
+
+ """);
+
+ var (_, _, packages) = _sut.Parse(path);
+
+ packages.Should().ContainSingle().Which.Version.Should().Be("unknown");
+ }
+
+ [Fact]
+ public void Parse_PackageAbsentFromPackagesProps_ShouldYieldUnknownVersion()
+ {
+ WriteFile("othercpm/Directory.Packages.props",
+ """
+
+
+
+
+
+ """);
+ var path = WriteFile("othercpm/App.csproj",
+ """
+
+
+
+
+
+ """);
+
+ var (_, _, packages) = _sut.Parse(path);
+
+ packages.Should().ContainSingle().Which.Version.Should().Be("unknown");
+ }
+
+ [Fact]
+ public void Parse_ExplicitPackageVersion_ShouldNotConsultPackagesProps()
+ {
+ WriteFile("explicit/Directory.Packages.props",
+ """
+
+
+
+
+
+ """);
+ var path = WriteFile("explicit/App.csproj",
+ """
+
+
+
+
+
+ """);
+
+ var (_, _, packages) = _sut.Parse(path);
+
+ packages.Should().ContainSingle().Which.Version.Should().Be("3.0.0");
+ }
+
+ [Fact]
+ public void Parse_ProjectReferences_ShouldBeReturnedVerbatim()
+ {
+ var path = WriteFile("refs/App.csproj",
+ """
+
+
+
+
+
+ """);
+
+ var (_, references, _) = _sut.Parse(path);
+
+ references.Should().ContainSingle().Which.Should().Be(@"..\Lib\Lib.csproj");
+ }
+
+ [Fact]
+ public void Parse_SameProjectTwice_ShouldProduceIdenticalDeterministicId()
+ {
+ var path = WriteFile("stable/App.csproj", "");
+
+ var (first, _, _) = _sut.Parse(path);
+ var (second, _, _) = _sut.Parse(path);
+
+ second.Id.Should().Be(first.Id);
+ first.Id.Should().NotBe(Guid.Empty);
+ }
+}
diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/EfAnalyzerCoverageTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/EfAnalyzerCoverageTests.cs
new file mode 100644
index 0000000..b5dd4ca
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.EntityFramework/EfAnalyzerCoverageTests.cs
@@ -0,0 +1,468 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using ProjGraph.Core.Models;
+using ProjGraph.Lib.EntityFramework.Infrastructure;
+using ProjGraph.Lib.EntityFramework.Infrastructure.Extensions;
+using ProjGraph.Tests.Shared.Helpers;
+
+namespace ProjGraph.Tests.Unit.EntityFramework;
+
+///
+/// Edge-case coverage for the Roslyn-backed EF analyzers: attribute shapes the happy path never sees,
+/// navigation types that are not entity references, unusual CLR/SQL type spellings, and snapshots whose
+/// BuildModel or [DbContext] attribute is missing or unusual.
+///
+[Trait("Category", "EntityFramework")]
+public sealed class EfAnalyzerCoverageTests
+{
+ private static IPropertySymbol GetProperty(string source, string typeName, string propertyName)
+ {
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var type = RoslynTestHelper.GetTypeSymbol(compilation, typeName)!;
+ return type.GetMembers().OfType().First(p => p.Name == propertyName);
+ }
+
+ [Fact]
+ public void AnalyzeEntity_TypeAttributeThatIsNotPrimaryKey_ShouldNotMarkAnyKey()
+ {
+ // [Serializable] is a resolvable type-level attribute that is not [PrimaryKey]; both the
+ // semantic-model and the syntax pass must skip it rather than treat its arguments as key names.
+ const string source = """
+ using System;
+
+ [Serializable]
+ public class Doc
+ {
+ public string Code { get; set; }
+ public string Title { get; set; }
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var entity = EntityAnalyzer.AnalyzeEntity(RoslynTestHelper.GetTypeSymbol(compilation, "Doc")!);
+
+ entity.Properties.Should().HaveCount(2);
+ entity.Properties.Should().OnlyContain(p => !p.IsPrimaryKey);
+ }
+
+ [Fact]
+ public void AnalyzeEntity_PrimaryKeyAttributeWithoutArguments_ShouldNotMarkAnyKey()
+ {
+ // A bare [PrimaryKey] names no columns, so there is nothing to promote — the parser must not
+ // dereference the missing argument list.
+ const string source = """
+ [PrimaryKey]
+ public class Doc
+ {
+ public string Code { get; set; }
+ public string Title { get; set; }
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var entity = EntityAnalyzer.AnalyzeEntity(RoslynTestHelper.GetTypeSymbol(compilation, "Doc")!);
+
+ entity.Properties.Should().OnlyContain(p => !p.IsPrimaryKey);
+ }
+
+ [Fact]
+ public void AnalyzeEntity_PrimaryKeyWithQualifiedNameof_ShouldMarkThatProperty()
+ {
+ // nameof(Doc.Code) is the idiomatic spelling; the argument is a member access, not a bare
+ // identifier, so the name has to be taken from the accessed member.
+ const string source = """
+ [PrimaryKey(nameof(Doc.Code))]
+ public class Doc
+ {
+ public string Code { get; set; }
+ public string Title { get; set; }
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var entity = EntityAnalyzer.AnalyzeEntity(RoslynTestHelper.GetTypeSymbol(compilation, "Doc")!);
+
+ entity.Properties.Should().ContainSingle(p => p.IsPrimaryKey)
+ .Which.Name.Should().Be("Code");
+ }
+
+ [Fact]
+ public void AnalyzeEntity_PrimaryKeyWithNonNameofArgument_ShouldNotMarkAnyKey()
+ {
+ // A constant reference is not a column name the analyzer can resolve syntactically; guessing
+ // "Code" from Keys.Code would be wrong whenever the constant's value differs from its name.
+ const string source = """
+ public static class Keys
+ {
+ public const string Code = "Code";
+ }
+
+ [PrimaryKey(Keys.Code)]
+ public class Doc
+ {
+ public string Code { get; set; }
+ public string Title { get; set; }
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var entity = EntityAnalyzer.AnalyzeEntity(RoslynTestHelper.GetTypeSymbol(compilation, "Doc")!);
+
+ entity.Properties.Should().OnlyContain(p => !p.IsPrimaryKey);
+ }
+
+ [Fact]
+ public void AnalyzeEntity_ColumnAttributeWithoutTypeName_ShouldNotSetPrecision()
+ {
+ const string source = """
+ using System.ComponentModel.DataAnnotations.Schema;
+
+ public class Invoice
+ {
+ public int Id { get; set; }
+
+ [Column("total_amount", Order = 1)]
+ public decimal Total { get; set; }
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var entity = EntityAnalyzer.AnalyzeEntity(RoslynTestHelper.GetTypeSymbol(compilation, "Invoice")!);
+
+ var total = entity.Properties.First(p => p.Name == "Total");
+ total.Precision.Should().BeNull();
+ total.Scale.Should().BeNull();
+ }
+
+ [Fact]
+ public void AnalyzeEntity_ColumnTypeNameWithoutDecimalPrecision_ShouldNotSetPrecision()
+ {
+ // nvarchar(100) carries a length, not a precision/scale pair — reading "100" as a precision
+ // would render a bogus "precision:100" constraint on a string column.
+ const string source = """
+ using System.ComponentModel.DataAnnotations.Schema;
+
+ public class Invoice
+ {
+ public int Id { get; set; }
+
+ [Column(TypeName = "nvarchar(100)")]
+ public string Reference { get; set; }
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var entity = EntityAnalyzer.AnalyzeEntity(RoslynTestHelper.GetTypeSymbol(compilation, "Invoice")!);
+
+ var reference = entity.Properties.First(p => p.Name == "Reference");
+ reference.Precision.Should().BeNull();
+ reference.Scale.Should().BeNull();
+ }
+
+ [Fact]
+ public void AnalyzeEntity_EnumType_ShouldProduceNoColumns()
+ {
+ // An enum declaration is a BaseTypeDeclarationSyntax but not a TypeDeclarationSyntax, so the
+ // syntax-based key scan must skip it instead of crashing on the unexpected node kind.
+ const string source = """
+ public enum Status
+ {
+ Draft = 0,
+ Published = 1
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var entity = EntityAnalyzer.AnalyzeEntity(RoslynTestHelper.GetTypeSymbol(compilation, "Status")!);
+
+ entity.Name.Should().Be("Status");
+ entity.Properties.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void IsNavigationProperty_ArrayOfEntities_ShouldReturnFalse()
+ {
+ // An array type is an IArrayTypeSymbol, not an INamedTypeSymbol; the analyzer reports it as a
+ // non-navigation so it stays a scalar column rather than silently vanishing.
+ const string source = """
+ public class Order { public int Id { get; set; } }
+
+ public class Customer
+ {
+ public int Id { get; set; }
+ public Order[] Orders { get; set; }
+ }
+ """;
+
+ var prop = GetProperty(source, "Customer", "Orders");
+
+ var result = NavigationPropertyAnalyzer.IsNavigationProperty(prop, out var target, out var isCollection);
+
+ result.Should().BeFalse();
+ target.Should().BeNull();
+ isCollection.Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsNavigationProperty_SingleArgumentGenericThatIsNotACollection_ShouldReturnFalse()
+ {
+ // Lazy has exactly one type argument but is not a collection, so the element-type path
+ // must not fire; falling through, Lazy itself is a System type and therefore not an entity.
+ const string source = """
+ using System;
+
+ public class Order { public int Id { get; set; } }
+
+ public class Customer
+ {
+ public int Id { get; set; }
+ public Lazy Deferred { get; set; }
+ }
+ """;
+
+ var prop = GetProperty(source, "Customer", "Deferred");
+
+ var result = NavigationPropertyAnalyzer.IsNavigationProperty(prop, out _, out var isCollection);
+
+ result.Should().BeFalse();
+ isCollection.Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsNavigationProperty_CollectionOfArrays_ShouldReturnFalse()
+ {
+ // List's element type is an array symbol, not a named type, so no entity element can
+ // be extracted and the property is not a navigation.
+ const string source = """
+ using System.Collections.Generic;
+
+ public class Report
+ {
+ public int Id { get; set; }
+ public List Rows { get; set; }
+ }
+ """;
+
+ var prop = GetProperty(source, "Report", "Rows");
+
+ var result = NavigationPropertyAnalyzer.IsNavigationProperty(prop, out _, out var isCollection);
+
+ result.Should().BeFalse();
+ isCollection.Should().BeFalse();
+ }
+
+ [Fact]
+ public void HasInverseReference_SelfReferencingNavigation_ShouldReturnFalse()
+ {
+ // Node.Parent points back at Node, so the only candidate inverse is the property itself; a
+ // navigation must never be treated as its own inverse.
+ const string source = """
+ public class Node
+ {
+ public int Id { get; set; }
+ public Node Parent { get; set; }
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var node = RoslynTestHelper.GetTypeSymbol(compilation, "Node")!;
+ var parent = node.GetMembers().OfType().First(p => p.Name == "Parent");
+
+ NavigationPropertyAnalyzer.HasInverseReference(parent, node).Should().BeFalse();
+ }
+
+ [Fact]
+ public void HasInverseCollection_SelfReferencingCollection_ShouldReturnFalse()
+ {
+ const string source = """
+ using System.Collections.Generic;
+
+ public class Node
+ {
+ public int Id { get; set; }
+ public List Children { get; set; }
+ }
+ """;
+
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var node = RoslynTestHelper.GetTypeSymbol(compilation, "Node")!;
+ var children = node.GetMembers().OfType().First(p => p.Name == "Children");
+
+ NavigationPropertyAnalyzer.HasInverseCollection(children, node).Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsNullable_NullableValueTypeWithoutNrt_ShouldReturnTrue()
+ {
+ // Without an enabled nullable context the annotation is oblivious, so nullability has to be read
+ // off the Nullable type itself.
+ const string source = """
+ public class Reading
+ {
+ public int? Value { get; set; }
+ }
+ """;
+
+ GetProperty(source, "Reading", "Value").Type.IsNullable().Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsEfValueType_NestedReferenceType_ShouldReturnFalse()
+ {
+ // A nested type's minimally-qualified display string is "Outer.Inner"; the fallback lookup has to
+ // compare the last segment, not the whole dotted string.
+ const string source = """
+ public class Outer
+ {
+ public class Inner { }
+ }
+
+ public class Holder
+ {
+ public Outer.Inner Nested { get; set; }
+ }
+ """;
+
+ GetProperty(source, "Holder", "Nested").Type.IsEfValueType().Should().BeFalse();
+ }
+
+ [Fact]
+ public void CreateWithDefaultValue_MethodCallExpression_ShouldPreserveTheRawExpression()
+ {
+ // A method call has no compile-time constant to resolve; the raw text is kept so the rendered
+ // ERD still shows what was configured instead of silently dropping the default.
+ var compilation = RoslynTestHelper.CreateCompilation("public class Anything { }");
+ var property = new EfProperty { Name = "CreatedAt", Type = "DateTime" };
+
+ var result = DefaultValueResolver.CreateWithDefaultValue(property, "GetUtcNow()", compilation);
+
+ result.DefaultValue.Should().Be("GetUtcNow()");
+ }
+
+ [Fact]
+ public void CreateWithDefaultValue_CastFollowedByCompoundExpression_ShouldPreserveTheRawExpression()
+ {
+ // The cast-stripping shortcut only applies when what follows the cast is a single token; here it
+ // is an arithmetic expression, so the whole thing stays verbatim rather than being mangled.
+ var compilation = RoslynTestHelper.CreateCompilation("public class Anything { }");
+ var property = new EfProperty { Name = "Retries", Type = "int" };
+
+ var result = DefaultValueResolver.CreateWithDefaultValue(property, "(int) 1 + 2", compilation);
+
+ result.DefaultValue.Should().Be("(int) 1 + 2");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ToClrType_BlankColumnType_ShouldReturnNull(string columnType)
+ {
+ SqlColumnTypeMapper.ToClrType(columnType).Should().BeNull();
+ }
+
+ [Fact]
+ public void ToClrType_QuotedTypeWithPrecisionSuffix_ShouldMapToClrType()
+ {
+ // Snapshot files spell the column type as a quoted literal, e.g. "decimal(18,2)".
+ SqlColumnTypeMapper.ToClrType("\"decimal(18,2)\"").Should().Be("decimal");
+ }
+
+ [Fact]
+ public void ToClrType_StringBackedColumnType_ShouldReturnNull()
+ {
+ // nvarchar maps to string; returning null keeps the caller's already-correct string type rather
+ // than round-tripping it through a guess.
+ SqlColumnTypeMapper.ToClrType("nvarchar(200)").Should().BeNull();
+ }
+
+ [Fact]
+ public void ToClrType_UnknownColumnType_ShouldReturnNull()
+ {
+ SqlColumnTypeMapper.ToClrType("hierarchyid").Should().BeNull();
+ }
+
+ private static (ClassDeclarationSyntax Class, INamedTypeSymbol Symbol, Compilation Compilation) LoadSnapshot(
+ string source, string metadataName)
+ {
+ var compilation = RoslynTestHelper.CreateCompilation(source);
+ var symbol = compilation.GetTypeByMetadataName(metadataName)!;
+ var declaration = (ClassDeclarationSyntax)symbol.DeclaringSyntaxReferences[0].GetSyntax();
+ return (declaration, symbol, compilation);
+ }
+
+ [Fact]
+ public void Parse_SnapshotWithDbContextAttribute_ShouldTakeContextNameFromTheAttribute()
+ {
+ // The snapshot class is named after the migration assembly's convention, not the context; the
+ // [DbContext(typeof(T))] argument is the authoritative source of the context name.
+ const string source = """
+ using System;
+
+ namespace Microsoft.EntityFrameworkCore.Infrastructure
+ {
+ public sealed class DbContextAttribute : Attribute
+ {
+ public DbContextAttribute(Type contextType) => ContextType = contextType;
+
+ public Type ContextType { get; }
+ }
+ }
+
+ namespace App
+ {
+ public class OrderingContext { }
+
+ [Microsoft.EntityFrameworkCore.Infrastructure.DbContext(typeof(OrderingContext))]
+ public class AppSnapshot { }
+ }
+ """;
+
+ var (declaration, symbol, compilation) = LoadSnapshot(source, "App.AppSnapshot");
+
+ var model = ModelSnapshotParser.Parse(declaration, symbol, compilation);
+
+ model.ContextName.Should().Be("OrderingContext");
+ }
+
+ [Fact]
+ public void Parse_SnapshotWithoutBuildModel_ShouldReturnAnEmptyModelNamedAfterTheClass()
+ {
+ const string source = """
+ namespace App
+ {
+ public class OrderingModelSnapshot { }
+ }
+ """;
+
+ var (declaration, symbol, compilation) = LoadSnapshot(source, "App.OrderingModelSnapshot");
+
+ var model = ModelSnapshotParser.Parse(declaration, symbol, compilation);
+
+ model.ContextName.Should().Be("Ordering");
+ model.Entities.Should().BeEmpty();
+ model.Relationships.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Parse_SnapshotWithBodilessBuildModel_ShouldReturnAnEmptyModel()
+ {
+ // An abstract/partial BuildModel declaration has neither a block body nor an expression body,
+ // there is nothing to walk and the walkers must not be handed a null body.
+ const string source = """
+ namespace App
+ {
+ public abstract class OrderingModelSnapshot
+ {
+ protected abstract void BuildModel(object modelBuilder);
+ }
+ }
+ """;
+
+ var (declaration, symbol, compilation) = LoadSnapshot(source, "App.OrderingModelSnapshot");
+
+ var model = ModelSnapshotParser.Parse(declaration, symbol, compilation);
+
+ model.ContextName.Should().Be("Ordering");
+ model.Entities.Should().BeEmpty();
+ }
+}
diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/EfDiscoveryCoverageTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/EfDiscoveryCoverageTests.cs
new file mode 100644
index 0000000..d03cd6a
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.EntityFramework/EfDiscoveryCoverageTests.cs
@@ -0,0 +1,255 @@
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using ProjGraph.Lib.Core.Infrastructure;
+using ProjGraph.Lib.EntityFramework.Application;
+using ProjGraph.Lib.EntityFramework.Application.UseCases;
+using ProjGraph.Lib.EntityFramework.Infrastructure;
+using ProjGraph.Tests.Shared.Helpers;
+
+namespace ProjGraph.Tests.Unit.EntityFramework;
+
+///
+/// Edge-case coverage for and the file-discovery side of
+/// : search boundaries (filesystem root, build-output directories, recursion
+/// depth), DbSet<T> type arguments that are not entity names, and owned navigations whose CLR
+/// type is only reachable through a collection element type.
+///
+[Trait("Category", "EntityFramework")]
+public sealed class EfDiscoveryCoverageTests : IDisposable
+{
+ private readonly EntityFileDiscovery _sut = new(new PhysicalFileSystem());
+ private readonly string _tempDir;
+
+ public EfDiscoveryCoverageTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "efdcov_" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, true);
+ }
+ }
+
+ private static ClassDeclarationSyntax FirstClass(string code)
+ {
+ return CSharpSyntaxTree.ParseText(code).GetRoot()
+ .DescendantNodes().OfType().First();
+ }
+
+ private static EfAnalysisService CreateService()
+ {
+ var fs = new PhysicalFileSystem();
+ var analyzer = new EfModelAnalyzer(new CompilationFactory(), fs, new EntityFileDiscovery(fs));
+ return new EfAnalysisService(
+ new AnalyzeContextUseCase(analyzer),
+ new DiscoverContextsUseCase(analyzer, fs),
+ new AnalyzeSnapshotUseCase(analyzer),
+ new DiscoverSnapshotsUseCase(analyzer, fs));
+ }
+
+ [Fact]
+ public void BuildSearchDirectories_AtFilesystemRoot_ShouldReturnOnlyThatDirectory()
+ {
+ // The root has no parent; the search scope must degrade to the root itself rather than
+ // dereferencing a null parent.
+ var root = Path.GetPathRoot(Path.GetFullPath(_tempDir))!;
+
+ var result = _sut.BuildSearchDirectories(root);
+
+ result.Should().ContainSingle().Which.Should().Be(root);
+ }
+
+ [Fact]
+ public void SearchForBaseClassFiles_NoBaseClassNames_ShouldReturnEmptyWithoutSearching()
+ {
+ // Nothing to look for means the recursion must not start at all — an entity hierarchy with no
+ // base types must not trigger a full tree walk.
+ File.WriteAllText(Path.Combine(_tempDir, "BaseEntity.cs"), "public class BaseEntity { }");
+
+ var result = _sut.SearchForBaseClassFiles([], new DirectoryInfo(_tempDir));
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void SearchForBaseClassFiles_FileUnderBuildOutput_ShouldBeSkipped()
+ {
+ // A copy of the source under bin/ is a build artifact; matching it would put a stale duplicate
+ // declaration into the compilation.
+ var binDir = Path.Combine(_tempDir, "bin");
+ Directory.CreateDirectory(binDir);
+ File.WriteAllText(Path.Combine(binDir, "BaseEntity.cs"), "public class BaseEntity { }");
+
+ var result = _sut.SearchForBaseClassFiles(["BaseEntity"], new DirectoryInfo(_tempDir));
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void SearchForBaseClassFiles_BeyondMaxSearchDepth_ShouldNotDescend()
+ {
+ // The recursion is depth-limited to keep the scan bounded on deep repositories; a base class
+ // buried deeper than the limit is simply not found.
+ var deepDir = _tempDir;
+ for (var i = 0; i < 12; i++)
+ {
+ deepDir = Path.Combine(deepDir, $"d{i}");
+ }
+
+ Directory.CreateDirectory(deepDir);
+ File.WriteAllText(Path.Combine(deepDir, "BaseEntity.cs"), "public class BaseEntity { }");
+
+ var result = _sut.SearchForBaseClassFiles(["BaseEntity"], new DirectoryInfo(_tempDir));
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task DiscoverEntityFilesAsync_EntityUnderBuildOutput_ShouldBeSkipped()
+ {
+ var objDir = Path.Combine(_tempDir, "obj");
+ Directory.CreateDirectory(objDir);
+ await File.WriteAllTextAsync(Path.Combine(objDir, "Customer.cs"),
+ "public class Customer { public int Id { get; set; } }");
+
+ var contextFilePath = Path.Combine(_tempDir, "MyContext.cs");
+ await File.WriteAllTextAsync(contextFilePath, "// context file");
+
+ var result = await _sut.DiscoverEntityFilesAsync([_tempDir], ["Customer"], contextFilePath);
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task DiscoverConfigurationFilesAsync_ConfigUnderBuildOutput_ShouldBeSkipped()
+ {
+ const string configCode = """
+ using Microsoft.EntityFrameworkCore;
+ using Microsoft.EntityFrameworkCore.Metadata.Builders;
+ public class GadgetConfiguration : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder builder) { }
+ }
+ """;
+ var binDir = Path.Combine(_tempDir, "bin");
+ Directory.CreateDirectory(binDir);
+ await File.WriteAllTextAsync(Path.Combine(binDir, "GadgetConfiguration.cs"), configCode);
+
+ var contextFilePath = Path.Combine(_tempDir, "MyContext.cs");
+ await File.WriteAllTextAsync(contextFilePath, "public class MyContext { }");
+
+ var result = await _sut.DiscoverConfigurationFilesAsync([_tempDir], contextFilePath);
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task DiscoverConfigurationFilesAsync_InterfaceDerivingFromTheConfigurationInterface_ShouldBeIgnored()
+ {
+ // An interface that extends IEntityTypeConfiguration of T declares no Configure body to walk,
+ // registering it as a config class would hand the walker a member-less type.
+ const string code = """
+ using Microsoft.EntityFrameworkCore;
+ public interface IGadgetConfiguration : IEntityTypeConfiguration { }
+ """;
+ await File.WriteAllTextAsync(Path.Combine(_tempDir, "IGadgetConfiguration.cs"), code);
+
+ var contextFilePath = Path.Combine(_tempDir, "MyContext.cs");
+ await File.WriteAllTextAsync(contextFilePath, "public class MyContext { }");
+
+ var result = await _sut.DiscoverConfigurationFilesAsync([_tempDir], contextFilePath);
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ExtractEntityTypeNames_GenericPropertyThatIsNotADbSet_ShouldBeIgnored()
+ {
+ const string code = """
+ using System.Collections.Generic;
+ using Microsoft.EntityFrameworkCore;
+ public class MyContext : DbContext
+ {
+ public DbSet Blogs { get; set; }
+ public List Cache { get; set; }
+ public Lazy Deferred { get; set; }
+ }
+ """;
+
+ var result = _sut.ExtractEntityTypeNames(FirstClass(code));
+
+ result.Should().BeEquivalentTo("Blog");
+ }
+
+ [Fact]
+ public void ExtractEntityTypeNames_PredefinedTypeArgument_ShouldFallBackToItsKeyword()
+ {
+ // A DbSet over a keyword type is not a real entity, but the extraction must still produce a
+ // stable name rather than throwing on the unexpected type-syntax kind.
+ const string code = """
+ using Microsoft.EntityFrameworkCore;
+ public class MyContext : DbContext
+ {
+ public DbSet Counters { get; set; }
+ }
+ """;
+
+ var result = _sut.ExtractEntityTypeNames(FirstClass(code));
+
+ result.Should().BeEquivalentTo("int");
+ }
+
+ [Fact]
+ public async Task AnalyzeContextAsync_OwnsManyOverAnArrayNavigationInASeparateFile_ShouldCaptureColumns()
+ {
+ // OwnsMany's CLR type hides behind the navigation's element type. For an array-typed navigation
+ // that element type is an ArrayTypeSyntax element, not a generic argument — if it is not unwrapped
+ // the owned type's file is never found and the owned entity materializes with zero columns.
+ using var temp = new TestDirectory();
+
+ const string contextContent = """
+ using Microsoft.EntityFrameworkCore;
+ namespace Test;
+
+ public class ArrayOwnedContext : DbContext
+ {
+ public DbSet Customers { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity().OwnsMany(c => c.Addresses);
+ }
+ }
+
+ public class Customer
+ {
+ public int Id { get; set; }
+ public Address[] Addresses { get; set; } = null!;
+ }
+ """;
+ const string addressContent = """
+ namespace Test;
+
+ public class Address
+ {
+ public string Street { get; set; } = "";
+ public string City { get; set; } = "";
+ }
+ """;
+
+ var contextPath = temp.CreateFile("Context.cs", contextContent);
+ temp.CreateFile("Address.cs", addressContent);
+
+ var model = await CreateService().AnalyzeContextAsync(contextPath, "ArrayOwnedContext");
+
+ var owned = model.Entities.SingleOrDefault(e => e.Key == "Customer.Addresses");
+ owned.Should().NotBeNull("OwnsMany over an array navigation must still capture the owned type");
+ owned!.IsCollection.Should().BeTrue();
+ owned.Properties.Select(p => p.Name).Should().Contain("Street").And.Contain("City");
+ }
+
+}
diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/EfRenderingCoverageTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/EfRenderingCoverageTests.cs
new file mode 100644
index 0000000..fad6db6
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.EntityFramework/EfRenderingCoverageTests.cs
@@ -0,0 +1,220 @@
+using ProjGraph.Core.Models;
+using ProjGraph.Lib.EntityFramework.Rendering;
+
+namespace ProjGraph.Tests.Unit.EntityFramework;
+
+///
+/// Edge-case coverage for : rendering fallbacks for models that carry
+/// values the happy path never produces (blank column types, unknown relationship kinds, an owned type
+/// whose owner is missing from the model, and an ownership cycle).
+///
+[Trait("Category", "EntityFramework")]
+public sealed class EfRenderingCoverageTests
+{
+ private readonly MermaidErdRenderer _renderer = new();
+
+ [Fact]
+ public void Format_ShouldBeMermaid()
+ {
+ _renderer.Format.Should().Be("mermaid");
+ }
+
+ [Fact]
+ public void Render_PropertyWithBlankType_ShouldFallBackToUnknown()
+ {
+ // A property whose type could not be resolved must still produce a syntactically valid Mermaid
+ // column line: " Legacy" (no type token) is not valid ER syntax, "unknown Legacy" is.
+ var model = new EfModel
+ {
+ ContextName = "TestDbContext",
+ Entities =
+ [
+ new EfEntity
+ {
+ Name = "Legacy",
+ Properties =
+ [
+ new EfProperty { Name = "Id", Type = "int", IsPrimaryKey = true },
+ new EfProperty { Name = "Payload", Type = "" }
+ ]
+ }
+ ]
+ };
+
+ var result = _renderer.Render(model);
+
+ result.Should().Contain("unknown Payload");
+ }
+
+ [Fact]
+ public void Render_PropertyWhoseTypeSanitizesToNothing_ShouldFallBackToUnknown()
+ {
+ // "?" survives as a non-empty Type but sanitizes away entirely, so the fallback has to be applied
+ // after sanitization rather than on the raw value.
+ var model = new EfModel
+ {
+ ContextName = "TestDbContext",
+ Entities =
+ [
+ new EfEntity
+ {
+ Name = "Legacy",
+ Properties = [new EfProperty { Name = "Payload", Type = "?" }]
+ }
+ ]
+ };
+
+ var result = _renderer.Render(model);
+
+ result.Should().Contain("unknown Payload");
+ }
+
+ [Fact]
+ public void Render_UnrecognizedRelationshipType_ShouldEmitNeutralConnector()
+ {
+ // Guards the switch's default arm: an out-of-range enum value must degrade to a plain link
+ // instead of throwing and taking the whole diagram down.
+ var model = new EfModel
+ {
+ ContextName = "TestDbContext",
+ Entities =
+ [
+ new EfEntity { Name = "Alpha" },
+ new EfEntity { Name = "Beta" }
+ ],
+ Relationships =
+ [
+ new EfRelationship
+ {
+ SourceEntity = "Alpha",
+ TargetEntity = "Beta",
+ Type = (EfRelationshipType)99
+ }
+ ]
+ };
+
+ var result = _renderer.Render(model);
+
+ result.Should().Contain("Alpha -- Beta : \"\"");
+ }
+
+ [Fact]
+ public void Render_PrecisionWithoutScale_ShouldRenderPrecisionOnly()
+ {
+ var model = new EfModel
+ {
+ ContextName = "TestDbContext",
+ Entities =
+ [
+ new EfEntity
+ {
+ Name = "Product",
+ Properties = [new EfProperty { Name = "Weight", Type = "decimal", Precision = 10 }]
+ }
+ ]
+ };
+
+ var result = _renderer.Render(model);
+
+ result.Should().Contain("precision:10");
+ result.Should().NotContain("precision(");
+ }
+
+ [Fact]
+ public void Render_OwnedEntityWithUnresolvableOwner_ShouldStillDrawBoxButNoOwnershipLine()
+ {
+ // An owned entity whose OwnerEntity names no entity in the model cannot be inlined (there is no
+ // owner table to compare against) and cannot get an identifying relationship line either — but it
+ // must not be dropped, or its columns disappear from the diagram with no trace.
+ var model = new EfModel
+ {
+ ContextName = "TestDbContext",
+ Entities =
+ [
+ new EfEntity
+ {
+ Name = "Address",
+ Key = "Ghost.ShipTo",
+ IsOwned = true,
+ OwnerEntity = "Ghost",
+ NavigationName = "ShipTo",
+ Properties = [new EfProperty { Name = "Street", Type = "string" }]
+ }
+ ]
+ };
+
+ var result = _renderer.Render(model);
+
+ result.Should().Contain("Address {");
+ result.Should().Contain("string Street");
+ result.Should().NotContain("||--||");
+ result.Should().NotContain("Ghost");
+ }
+
+ [Fact]
+ public void Render_EntityNameThatIsNotABareIdentifier_ShouldBeQuoted()
+ {
+ var model = new EfModel
+ {
+ ContextName = "TestDbContext",
+ Entities = [new EfEntity { Name = "Order Detail" }]
+ };
+
+ var result = _renderer.Render(model);
+
+ result.Should().Contain("\"Order Detail\" {");
+ }
+
+ [Fact]
+ public void Render_OwnedEntitiesSharingAKey_ShouldTerminateAndInlineEachOnce()
+ {
+ // Two owned entities that share an EffectiveKey form a cycle in the owner graph: the inner one's
+ // children resolve back to the key already being expanded. Without the recursion guard this
+ // recurses forever and dies with an uncatchable StackOverflowException. The guard must stop the
+ // second expansion of the key while still emitting everything reached before it.
+ var model = new EfModel
+ {
+ ContextName = "TestDbContext",
+ Entities =
+ [
+ new EfEntity
+ {
+ Name = "Invoice",
+ TableName = "Invoices",
+ Properties = [new EfProperty { Name = "Id", Type = "int", IsPrimaryKey = true }]
+ },
+ new EfEntity
+ {
+ Name = "Address",
+ Key = "Invoice.ShipTo",
+ TableName = "Invoices",
+ IsOwned = true,
+ OwnerEntity = "Invoice",
+ NavigationName = "ShipTo",
+ Properties = [new EfProperty { Name = "Street", Type = "string" }]
+ },
+ new EfEntity
+ {
+ // Deliberately duplicates the key above, so its own children lookup ("who is owned by
+ // Invoice.ShipTo?") finds itself and loops.
+ Name = "Geo",
+ Key = "Invoice.ShipTo",
+ TableName = "Invoices",
+ IsOwned = true,
+ OwnerEntity = "Invoice.ShipTo",
+ NavigationName = "Geo",
+ Properties = [new EfProperty { Name = "Lat", Type = "double" }]
+ }
+ ]
+ };
+
+ var result = _renderer.Render(model);
+
+ // Everything folds onto the single Invoices table, with EF's compounding column prefixes.
+ result.Should().Contain("Invoice {");
+ result.Should().Contain("ShipTo_Street");
+ result.Should().Contain("ShipTo_Geo_Lat");
+ result.Should().NotContain("Address {");
+ result.Should().NotContain("Geo {");
+ }
+}
diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/FluentSyntaxCoverageTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/FluentSyntaxCoverageTests.cs
new file mode 100644
index 0000000..3f6f03e
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.EntityFramework/FluentSyntaxCoverageTests.cs
@@ -0,0 +1,277 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using ProjGraph.Core.Models;
+using ProjGraph.Lib.EntityFramework.Infrastructure;
+using ProjGraph.Tests.Shared.Helpers;
+
+namespace ProjGraph.Tests.Unit.EntityFramework;
+
+///
+/// Edge-case unit tests for , the shared syntax-walking primitives behind every
+/// Fluent API walker: receiver-chain / ancestor owning-entity resolution across owned-type and join-entity
+/// fences, the unusual fluent chain shapes whose name extraction must degrade to
+/// rather than guess, and entity materialization when a type symbol cannot be resolved.
+///
+[Trait("Category", "EntityFramework")]
+public sealed class FluentSyntaxCoverageTests
+{
+ /// Parses and returns its single method declaration.
+ /// The C# source to parse.
+ private static MethodDeclarationSyntax ParseMethod(string source)
+ => CSharpSyntaxTree.ParseText(source).GetRoot()
+ .DescendantNodes().OfType().Single();
+
+ ///
+ /// Returns the outermost invocation in whose immediate member name is
+ /// (pre-order traversal yields the outermost chain link first).
+ ///
+ /// The node to search.
+ /// The simple method name to match.
+ private static InvocationExpressionSyntax Call(SyntaxNode scope, string methodName)
+ => scope.DescendantNodes().OfType()
+ .First(i => i.Expression is MemberAccessExpressionSyntax ma
+ && ma.Name.Identifier.Text == methodName);
+
+ /// Wraps in a minimal OnModelCreating method and parses it.
+ /// The statements to place inside the method body.
+ private static MethodDeclarationSyntax Method(string body)
+ => ParseMethod("class Ctx { void OnModelCreating(dynamic modelBuilder) { " + body + " } }");
+
+ [Fact]
+ public void ResolveOwningEntity_ReceiverChainLinkIsNotMemberAccess_FallsBackToAmbient()
+ {
+ // The chain's receiver is a bare `Factory()` call, not a member access, so the receiver walk has
+ // no name to inspect and must keep climbing rather than abandon resolution.
+ var method = Method("Factory().Property(a => a.Name);");
+
+ var resolved = FluentSyntax.ResolveOwningEntity(Call(method, "Property"), "Account");
+
+ resolved.Should().Be("Account");
+ }
+
+ [Fact]
+ public void ResolveOwningEntity_ChainCrossesUsingEntity_ReturnsNull()
+ {
+ // A join entity is out of scope: a ToTable chained onto UsingEntity must NOT be attributed to the
+ // Post entity the chain would otherwise reach, nor to the ambient entity.
+ var method = Method("""modelBuilder.Entity().UsingEntity("PostTag").ToTable("post_tag");""");
+
+ var resolved = FluentSyntax.ResolveOwningEntity(Call(method, "ToTable"), "Post");
+
+ resolved.Should().BeNull();
+ }
+
+ [Fact]
+ public void ResolveOwningEntity_ChainCrossesOwnsOne_ResolvesToOwnedKey()
+ {
+ var method = Method("""modelBuilder.Entity().OwnsOne(o => o.ShipTo).ToTable("ship_to");""");
+
+ var resolved = FluentSyntax.ResolveOwningEntity(Call(method, "ToTable"), null);
+
+ resolved.Should().Be("Order.ShipTo");
+ }
+
+ [Fact]
+ public void ResolveOwningEntity_ChainCrossesOwnsOneWithUnresolvableNavigation_ReturnsNull()
+ {
+ // The owned navigation cannot be named (the argument is neither a lambda member access nor a pair
+ // of string literals), so the chain settles on "unresolvable" rather than leaking onto Order.
+ var method = Method("modelBuilder.Entity().OwnsOne(AddressConfig).Property(a => a.City);");
+
+ var resolved = FluentSyntax.ResolveOwningEntity(Call(method, "Property"), "Order");
+
+ resolved.Should().BeNull("an unnameable owned target must not fall back to its owner");
+ }
+
+ [Fact]
+ public void ResolveOwningEntity_InsideOwnedBuilderLambda_StopsAtFenceAndKeepsAmbient()
+ {
+ // The ancestor search would otherwise climb past the OwnsOne fence and reattribute the nested
+ // Property call to Entity(). The ambient owned key must win instead.
+ var method = Method("modelBuilder.Entity(e => e.OwnsOne(o => o.ShipTo, a => a.Property(x => x.City)));");
+
+ var resolved = FluentSyntax.ResolveOwningEntity(Call(method, "Property"), "Order.ShipTo");
+
+ resolved.Should().Be("Order.ShipTo");
+ }
+
+ [Fact]
+ public void ResolveOwningEntity_InsideUsingEntityLambda_KeepsAmbient()
+ {
+ var method = Method("""modelBuilder.Entity(e => e.HasMany(p => p.Tags).WithMany().UsingEntity("PostTag", j => j.Property("PostId")));""");
+
+ var resolved = FluentSyntax.ResolveOwningEntity(Call(method, "Property"), "PostTag");
+
+ resolved.Should().Be("PostTag", "a join-entity builder's own configuration stays on the join entity");
+ }
+
+ [Fact]
+ public void FindConfigRoots_CallsInsideUsingEntityArgumentList_AreExcluded()
+ {
+ var method = Method("""modelBuilder.Entity().HasMany(p => p.Tags).WithMany().UsingEntity("PostTag", j => j.Property("PostId"));""");
+
+ var roots = FluentSyntax.FindConfigRoots(method, "Property").ToList();
+
+ roots.Should().BeEmpty("join-entity builder configuration must not be walked as the outer entity's");
+ }
+
+ [Fact]
+ public void IsInsideNestedBuilderScope_NodeOnTheFenceReceiverSpine_IsNotFenced()
+ {
+ // The Entity() call is the OwnsOne call's receiver, not one of its arguments, so it must
+ // not be treated as fenced off by the very call chained onto it.
+ var method = Method("modelBuilder.Entity().OwnsOne(o => o.ShipTo, a => a.Property(x => x.City));");
+ var entityCall = Call(method, "Entity");
+
+ FluentSyntax.IsInsideNestedBuilderScope(entityCall, method).Should().BeFalse();
+ FluentSyntax.IsInsideNestedBuilderScope(Call(method, "Property"), method).Should().BeTrue();
+ }
+
+ [Fact]
+ public void OwnedNavigationName_NoArguments_ReturnsNull()
+ {
+ var method = Method("modelBuilder.Entity().OwnsOne();");
+
+ FluentSyntax.OwnedNavigationName(Call(method, "OwnsOne")).Should().BeNull();
+ }
+
+ [Fact]
+ public void OwnedNavigationName_ParenthesizedLambda_ReturnsMemberName()
+ {
+ var method = Method("modelBuilder.Entity().OwnsOne((o) => o.ShipTo, a => a.Property(x => x.City));");
+
+ FluentSyntax.OwnedNavigationName(Call(method, "OwnsOne")).Should().Be("ShipTo");
+ }
+
+ [Fact]
+ public void OwnedNavigationName_SnapshotStringLiteralForm_ReturnsSecondLiteral()
+ {
+ var method = Method("""b.OwnsOne("Fixtures.Address", "ShipTo", b1 => b1.Property("City"));""");
+
+ FluentSyntax.OwnedNavigationName(Call(method, "OwnsOne")).Should().Be("ShipTo",
+ "the snapshot form names the owned TYPE first and the navigation second");
+ }
+
+ [Fact]
+ public void OwnedNavigationName_NonLambdaNonLiteralArgument_ReturnsNull()
+ {
+ var method = Method("modelBuilder.Entity().OwnsOne(AddressConfig, a => a.Property(x => x.City));");
+
+ FluentSyntax.OwnedNavigationName(Call(method, "OwnsOne")).Should().BeNull();
+ }
+
+ [Fact]
+ public void EntityNameFromInvocation_QualifiedGenericArgument_StripsNamespace()
+ {
+ var method = Method("modelBuilder.Entity();");
+
+ FluentSyntax.EntityNameFromInvocation(Call(method, "Entity")).Should().Be("Order");
+ }
+
+ [Fact]
+ public void EntityNameFromInvocation_UnqualifiedStringLiteral_ReturnsWholeLiteral()
+ {
+ var method = Method("""modelBuilder.Entity("Order");""");
+
+ FluentSyntax.EntityNameFromInvocation(Call(method, "Entity")).Should().Be("Order");
+ }
+
+ [Fact]
+ public void EntityNameFromInvocation_NonLiteralArgument_ReturnsNull()
+ {
+ var method = Method("modelBuilder.Entity(typeof(Order));");
+
+ FluentSyntax.EntityNameFromInvocation(Call(method, "Entity")).Should().BeNull(
+ "a typeof argument is not a name this syntax-only walker can read");
+ }
+
+ [Fact]
+ public void GenericTypeArgumentName_NonGenericInvocation_ReturnsNull()
+ {
+ var method = Method("""modelBuilder.Entity("Ns.Order");""");
+
+ FluentSyntax.GenericTypeArgumentName(Call(method, "Entity")).Should().BeNull();
+ }
+
+ [Fact]
+ public void MaterializeEntity_UnresolvableTypeName_AddsBareEntity()
+ {
+ var compilation = RoslynTestHelper.CreateCompilation("public class Present { public int Id { get; set; } }");
+ var entities = new Dictionary();
+ var model = new EfModel();
+
+ FluentSyntax.MaterializeEntity("Absent", entities, model, compilation);
+
+ entities.Should().ContainKey("Absent");
+ entities["Absent"].Name.Should().Be("Absent");
+ entities["Absent"].Properties.Should().BeEmpty("an unresolvable type degrades to a bare entity");
+ model.Entities.Should().ContainSingle(e => e.Name == "Absent");
+ }
+
+ [Fact]
+ public void MaterializeEntity_ResolvableTypeName_SeedsPropertiesFromSymbol()
+ {
+ var compilation = RoslynTestHelper.CreateCompilation(
+ "public class Present { public int Id { get; set; } public string Label { get; set; } = \"\"; }");
+ var entities = new Dictionary();
+ var model = new EfModel();
+
+ FluentSyntax.MaterializeEntity("Present", entities, model, compilation);
+
+ entities["Present"].Properties.Select(p => p.Name).Should().Contain("Id").And.Contain("Label");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void MaterializeEntity_NullOrEmptyName_DoesNothing(string? entityName)
+ {
+ var compilation = RoslynTestHelper.CreateCompilation("public class Present { public int Id { get; set; } }");
+ var entities = new Dictionary();
+ var model = new EfModel();
+
+ FluentSyntax.MaterializeEntity(entityName, entities, model, compilation);
+
+ entities.Should().BeEmpty();
+ model.Entities.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void MaterializeEntity_AlreadyKnownEntity_PreservesTheExistingInstance()
+ {
+ var compilation = RoslynTestHelper.CreateCompilation("public class Present { public int Id { get; set; } }");
+ var existing = new EfEntity { Name = "Present", TableName = "presents" };
+ var entities = new Dictionary { ["Present"] = existing };
+ var model = new EfModel();
+ model.Entities.Add(existing);
+
+ FluentSyntax.MaterializeEntity("Present", entities, model, compilation);
+
+ entities["Present"].Should().BeSameAs(existing,
+ "re-materializing must not discard configuration already applied to the entity");
+ model.Entities.Should().ContainSingle();
+ }
+
+ [Fact]
+ public void MaterializeEntity_NameAlreadyInModelButNotInDictionary_DoesNotDuplicateInModel()
+ {
+ var compilation = RoslynTestHelper.CreateCompilation("public class Present { public int Id { get; set; } }");
+ var entities = new Dictionary();
+ var model = new EfModel();
+ model.Entities.Add(new EfEntity { Name = "Present" });
+
+ FluentSyntax.MaterializeEntity("Present", entities, model, compilation);
+
+ entities.Should().ContainKey("Present");
+ model.Entities.Should().ContainSingle(e => e.Name == "Present",
+ "the model already carried this entity, so it must not be added a second time");
+ }
+
+ [Fact]
+ public void LastSegment_ValueWithoutDot_ReturnsWholeValue()
+ {
+ FluentSyntax.LastSegment("Order").Should().Be("Order");
+ FluentSyntax.LastSegment("Data.Models.Order").Should().Be("Order");
+ }
+}
diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/FluentWalkerCoverageTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/FluentWalkerCoverageTests.cs
new file mode 100644
index 0000000..f30cd17
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.EntityFramework/FluentWalkerCoverageTests.cs
@@ -0,0 +1,761 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using ProjGraph.Core.Models;
+using ProjGraph.Lib.EntityFramework.Infrastructure;
+using ProjGraph.Tests.Shared.Helpers;
+
+namespace ProjGraph.Tests.Unit.EntityFramework;
+
+///
+/// Edge-case unit tests for the Fluent API walkers (,
+/// , ,
+/// and ): the guard paths that
+/// must degrade gracefully rather than fabricate model content — unreadable (non-literal) arguments,
+/// navigations whose CLR type cannot be resolved, configuration chained onto an out-of-scope join-entity
+/// builder, and config classes that are not really config classes.
+///
+[Trait("Category", "EntityFramework")]
+public sealed class FluentWalkerCoverageTests
+{
+ ///
+ /// Compiles , locates the OnModelCreating method in the first source,
+ /// and seeds the entities dictionary and model from (simulating DbSet
+ /// discovery) so a walker can be driven in isolation.
+ ///
+ /// The C# sources to compile; the first must declare OnModelCreating.
+ /// The entity class names to pre-analyze into instances.
+ private static (MethodDeclarationSyntax Method, Compilation Compilation, Dictionary Entities, EfModel Model)
+ Build(string[] sources, params string[] entityNames)
+ {
+ var compilation = RoslynTestHelper.CreateCompilation(sources);
+ var method = compilation.SyntaxTrees[0].GetRoot()
+ .DescendantNodes().OfType()
+ .First(m => m.Identifier.Text == "OnModelCreating");
+
+ var entities = new Dictionary();
+ var model = new EfModel();
+ foreach (var name in entityNames)
+ {
+ var symbol = RoslynTestHelper.GetTypeSymbol(compilation, name)!;
+ var entity = EntityAnalyzer.AnalyzeEntity(symbol);
+ entities[name] = entity;
+ model.Entities.Add(entity);
+ }
+
+ return (method, compilation, entities, model);
+ }
+
+ /// Compiles a single source. See .
+ /// The C# source to compile.
+ /// The entity class names to pre-analyze.
+ private static (MethodDeclarationSyntax Method, Compilation Compilation, Dictionary Entities, EfModel Model)
+ Build(string source, params string[] entityNames)
+ => Build([source], entityNames);
+
+ private static EfProperty Property(Dictionary entities, string entity, string property)
+ => entities[entity].Properties.Single(p => p.Name == property);
+
+ // ---------------------------------------------------------------- FluentEntityWalker
+
+ [Fact]
+ public void Apply_ToTableWithNonLiteralArgument_LeavesTableNameUnset()
+ {
+ const string source = """
+ public static class Tables { public const string Account = "accounts"; }
+ public class Account { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().ToTable(Tables.Account);
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Account");
+
+ FluentEntityWalker.Apply(method, entities, model, compilation);
+
+ entities["Account"].TableName.Should().BeEmpty(
+ "the syntax-only walker cannot read a constant reference, and must not invent a table name");
+ }
+
+ [Fact]
+ public void Apply_ToTableChainedOntoUsingEntity_DoesNotLeakOntoOuterEntity()
+ {
+ const string source = """
+ public class Post { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().UsingEntity("PostTag").ToTable("post_tag");
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Post");
+
+ FluentEntityWalker.Apply(method, entities, model, compilation);
+
+ entities["Post"].TableName.Should().BeEmpty(
+ "the table belongs to the out-of-scope join entity, not to Post");
+ }
+
+ [Fact]
+ public void CollectEntityNames_EntityWithUnreadableArgument_IsSkipped()
+ {
+ const string source = """
+ public class Account { public int Id { get; set; } }
+ public class Order { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ {
+ modelBuilder.Entity(typeof(Account));
+ modelBuilder.Entity();
+ }
+ }
+ """;
+ var (method, _, _, _) = Build(source);
+
+ var names = FluentEntityWalker.CollectEntityNames(method);
+
+ names.Should().BeEquivalentTo(["Order"],
+ "a typeof argument yields no readable entity name and must not produce a phantom entry");
+ }
+
+ // ---------------------------------------------------------------- FluentPropertyWalker
+
+ [Fact]
+ public void Apply_PropertyWithUnnameableArgument_ConfiguresNothing()
+ {
+ const string source = """
+ public static class Names { public const string Label = "Label"; }
+ public class Account { public int Id { get; set; } public string Label { get; set; } = ""; }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.Property(Names.Label).HasMaxLength(50));
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Account");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ Property(entities, "Account", "Label").MaxLength.Should().BeNull();
+ entities["Account"].Properties.Should().HaveCount(2, "no phantom property may be fabricated");
+ }
+
+ [Fact]
+ public void Apply_PropertyLambdaBodyIsNotAMemberAccess_ConfiguresNothing()
+ {
+ const string source = """
+ public class Account { public int Id { get; set; } public string Label { get; set; } = ""; }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.Property(a => 1).HasMaxLength(50));
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Account");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ entities["Account"].Properties.Should().HaveCount(2)
+ .And.OnlyContain(p => p.MaxLength == null);
+ }
+
+ [Fact]
+ public void Apply_ParenthesizedLambdaProperty_AppliesConfiguration()
+ {
+ const string source = """
+ public class Account { public int Id { get; set; } public string Label { get; set; } = ""; }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.Property((a) => a.Label).HasMaxLength(75));
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Account");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ Property(entities, "Account", "Label").MaxLength.Should().Be(75);
+ }
+
+ [Fact]
+ public void Apply_HasKeyChainedOntoUsingEntity_DoesNotLeakOntoOuterEntity()
+ {
+ const string source = """
+ public class Post { public int Id { get; set; } public string Code { get; set; } = ""; }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().UsingEntity("PostTag").HasKey("Code");
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Post");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ Property(entities, "Post", "Code").IsPrimaryKey.Should().BeFalse(
+ "the key belongs to the out-of-scope join entity, not to Post");
+ }
+
+ [Fact]
+ public void Apply_HasMaxLengthWithNonNumericArgument_LeavesMaxLengthUnset()
+ {
+ const string source = """
+ public static class Limits { public const int Label = 50; }
+ public class Account { public int Id { get; set; } public string Label { get; set; } = ""; }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.Property(a => a.Label).HasMaxLength(Limits.Label));
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Account");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ Property(entities, "Account", "Label").MaxLength.Should().BeNull();
+ }
+
+ [Fact]
+ public void Apply_HasPrecisionWithNonNumericArgument_LeavesPrecisionUnset()
+ {
+ const string source = """
+ public static class Limits { public const int Precision = 18; }
+ public class Account { public int Id { get; set; } public decimal Balance { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.Property(a => a.Balance).HasPrecision(Limits.Precision));
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Account");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ var balance = Property(entities, "Account", "Balance");
+ balance.Precision.Should().BeNull();
+ balance.Scale.Should().BeNull();
+ }
+
+ [Fact]
+ public void Apply_HasPrecisionWithoutScale_SetsPrecisionOnly()
+ {
+ const string source = """
+ public class Account { public int Id { get; set; } public decimal Balance { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.Property(a => a.Balance).HasPrecision(18));
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Account");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ var balance = Property(entities, "Account", "Balance");
+ balance.Precision.Should().Be(18);
+ balance.Scale.Should().BeNull("the single-argument overload declares no scale");
+ }
+
+ [Fact]
+ public void Apply_HasPrecisionWithUnreadableScale_KeepsPrecisionAndDropsScale()
+ {
+ const string source = """
+ public static class Limits { public const int Scale = 2; }
+ public class Account { public int Id { get; set; } public decimal Balance { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.Property(a => a.Balance).HasPrecision(18, Limits.Scale));
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Account");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ var balance = Property(entities, "Account", "Balance");
+ balance.Precision.Should().Be(18);
+ balance.Scale.Should().BeNull();
+ }
+
+ [Fact]
+ public void Apply_IsRequiredFalse_MarksPropertyOptional()
+ {
+ const string source = """
+ public class Account { public int Id { get; set; } public string Label { get; set; } = ""; }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.Property(a => a.Label).IsRequired(false));
+ }
+ """;
+ var (method, compilation, entities, _) = Build(source, "Account");
+
+ FluentPropertyWalker.Apply(method, entities, compilation);
+
+ var label = Property(entities, "Account", "Label");
+ label.IsRequired.Should().BeFalse("an explicit IsRequired(false) overrides the non-nullable convention");
+ label.IsExplicitlyRequired.Should().BeFalse();
+ }
+
+ // ---------------------------------------------------------------- FluentRelationshipWalker
+
+ [Fact]
+ public void Apply_HasOneWithUnnameableArgument_ProducesNoRelationship()
+ {
+ const string source = """
+ public class Order { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.HasOne(NavigationSelector).WithMany());
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentRelationshipWalker.Apply(method, entities, model, compilation);
+
+ model.Relationships.Should().BeEmpty("an unreadable target must not be guessed at");
+ }
+
+ [Fact]
+ public void Apply_HasOneLambdaBodyIsNotAMemberAccess_ProducesNoRelationship()
+ {
+ const string source = """
+ public class Order { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.HasOne(o => 1).WithMany());
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentRelationshipWalker.Apply(method, entities, model, compilation);
+
+ model.Relationships.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Apply_NavigationNotDeclaredOnSourceEntity_FallsBackToNavigationName()
+ {
+ const string source = """
+ public class Order { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.HasOne(o => o.Ghost).WithMany());
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentRelationshipWalker.Apply(method, entities, model, compilation);
+
+ var relationship = model.Relationships.Should().ContainSingle().Subject;
+ relationship.SourceEntity.Should().Be("Ghost",
+ "with no symbol to resolve, the raw navigation name is the best available target");
+ relationship.TargetEntity.Should().Be("Order");
+ relationship.Type.Should().Be(EfRelationshipType.OneToMany);
+ }
+
+ [Fact]
+ public void Apply_NavigationTargetIsNotAKnownEntity_FallsBackToNavigationName()
+ {
+ const string source = """
+ public class Person { public int Id { get; set; } }
+ public class Order { public int Id { get; set; } public Person Buyer { get; set; } = new(); }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.HasOne(o => o.Buyer).WithMany());
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentRelationshipWalker.Apply(method, entities, model, compilation);
+
+ model.Relationships.Should().ContainSingle().Subject.SourceEntity.Should().Be("Buyer",
+ "Person is not a modelled entity here, so its type name must not be substituted");
+ }
+
+ [Fact]
+ public void Apply_HasForeignKeyWithNoReadableArgument_FabricatesNoProperty()
+ {
+ const string source = """
+ using System.Collections.Generic;
+ public class Item { public int Id { get; set; } public Order Order { get; set; } = new(); }
+ public class Order { public int Id { get; set; } public List- Items { get; set; } = new(); }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e => e.HasMany(o => o.Items).WithOne(i => i.Order).HasForeignKey());
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order", "Item");
+ var itemPropertyCount = entities["Item"].Properties.Count;
+
+ FluentRelationshipWalker.Apply(method, entities, model, compilation);
+
+ model.Relationships.Should().ContainSingle("the relationship itself is still valid");
+ entities["Item"].Properties.Should().HaveCount(itemPropertyCount,
+ "an argument-less HasForeignKey names no column, so none may be invented");
+ }
+
+ [Fact]
+ public void Apply_HasForeignKeyForUnknownDependentType_FabricatesNoProperty()
+ {
+ const string source = """
+ using System.Collections.Generic;
+ public class Item { public int Id { get; set; } public Order Order { get; set; } = new(); }
+ public class Order { public int Id { get; set; } public List
- Items { get; set; } = new(); }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity(e =>
+ e.HasMany(o => o.Items).WithOne(i => i.Order).HasForeignKey("GhostRef"));
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order", "Item");
+
+ FluentRelationshipWalker.Apply(method, entities, model, compilation);
+
+ entities["Item"].Properties.Should().NotContain(p => p.Name == "GhostRef");
+ entities["Order"].Properties.Should().NotContain(p => p.Name == "GhostRef");
+ }
+
+ // ---------------------------------------------------------------- FluentOwnedTypeWalker
+
+ [Fact]
+ public void Apply_OwnsOneWhenOwnerIsNotKnown_CapturesNothing()
+ {
+ const string source = """
+ public class Address { public string City { get; set; } = ""; }
+ public class Order { public int Id { get; set; } public Address ShipTo { get; set; } = new(); }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().OwnsOne(o => o.ShipTo, a => a.Property(x => x.City));
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source);
+
+ FluentOwnedTypeWalker.Apply(method, entities, model, compilation);
+
+ entities.Should().BeEmpty("the owner was never discovered, so its owned type cannot be attached");
+ model.Entities.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Apply_OwnsOneWithUnnameableNavigation_CapturesNothing()
+ {
+ const string source = """
+ public class Order { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().OwnsOne(AddressConfig, a => a.Property(x => x.City));
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentOwnedTypeWalker.Apply(method, entities, model, compilation);
+
+ model.Entities.Should().NotContain(e => e.IsOwned);
+ }
+
+ [Fact]
+ public void Apply_OwnsOneWithUnresolvableNavigationType_CapturesBareOwnedEntity()
+ {
+ const string source = """
+ public class Order { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().OwnsOne(o => o.Mystery, a => a.Property("City"));
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentOwnedTypeWalker.Apply(method, entities, model, compilation);
+
+ var owned = model.Entities.Should().ContainSingle(e => e.IsOwned).Subject;
+ owned.Name.Should().Be("Mystery", "with no CLR type to resolve, the navigation name is the fallback");
+ owned.OwnerEntity.Should().Be("Order");
+ owned.Properties.Should().ContainSingle(p => p.Name == "City",
+ "the owned builder's own configuration is still captured");
+ entities.Should().ContainKey("Order.Mystery");
+ }
+
+ [Fact]
+ public void Apply_ExplicitGenericOwnsOne_SeedsOwnedTypeFromGenericArgument()
+ {
+ const string source = """
+ public class Address { public string City { get; set; } = ""; public string Zip { get; set; } = ""; }
+ public class Order { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().OwnsOne(o => o.Shipping, a => a.Property(x => x.City));
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentOwnedTypeWalker.Apply(method, entities, model, compilation);
+
+ var owned = model.Entities.Should().ContainSingle(e => e.IsOwned).Subject;
+ owned.Name.Should().Be("Address");
+ owned.NavigationName.Should().Be("Shipping");
+ owned.Properties.Select(p => p.Name).Should().Contain("City").And.Contain("Zip",
+ "the explicit generic argument names the CLR type to seed columns from");
+ }
+
+ [Fact]
+ public void Apply_OwnsManyOnNonGenericNavigation_UsesTheNavigationTypeItself()
+ {
+ const string source = """
+ public class Address { public string City { get; set; } = ""; }
+ public class Order { public int Id { get; set; } public Address Billing { get; set; } = new(); }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().OwnsMany(o => o.Billing, a => a.Property(x => x.City));
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentOwnedTypeWalker.Apply(method, entities, model, compilation);
+
+ var owned = model.Entities.Should().ContainSingle(e => e.IsOwned).Subject;
+ owned.Name.Should().Be("Address", "there is no element type to unwrap, so the navigation type is used");
+ owned.IsCollection.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Apply_WithOwnerHasForeignKeyLambdaForm_MarksOwnedForeignKey()
+ {
+ const string source = """
+ using System.Collections.Generic;
+ public class Line { public int Id { get; set; } public int OwnerRef { get; set; } }
+ public class Order { public int Id { get; set; } public List Lines { get; set; } = new(); }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().OwnsMany(o => o.Lines, b => b.WithOwner().HasForeignKey(x => x.OwnerRef));
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentOwnedTypeWalker.Apply(method, entities, model, compilation);
+
+ var owned = entities["Order.Lines"];
+ owned.Name.Should().Be("Line", "OwnsMany unwraps the collection's element type");
+ owned.Properties.Single(p => p.Name == "OwnerRef").IsForeignKey.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Apply_HasForeignKeyWithoutWithOwner_IsIgnored()
+ {
+ const string source = """
+ using System.Collections.Generic;
+ public class Line { public int Id { get; set; } public int OwnerRef { get; set; } }
+ public class Order { public int Id { get; set; } public List Lines { get; set; } = new(); }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.Entity().OwnsMany(o => o.Lines, b => b.HasForeignKey("OwnerRef"));
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Order");
+
+ FluentOwnedTypeWalker.Apply(method, entities, model, compilation);
+
+ entities["Order.Lines"].Properties.Single(p => p.Name == "OwnerRef").IsForeignKey.Should().BeFalse(
+ "only the WithOwner().HasForeignKey(...) form declares the owned type's FK back to its owner");
+ }
+
+ [Fact]
+ public void ResolveTables_OwnedTypeWithoutResolvableOwner_LeavesTableUnresolved()
+ {
+ var orphan = new EfEntity
+ {
+ Name = "Address",
+ Key = "Order.ShipTo",
+ IsOwned = true,
+ OwnerEntity = null,
+ NavigationName = "ShipTo"
+ };
+ var dangling = new EfEntity
+ {
+ Name = "Address",
+ Key = "Ghost.BillTo",
+ IsOwned = true,
+ OwnerEntity = "Ghost",
+ NavigationName = "BillTo"
+ };
+ var entities = new Dictionary
+ {
+ ["Order.ShipTo"] = orphan,
+ ["Ghost.BillTo"] = dangling
+ };
+ var model = new EfModel();
+ model.Entities.Add(orphan);
+ model.Entities.Add(dangling);
+
+ FluentOwnedTypeWalker.ResolveTables(entities, model);
+
+ entities["Order.ShipTo"].TableName.Should().BeEmpty();
+ entities["Ghost.BillTo"].TableName.Should().BeEmpty(
+ "an owner that is not in the dictionary supplies no table to inherit");
+ }
+
+ [Fact]
+ public void StripShadowKeys_OwnedTypeOnItsOwnTable_KeepsPrimaryAndForeignKeys()
+ {
+ var owner = new EfEntity { Name = "Order", TableName = "Orders" };
+ var owned = new EfEntity
+ {
+ Name = "Line",
+ Key = "Order.Lines",
+ IsOwned = true,
+ OwnerEntity = "Order",
+ NavigationName = "Lines",
+ IsCollection = true,
+ TableName = "Orders_Lines",
+ Properties =
+ {
+ new EfProperty { Name = "Id", Type = "int", IsPrimaryKey = true },
+ new EfProperty { Name = "OrderId", Type = "int", IsForeignKey = true }
+ }
+ };
+ var entities = new Dictionary { ["Order"] = owner, ["Order.Lines"] = owned };
+ var model = new EfModel();
+ model.Entities.Add(owner);
+ model.Entities.Add(owned);
+
+ FluentOwnedTypeWalker.StripShadowKeys(entities, model);
+
+ var stripped = entities["Order.Lines"];
+ stripped.Properties.Should().HaveCount(2);
+ stripped.Properties.Single(p => p.Name == "Id").IsPrimaryKey.Should().BeTrue(
+ "an owned type on its own table draws its own box, where the key is real");
+ stripped.Properties.Should().ContainSingle(p => p.Name == "OrderId" && p.IsForeignKey,
+ "the FK to the owner is a separate, real column when the tables differ");
+ }
+
+ // ---------------------------------------------------------------- EntityConfigurationWalker
+
+ [Fact]
+ public void Apply_DuplicateConfigClassNames_AppliesOnlyTheFirst()
+ {
+ const string context = """
+ public class Account { public int Id { get; set; } }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.ApplyConfiguration(new AccountConfiguration());
+ }
+ """;
+ const string first = """
+ using Microsoft.EntityFrameworkCore;
+ using Microsoft.EntityFrameworkCore.Metadata.Builders;
+ public class AccountConfiguration : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder builder) => builder.ToTable("accounts_first");
+ }
+ """;
+ const string second = """
+ using Microsoft.EntityFrameworkCore;
+ using Microsoft.EntityFrameworkCore.Metadata.Builders;
+ public class AccountConfiguration : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder builder) => builder.ToTable("accounts_second");
+ }
+ """;
+ var (method, compilation, entities, model) = Build([context, first, second], "Account");
+
+ EntityConfigurationWalker.Apply(method, entities, model, compilation);
+
+ entities["Account"].TableName.Should().Be("accounts_first",
+ "a config-class name is applied once; a same-named duplicate must not re-run and overwrite it");
+ }
+
+ [Fact]
+ public void Apply_ConfigClassWithoutConfigureMethod_IsSkipped()
+ {
+ const string source = """
+ using Microsoft.EntityFrameworkCore;
+ using Microsoft.EntityFrameworkCore.Metadata.Builders;
+ public class Widget { public int Id { get; set; } }
+ public class WidgetConfiguration : IEntityTypeConfiguration
+ {
+ public void Setup(EntityTypeBuilder builder) => builder.ToTable("widgets");
+ }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.ApplyConfigurationsFromAssembly(typeof(Ctx).Assembly);
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Widget");
+
+ EntityConfigurationWalker.Apply(method, entities, model, compilation);
+
+ entities["Widget"].TableName.Should().BeEmpty(
+ "without a Configure method there is no body EF would ever run");
+ }
+
+ [Fact]
+ public void Apply_ApplyConfigurationWithNonObjectCreationArgument_FoldsNothing()
+ {
+ const string source = """
+ using Microsoft.EntityFrameworkCore;
+ using Microsoft.EntityFrameworkCore.Metadata.Builders;
+ public class Widget { public int Id { get; set; } }
+ public class WidgetConfiguration : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder builder) => builder.ToTable("widgets");
+ }
+ public class Ctx
+ {
+ private readonly WidgetConfiguration _config = new();
+ void OnModelCreating(dynamic modelBuilder) => modelBuilder.ApplyConfiguration(_config);
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Widget");
+
+ EntityConfigurationWalker.Apply(method, entities, model, compilation);
+
+ entities["Widget"].TableName.Should().BeEmpty(
+ "the syntax-only walker cannot tell which config class a field reference denotes");
+ }
+
+ [Fact]
+ public void Apply_GenericBaseTypeThatIsNotTheConfigurationInterface_IsSkipped()
+ {
+ const string source = """
+ using System.Collections.Generic;
+ using Microsoft.EntityFrameworkCore;
+ using Microsoft.EntityFrameworkCore.Metadata.Builders;
+ public class Widget { public int Id { get; set; } }
+ public class WidgetBag : List
+ {
+ public void Configure(EntityTypeBuilder builder) => builder.ToTable("from_bag");
+ }
+ public class WidgetPair : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder builder) => builder.ToTable("from_pair");
+ }
+ public class Ctx
+ {
+ void OnModelCreating(dynamic modelBuilder)
+ => modelBuilder.ApplyConfigurationsFromAssembly(typeof(Ctx).Assembly);
+ }
+ """;
+ var (method, compilation, entities, model) = Build(source, "Widget");
+
+ EntityConfigurationWalker.Apply(method, entities, model, compilation);
+
+ entities["Widget"].TableName.Should().BeEmpty(
+ "neither an unrelated generic base nor a two-argument look-alike is IEntityTypeConfiguration");
+ }
+}