Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Coster test coverage #294

Merged
merged 5 commits into from
Oct 25, 2024
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
14 changes: 13 additions & 1 deletion coster/src/AzureVmCoster/Models/CosterConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
using AzureVmCoster.Services;

namespace AzureVmCoster.Models;

internal sealed class CosterConfiguration
{
public IList<string> ExcludedVms { get; set; } = default!;
public IList<string> ExcludedVms { get; set; } = new List<string>();

public static async Task<CosterConfiguration> FromPathAsync(string? path)
{
if (!string.IsNullOrWhiteSpace(path))
{
return await JsonReader.DeserializeAsync<CosterConfiguration>(path) ?? new CosterConfiguration();
}

return new CosterConfiguration();
}
}
96 changes: 23 additions & 73 deletions coster/src/AzureVmCoster/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,35 @@ public static class Program

public static async Task<int> Main(string[] args)
{
ArgumentNullException.ThrowIfNull(args);

var (inputFilePath, configurationFilePath, culture) = ParseConfiguration(args);

if (string.IsNullOrWhiteSpace(inputFilePath))
try
{
Console.WriteLine("The input file path is required: -i <file-path>");
return -1;
}
ArgumentNullException.ThrowIfNull(args);

StripSurroundingDoubleQuotes(ref inputFilePath);
var (inputFilePath, configurationFilePath, culture) = ParseConfiguration(args);

var inputFile = new FileInfo(inputFilePath);
var inputFile = InputFileValidator.Validate(inputFilePath);
var inputVms = InputVmParser.Parse(inputFile, culture);

if (!inputFile.Exists)
{
Console.WriteLine($"The file '{inputFile.FullName}' is not accessible");
return -1;
}
var pricer = new Pricer(PricingDirectory);
pricer.EnsurePricingExists(inputVms);

if (!".csv".Equals(inputFile.Extension, StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine($"The file '{inputFile.FullName}' doesn't have a '.csv' extension");
return -1;
}
var configuration = await CosterConfiguration.FromPathAsync(configurationFilePath);

var configuration = new CosterConfiguration();
var pricings = await new VmPricingParser(PricingDirectory).ParseAsync();
pricings = Pricer.FilterPricing(pricings, configuration.ExcludedVms);

if (!string.IsNullOrWhiteSpace(configurationFilePath))
{
StripSurroundingDoubleQuotes(ref configurationFilePath);
var pricedVms = Pricer.Price(inputVms, pricings);
PricedVmWriter.Write(inputFile.Name, pricedVms, culture);

configuration = await JsonReader.DeserializeAsync<CosterConfiguration>(configurationFilePath) ?? new CosterConfiguration();
return 0;
}
#pragma warning disable CA1031 // This is a catch-all so that we can log the exception
catch (Exception e)
#pragma warning restore CA1031
{
Console.WriteLine(e);
return -1;
}

var inputVms = InputVmParser.Parse(inputFile, culture);

var pricer = new Pricer(PricingDirectory);
pricer.EnsurePricingExists(inputVms);

var pricings = await new VmPricingParser(PricingDirectory).ParseAsync();
pricings = Pricer.FilterPricing(pricings, configuration.ExcludedVms);

var pricedVms = Pricer.Price(inputVms, pricings);
PricedVmWriter.Write(inputFile.Name, pricedVms, culture);

return 0;
}

private static (string? inputFilePath, string? configurationFilePath, CultureInfo culture) ParseConfiguration(string[] args)
Expand All @@ -66,9 +48,11 @@ private static (string? inputFilePath, string? configurationFilePath, CultureInf
#if DEBUG
Console.Write("Input file path: ");
inputFilePath = Console.ReadLine();
ArgumentReader.StripSurroundingDoubleQuotes(ref inputFilePath);

Console.Write("Configuration file path (leave blank if not used): ");
configurationFilePath = Console.ReadLine();
ArgumentReader.StripSurroundingDoubleQuotes(ref configurationFilePath);

Console.Write("Culture (leave blank for system default): ");
var cultureInput = Console.ReadLine();
Expand All @@ -78,43 +62,9 @@ private static (string? inputFilePath, string? configurationFilePath, CultureInf
culture = new CultureInfo(cultureInput);
}
#else
for (var offset = 0; offset < args.Length; offset += 2)
{
switch (args[offset])
{
case "-l":
case "--culture":
culture = new CultureInfo(args[offset + 1]);
break;
case "-i":
case "--input":
inputFilePath = args[offset + 1];
break;
case "-c":
case "--configuration":
configurationFilePath = args[offset + 1];
break;
default:
Console.WriteLine($"'{args[offset]}' is not a known switch, supported values are: '-l', '--culture', '-i', '--input', '-c', '--configuration'");
break;
}
}
(inputFilePath, configurationFilePath, culture) = ArgumentReader.Read(args);
#endif

return (inputFilePath, configurationFilePath, culture);
}

/// <summary>
/// <para>Strip starting and trailing double quotes if present.</para>
/// <para>When copying a path from the Explorer, Windows surrounds the path with double quotes so that it remains
/// usable if a space is present.</para>
/// </summary>
/// <param name="filePath">The reference will be assigned to only if the path starts with ".</param>
private static void StripSurroundingDoubleQuotes(ref string filePath)
{
if (filePath.StartsWith('"'))
{
filePath = filePath.Replace("\"", "", StringComparison.Ordinal);
}
}
}
51 changes: 51 additions & 0 deletions coster/src/AzureVmCoster/Services/ArgumentReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace AzureVmCoster.Services;

internal static class ArgumentReader
{
public static (string? inputFilePath, string? configurationFilePath, CultureInfo culture) Read(string[] args)
{
string? inputFilePath = null;
string? configurationFilePath = null;
var culture = Thread.CurrentThread.CurrentCulture;

for (var offset = 0; offset < args.Length; offset += 2)
{
switch (args[offset])
{
case "-l":
case "--culture":
culture = new CultureInfo(args[offset + 1]);
break;
case "-i":
case "--input":
inputFilePath = args[offset + 1];
StripSurroundingDoubleQuotes(ref inputFilePath);
break;
case "-c":
case "--configuration":
configurationFilePath = args[offset + 1];
StripSurroundingDoubleQuotes(ref configurationFilePath);
break;
default:
Console.WriteLine($"'{args[offset]}' is not a known switch, supported values are: '-l', '--culture', '-i', '--input', '-c', '--configuration'");
break;
}
}

return (inputFilePath, configurationFilePath, culture);
}

/// <summary>
/// <para>Strip starting and trailing double quotes if present.</para>
/// <para>When copying a path from the Explorer, Windows surrounds the path with double quotes so that it remains
/// usable if a space is present.</para>
/// </summary>
/// <param name="filePath">The reference will be assigned to only if the path starts with ".</param>
public static void StripSurroundingDoubleQuotes(ref string? filePath)
{
if (filePath != null && filePath.StartsWith('"'))
{
filePath = filePath.Replace("\"", "", StringComparison.Ordinal);
}
}
}
26 changes: 26 additions & 0 deletions coster/src/AzureVmCoster/Services/InputFileValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace AzureVmCoster.Services;

internal static class InputFileValidator
{
public static FileInfo Validate(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentOutOfRangeException(nameof(path), "The input file path is required: -i <file-path>");
}

var inputFile = new FileInfo(path);

if (!inputFile.Exists)
{
throw new InvalidOperationException($"The file '{inputFile.FullName}' is not accessible");
}

if (!".csv".Equals(inputFile.Extension, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentOutOfRangeException(nameof(path), $"The file '{inputFile.FullName}' doesn't have a '.csv' extension");
}

return inputFile;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace AzureVmCosterTests.Models;

public class CosterConfigurationTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task GivenNoFileProvided_ThenDefaultConfiguration(string? path)
{
// Act
var actualConfiguration = await CosterConfiguration.FromPathAsync(path);

// Assert
actualConfiguration.ExcludedVms.Should().BeEmpty();
}

[Fact]
public async Task GivenValidConfigurationFile_ThenReadExcludedVms()
{
// Arrange
const string path = "TestFiles/Configuration/configuration.json";

// Act
var actualConfiguration = await CosterConfiguration.FromPathAsync(path);

// Assert
var expectedExcludedVms = new List<string> { "B2ts v2" };
actualConfiguration.ExcludedVms.Should().BeEquivalentTo(expectedExcludedVms);
}
}
91 changes: 91 additions & 0 deletions coster/tests/AzureVmCosterTests/Services/ArgumentReaderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Globalization;
using AzureVmCoster.Services;

namespace AzureVmCosterTests.Services;

public class ArgumentReaderTests
{
[Fact]
public void GivenValidArguments_WhenShortNames_ThenReturnExpectedConfiguration()
{
// Arrange
var args = new[] { "-l", "en-us", "-i", "/tmp/input.json", "-c", "/tmp/config.json" };

// Act
var (inputFilePath, configurationFilePath, culture) = ArgumentReader.Read(args);

// Assert
inputFilePath.Should().Be("/tmp/input.json");
configurationFilePath.Should().Be("/tmp/config.json");
culture.Should().Be(new CultureInfo("en-us"));
}

[Fact]
public void GivenDoubleQuotePaths_ThenStripDoubleQuotes()
{
// Arrange
var args = new[] { "-i", @"""C:\tmp\input.json""", "-c", @"""C:\tmp\input\config.json""" };

// Act
var (inputFilePath, configurationFilePath, _) = ArgumentReader.Read(args);

// Assert
inputFilePath.Should().Be(@"C:\tmp\input.json");
configurationFilePath.Should().Be(@"C:\tmp\input\config.json");
}

[Fact]
public void GivenUnknownArgument_ThenIgnoreUnknownArgument()
{
// Arrange
var args = new[] { "--unknown", "ignored", "-i", "/tmp/input.json" };

// Act
var (inputFilePath, _, _) = ArgumentReader.Read(args);

// Assert
inputFilePath.Should().Be("/tmp/input.json");
}

[Fact]
public void GivenValidArguments_WhenLongNames_ThenReturnExpectedConfiguration()
{
// Arrange
var args = new[] { "--culture", "en-us", "--input", "/tmp/input.json", "--configuration", "/tmp/config.json" };

// Act
var (inputFilePath, configurationFilePath, culture) = ArgumentReader.Read(args);

// Assert
inputFilePath.Should().Be("/tmp/input.json");
configurationFilePath.Should().Be("/tmp/config.json");
culture.Should().Be(new CultureInfo("en-us"));
}

[Fact]
public void GivenNoCultureArgumentProvided_ThenUseThreadCulture()
{
// Arrange
var args = new[] { "-i", "/tmp/input.json", "-c", "/tmp/config.json" };

// Act
var thread = new Thread(AssertThreadCulture);
thread.Start();

var hasThreadTerminated = thread.Join(TimeSpan.FromSeconds(1));
hasThreadTerminated.Should().BeTrue();
return;

void AssertThreadCulture()
{
Thread.CurrentThread.CurrentCulture = new CultureInfo("pl-pl");

var (inputFilePath, configurationFilePath, culture) = ArgumentReader.Read(args);

// Assert
inputFilePath.Should().Be("/tmp/input.json");
configurationFilePath.Should().Be("/tmp/config.json");
culture.Should().Be(new CultureInfo("pl-pl"));
}
}
}
Loading