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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ public static class SampleCommandOutcomes
Minimum = 7ms, Maximum = 8ms, Average = 7ms

""";

internal const string PingTimeout = """

Pinging 1.1.1.1 with 32 bytes of data:
PING: transmit failed. General failure.
PING: transmit failed. General failure.
PING: transmit failed. General failure.
PING: transmit failed. General failure.

Ping statistics for 2a00:1450:4001:806::200e:
Packets: Sent = 4, Received = 0, Lost = 4 (100% loss),

""";
}

public static class Payloads
Expand All @@ -47,7 +60,8 @@ public static class Payloads
"Regex.Match($'time=(\\d+)ms');",
"Regex.YieldGroup(1);",
"Values.Average();"
]
],
"valueType": "decimal"
}
]
}
Expand All @@ -62,19 +76,22 @@ public static class Payloads
"type": "parser",
"statements": [
"Regex.Match($'time=(\\d+)ms');"
]
],
"cardinality": "multiple"
},
{
"type": "parser",
"statements": [
"Regex.YieldGroup(1);"
]
],
"cardinality": "multiple"
},
{
"type": "parser",
"statements": [
"Values.Average();"
]
],
"valueType": "decimal"
}
]
}
Expand All @@ -86,6 +103,14 @@ public static class Payloads
internal static string PingResultMultipleTransformations =>
InjectMultilineArray(PingResultMultipleTransformationsTemplate, SampleCommandOutcomes.Ping);

/// <summary>
/// Simulates a ping cli result when there is, for example, no internet connection.
/// The transformation relies on the presence of at least one value to aggregate, but there is none.
/// Refer to GitHub Issue <see href="https://github.com/OlliMartin/wndw.ctl/issues/35">#35</see>
/// </summary>
internal static string PingTimeoutResultOneTransformation =>
InjectMultilineArray(PingResultOneTransformationTemplate, SampleCommandOutcomes.PingTimeout);

private static string InjectMultilineArray(string template, string toInject)
{
string[] lines = toInject.Split(Environment.NewLine);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
using System.Text.Json;
using FluentAssertions;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
using Oma.WndwCtrl.Abstractions.Model;
using Oma.WndwCtrl.Api.IntegrationTests.TestFramework;
using Oma.WndwCtrl.CliOutputParser.Interfaces;

namespace Oma.WndwCtrl.Api.IntegrationTests.Endpoints.TestController;

[UsedImplicitly]
[PublicAPI]
internal class NestedProblemDetails : ProblemDetails
{
public List<NestedProblemDetails> InnerErrors { get; init; } = [];
}

public sealed partial class TransformationChainTests(
MockedCommandExecutorApiFixture mockedCommandExecutorApiFixture
)
Expand All @@ -32,10 +39,10 @@ public async Task ShouldProcessPingResultInOneTransformation()
using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true);
using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken);

const double expected = 7.5;
const decimal expected = 7.5m;

httpResponse.Should().Be200Ok().And.Satisfy<TransformationOutcome<ParserResult>>(
response => { AssertParserResultOneNumber(response, expected); }
httpResponse.Should().Be200Ok().And.Satisfy<TransformationOutcome<decimal>>(
response => { response.Outcome.Should().Be(expected); }
);
}

Expand All @@ -46,23 +53,56 @@ public async Task ShouldProcessPingResultInMultipleTransformations()
using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true);
using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken);

const double expected = 7.5;
const decimal expected = 7.5m;

httpResponse.Should().Be200Ok().And.Satisfy<TransformationOutcome<decimal>>(
response => { response.Outcome.Should().Be(expected); }
);
}

[Fact]
public async Task ShouldReturnProblemDetailsOnEmptyValueAggregation()
{
string payload = Payloads.PingTimeoutResultOneTransformation;
using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true);
using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken);

httpResponse.Should().Be500InternalServerError().And.Satisfy<NestedProblemDetails>(
response =>
{
response.Detail.Should()
.Be(
"The CLI parser was in an invalid state during the transformation and could not proceed."
);

httpResponse.Should().Be200Ok().And.Satisfy<TransformationOutcome<ParserResult>>(
response => { AssertParserResultOneNumber(response, expected); }
response.InnerErrors.Should().HaveCount(expected: 1).And.Satisfy(
pd => pd.Title ==
"The CLI parser was in an invalid state during the transformation and could not proceed."
).And.Satisfy(
pd => pd.Detail ==
"Strict aggregation (function: 'Average') requires at least one value to be present, but the collection was empty. This could be caused by an invalid transformation or the input text was in an invalid/irregular format."
);
}
);
}

private static void AssertParserResultOneNumber(
TransformationOutcome<ParserResult> response,
double expectedValue
)
[Fact]
public async Task ShouldReturn500OnEmptyValueAggregation()
{
response.Success.Should().BeTrue();
response.Outcome.Should().NotBeNull();
response.Outcome!.First().Should().BeAssignableTo<JsonElement>();
JsonElement jsonElement = (JsonElement)response.Outcome!.First();
double num = jsonElement.GetDouble();
num.Should().Be(expectedValue);
string payload = Payloads.PingTimeoutResultOneTransformation;
using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true);
using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken);

httpResponse.Should().Be500InternalServerError();
}

[Fact]
public async Task ShouldReturnJsonOnEmptyValueAggregation()
{
string payload = Payloads.PingTimeoutResultOneTransformation;
using HttpRequestMessage httpRequestMessage = ConstructCommandHttpRequestMessage(payload, isJson: true);
using HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequestMessage, _cancelToken);

httpResponse.Should().HaveHeader("Content-Type").And.Match("application/json*");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
using FluentAssertions;
using FluentAssertions.Execution;
using LanguageExt;
using LanguageExt.Common;
using NSubstitute;
using Oma.WndwCtrl.Abstractions.Errors;
using Oma.WndwCtrl.Abstractions.Extensions;
using Oma.WndwCtrl.Abstractions.Model;
using Oma.WndwCtrl.CliOutputParser.Interfaces;
using Oma.WndwCtrl.Core.Executors.Transformers;
using Oma.WndwCtrl.Core.Model.Transformations;
using static LanguageExt.Prelude;
using ValueType = Oma.WndwCtrl.Abstractions.Model.ValueType;

namespace Oma.WndwCtrl.Core.Tests.Executors.Transformers;

public sealed class ParserTransformerTests : IDisposable
{
private readonly CancellationToken _cancelToken;
private readonly ICliOutputParser _cliOutputParserMock = Substitute.For<ICliOutputParser>();

private readonly ParserTransformer _instance;

private Func<Either<Error, ParserResult>> _mockedParseResultFunc;

private Either<FlowError, TransformationOutcome>? _result;

private ParserTransformation _transformationInput = new()
{
Statements = ["not used - it's mocked",],
Cardinality = Cardinality.Single,
ValueType = ValueType.String,
};

public ParserTransformerTests()
{
_cancelToken = TestContext.Current.CancellationToken;

_mockedParseResultFunc = () => Right<ParserResult>(["Default Text Value",]);

_cliOutputParserMock.Parse(Arg.Any<string>(), Arg.Any<string>())
.Returns(_ => _mockedParseResultFunc());

_instance = new ParserTransformer(_cliOutputParserMock);
}

public void Dispose()
{
_result?.Dispose();
}

[Fact]
public async Task SanityCheck()
{
await ExecuteTransformerAsync();
SatisfiesRight<string>(to => to.Outcome.Should().Be("Default Text Value"));
}

[Fact]
public async Task ShouldSuccessfullyParseSingleBool()
{
_transformationInput = _transformationInput with
{
ValueType = ValueType.Boolean,
};

_mockedParseResultFunc = () => Right<ParserResult>([true,]);

await ExecuteTransformerAsync();

SatisfiesRight<bool>(to => to.Outcome.Should().BeTrue());
}

[Fact]
public async Task ShouldSuccessfullyParseSingleString()
{
const string expected = "this is a string override";

_transformationInput = _transformationInput with
{
ValueType = ValueType.String,
};

_mockedParseResultFunc = () => Right<ParserResult>([expected,]);

await ExecuteTransformerAsync();

SatisfiesRight<string>(to => to.Outcome.Should().Be(expected));
}

[Fact]
public async Task ShouldSuccessfullyParseSingleLong()
{
const long expected = (long)int.MaxValue + 1;

_transformationInput = _transformationInput with
{
ValueType = ValueType.Long,
};

_mockedParseResultFunc = () => Right<ParserResult>([expected,]);

await ExecuteTransformerAsync();

SatisfiesRight<long>(to => to.Outcome.Should().Be(expected));
}

[Fact]
public async Task ShouldSuccessfullyParseSingleDouble()
{
const double expected = 12345.6789d;

_transformationInput = _transformationInput with
{
ValueType = ValueType.Decimal,
};

_mockedParseResultFunc = () => Right<ParserResult>([expected,]);

await ExecuteTransformerAsync();

SatisfiesRight<decimal>(to => to.Outcome.Should().Be((decimal)expected));
}

private async Task ExecuteTransformerAsync()
{
using TransformationOutcome emptyOutcome = new();

_result = await _instance.TransformCommandOutcomeAsync(
_transformationInput,
Right(emptyOutcome),
_cancelToken
);
}

private void SatisfiesRight<T>(params Action<TransformationOutcome<T>>[] assertions)
{
using AssertionScope scope = new();

_result.Should().NotBeNull();

_result?.Match(
Right: val =>
{
if (val is not TransformationOutcome<T> casted)
{
throw new InvalidOperationException(
$"Expected outcome to be of type {typeof(T).Name}, but it was {val.GetType()}."
);
}

foreach (Action<TransformationOutcome<T>> assertion in assertions) assertion(casted);
},
Left: val => val.Should().BeNull()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ MIN : 'Min';
SUM : 'Sum';
AT : 'At';
INDEX : 'Index';
COUNT : 'Count';

DOT : '.';
LPAREN : '(';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ map : anchorFrom
multiply : regexMatch ;

reduce : regexYield
| valuesAvg | valuesSum | valuesMin | valuesMax
| valuesFirst | valuesLast
| valuesAt;
| strictValueAggregation
| forgivingValueAggregation
;

// Requires at least one value to be populated, otherwise returns an error
strictValueAggregation : valuesAvg | valuesMin | valuesMax | valuesFirst | valuesLast;

// Returns 0 if no values are populated
forgivingValueAggregation : valuesSum | valuesCount | valuesAt;

anchorFrom : ANCHOR DOT FROM LPAREN STRING_LITERAL RPAREN SEMI;
anchorTo : ANCHOR DOT TO LPAREN STRING_LITERAL RPAREN SEMI;
Expand All @@ -28,10 +34,13 @@ regexYield : REGEX DOT YIELD_GROUP LPAREN INT RPAREN SEMI;

// Would be cleaner to combine those..
valuesAvg : VALUES DOT AVERAGE LPAREN RPAREN SEMI;
valuesSum : VALUES DOT SUM LPAREN RPAREN SEMI;
valuesMin : VALUES DOT MIN LPAREN RPAREN SEMI;
valuesMax : VALUES DOT MAX LPAREN RPAREN SEMI;

valuesFirst : VALUES DOT FIRST LPAREN RPAREN SEMI;
valuesLast : VALUES DOT LAST LPAREN RPAREN SEMI;

// Forgiving, i.e. falling back (not raising an error) when there are no values in the enumeration
valuesSum : VALUES DOT SUM LPAREN RPAREN SEMI;
valuesCount : VALUES DOT COUNT LPAREN RPAREN SEMI;
valuesAt : VALUES DOT (AT | INDEX) LPAREN INT RPAREN SEMI;
Loading