diff --git a/TcBuilder.Tests/Commands/BuildCommandTests.cs b/TcBuilder.Tests/Commands/BuildCommandTests.cs new file mode 100644 index 0000000..ed0dbb8 --- /dev/null +++ b/TcBuilder.Tests/Commands/BuildCommandTests.cs @@ -0,0 +1,82 @@ +using System.CommandLine; +using Microsoft.Extensions.Logging.Abstractions; +using TcBuilder.Commands; +using TcBuilder.Services; +using TcBuilder.Tests.Infrastructure; + +namespace TcBuilder.Tests.Commands; + +/// +/// Fast unit tests for the build verb — no TwinCAT, no filesystem +/// beyond an empty placeholder .sln. Verifies that CLI parsing dispatches +/// the right to the service. +/// +public sealed class BuildCommandTests +{ + [Fact] + public async Task Fails_When_Solution_Does_Not_Exist() + { + FakeTwinCatBuildService fake = new(); + RootCommand root = BuildRoot(fake); + + string missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.sln"); + + int exitCode = await root.Parse(["build", missing]).InvokeAsync(); + + exitCode.ShouldBe(1); + fake.BuildInvocations.ShouldBeEmpty(); + } + + [Fact] + public async Task Passes_Configuration_Flag_To_Service() + { + FakeTwinCatBuildService fake = new(); + RootCommand root = BuildRoot(fake); + + await using TempSolutionFile sln = TempSolutionFile.Create(); + + int exitCode = await root.Parse(["build", sln.Path, "-c", "Debug"]).InvokeAsync(); + + exitCode.ShouldBe(0); + fake.BuildInvocations.Count.ShouldBe(1); + fake.BuildInvocations[0].Configuration.ShouldBe("Debug"); + fake.BuildInvocations[0].Solution.FullName.ShouldBe(sln.Path); + } + + [Fact] + public async Task Defaults_Configuration_To_Release() + { + FakeTwinCatBuildService fake = new(); + RootCommand root = BuildRoot(fake); + + await using TempSolutionFile sln = TempSolutionFile.Create(); + + await root.Parse(["build", sln.Path]).InvokeAsync(); + + fake.BuildInvocations[0].Configuration.ShouldBe("Release"); + } + + [Fact] + public async Task Returns_Non_Zero_When_Build_Fails() + { + FakeTwinCatBuildService fake = new() + { + NextBuildResult = BuildResult.Fail(TimeSpan.Zero, "boom"), + }; + RootCommand root = BuildRoot(fake); + + await using TempSolutionFile sln = TempSolutionFile.Create(); + + int exitCode = await root.Parse(["build", sln.Path]).InvokeAsync(); + + exitCode.ShouldBe(1); + } + + private static RootCommand BuildRoot(ITwinCatBuildService service) + { + BuildCommand command = new(service, NullLogger.Instance); + RootCommand root = new(); + root.Subcommands.Add(command); + return root; + } +} diff --git a/TcBuilder.Tests/GlobalUsings.cs b/TcBuilder.Tests/GlobalUsings.cs new file mode 100644 index 0000000..b37457e --- /dev/null +++ b/TcBuilder.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using Shouldly; diff --git a/TcBuilder.Tests/Infrastructure/FakeTwinCatBuildService.cs b/TcBuilder.Tests/Infrastructure/FakeTwinCatBuildService.cs new file mode 100644 index 0000000..e825dc9 --- /dev/null +++ b/TcBuilder.Tests/Infrastructure/FakeTwinCatBuildService.cs @@ -0,0 +1,36 @@ +using TcBuilder.Services; + +namespace TcBuilder.Tests.Infrastructure; + +/// +/// Hand-rolled test double for . +/// Records the arguments it receives and returns whatever the test configured. +/// +internal sealed class FakeTwinCatBuildService : ITwinCatBuildService +{ + public List BuildInvocations { get; } = new(); + public List CleanInvocations { get; } = new(); + public List InfoInvocations { get; } = new(); + + public BuildResult NextBuildResult { get; set; } = BuildResult.Ok(TimeSpan.Zero); + public BuildResult NextCleanResult { get; set; } = BuildResult.Ok(TimeSpan.Zero); + public SolutionInfo NextInfo { get; set; } = new("stub", "stub", 0); + + public Task BuildAsync(BuildRequest request, CancellationToken cancellationToken) + { + BuildInvocations.Add(request); + return Task.FromResult(NextBuildResult); + } + + public Task CleanAsync(FileInfo solution, CancellationToken cancellationToken) + { + CleanInvocations.Add(solution); + return Task.FromResult(NextCleanResult); + } + + public Task GetInfoAsync(FileInfo solution, CancellationToken cancellationToken) + { + InfoInvocations.Add(solution); + return Task.FromResult(NextInfo); + } +} diff --git a/TcBuilder.Tests/Infrastructure/TempSolutionFile.cs b/TcBuilder.Tests/Infrastructure/TempSolutionFile.cs new file mode 100644 index 0000000..895a720 --- /dev/null +++ b/TcBuilder.Tests/Infrastructure/TempSolutionFile.cs @@ -0,0 +1,39 @@ +namespace TcBuilder.Tests.Infrastructure; + +/// +/// Creates an empty throwaway .sln file so command-wiring tests can +/// satisfy the "solution must exist" check without a real TwinCAT project. +/// +internal sealed class TempSolutionFile : IAsyncDisposable +{ + public string Path { get; } + + private TempSolutionFile(string path) => Path = path; + + public static TempSolutionFile Create() + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"tcbuilder-tests-{Guid.NewGuid():N}.sln"); + + File.WriteAllText(path, "Microsoft Visual Studio Solution File, Format Version 12.00" + Environment.NewLine); + return new TempSolutionFile(path); + } + + public ValueTask DisposeAsync() + { + try + { + if (File.Exists(Path)) + { + File.Delete(Path); + } + } + catch + { + // best effort — tests shouldn't fail because of cleanup + } + + return ValueTask.CompletedTask; + } +} diff --git a/TcBuilder.Tests/Infrastructure/TwinCatProjectFixture.cs b/TcBuilder.Tests/Infrastructure/TwinCatProjectFixture.cs new file mode 100644 index 0000000..d357d65 --- /dev/null +++ b/TcBuilder.Tests/Infrastructure/TwinCatProjectFixture.cs @@ -0,0 +1,93 @@ +namespace TcBuilder.Tests.Infrastructure; + +/// +/// Copies a named TwinCAT test asset into a fresh temp directory and cleans +/// it up on disposal. Assets live under TestAssets/ in the test +/// project and are copied next to the test assembly at build time. +/// +/// +/// +/// await using var fixture = TwinCatProjectFixture.Load("MinimalTwinCatSolution"); +/// var result = await service.BuildAsync( +/// new BuildRequest(fixture.SolutionFile, "Release", null), +/// CancellationToken.None); +/// +/// +internal sealed class TwinCatProjectFixture : IAsyncDisposable +{ + /// The fresh temp directory containing the copied asset. + public DirectoryInfo WorkingDirectory { get; } + + /// The first .sln discovered under . + public FileInfo SolutionFile { get; } + + private TwinCatProjectFixture(DirectoryInfo workingDirectory, FileInfo solutionFile) + { + WorkingDirectory = workingDirectory; + SolutionFile = solutionFile; + } + + /// + /// Load the asset at TestAssets/{assetName}/ into a new temp dir. + /// + public static TwinCatProjectFixture Load(string assetName) + { + string source = Path.Combine(AppContext.BaseDirectory, "TestAssets", assetName); + if (!Directory.Exists(source)) + { + throw new DirectoryNotFoundException( + $"Test asset '{assetName}' not found at '{source}'. " + + $"Drop a TwinCAT solution into TcBuilder.Tests/TestAssets/{assetName}/ and rebuild."); + } + + string destination = Path.Combine( + Path.GetTempPath(), + "tcbuilder-tests", + Guid.NewGuid().ToString("N")); + + CopyDirectory(source, destination); + + string[] solutions = Directory.GetFiles(destination, "*.sln", SearchOption.AllDirectories); + if (solutions.Length == 0) + { + throw new FileNotFoundException( + $"No .sln file found under the copied asset at '{destination}'."); + } + + return new TwinCatProjectFixture( + new DirectoryInfo(destination), + new FileInfo(solutions[0])); + } + + public ValueTask DisposeAsync() + { + try + { + if (WorkingDirectory.Exists) + { + WorkingDirectory.Delete(recursive: true); + } + } + catch + { + // best effort + } + + return ValueTask.CompletedTask; + } + + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + + foreach (string dir in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(dir.Replace(source, destination)); + } + + foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + File.Copy(file, file.Replace(source, destination), overwrite: true); + } + } +} diff --git a/TcBuilder.Tests/Integration/BuildIntegrationTests.cs b/TcBuilder.Tests/Integration/BuildIntegrationTests.cs new file mode 100644 index 0000000..4325140 --- /dev/null +++ b/TcBuilder.Tests/Integration/BuildIntegrationTests.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Logging.Abstractions; +using TcBuilder.Services; +using TcBuilder.Tests.Infrastructure; + +namespace TcBuilder.Tests.Integration; + +/// +/// End-to-end tests that exercise the real +/// against a fixture TwinCAT solution copied to a fresh temp directory. +/// These require TwinCAT / TcXaeShell on the machine running the tests and +/// stay skipped until TestAssets/MinimalTwinCatSolution/ is populated. +/// +[Trait("Category", "Integration")] +public sealed class BuildIntegrationTests +{ + private const string MinimalAsset = "MinimalTwinCatSolution"; + + [Fact(Skip = "Enable once TcBuilder.Tests/TestAssets/MinimalTwinCatSolution is populated.")] + public async Task Builds_The_Minimal_Fixture_Solution() + { + await using TwinCatProjectFixture fixture = TwinCatProjectFixture.Load(MinimalAsset); + + TwinCatBuildService service = new(NullLogger.Instance); + + BuildRequest request = new( + Solution: fixture.SolutionFile, + Configuration: "Release", + OutputDirectory: null); + + BuildResult result = await service.BuildAsync(request, TestContext.Current.CancellationToken); + + result.Success.ShouldBeTrue(result.Message); + } + + [Fact(Skip = "Enable once TcBuilder.Tests/TestAssets/MinimalTwinCatSolution is populated.")] + public async Task Clean_Then_Build_Yields_A_Fresh_Artifact() + { + await using TwinCatProjectFixture fixture = TwinCatProjectFixture.Load(MinimalAsset); + + TwinCatBuildService service = new(NullLogger.Instance); + CancellationToken ct = TestContext.Current.CancellationToken; + + BuildResult clean = await service.CleanAsync(fixture.SolutionFile, ct); + clean.Success.ShouldBeTrue(clean.Message); + + BuildResult build = await service.BuildAsync( + new BuildRequest(fixture.SolutionFile, "Release", null), + ct); + + build.Success.ShouldBeTrue(build.Message); + } +} diff --git a/TcBuilder.Tests/TcBuilder.Tests.csproj b/TcBuilder.Tests/TcBuilder.Tests.csproj new file mode 100644 index 0000000..b8fbb5c --- /dev/null +++ b/TcBuilder.Tests/TcBuilder.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + latest + + + Exe + false + true + + + + + + + + + + + + + + + + + diff --git a/TcBuilder.Tests/TestAssets/README.md b/TcBuilder.Tests/TestAssets/README.md new file mode 100644 index 0000000..6dfa5e6 --- /dev/null +++ b/TcBuilder.Tests/TestAssets/README.md @@ -0,0 +1,33 @@ +# Test assets + +Drop fixture TwinCAT solutions here. Each subfolder is treated as a named +asset that tests load by name: + +```csharp +await using var fixture = TwinCatProjectFixture.Load("MinimalTwinCatSolution"); +// fixture.SolutionFile is the copied .sln in a fresh temp directory +// the directory is deleted on DisposeAsync +``` + +Everything under `TestAssets/` is copied next to the test assembly at build +time (see the `` item in +`TcBuilder.Tests.csproj`). At runtime the fixture resolves assets relative to +`AppContext.BaseDirectory`, so the copy-to-output mechanism is what makes the +layout below discoverable. + +## Expected contents + +### `MinimalTwinCatSolution/` (TODO) + +A stripped-down TwinCAT solution with a single PLC project — enough for +`TwinCatBuildService.BuildAsync` to exercise the full open-solution / +build-solution automation path. + +Once populated, remove the `Skip = "..."` from the tests in +`Integration/BuildIntegrationTests.cs`. + +## Adding more fixtures + +Any additional folder under `TestAssets/` becomes a new asset name. For +example, a `LargeMotionSolution/` folder would be loaded with +`TwinCatProjectFixture.Load("LargeMotionSolution")`. diff --git a/TcBuilder.sln b/TcBuilder.sln index 2b1190f..c5dcbed 100644 --- a/TcBuilder.sln +++ b/TcBuilder.sln @@ -1,9 +1,10 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.37027.9 d17.14 +VisualStudioVersion = 17.14.37027.9 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcBuilder", "TcBuilder.csproj", "{0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcBuilder", "TcBuilder\TcBuilder.csproj", "{0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcBuilder.Tests", "TcBuilder.Tests\TcBuilder.Tests.csproj", "{64BB703E-B925-61B5-C243-4EEBAAB8C36D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,10 +16,10 @@ Global {0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}.Debug|Any CPU.Build.0 = Debug|Any CPU {0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}.Release|Any CPU.ActiveCfg = Release|Any CPU {0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}.Release|Any CPU.Build.0 = Release|Any CPU - {8E7F3B2A-9C4D-4E1F-A0B2-1C3D4E5F6789}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8E7F3B2A-9C4D-4E1F-A0B2-1C3D4E5F6789}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8E7F3B2A-9C4D-4E1F-A0B2-1C3D4E5F6789}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8E7F3B2A-9C4D-4E1F-A0B2-1C3D4E5F6789}.Release|Any CPU.Build.0 = Release|Any CPU + {64BB703E-B925-61B5-C243-4EEBAAB8C36D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64BB703E-B925-61B5-C243-4EEBAAB8C36D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64BB703E-B925-61B5-C243-4EEBAAB8C36D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64BB703E-B925-61B5-C243-4EEBAAB8C36D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Commands/BuildCommand.cs b/TcBuilder/Commands/BuildCommand.cs similarity index 97% rename from Commands/BuildCommand.cs rename to TcBuilder/Commands/BuildCommand.cs index 25e3785..c8ceb39 100644 --- a/Commands/BuildCommand.cs +++ b/TcBuilder/Commands/BuildCommand.cs @@ -7,7 +7,7 @@ namespace TcBuilder.Commands; /// /// tcbuilder build <solution> [--configuration] [--output] /// -internal sealed class BuildCommand : Command +public class BuildCommand : Command { public BuildCommand(ITwinCatBuildService buildService, ILogger logger) : base("build", "Build a TwinCAT solution.") diff --git a/Commands/CleanCommand.cs b/TcBuilder/Commands/CleanCommand.cs similarity index 100% rename from Commands/CleanCommand.cs rename to TcBuilder/Commands/CleanCommand.cs diff --git a/Commands/InfoCommand.cs b/TcBuilder/Commands/InfoCommand.cs similarity index 100% rename from Commands/InfoCommand.cs rename to TcBuilder/Commands/InfoCommand.cs diff --git a/Program.cs b/TcBuilder/Program.cs similarity index 100% rename from Program.cs rename to TcBuilder/Program.cs diff --git a/Services/ITwinCatBuildService.cs b/TcBuilder/Services/ITwinCatBuildService.cs similarity index 80% rename from Services/ITwinCatBuildService.cs rename to TcBuilder/Services/ITwinCatBuildService.cs index d013a42..8ddf523 100644 --- a/Services/ITwinCatBuildService.cs +++ b/TcBuilder/Services/ITwinCatBuildService.cs @@ -4,7 +4,7 @@ namespace TcBuilder.Services; /// Abstraction over the TwinCAT solution build pipeline. /// Keeps command handlers free of automation details (DTE, MSBuild, etc.). /// -internal interface ITwinCatBuildService +public interface ITwinCatBuildService { Task BuildAsync(BuildRequest request, CancellationToken cancellationToken); @@ -14,13 +14,13 @@ internal interface ITwinCatBuildService } /// Inputs for a single build invocation. -internal sealed record BuildRequest( +public record BuildRequest( FileInfo Solution, string Configuration, DirectoryInfo? OutputDirectory); /// Outcome of a build or clean. -internal sealed record BuildResult(bool Success, string? Message, TimeSpan Elapsed) +public record BuildResult(bool Success, string? Message, TimeSpan Elapsed) { public static BuildResult Ok(TimeSpan elapsed, string? message = null) => new(true, message, elapsed); @@ -30,4 +30,4 @@ public static BuildResult Fail(TimeSpan elapsed, string message) => } /// Surface-level metadata about a TwinCAT solution. -internal sealed record SolutionInfo(string Name, string Path, int ProjectCount); +public record SolutionInfo(string Name, string Path, int ProjectCount); diff --git a/Services/TwinCatBuildService.cs b/TcBuilder/Services/TwinCatBuildService.cs similarity index 97% rename from Services/TwinCatBuildService.cs rename to TcBuilder/Services/TwinCatBuildService.cs index 2f4f20e..979737f 100644 --- a/Services/TwinCatBuildService.cs +++ b/TcBuilder/Services/TwinCatBuildService.cs @@ -8,7 +8,7 @@ namespace TcBuilder.Services; /// This is a scaffold — the real automation (TcXaeShell / Visual Studio DTE) /// hangs off the TODOs below. /// -internal sealed class TwinCatBuildService : ITwinCatBuildService +public class TwinCatBuildService : ITwinCatBuildService { private readonly ILogger _logger; diff --git a/TcBuilder.csproj b/TcBuilder/TcBuilder.csproj similarity index 100% rename from TcBuilder.csproj rename to TcBuilder/TcBuilder.csproj