diff --git a/src/ArchlensTests/Application/UpdateGraphUseCaseTests.cs b/src/ArchlensTests/Application/UpdateGraphUseCaseTests.cs new file mode 100644 index 00000000..10474843 --- /dev/null +++ b/src/ArchlensTests/Application/UpdateGraphUseCaseTests.cs @@ -0,0 +1,238 @@ +using Archlens.Application; +using Archlens.Domain; +using Archlens.Domain.Interfaces; +using Archlens.Domain.Models; +using Archlens.Domain.Models.Enums; +using Archlens.Domain.Models.Records; +using ArchlensTests.Utils; + +namespace ArchlensTests.Application; + +public sealed class UpdateGraphUseCaseTests : IDisposable +{ + private readonly TestFileSystem _fs = new(); + + public void Dispose() => _fs.Dispose(); + + private BaseOptions MakeBaseOptions() => new( + FullRootPath: _fs.Root, + ProjectRoot: _fs.Root, + ProjectName: "TestProject" + ); + + private ParserOptions MakeParserOptions() => new( + BaseOptions: MakeBaseOptions(), + Languages: [], + Exclusions: [], + FileExtensions: [".cs"] + ); + + private RenderOptions MakeRenderOptions(RenderFormat format) => new( + BaseOptions: MakeBaseOptions(), + Format: format, + Views: [new View("overview", [], [])], + SaveLocation: Path.Combine(_fs.Root, "diagrams") + ); + + private SnapshotOptions MakeSnapshotOptions() => new( + BaseOptions: MakeBaseOptions(), + SnapshotManager: SnapshotManager.Local, + GitInfo: new GitInfo("", "main") + ); + + private sealed class NullParser : IDependencyParser + { + public Task> ParseFileDependencies(string absPath, CancellationToken ct = default) + => Task.FromResult>([]); + } + + private sealed class RendererSpy : RendererBase + { + public int RenderCalled { get; private set; } + public override string FileExtension => "json"; + + protected override string Render(RenderGraph graph, View view, RenderOptions options) + { + RenderCalled++; + return "{}"; + } + } + + private sealed class StubSnapshotManager(ProjectDependencyGraph? snapshot) : ISnapshotManager + { + public int SaveCalled { get; private set; } + + public Task GetLastSavedDependencyGraphAsync( + SnapshotOptions options, CancellationToken ct = default) + => Task.FromResult(snapshot); + + public Task SaveGraphAsync(ProjectDependencyGraph graph, SnapshotOptions options, CancellationToken ct = default) + { + SaveCalled++; + return Task.CompletedTask; + } + } + + private UpdateGraphUseCase MakeUseCase( + RenderFormat format, + ISnapshotManager snapshotManager, + RendererBase renderer, + bool diff = false) => new( + baseOptions: MakeBaseOptions(), + parserOptions: MakeParserOptions(), + renderOptions: MakeRenderOptions(format), + snapshotOptions: MakeSnapshotOptions(), + parsers: [new NullParser()], + renderer: renderer, + snapshotManager: snapshotManager, + diff: diff + ); + + private static ProjectDependencyGraph MakeEmptyGraph(string root) + { + var g = new ProjectDependencyGraph(root); + g.UpsertProjectItem(RelativePath.Directory(root, "./"), ProjectItemType.Directory); + return g; + } + + private string SavedFilePath(bool diff = false) + { + var diffPart = diff ? "-diff" : ""; + return Path.Combine(_fs.Root, "diagrams", $"TestProject{diffPart}-overview.json"); + } + + [Fact] + public async Task RunAsync_WhenFormatIsNone_DoesNotCallRenderer() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var stub = new StubSnapshotManager(null); + + var sut = MakeUseCase(RenderFormat.None, stub, spy); + await sut.RunAsync(); + + Assert.Equal(0, spy.RenderCalled); + } + + [Fact] + public async Task RunAsync_WhenFormatIsNone_StillSavesSnapshot() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var stub = new StubSnapshotManager(null); + + var sut = MakeUseCase(RenderFormat.None, stub, spy); + await sut.RunAsync(); + + Assert.Equal(1, stub.SaveCalled); + } + + [Fact] + public async Task RunAsync_WhenFormatIsNone_NoOutputFilesAreCreated() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var stub = new StubSnapshotManager(null); + + var sut = MakeUseCase(RenderFormat.None, stub, spy); + await sut.RunAsync(); + + Assert.False(File.Exists(SavedFilePath()), "Expected no output file when format is None."); + } + + [Fact] + public async Task RunAsync_WhenFormatIs_NotNoneAndNotDiff_CallsRenderViews() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var stub = new StubSnapshotManager(null); + + var sut = MakeUseCase(RenderFormat.Json, stub, spy); + await sut.RunAsync(); + + Assert.Equal(1, spy.RenderCalled); + } + + [Fact] + public async Task RunAsync_WhenFormatIs_NotNoneAndNotDiff_CreatesOutputFile() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var stub = new StubSnapshotManager(null); + + var sut = MakeUseCase(RenderFormat.Json, stub, spy); + await sut.RunAsync(); + + Assert.True(File.Exists(SavedFilePath()), "Expected output file to be written."); + Assert.False(File.Exists(SavedFilePath(diff: true)), "Expected no diff output file."); + } + + [Fact] + public async Task RunAsync_WhenFormatIs_NotNoneAndNotDiff_StillSavesSnapshot() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var stub = new StubSnapshotManager(null); + + var sut = MakeUseCase(RenderFormat.Json, stub, spy); + await sut.RunAsync(); + + Assert.Equal(1, stub.SaveCalled); + } + + [Fact] + public async Task RunAsync_WhenDiffMode_AndSnapshotExists_CreatesDiffOutputFile() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var snapshot = MakeEmptyGraph(_fs.Root); + var stub = new StubSnapshotManager(snapshot); + + var sut = MakeUseCase(RenderFormat.Json, stub, spy, diff: true); + await sut.RunAsync(); + + Assert.True(File.Exists(SavedFilePath(diff: true)), "Expected diff output file to be written."); + Assert.False(File.Exists(SavedFilePath(diff: false)), "Expected no regular output file in diff mode."); + } + + [Fact] + public async Task RunAsync_WhenDiffMode_AndSnapshotExists_StillSavesSnapshot() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var snapshot = MakeEmptyGraph(_fs.Root); + var stub = new StubSnapshotManager(snapshot); + + var sut = MakeUseCase(RenderFormat.Json, stub, spy, diff: true); + await sut.RunAsync(); + + Assert.Equal(1, stub.SaveCalled); + } + + [Fact] + public async Task RunAsync_WhenDiffMode_AndNoSnapshot_ThrowsInvalidOperationException() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var stub = new StubSnapshotManager(null); + + var sut = MakeUseCase(RenderFormat.Json, stub, spy, diff: true); + + await Assert.ThrowsAsync(() => sut.RunAsync()); + } + + [Fact] + public async Task RunAsync_PropagatesCancellation() + { + _fs.File("src/A.cs", "class A {}"); + var spy = new RendererSpy(); + var stub = new StubSnapshotManager(null); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var sut = MakeUseCase(RenderFormat.None, stub, spy); + + await Assert.ThrowsAnyAsync(() => sut.RunAsync(cts.Token)); + } +} diff --git a/src/ArchlensTests/Infra/Renderers/NoneRendererTests.cs b/src/ArchlensTests/Infra/Renderers/NoneRendererTests.cs new file mode 100644 index 00000000..75506bee --- /dev/null +++ b/src/ArchlensTests/Infra/Renderers/NoneRendererTests.cs @@ -0,0 +1,40 @@ +using Archlens.Domain.Models; +using Archlens.Domain.Models.Enums; +using Archlens.Domain.Models.Records; +using Archlens.Infra.Renderers; +using ArchlensTests.Utils; + +namespace ArchlensTests.Infra.Renderers; + +public sealed class NoneRendererTests : IDisposable +{ + private readonly TestFileSystem _fs = new(); + private readonly NoneRenderer _renderer = new(); + + public void Dispose() => _fs.Dispose(); + + private RenderOptions Opts( + string viewName = "testView", + IReadOnlyList? packages = null, + IReadOnlyList? ignore = null) => new( + BaseOptions: new( + ProjectRoot: _fs.Root, + ProjectName: "Archlens", + FullRootPath: _fs.Root), + Format: RenderFormat.None, + Views: [new View(viewName, packages ?? [], ignore ?? [])], + SaveLocation: $"{_fs.Root}/diagrams"); + + private ProjectDependencyGraph DefaultGraph() => + TestDependencyGraph.MakeDependencyGraph(_fs.Root); + + private string Render(ProjectDependencyGraph graph, RenderOptions opts) => + _renderer.RenderView(graph, opts.Views[0], opts); + + [Fact] + public void Render_ReturnsEmptyString() + { + var result = Render(DefaultGraph(), Opts()); + Assert.Empty(result); + } +} diff --git a/src/c-sharp/Application/UpdateGraphUseCase.cs b/src/c-sharp/Application/UpdateGraphUseCase.cs index dc6de74f..1cc1c109 100644 --- a/src/c-sharp/Application/UpdateGraphUseCase.cs +++ b/src/c-sharp/Application/UpdateGraphUseCase.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Archlens.Domain; using Archlens.Domain.Interfaces; +using Archlens.Domain.Models.Enums; using Archlens.Domain.Models.Records; namespace Archlens.Application; @@ -25,13 +26,16 @@ public async Task RunAsync(CancellationToken ct = default) var projectChanges = await ChangeDetector.GetProjectChangesAsync(parserOptions, snapshotGraph, ct); var graph = await new DependencyGraphBuilder(parsers, baseOptions).GetGraphAsync(projectChanges, snapshotGraph, ct); - if (diff) + if (renderOptions.Format != RenderFormat.None) { - var compareGraph = await snapshotManager.GetLastSavedDependencyGraphAsync(snapshotOptions, ct) ?? throw new InvalidOperationException("Diff mode requires a saved snapshot, but none was found."); - await renderer.RenderDiffViewsAndSaveToFiles(graph, compareGraph, renderOptions, ct); + if (diff) + { + var compareGraph = await snapshotManager.GetLastSavedDependencyGraphAsync(snapshotOptions, ct) ?? throw new InvalidOperationException("Diff mode requires a saved snapshot, but none was found."); + await renderer.RenderDiffViewsAndSaveToFiles(graph, compareGraph, renderOptions, ct); + } + else + await renderer.RenderViewsAndSaveToFiles(graph, renderOptions, ct); } - else - await renderer.RenderViewsAndSaveToFiles(graph, renderOptions, ct); await snapshotManager.SaveGraphAsync(graph, snapshotOptions, ct); } diff --git a/src/c-sharp/Domain/Models/Enums/RenderFormat.cs b/src/c-sharp/Domain/Models/Enums/RenderFormat.cs index fe435ead..aa9efb20 100644 --- a/src/c-sharp/Domain/Models/Enums/RenderFormat.cs +++ b/src/c-sharp/Domain/Models/Enums/RenderFormat.cs @@ -2,6 +2,7 @@ namespace Archlens.Domain.Models.Enums; public enum RenderFormat { + None, Json, PlantUML } @@ -12,6 +13,7 @@ public static string ToFileExtension(this RenderFormat format) { return format switch { + RenderFormat.None => "none", RenderFormat.Json => "json", RenderFormat.PlantUML => "puml", _ => format.ToString(), diff --git a/src/c-sharp/Infra/ConfigManager.cs b/src/c-sharp/Infra/ConfigManager.cs index 20818445..6600631e 100644 --- a/src/c-sharp/Infra/ConfigManager.cs +++ b/src/c-sharp/Infra/ConfigManager.cs @@ -222,6 +222,7 @@ private static RenderFormat MapFormat(string raw) var s = raw.Trim().ToLowerInvariant(); return s switch { + "none" => RenderFormat.None, "json" or "application/json" => RenderFormat.Json, "puml" or "plantuml" or "plant-uml" => RenderFormat.PlantUML, _ => throw new NotSupportedException($"Unsupported format: '{raw}'.") diff --git a/src/c-sharp/Infra/Factories/RendererFactory.cs b/src/c-sharp/Infra/Factories/RendererFactory.cs index 4ba573fa..aa074956 100644 --- a/src/c-sharp/Infra/Factories/RendererFactory.cs +++ b/src/c-sharp/Infra/Factories/RendererFactory.cs @@ -10,6 +10,7 @@ public static class RendererFactory { public static RendererBase SelectRenderer(RenderOptions options) => options.Format switch { + RenderFormat.None => new NoneRenderer(), RenderFormat.Json => new JsonRenderer(), RenderFormat.PlantUML => new PlantUMLRenderer(), _ => throw new ArgumentOutOfRangeException(nameof(options)) diff --git a/src/c-sharp/Infra/Renderers/NoneRenderer.cs b/src/c-sharp/Infra/Renderers/NoneRenderer.cs new file mode 100644 index 00000000..241c411d --- /dev/null +++ b/src/c-sharp/Infra/Renderers/NoneRenderer.cs @@ -0,0 +1,16 @@ +using System; +using Archlens.Domain; +using Archlens.Domain.Models.Records; + +namespace Archlens.Infra.Renderers; + +public sealed class NoneRenderer : RendererBase +{ + public override string FileExtension => ""; + + protected override string Render(RenderGraph graph, View view, RenderOptions options) + { + Console.WriteLine("Info: Renderer is none - no output will be rendered."); + return ""; + } +} \ No newline at end of file