diff --git a/source/Core/CreativeCoders.Core/ObjectExtensions.cs b/source/Core/CreativeCoders.Core/ObjectExtensions.cs index a1f4ba60..014047e4 100644 --- a/source/Core/CreativeCoders.Core/ObjectExtensions.cs +++ b/source/Core/CreativeCoders.Core/ObjectExtensions.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; using System.Threading.Tasks; using JetBrains.Annotations; @@ -66,7 +69,7 @@ public static async ValueTask TryDisposeAsync(this object instance) throw new MissingMemberException(instance.GetType().Name, propertyName); } - return (T?) propInfo.GetValue(instance); + return (T?)propInfo.GetValue(instance); } public static void SetPropertyValue(this object instance, string propertyName, T? value) @@ -80,4 +83,19 @@ public static void SetPropertyValue(this object instance, string propertyName propInfo.SetValue(instance, value); } + + public static Dictionary ToDictionary(this object obj) + { + Ensure.NotNull(obj); + + return obj + .GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(propertyInfo => ReadProperty(obj, propertyInfo)) + .ToDictionary(x => x.PropertyName, x => x.PropertyValue); + } + + private static (string PropertyName, object? PropertyValue) ReadProperty(object obj, + PropertyInfo propertyInfo) + => (propertyInfo.Name, propertyInfo.GetValue(obj)); } diff --git a/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs b/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs new file mode 100644 index 00000000..fa8be59f --- /dev/null +++ b/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; + +namespace CreativeCoders.Core.Placeholders; + +#nullable enable + +public class PlaceholderReplacer( + string placeholderPrefix, + string placeholderSuffix, + IDictionary placeholders) +{ + private readonly string _placeholderPrefix = Ensure.IsNotNullOrWhitespace(placeholderPrefix); + + private readonly string _placeholderSuffix = Ensure.IsNotNullOrWhitespace(placeholderSuffix); + + private readonly IDictionary _placeholders = Ensure.NotNull(placeholders); + + public string Replace(string text, bool allowNull = false) + { + if (_placeholders.Count == 0) + { + return text; + } + + return _placeholders + .Aggregate(text, + (current, placeholder) => + current.Replace($"{_placeholderPrefix}{placeholder.Key}{_placeholderSuffix}", + placeholder.Value.ToStringSafe(allowNull ? "null" : string.Empty))); + } + + public IEnumerable Replace(IEnumerable lines, bool allowNull = false) + { + return _placeholders.Count == 0 + ? lines + : lines.Select(x => Replace(x, allowNull)); + } +} diff --git a/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs b/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs new file mode 100644 index 00000000..f3251fe8 --- /dev/null +++ b/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using CreativeCoders.Core.Placeholders; + +namespace CreativeCoders.Core.Text; + +#nullable enable + +public static class EnumerableStringExtensions +{ + public static Dictionary ToDictionary(this IEnumerable items, string separator, + bool ignoreInvalidEntries = true) + { + Ensure.NotNull(items); + Ensure.NotNull(separator); + + return items + .Select(x => x.SplitIntoKeyValue(separator)) + .Where(x => + { + if (x == null && !ignoreInvalidEntries) + { + throw new ArgumentException("Invalid key/value entry found"); + } + + return x != null; + }) + .ToDictionary(x => x.Key, x => x.Value); + } + + [ExcludeFromCodeCoverage] + public static IEnumerable ReplacePlaceholders(this IEnumerable items, + string placeholderPrefix, string placeholderSuffix, + IDictionary placeholders) + { + Ensure.NotNull(items); + + var replacer = new PlaceholderReplacer(placeholderPrefix, placeholderSuffix, placeholders); + + return replacer.Replace(items); + } +} diff --git a/source/Core/CreativeCoders.Core/Text/KeyAndValue.cs b/source/Core/CreativeCoders.Core/Text/KeyAndValue.cs new file mode 100644 index 00000000..7227088f --- /dev/null +++ b/source/Core/CreativeCoders.Core/Text/KeyAndValue.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace CreativeCoders.Core.Text; + +public class KeyAndValue(string key, string value) +{ + public string Key { get; } = Ensure.NotNull(key); + + public string Value { get; } = Ensure.NotNull(value); +} diff --git a/source/Core/CreativeCoders.Core/Text/StringExtension.cs b/source/Core/CreativeCoders.Core/Text/StringExtension.cs index 1efca63e..2e600c2d 100644 --- a/source/Core/CreativeCoders.Core/Text/StringExtension.cs +++ b/source/Core/CreativeCoders.Core/Text/StringExtension.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; @@ -190,4 +191,26 @@ private static string SeparatedToPascalCase(this string? text, char separator) return parts.Aggregate(string.Empty, (current, part) => current + char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()); } + + public static KeyAndValue? SplitIntoKeyValue(this string? text, string separator) + { + Ensure.NotNull(separator); + + if (string.IsNullOrEmpty(text)) + { + return null; + } + + var separatorIndex = text.IndexOf(separator, StringComparison.Ordinal); + + if (separatorIndex == -1) + { + return null; + } + + var key = text[..separatorIndex]; + var value = text[(separatorIndex + 1)..]; + + return new KeyAndValue(key, value); + } } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs index 73e535cd..37b94c3d 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs @@ -7,11 +7,27 @@ public interface IProcessExecutor { T? Execute(); + T? Execute(string[] args); + + T? Execute(IDictionary placeholderVars); + Task ExecuteAsync(); + Task ExecuteAsync(string[] args); + + Task ExecuteAsync(IDictionary placeholderVars); + ProcessExecutionResult ExecuteEx(); + ProcessExecutionResult ExecuteEx(string[] args); + + ProcessExecutionResult ExecuteEx(IDictionary placeholderVars); + Task> ExecuteExAsync(); + + Task> ExecuteExAsync(string[] args); + + Task> ExecuteExAsync(IDictionary placeholderVars); } [PublicAPI] @@ -19,13 +35,37 @@ public interface IProcessExecutor { void Execute(); + void Execute(string[] args); + + void Execute(IDictionary placeholderVars); + Task ExecuteAsync(); + Task ExecuteAsync(string[] args); + + Task ExecuteAsync(IDictionary placeholderVars); + IProcess ExecuteEx(); + IProcess ExecuteEx(string[] args); + + IProcess ExecuteEx(IDictionary placeholderVars); + Task ExecuteExAsync(); + Task ExecuteExAsync(string[] args); + + Task ExecuteExAsync(IDictionary placeholderVars); + int ExecuteAndReturnExitCode(); + int ExecuteAndReturnExitCode(string[] args); + + int ExecuteAndReturnExitCode(IDictionary placeholderVars); + Task ExecuteAndReturnExitCodeAsync(); + + Task ExecuteAndReturnExitCodeAsync(string[] args); + + Task ExecuteAndReturnExitCodeAsync(IDictionary placeholderVars); } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs index d828319e..8a7e575d 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs @@ -10,23 +10,61 @@ public void Execute() using var _ = ExecuteEx(); } + public void Execute(string[] args) + { + using var _ = ExecuteEx(args); + } + + public void Execute(IDictionary placeholderVars) + { + using var _ = ExecuteEx(placeholderVars); + } + public async Task ExecuteAsync() { using var _ = await ExecuteExAsync().ConfigureAwait(false); } + public async Task ExecuteAsync(string[] args) + { + using var _ = await ExecuteExAsync(args).ConfigureAwait(false); + } + + public async Task ExecuteAsync(IDictionary placeholderVars) + { + using var _ = await ExecuteExAsync(placeholderVars).ConfigureAwait(false); + } + public IProcess ExecuteEx() + => ExecuteEx(null, null); + + public IProcess ExecuteEx(string[] args) + => ExecuteEx(args, null); + + public IProcess ExecuteEx(IDictionary placeholderVars) + => ExecuteEx(null, placeholderVars); + + private IProcess ExecuteEx(string[]? args, IDictionary? placeholderVars) { - var process = StartProcess(); + var process = StartProcess(args, placeholderVars); process.WaitForExit(); return process; } - public async Task ExecuteExAsync() + public Task ExecuteExAsync() + => ExecuteExAsync(null, null); + + public Task ExecuteExAsync(string[] args) + => ExecuteExAsync(args, null); + + public Task ExecuteExAsync(IDictionary placeholderVars) + => ExecuteExAsync(null, placeholderVars); + + private async Task ExecuteExAsync(string[]? args, IDictionary? placeholderVars) { - var process = StartProcess(); + var process = StartProcess(args, placeholderVars); await process.WaitForExitAsync().ConfigureAwait(false); @@ -40,12 +78,40 @@ public int ExecuteAndReturnExitCode() return process.ExitCode; } + public int ExecuteAndReturnExitCode(string[] args) + { + using var process = ExecuteEx(args); + + return process.ExitCode; + } + + public int ExecuteAndReturnExitCode(IDictionary placeholderVars) + { + using var process = ExecuteEx(placeholderVars); + + return process.ExitCode; + } + public async Task ExecuteAndReturnExitCodeAsync() { using var process = await ExecuteExAsync().ConfigureAwait(false); return process.ExitCode; } + + public async Task ExecuteAndReturnExitCodeAsync(string[] args) + { + using var process = await ExecuteExAsync(args).ConfigureAwait(false); + + return process.ExitCode; + } + + public async Task ExecuteAndReturnExitCodeAsync(IDictionary placeholderVars) + { + using var process = await ExecuteExAsync(placeholderVars).ConfigureAwait(false); + + return process.ExitCode; + } } public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IProcessFactory processFactory) @@ -54,22 +120,49 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro private readonly IProcessOutputParser _outputParser = Ensure.NotNull(processExecutorInfo.OutputParser); public T? Execute() + => Execute(null, null); + + public T? Execute(string[] args) + => Execute(args, null); + + public T? Execute(IDictionary placeholderVars) + => Execute(null, placeholderVars); + + private T? Execute(string[]? args, IDictionary? placeholderVars) { - using var result = ExecuteEx(); + using var result = ExecuteEx(args, placeholderVars); return result.Value; } - public async Task ExecuteAsync() + public Task ExecuteAsync() + => ExecuteAsync(null, null); + + public Task ExecuteAsync(string[] args) + => ExecuteAsync(args, null); + + public Task ExecuteAsync(IDictionary placeholderVars) + => ExecuteAsync(null, placeholderVars); + + private async Task ExecuteAsync(string[]? args, IDictionary? placeholderVars) { - using var result = await ExecuteExAsync().ConfigureAwait(false); + using var result = await ExecuteExAsync(args, placeholderVars).ConfigureAwait(false); return result.Value; } - public ProcessExecutionResult ExecuteEx() + public ProcessExecutionResult ExecuteEx() => ExecuteEx(null, null); + + public ProcessExecutionResult ExecuteEx(string[] args) + => ExecuteEx(args, null); + + public ProcessExecutionResult ExecuteEx(IDictionary placeholderVars) + => ExecuteEx(null, placeholderVars); + + private ProcessExecutionResult ExecuteEx(string[]? args, + IDictionary? placeholderVars) { - var process = StartProcess(); + var process = StartProcess(args, placeholderVars); var output = process.StandardOutput.ReadToEnd(); @@ -78,9 +171,19 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); } - public async Task> ExecuteExAsync() + public Task> ExecuteExAsync() + => ExecuteExAsync(null, null); + + public Task> ExecuteExAsync(string[] args) + => ExecuteExAsync(args, null); + + public Task> ExecuteExAsync(IDictionary placeholderVars) + => ExecuteExAsync(null, placeholderVars); + + private async Task> ExecuteExAsync(string[]? args, + IDictionary? placeholderVars) { - var process = StartProcess(); + var process = StartProcess(args, placeholderVars); var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs index 5d342120..50e26cc2 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using CreativeCoders.Core; +using CreativeCoders.Core.Text; namespace CreativeCoders.ProcessUtils.Execution; @@ -11,12 +12,13 @@ public abstract class ProcessExecutorBase( private readonly IProcessFactory _processFactory = Ensure.NotNull(processFactory); - private ProcessStartInfo CreateProcessStartInfo() + private ProcessStartInfo CreateProcessStartInfo(string[]? args, + IDictionary? placeholderVars) { var startupInfo = new ProcessStartInfo { FileName = _processExecutorInfo.FileName, - Arguments = string.Join(" ", _processExecutorInfo.Arguments), + Arguments = string.Join(" ", BuildArguments(args, placeholderVars)), RedirectStandardOutput = _processExecutorInfo.RedirectStandardOutput, RedirectStandardError = _processExecutorInfo.RedirectStandardError, RedirectStandardInput = _processExecutorInfo.RedirectStandardInput, @@ -27,9 +29,25 @@ private ProcessStartInfo CreateProcessStartInfo() return startupInfo; } - protected IProcess StartProcess() + private string[] BuildArguments(string[]? args, IDictionary? placeholderVars) { - var startupInfo = CreateProcessStartInfo(); + return placeholderVars != null + ? ReplacePlaceholders(_processExecutorInfo.Arguments, placeholderVars) + : args ?? _processExecutorInfo.Arguments; + } + + private static string[] ReplacePlaceholders(string[] arguments, + IDictionary placeholderVars) + { + return arguments + .ReplacePlaceholders("{{", "}}", placeholderVars) + .ToArray(); + } + + protected IProcess StartProcess(string[]? args = null, + IDictionary? placeholderVars = null) + { + var startupInfo = CreateProcessStartInfo(args, placeholderVars); var process = _processFactory.StartProcess(startupInfo); diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs index f0564655..f5bd9fc3 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs @@ -31,7 +31,8 @@ public IProcessExecutor Build() var executorInfo = new ProcessExecutorInfo( FileName, - Arguments ?? []); + Arguments ?? [], + UsePlaceholderVars); return new ProcessExecutor(executorInfo, _processFactory); } @@ -65,7 +66,8 @@ public IProcessExecutorBuilder SetOutputParser(IProcessOutputParser parser return this; } - public IProcessExecutorBuilder SetOutputParser(Action? configure = null) where TParser : IProcessOutputParser, new() + public IProcessExecutorBuilder SetOutputParser(Action? configure = null) + where TParser : IProcessOutputParser, new() { var parser = new TParser(); @@ -106,7 +108,9 @@ public IProcessExecutor Build() var executorInfo = new ProcessExecutorInfo( FileName, Arguments ?? [], - _outputParser ?? throw new InvalidOperationException("OutputParser must be set before building the executor.")); + UsePlaceholderVars, + _outputParser ?? + throw new InvalidOperationException("OutputParser must be set before building the executor.")); return new ProcessExecutor(executorInfo, _processFactory); } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs index 51911e13..e00cc3a6 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs @@ -5,4 +5,6 @@ public abstract class ProcessExecutorBuilderBase protected string? FileName { get; set; } protected string[]? Arguments { get; set; } -} \ No newline at end of file + + protected bool UsePlaceholderVars { get; set; } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorExtensions.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorExtensions.cs new file mode 100644 index 00000000..48a59c50 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorExtensions.cs @@ -0,0 +1,48 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils.Execution; + +public static class ProcessExecutorExtensions +{ + public static void Execute(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + executor.Execute(placeholderVars.ToDictionary()); + } + + public static Task ExecuteAsync(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteAsync(placeholderVars.ToDictionary()); + } + + public static IProcess ExecuteEx(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteEx(placeholderVars.ToDictionary()); + } + + public static Task ExecuteExAsync(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteExAsync(placeholderVars.ToDictionary()); + } + + public static int ExecuteAndReturnExitCode(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteAndReturnExitCode(placeholderVars.ToDictionary()); + } + + public static Task ExecuteAndReturnExitCodeAsync(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteAndReturnExitCodeAsync(placeholderVars.ToDictionary()); + } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorGenericExtensions.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorGenericExtensions.cs new file mode 100644 index 00000000..e5a3dac9 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorGenericExtensions.cs @@ -0,0 +1,32 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils.Execution; + +public static class ProcessExecutorGenericExtensions +{ + public static T? Execute(this IProcessExecutor executor, TVars placeholderVars) + where TVars : class + { + return executor.Execute(placeholderVars.ToDictionary()); + } + + public static Task ExecuteAsync(this IProcessExecutor executor, TVars placeholderVars) + where TVars : class + { + return executor.ExecuteAsync(placeholderVars.ToDictionary()); + } + + public static ProcessExecutionResult ExecuteEx(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteEx(placeholderVars.ToDictionary()); + } + + public static Task> ExecuteExAsync(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteExAsync(placeholderVars.ToDictionary()); + } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs index 4cb67098..16bde167 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs @@ -1,12 +1,16 @@ namespace CreativeCoders.ProcessUtils.Execution; -public class ProcessExecutorInfo(string fileName, string[] arguments, IProcessOutputParser outputParser) - : ProcessExecutorInfo(fileName, arguments) +public class ProcessExecutorInfo( + string fileName, + string[] arguments, + bool usePlaceholderVars, + IProcessOutputParser outputParser) + : ProcessExecutorInfo(fileName, arguments, usePlaceholderVars) { public IProcessOutputParser OutputParser { get; set; } = outputParser; } -public class ProcessExecutorInfo(string fileName, string[] arguments) +public class ProcessExecutorInfo(string fileName, string[] arguments, bool usePlaceholderVars) { public string FileName { get; } = fileName; diff --git a/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj b/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj index a1a4497f..814af972 100644 --- a/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj +++ b/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj @@ -28,7 +28,7 @@ - + diff --git a/tests/CreativeCoders.Core.UnitTests/ObjectExtensionsTests.cs b/tests/CreativeCoders.Core.UnitTests/ObjectExtensionsTests.cs index eed8a272..58fe0ae3 100644 --- a/tests/CreativeCoders.Core.UnitTests/ObjectExtensionsTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ObjectExtensionsTests.cs @@ -1,17 +1,22 @@ using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; -using CreativeCoders.UnitTests; +using AwesomeAssertions; using FakeItEasy; using Xunit; +#nullable enable + namespace CreativeCoders.Core.UnitTests; +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] public class ObjectExtensionsTests { [Fact] public void ToStringSafe_InstanceIsNull_ReturnsEmptyString() { - var result = ((object) null).ToStringSafe(); + var result = ((object)null!).ToStringSafe(); Assert.Equal(string.Empty, result); } @@ -21,7 +26,7 @@ public void ToStringSafe_InstanceIsNullAndDefaultIsGiven_ReturnsDefaultValue() { const string text = "This is a test"; - var result = ((object) null).ToStringSafe(text); + var result = ((object)null!).ToStringSafe(text); Assert.Equal(text, result); } @@ -123,7 +128,7 @@ public async Task TryDisposeAsync_AsyncDisposableAndDisposable_DisposeAsyncIsCal await instance.TryDisposeAsync(); A.CallTo(() => instance.DisposeAsync()).MustHaveHappenedOnceExactly(); - A.CallTo(() => disposable.Dispose()).MustNotHaveHappened(); + A.CallTo(() => disposable!.Dispose()).MustNotHaveHappened(); } [Fact] @@ -131,7 +136,7 @@ public void GetPropertyValue_ReadFromExistingProperty_ReturnsPropertyValue() { const string expectedData = "TestText"; - var instance = new UnitTestDemoObject {Text = expectedData}; + var instance = new UnitTestDemoObject { Text = expectedData }; Assert.Equal(expectedData, instance.GetPropertyValue(nameof(instance.Text))); } @@ -163,4 +168,33 @@ public void SetPropertyValue_WriteToNotExistingProperty_ThrowsMissingMemberExcep Assert.Throws(() => instance.SetPropertyValue("Text", string.Empty)); } + + [Fact] + public void ToDictionary_ObjectWithProperties_ReturnsDictionaryWithPropertiesAsKeyValuePairs() + { + // Arrange + var obj = new UnitTestDemoObject + { + Text = "Test", + IntValue = 1234, + BoolValue = true + }; + + // Act + var dictionary = obj.ToDictionary(); + + // Assert + dictionary + .Should() + .HaveCount(3); + + dictionary + .Should() + .BeEquivalentTo(new Dictionary + { + { "Text", "Test" }, + { "IntValue", 1234 }, + { "BoolValue", true } + }); + } } diff --git a/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs b/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs new file mode 100644 index 00000000..6429956b --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using AwesomeAssertions; +using CreativeCoders.Core.Placeholders; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.Placeholders; + +#nullable enable + +/// +/// Tests for to verify placeholder replacement for single strings +/// and sequences, as well as guard clauses and edge cases. +/// +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class PlaceholderReplacerTests +{ + [Fact] + public void Replace_String_ReplacesAllOccurrencesOfAllPlaceholders() + { + // Arrange + var placeholders = new Dictionary + { + ["Name"] = "Alice", + ["City"] = "Berlin" + }; + + var replacer = new PlaceholderReplacer("${", "}", placeholders); + const string input = "Hello ${Name}, welcome to ${City}. ${Name} likes ${City}."; + + // Act + var result = replacer.Replace(input); + + // Assert + result + .Should() + .Be("Hello Alice, welcome to Berlin. Alice likes Berlin."); + } + + [Fact] + public void Replace_String_ReplacesAllOccurrencesOfAllPlaceholdersWithDifferentTypes() + { + // Arrange + var placeholders = new Dictionary + { + ["Name"] = "Alice", + ["City"] = "Berlin", + ["Age"] = 25, + ["BigCity"] = true + }; + + var replacer = new PlaceholderReplacer("${", "}", placeholders); + const string input = + "Hello ${Name}, welcome to ${City}. ${Name} likes ${City}. You are ${Age} years old. ${City} is a big city = ${BigCity}."; + + // Act + var result = replacer.Replace(input); + + // Assert + result + .Should() + .Be( + "Hello Alice, welcome to Berlin. Alice likes Berlin. You are 25 years old. Berlin is a big city = True."); + } + + [Theory] + [InlineData("NullName", null, true, "null")] + [InlineData("EmptyName", null, false, "")] + public void Replace_String_WithNullVar_AllowNullValuesLeadsToCorrectResult(string placeholderName, + object? placeholderValue, bool allowNullValues, string expectedResult) + { + // Arrange + var placeholders = new Dictionary + { + [placeholderName] = placeholderValue + }; + + var replacer = new PlaceholderReplacer("${", "}", placeholders); + var inputText = "${" + placeholderName + "}"; + + // Act + var result = replacer.Replace(inputText, allowNullValues); + + // Assert + result + .Should() + .Be(expectedResult); + } + + [Fact] + public void Replace_String_WithNoMatchingPlaceholders_ReturnsOriginalString() + { + // Arrange + var placeholders = new Dictionary + { + ["User"] = "Bob" + }; + var replacer = new PlaceholderReplacer("<%", "%>", placeholders); + const string input = "Hello ${User}"; // token style does not match + + // Act + var result = replacer.Replace(input); + + // Assert + result + .Should() + .Be(input); + } + + [Fact] + public void Replace_String_WithEmptyPlaceholderDictionary_ReturnsSameReference() + { + // Arrange + var replacer = new PlaceholderReplacer("${", "}", new Dictionary()); + var input = "Keep me as is"; + + // Act + var result = replacer.Replace(input); + + // Assert + ReferenceEquals(result, input) + .Should() + .BeTrue(); + } + + [Fact] + public void Replace_Lines_ReplacesAcrossAllLines() + { + // Arrange + var placeholders = new Dictionary + { + ["Env"] = "Prod", + ["Ver"] = "1.2.3" + }; + var replacer = new PlaceholderReplacer("${", "}", placeholders); + var lines = new[] + { + "Environment: ${Env}", + "Version: ${Ver}", + "${Env}-${Ver}" + }; + + // Act + var result = replacer.Replace(lines).ToArray(); + + // Assert + result.Length + .Should() + .Be(3); + + result + .Should() + .BeEquivalentTo("Environment: Prod", "Version: 1.2.3", "Prod-1.2.3"); + } + + [Fact] + public void Replace_Lines_WithEmptyPlaceholderDictionary_ReturnsSameEnumerableReference() + { + // Arrange + var lines = new[] { "a", "b" }; + var replacer = new PlaceholderReplacer("${", "}", new Dictionary()); + + // Act + var result = replacer.Replace(lines); + + // Assert + ReferenceEquals(result, lines) + .Should() + .BeTrue(); + } + + [Fact] + public void Constructor_WithNullPlaceholders_Throws() + { + // Act + var act = () => new PlaceholderReplacer("${", "}", null!); + + // Assert + act + .Should() + .ThrowExactly(); + } + + [Fact] + public void Constructor_WithInvalidPrefixOrSuffix_Throws() + { + // Arrange + var placeholders = new Dictionary(); + + // Act + var actPrefixNull = () => new PlaceholderReplacer(null!, "}", placeholders); + var actPrefixEmpty = () => new PlaceholderReplacer(string.Empty, "}", placeholders); + var actPrefixWs = () => new PlaceholderReplacer(" ", "}", placeholders); + + var actSuffixNull = () => new PlaceholderReplacer("${", null!, placeholders); + var actSuffixEmpty = () => new PlaceholderReplacer("${", string.Empty, placeholders); + var actSuffixWs = () => new PlaceholderReplacer("${", " ", placeholders); + + // Assert + actPrefixNull.Should().ThrowExactly(); + actPrefixEmpty.Should().ThrowExactly(); + actPrefixWs.Should().ThrowExactly(); + + actSuffixNull.Should().ThrowExactly(); + actSuffixEmpty.Should().ThrowExactly(); + actSuffixWs.Should().ThrowExactly(); + } + + [Fact] + public void Replace_WithOverlappingPlaceholderNames_ReplacesExactTokensOnly() + { + // Arrange + var placeholders = new Dictionary + { + ["A"] = "x", + ["AB"] = "y" + }; + var replacer = new PlaceholderReplacer("${", "}", placeholders); + const string input = "${A}-${AB}-${A}${AB}"; + + // Act + var result = replacer.Replace(input); + + // Assert + result + .Should() + .Be("x-y-xy"); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorExtensionsTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorExtensionsTests.cs new file mode 100644 index 00000000..dad98c7e --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorExtensionsTests.cs @@ -0,0 +1,339 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils; +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; +using JetBrains.Annotations; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; + +#nullable enable + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +[SuppressMessage("ReSharper", "InvokeAsExtensionMethod")] +public class ProcessExecutorExtensionsTests +{ + [PublicAPI] + private sealed class TestVars + { + // Simple POCO used to verify that the extensions convert an object to a dictionary via ToDictionary() + public string? Name { get; init; } + + public int Number { get; init; } + } + + [Fact] + public void Generic_Execute_WithObjectVars_ForwardsDictionary_AndReturnsValue() + { + // Arrange + var vars = new TestVars { Name = "Alice", Number = 7 }; + + var fakeExecutor = A.Fake>(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.Execute(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns("ok"); + + // Act + var result = ProcessExecutorGenericExtensions.Execute(fakeExecutor, vars); + + // Assert + result + .Should().Be("ok"); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "Alice" }, + { "Number", 7 } + }); + } + + [Fact] + public async Task Generic_ExecuteAsync_WithObjectVars_ForwardsDictionary_AndReturnsValue() + { + // Arrange + var vars = new TestVars { Name = "Bob", Number = 42 }; + + var fakeExecutor = A.Fake>(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.FromResult("123")); + + // Act + var result = await ProcessExecutorGenericExtensions.ExecuteAsync(fakeExecutor, vars); + + // Assert + result + .Should().Be("123"); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "Bob" }, + { "Number", 42 } + }); + } + + [Fact] + public void Generic_ExecuteEx_WithObjectVars_ForwardsDictionary_AndReturnsResult() + { + // Arrange + var vars = new TestVars { Name = "X", Number = 1 }; + + var fakeExecutor = A.Fake>(); + var fakeProcess = A.Fake(); + var expected = new ProcessExecutionResult(fakeProcess, "val"); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteEx(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(expected); + + // Act + var result = ProcessExecutorGenericExtensions.ExecuteEx(fakeExecutor, vars); + + // Assert + result + .Should().BeSameAs(expected); + + result.Value + .Should().Be("val"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "X" }, + { "Number", 1 } + }); + } + + [Fact] + public async Task Generic_ExecuteExAsync_WithObjectVars_ForwardsDictionary_AndReturnsResult() + { + // Arrange + var vars = new TestVars { Name = "Y", Number = 2 }; + + var fakeExecutor = A.Fake>(); + var fakeProcess = A.Fake(); + var expected = new ProcessExecutionResult(fakeProcess, "data"); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteExAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.FromResult(expected)); + + // Act + var result = await ProcessExecutorGenericExtensions.ExecuteExAsync(fakeExecutor, vars); + + // Assert + result + .Should().BeSameAs(expected); + + result.Value + .Should().Be("data"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "Y" }, + { "Number", 2 } + }); + } + + [Fact] + public void NonGeneric_Execute_WithObjectVars_ForwardsDictionary() + { + // Arrange + var vars = new TestVars { Name = "NG", Number = 9 }; + + var fakeExecutor = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.Execute(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)); + + // Act + ProcessExecutorExtensions.Execute(fakeExecutor, vars); + + // Assert + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "NG" }, + { "Number", 9 } + }); + } + + [Fact] + public async Task NonGeneric_ExecuteAsync_WithObjectVars_ForwardsDictionary() + { + // Arrange + var vars = new TestVars { Name = "NGA", Number = 10 }; + + var fakeExecutor = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.CompletedTask); + + // Act + await ProcessExecutorExtensions.ExecuteAsync(fakeExecutor, vars); + + // Assert + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "NGA" }, + { "Number", 10 } + }); + } + + [Fact] + public void NonGeneric_ExecuteEx_WithObjectVars_ForwardsDictionary_AndReturnsProcess() + { + // Arrange + var vars = new TestVars { Name = "P", Number = 3 }; + + var fakeExecutor = A.Fake(); + var fakeProcess = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteEx(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(fakeProcess); + + // Act + var process = ProcessExecutorExtensions.ExecuteEx(fakeExecutor, vars); + + // Assert + process + .Should().BeSameAs(fakeProcess); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "P" }, + { "Number", 3 } + }); + } + + [Fact] + public async Task NonGeneric_ExecuteExAsync_WithObjectVars_ForwardsDictionary_AndReturnsProcess() + { + // Arrange + var vars = new TestVars { Name = "PA", Number = 4 }; + + var fakeExecutor = A.Fake(); + var fakeProcess = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteExAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.FromResult(fakeProcess)); + + // Act + var process = await ProcessExecutorExtensions.ExecuteExAsync(fakeExecutor, vars); + + // Assert + process + .Should().BeSameAs(fakeProcess); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "PA" }, + { "Number", 4 } + }); + } + + [Fact] + public void NonGeneric_ExecuteAndReturnExitCode_WithObjectVars_ForwardsDictionary_AndReturnsExitCode() + { + // Arrange + var vars = new TestVars { Name = "E", Number = 5 }; + + var fakeExecutor = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteAndReturnExitCode(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(77); + + // Act + var exitCode = ProcessExecutorExtensions.ExecuteAndReturnExitCode(fakeExecutor, vars); + + // Assert + exitCode + .Should().Be(77); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "E" }, + { "Number", 5 } + }); + } + + [Fact] + public async Task + NonGeneric_ExecuteAndReturnExitCodeAsync_WithObjectVars_ForwardsDictionary_AndReturnsExitCode() + { + // Arrange + var vars = new TestVars { Name = "EA", Number = 6 }; + + var fakeExecutor = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteAndReturnExitCodeAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.FromResult(99)); + + // Act + var exitCode = await ProcessExecutorExtensions.ExecuteAndReturnExitCodeAsync(fakeExecutor, vars); + + // Assert + exitCode + .Should().Be(99); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "EA" }, + { "Number", 6 } + }); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs index d40a1799..a54dc986 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.IO; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using AwesomeAssertions; @@ -10,6 +12,9 @@ namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; +#nullable enable + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] public class ProcessExecutorGenericTests { [Fact] @@ -24,7 +29,7 @@ public void Execute_ReadsOutput_AndParsesResult() A.CallTo(() => parser.ParseOutput("42\n")) .Returns(42); - var info = new ProcessExecutorInfo(fileName, args, parser); + var info = new ProcessExecutorInfo(fileName, args, false, parser); // Fake process with output var fakeProcess = A.Fake(); @@ -64,7 +69,7 @@ public void ExecuteEx_ReadsOutput_AndParsesResult() A.CallTo(() => parser.ParseOutput("42\n")) .Returns(42); - var info = new ProcessExecutorInfo(fileName, args, parser); + var info = new ProcessExecutorInfo(fileName, args, false, parser); // Fake process with output var fakeProcess = A.Fake(); @@ -110,7 +115,7 @@ public async Task ExecuteAsync_ReadsOutputAsync_AndParsesResult() A.CallTo(() => parser.ParseOutput("hello world")) .Returns("hello world"); - var info = new ProcessExecutorInfo(fileName, args, parser); + var info = new ProcessExecutorInfo(fileName, args, false, parser); var fakeProcess = A.Fake(); @@ -152,7 +157,7 @@ public async Task ExecuteExAsync_ReadsOutput_AndParsesResult() A.CallTo(() => parser.ParseOutput("42\n")) .Returns(42); - var info = new ProcessExecutorInfo(fileName, args, parser); + var info = new ProcessExecutorInfo(fileName, args, false, parser); // Fake process with output var fakeProcess = A.Fake(); @@ -186,4 +191,391 @@ public async Task ExecuteExAsync_ReadsOutput_AndParsesResult() result.Dispose(); A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); } + + [Fact] + public void Execute_WithArgs_ReadsOutput_ParsesResult_AndUsesProvidedArgs() + { + // Arrange + const string fileName = "calc"; + var defaultArgs = new[] { "1", "+", "1" }; + var providedArgs = new[] { "6", "+", "7" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("13")) + .Returns(13); + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("13"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.Execute(providedArgs); + + // Assert + result + .Should().Be(13); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("13")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void Execute_WithPlaceholderVars_ReadsOutput_ParsesResult_AndReplacesPlaceholders() + { + // Arrange + const string fileName = "calc"; + var argsWithPlaceholders = new[] { "{{a}}", "+", "{{b}}" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("11")) + .Returns(11); + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("11"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.Execute(new Dictionary { ["a"] = 5, ["b"] = 6 }); + + // Assert + result + .Should().Be(11); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be("5 + 6"); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("11")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void ExecuteEx_WithArgs_ReadsOutput_ParsesResult_UsesProvidedArgs_AndReturnsProcess() + { + // Arrange + const string fileName = "echo"; + var defaultArgs = new[] { "x" }; + var providedArgs = new[] { "hello", "world" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("ok")) + .Returns("ok"); + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("ok"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.ExecuteEx(providedArgs); + + // Assert + result.Value + .Should().Be("ok"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("ok")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void + ExecuteEx_WithPlaceholderVars_ReadsOutput_ParsesResult_ReplacesPlaceholders_AndReturnsProcess() + { + // Arrange + const string fileName = "echo"; + var argsWithPlaceholders = new[] { "say", "{{word}}" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("done")) + .Returns("done"); + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("done"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.ExecuteEx(new Dictionary { ["word"] = "hi" }); + + // Assert + result.Value + .Should().Be("done"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be("say hi"); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("done")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAsync_WithArgs_ReadsOutput_ParsesResult_AndUsesProvidedArgs() + { + // Arrange + const string fileName = "echo"; + var defaultArgs = new[] { "a" }; + var providedArgs = new[] { "1", "2" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("res")) + .Returns("res"); + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("res"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteAsync(providedArgs); + + // Assert + result + .Should().Be("res"); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("res")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAsync_WithPlaceholderVars_ReadsOutput_ParsesResult_AndReplacesPlaceholders() + { + // Arrange + const string fileName = "echo"; + var argsWithPlaceholders = new[] { "{{x}}", "{{y}}" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("ok")) + .Returns("ok"); + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("ok"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteAsync(new Dictionary + { ["x"] = "X", ["y"] = "Y" }); + + // Assert + result + .Should().Be("ok"); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be("X Y"); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("ok")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteExAsync_WithArgs_ReadsOutput_ParsesResult_UsesProvidedArgs_AndReturnsProcess() + { + // Arrange + const string fileName = "echo"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "hey" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("value")) + .Returns("value"); + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("value"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteExAsync(providedArgs); + + // Assert + result.Value + .Should().Be("value"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExitAsync()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("value")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task + ExecuteExAsync_WithPlaceholderVars_ReadsOutput_ParsesResult_ReplacesPlaceholders_AndReturnsProcess() + { + // Arrange + const string fileName = "echo"; + var argsWithPlaceholders = new[] { "say", "{{t}}" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("rdy")) + .Returns("rdy"); + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("rdy"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteExAsync(new Dictionary { ["t"] = "token" }); + + // Assert + result.Value + .Should().Be("rdy"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be("say token"); + + A.CallTo(() => fakeProcess.WaitForExitAsync()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("rdy")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } } diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs index ed978ea4..0d0a95b4 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -10,6 +11,8 @@ namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; +#nullable enable + [SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] public class ProcessExecutorTests { @@ -20,7 +23,7 @@ public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() const string fileName = "my-tool"; var args = new[] { "arg1", "arg2" }; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -73,6 +76,85 @@ public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); } + [Fact] + public void Execute_WithArgs_StartsProcess_UsesProvidedArgs_AndWaitsForExit() + { + // Arrange + const string fileName = "my-tool"; + var defaultArgs = new[] { "x", "y" }; + var providedArgs = new[] { "argA", "argB", "argC" }; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + executor.Execute(providedArgs); + + // Assert + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void Execute_WithPlaceholderVars_StartsProcess_ReplacesPlaceholders_AndWaitsForExit() + { + // Arrange + const string fileName = "my-tool"; + var argsWithPlaceholders = new[] { "run", "--value", "{{val}}" }; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + executor.Execute(new Dictionary { ["val"] = 42 }); + + // Assert + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be("run --value 42"); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + [Fact] public void ExecuteEx_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() { @@ -80,7 +162,7 @@ public void ExecuteEx_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() const string fileName = "my-tool"; var args = new[] { "arg1", "arg2" }; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -137,6 +219,75 @@ public void ExecuteEx_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); } + [Fact] + public void ExecuteEx_WithArgs_StartsProcess_UsesProvidedArgs_AndReturnsProcess() + { + // Arrange + const string fileName = "tool-ex"; + var defaultArgs = new[] { "d1" }; + var providedArgs = new[] { "n1", "n2" }; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = executor.ExecuteEx(providedArgs); + + // Assert + process + .Should() + .BeSameAs(fakeProcess); + + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.Arguments + .Should() + .Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + + [Fact] + public void ExecuteEx_WithPlaceholderVars_StartsProcess_ReplacesPlaceholders_AndReturnsProcess() + { + // Arrange + const string fileName = "tool-ex"; + var argsWithPlaceholders = new[] { "run", "{{name}}" }; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = executor.ExecuteEx(new Dictionary { ["name"] = "John" }); + + // Assert + process.Should().BeSameAs(fakeProcess); + capturedStartInfo!.Arguments.Should().Be("run John"); + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + [Fact] public async Task ExecuteAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsForExitAsync() { @@ -144,7 +295,7 @@ public async Task ExecuteAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsFor var fileName = "my-tool-async"; var args = new[] { "a", "b" }; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -208,7 +359,7 @@ public async Task ExecuteExAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsF var fileName = "my-tool-async"; var args = new[] { "a", "b" }; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -269,6 +420,149 @@ public async Task ExecuteExAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsF A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); } + [Fact] + public async Task ExecuteAsync_WithArgs_StartsProcess_UsesProvidedArgs_AndWaitsForExitAsync() + { + // Arrange + var fileName = "my-tool-async"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "x", "y" }; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + await executor.ExecuteAsync(providedArgs); + + // Assert + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task + ExecuteAsync_WithPlaceholderVars_StartsProcess_ReplacesPlaceholders_AndWaitsForExitAsync() + { + // Arrange + var fileName = "my-tool-async"; + var argsWithPlaceholders = new[] { "go", "--n", "{{num}}" }; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + await executor.ExecuteAsync(new Dictionary { ["num"] = 7 }); + + // Assert + capturedStartInfo!.Arguments + .Should().Be("go --n 7"); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteExAsync_WithArgs_StartsProcess_UsesProvidedArgs_AndReturnsProcess() + { + // Arrange + var fileName = "my-tool-async"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "x", "y" }; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = await executor.ExecuteExAsync(providedArgs); + + // Assert + process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + + [Fact] + public async Task + ExecuteExAsync_WithPlaceholderVars_StartsProcess_ReplacesPlaceholders_AndReturnsProcess() + { + // Arrange + var fileName = "my-tool-async"; + var argsWithPlaceholders = new[] { "go", "{{word}}" }; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = + await executor.ExecuteExAsync(new Dictionary { ["word"] = "ok" }); + + // Assert + process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo!.Arguments + .Should().Be("go ok"); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + [Fact] public void ExecuteAndReturnExitCode_StartsProcess_WithConfiguredStartInfo_ReturnsExitCode() { @@ -277,7 +571,7 @@ public void ExecuteAndReturnExitCode_StartsProcess_WithConfiguredStartInfo_Retur var args = new[] { "arg1", "arg2" }; const int expectedExitCode = 1235; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -310,7 +604,7 @@ public async Task ExecuteAndReturnExitCodeAsync_StartsProcess_WithConfiguredStar var args = new[] { "a", "b" }; const int expectedExitCode = 1235; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -338,4 +632,138 @@ public async Task ExecuteAndReturnExitCodeAsync_StartsProcess_WithConfiguredStar A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); } + + [Fact] + public void ExecuteAndReturnExitCode_WithArgs_UsesProvidedArgs_AndReturnsExitCode() + { + // Arrange + const string fileName = "my-tool"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "a1", "a2" }; + const int expectedExitCode = 55; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + A.CallTo(() => fakeProcess.ExitCode).Returns(expectedExitCode); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = executor.ExecuteAndReturnExitCode(providedArgs); + + // Assert + exitCode.Should().Be(expectedExitCode); + capturedStartInfo!.Arguments.Should().Be(string.Join(" ", providedArgs)); + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void ExecuteAndReturnExitCode_WithPlaceholderVars_ReplacesPlaceholders_AndReturnsExitCode() + { + // Arrange + const string fileName = "my-tool"; + var argsWithPlaceholders = new[] { "do", "{{n}}" }; + const int expectedExitCode = 77; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + A.CallTo(() => fakeProcess.ExitCode).Returns(expectedExitCode); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = executor.ExecuteAndReturnExitCode(new Dictionary { ["n"] = 9 }); + + // Assert + exitCode.Should().Be(expectedExitCode); + capturedStartInfo!.Arguments.Should().Be("do 9"); + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAndReturnExitCodeAsync_WithArgs_UsesProvidedArgs_AndReturnsExitCode() + { + // Arrange + var fileName = "my-tool-async"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "a1", "a2" }; + const int expectedExitCode = 101; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + A.CallTo(() => fakeProcess.ExitCode).Returns(expectedExitCode); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = await executor.ExecuteAndReturnExitCodeAsync(providedArgs); + + // Assert + exitCode.Should().Be(expectedExitCode); + capturedStartInfo!.Arguments.Should().Be(string.Join(" ", providedArgs)); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task + ExecuteAndReturnExitCodeAsync_WithPlaceholderVars_ReplacesPlaceholders_AndReturnsExitCode() + { + // Arrange + var fileName = "my-tool-async"; + var argsWithPlaceholders = new[] { "do", "{{n}}" }; + const int expectedExitCode = 202; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + A.CallTo(() => fakeProcess.ExitCode).Returns(expectedExitCode); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = + await executor.ExecuteAndReturnExitCodeAsync(new Dictionary { ["n"] = 3 }); + + // Assert + exitCode.Should().Be(expectedExitCode); + capturedStartInfo!.Arguments.Should().Be("do 3"); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } } diff --git a/tests/CreativeCoders.Core.UnitTests/Text/EnumerableStringExtensionsTests.cs b/tests/CreativeCoders.Core.UnitTests/Text/EnumerableStringExtensionsTests.cs new file mode 100644 index 00000000..3ab4f834 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/Text/EnumerableStringExtensionsTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AwesomeAssertions; +using CreativeCoders.Core.Text; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.Text; + +/// +/// Tests for verifying conversion of string sequences +/// into dictionaries using a key/value separator. +/// +public class EnumerableStringExtensionsTests +{ + [Fact] + public void ToDictionary_WithValidItems_ReturnsExpectedDictionary() + { + // Arrange + var items = new[] { "A:1", "B:2", "C:3" }; + + // Act + var result = items.ToDictionary(":"); + + // Assert + result + .Should() + .HaveCount(3); + + result["A"].Should().Be("1"); + result["B"].Should().Be("2"); + result["C"].Should().Be("3"); + } + + [Fact] + public void ToDictionary_WithDuplicateKeys_ThrowsArgumentException() + { + // Arrange + var items = new[] { "Key:1", "Key:2" }; + + // Act + var act = () => items.ToDictionary(":"); + + // Assert + act + .Should() + .ThrowExactly(); + } + + [Fact] + public void ToDictionary_ItemWithoutSeparator_ReturnsOnlyValidItems() + { + // Arrange + var items = new[] { "Key:1", "Invalid" }; + + // Act + var dict = items.ToDictionary(":", true); + + // Assert + dict + .Should() + .HaveCount(1); + + dict["Key"] + .Should() + .Be("1"); + } + + [Fact] + public void ToDictionary_ItemWithoutSeparator_ThrowsArgumentException() + { + // Arrange + var items = new[] { "Key:1", "Invalid" }; + + // Act + var act = () => items.ToDictionary(":", false); + + // Assert + act + .Should() + .ThrowExactly(); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/Text/StringExtensionSplitIntoKeyValueTests.cs b/tests/CreativeCoders.Core.UnitTests/Text/StringExtensionSplitIntoKeyValueTests.cs new file mode 100644 index 00000000..83aa2f47 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/Text/StringExtensionSplitIntoKeyValueTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using AwesomeAssertions; +using CreativeCoders.Core.Text; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.Text; + +/// +/// Tests for . +/// +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class StringExtensionSplitIntoKeyValueTests +{ + [Fact] + public void SplitIntoKeyValue_WithSingleCharSeparator_ReturnsKeyAndValue() + { + // Arrange + const string input = "User:Alice"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .NotBeNull(); + + kv!.Key + .Should() + .Be("User"); + + kv.Value + .Should() + .Be("Alice"); + } + + [Fact] + public void SplitIntoKeyValue_WithMultipleSeparators_UsesFirstOccurrence() + { + // Arrange + const string input = "A:B:C"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .NotBeNull(); + + kv!.Key + .Should() + .Be("A"); + + kv.Value + .Should() + .Be("B:C"); + } + + [Fact] + public void SplitIntoKeyValue_WithEmptyKey_ReturnsEmptyKey() + { + // Arrange + const string input = ":value"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .NotBeNull(); + + kv!.Key + .Should() + .Be(string.Empty); + + kv.Value + .Should() + .Be("value"); + } + + [Fact] + public void SplitIntoKeyValue_WithEmptyValue_ReturnsEmptyValue() + { + // Arrange + const string input = "key:"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .NotBeNull(); + + kv!.Key + .Should() + .Be("key"); + + kv.Value + .Should() + .Be(string.Empty); + } + + [Fact] + public void SplitIntoKeyValue_WhenSeparatorMissing_ThrowsArgumentOutOfRangeException() + { + // Arrange + const string input = "NoSeparatorHere"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .BeNull(); + } + + [Fact] + public void SplitIntoKeyValue_InputIsNull_ThrowsArgumentOutOfRangeException() + { + // Arrange + const string input = null; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .BeNull(); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/UnitTestDemoObject.cs b/tests/CreativeCoders.Core.UnitTests/UnitTestDemoObject.cs new file mode 100644 index 00000000..ed9d6155 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/UnitTestDemoObject.cs @@ -0,0 +1,14 @@ +#nullable enable +using JetBrains.Annotations; + +namespace CreativeCoders.Core.UnitTests; + +[PublicAPI] +public class UnitTestDemoObject +{ + public string? Text { get; set; } + + public int IntValue { get; set; } + + public bool BoolValue { get; set; } +}