Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions TcBuilder.Tests/Commands/BuildCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Fast unit tests for the <c>build</c> verb — no TwinCAT, no filesystem
/// beyond an empty placeholder .sln. Verifies that CLI parsing dispatches
/// the right <see cref="BuildRequest"/> to the service.
/// </summary>
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();

Check warning on line 24 in TcBuilder.Tests/Commands/BuildCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 24 in TcBuilder.Tests/Commands/BuildCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

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();

Check warning on line 38 in TcBuilder.Tests/Commands/BuildCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 38 in TcBuilder.Tests/Commands/BuildCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

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();

Check warning on line 54 in TcBuilder.Tests/Commands/BuildCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 54 in TcBuilder.Tests/Commands/BuildCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

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();

Check warning on line 70 in TcBuilder.Tests/Commands/BuildCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 70 in TcBuilder.Tests/Commands/BuildCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

exitCode.ShouldBe(1);
}

private static RootCommand BuildRoot(ITwinCatBuildService service)
{
BuildCommand command = new(service, NullLogger<BuildCommand>.Instance);
RootCommand root = new();
root.Subcommands.Add(command);
return root;
}
}
2 changes: 2 additions & 0 deletions TcBuilder.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
global using Xunit;
global using Shouldly;
36 changes: 36 additions & 0 deletions TcBuilder.Tests/Infrastructure/FakeTwinCatBuildService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using TcBuilder.Services;

namespace TcBuilder.Tests.Infrastructure;

/// <summary>
/// Hand-rolled test double for <see cref="ITwinCatBuildService"/>.
/// Records the arguments it receives and returns whatever the test configured.
/// </summary>
internal sealed class FakeTwinCatBuildService : ITwinCatBuildService
{
public List<BuildRequest> BuildInvocations { get; } = new();
public List<FileInfo> CleanInvocations { get; } = new();
public List<FileInfo> 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<BuildResult> BuildAsync(BuildRequest request, CancellationToken cancellationToken)
{
BuildInvocations.Add(request);
return Task.FromResult(NextBuildResult);
}

public Task<BuildResult> CleanAsync(FileInfo solution, CancellationToken cancellationToken)
{
CleanInvocations.Add(solution);
return Task.FromResult(NextCleanResult);
}

public Task<SolutionInfo> GetInfoAsync(FileInfo solution, CancellationToken cancellationToken)
{
InfoInvocations.Add(solution);
return Task.FromResult(NextInfo);
}
}
39 changes: 39 additions & 0 deletions TcBuilder.Tests/Infrastructure/TempSolutionFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace TcBuilder.Tests.Infrastructure;

/// <summary>
/// Creates an empty throwaway <c>.sln</c> file so command-wiring tests can
/// satisfy the "solution must exist" check without a real TwinCAT project.
/// </summary>
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;
}
}
93 changes: 93 additions & 0 deletions TcBuilder.Tests/Infrastructure/TwinCatProjectFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
namespace TcBuilder.Tests.Infrastructure;

/// <summary>
/// Copies a named TwinCAT test asset into a fresh temp directory and cleans
/// it up on disposal. Assets live under <c>TestAssets/</c> in the test
/// project and are copied next to the test assembly at build time.
/// </summary>
/// <example>
/// <code>
/// await using var fixture = TwinCatProjectFixture.Load("MinimalTwinCatSolution");
/// var result = await service.BuildAsync(
/// new BuildRequest(fixture.SolutionFile, "Release", null),
/// CancellationToken.None);
/// </code>
/// </example>
internal sealed class TwinCatProjectFixture : IAsyncDisposable
{
/// <summary>The fresh temp directory containing the copied asset.</summary>
public DirectoryInfo WorkingDirectory { get; }

/// <summary>The first <c>.sln</c> discovered under <see cref="WorkingDirectory"/>.</summary>
public FileInfo SolutionFile { get; }

private TwinCatProjectFixture(DirectoryInfo workingDirectory, FileInfo solutionFile)
{
WorkingDirectory = workingDirectory;
SolutionFile = solutionFile;
}

/// <summary>
/// Load the asset at <c>TestAssets/{assetName}/</c> into a new temp dir.
/// </summary>
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);
}
}
}
52 changes: 52 additions & 0 deletions TcBuilder.Tests/Integration/BuildIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging.Abstractions;
using TcBuilder.Services;
using TcBuilder.Tests.Infrastructure;

namespace TcBuilder.Tests.Integration;

/// <summary>
/// End-to-end tests that exercise the real <see cref="TwinCatBuildService"/>
/// 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 <c>TestAssets/MinimalTwinCatSolution/</c> is populated.
/// </summary>
[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<TwinCatBuildService>.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<TwinCatBuildService>.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);
}
}
32 changes: 32 additions & 0 deletions TcBuilder.Tests/TcBuilder.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>

<!-- xUnit v3 runs on Microsoft.Testing.Platform; test projects are console apps. -->
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="Shouldly" Version="4.3.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TcBuilder\TcBuilder.csproj" />
</ItemGroup>

<ItemGroup>
<!--
Anything under TestAssets/ is copied next to the test assembly at build
time. Drop fixture TwinCAT solutions here (see TestAssets/README.md).
-->
<None Include="TestAssets\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
33 changes: 33 additions & 0 deletions TcBuilder.Tests/TestAssets/README.md
Original file line number Diff line number Diff line change
@@ -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 `<None Include="TestAssets\**\*" />` 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")`.
Loading
Loading