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"); + } +}