diff --git a/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChain.Payloads.cs b/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChain.Payloads.cs
index 2eced71..b5c47c3 100644
--- a/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChain.Payloads.cs
+++ b/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChain.Payloads.cs
@@ -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
@@ -47,7 +60,8 @@ public static class Payloads
"Regex.Match($'time=(\\d+)ms');",
"Regex.YieldGroup(1);",
"Values.Average();"
- ]
+ ],
+ "valueType": "decimal"
}
]
}
@@ -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"
}
]
}
@@ -86,6 +103,14 @@ public static class Payloads
internal static string PingResultMultipleTransformations =>
InjectMultilineArray(PingResultMultipleTransformationsTemplate, SampleCommandOutcomes.Ping);
+ ///
+ /// 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 #35
+ ///
+ internal static string PingTimeoutResultOneTransformation =>
+ InjectMultilineArray(PingResultOneTransformationTemplate, SampleCommandOutcomes.PingTimeout);
+
private static string InjectMultilineArray(string template, string toInject)
{
string[] lines = toInject.Split(Environment.NewLine);
diff --git a/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChainTests.cs b/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChainTests.cs
index badf744..03e89a6 100644
--- a/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChainTests.cs
+++ b/.integrationTests/Oma.WndwCtrl.Api.IntegrationTests/Endpoints/TestController/TransformationChainTests.cs
@@ -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 InnerErrors { get; init; } = [];
+}
+
public sealed partial class TransformationChainTests(
MockedCommandExecutorApiFixture mockedCommandExecutorApiFixture
)
@@ -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>(
- response => { AssertParserResultOneNumber(response, expected); }
+ httpResponse.Should().Be200Ok().And.Satisfy>(
+ response => { response.Outcome.Should().Be(expected); }
);
}
@@ -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>(
+ 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(
+ 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>(
- 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 response,
- double expectedValue
- )
+ [Fact]
+ public async Task ShouldReturn500OnEmptyValueAggregation()
{
- response.Success.Should().BeTrue();
- response.Outcome.Should().NotBeNull();
- response.Outcome!.First().Should().BeAssignableTo();
- 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*");
}
}
\ No newline at end of file
diff --git a/.unitTests/Oma.WndwCtrl.Core.Tests/Executors/Transformers/ParserTransformerTests.cs b/.unitTests/Oma.WndwCtrl.Core.Tests/Executors/Transformers/ParserTransformerTests.cs
new file mode 100644
index 0000000..7f03812
--- /dev/null
+++ b/.unitTests/Oma.WndwCtrl.Core.Tests/Executors/Transformers/ParserTransformerTests.cs
@@ -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();
+
+ private readonly ParserTransformer _instance;
+
+ private Func> _mockedParseResultFunc;
+
+ private Either? _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(["Default Text Value",]);
+
+ _cliOutputParserMock.Parse(Arg.Any(), Arg.Any())
+ .Returns(_ => _mockedParseResultFunc());
+
+ _instance = new ParserTransformer(_cliOutputParserMock);
+ }
+
+ public void Dispose()
+ {
+ _result?.Dispose();
+ }
+
+ [Fact]
+ public async Task SanityCheck()
+ {
+ await ExecuteTransformerAsync();
+ SatisfiesRight(to => to.Outcome.Should().Be("Default Text Value"));
+ }
+
+ [Fact]
+ public async Task ShouldSuccessfullyParseSingleBool()
+ {
+ _transformationInput = _transformationInput with
+ {
+ ValueType = ValueType.Boolean,
+ };
+
+ _mockedParseResultFunc = () => Right([true,]);
+
+ await ExecuteTransformerAsync();
+
+ SatisfiesRight(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([expected,]);
+
+ await ExecuteTransformerAsync();
+
+ SatisfiesRight(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([expected,]);
+
+ await ExecuteTransformerAsync();
+
+ SatisfiesRight(to => to.Outcome.Should().Be(expected));
+ }
+
+ [Fact]
+ public async Task ShouldSuccessfullyParseSingleDouble()
+ {
+ const double expected = 12345.6789d;
+
+ _transformationInput = _transformationInput with
+ {
+ ValueType = ValueType.Decimal,
+ };
+
+ _mockedParseResultFunc = () => Right([expected,]);
+
+ await ExecuteTransformerAsync();
+
+ SatisfiesRight(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(params Action>[] assertions)
+ {
+ using AssertionScope scope = new();
+
+ _result.Should().NotBeNull();
+
+ _result?.Match(
+ Right: val =>
+ {
+ if (val is not TransformationOutcome casted)
+ {
+ throw new InvalidOperationException(
+ $"Expected outcome to be of type {typeof(T).Name}, but it was {val.GetType()}."
+ );
+ }
+
+ foreach (Action> assertion in assertions) assertion(casted);
+ },
+ Left: val => val.Should().BeNull()
+ );
+ }
+}
\ No newline at end of file
diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputLexer.g4 b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputLexer.g4
index 4713300..a3ce6b0 100644
--- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputLexer.g4
+++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputLexer.g4
@@ -25,6 +25,7 @@ MIN : 'Min';
SUM : 'Sum';
AT : 'At';
INDEX : 'Index';
+COUNT : 'Count';
DOT : '.';
LPAREN : '(';
diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputParser.g4 b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputParser.g4
index e0bf4b9..dd87a02 100644
--- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputParser.g4
+++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser.Grammar/CliOutputParser.g4
@@ -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;
@@ -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;
\ No newline at end of file
diff --git a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/CliOutputParserImpl.cs b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/CliOutputParserImpl.cs
index 90b3abf..9f54bf7 100644
--- a/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/CliOutputParserImpl.cs
+++ b/Oma.CliOutputParser/Oma.WndwCtrl.CliOutputParser/CliOutputParserImpl.cs
@@ -1,8 +1,10 @@
using Antlr4.Runtime.Tree;
using LanguageExt;
using LanguageExt.Common;
+using Oma.WndwCtrl.Abstractions.Errors;
using Oma.WndwCtrl.CliOutputParser.Interfaces;
using Oma.WndwCtrl.CliOutputParser.Visitors;
+using static LanguageExt.Prelude;
namespace Oma.WndwCtrl.CliOutputParser;
@@ -39,12 +41,12 @@ Func transformationListenerFactory
Either
treeOrError = treeCache.GetOrCreateTree(transformation);
- return treeOrError.Map(
+ return treeOrError.Bind(
tree => ExecuteParser(transformationListenerFactory, tree)
);
}
- private static ParserResult ExecuteParser(
+ private static Either ExecuteParser(
Func transformationListenerFactory,
Grammar.CliOutputParser.TransformationContext tree
)
@@ -52,13 +54,35 @@ Grammar.CliOutputParser.TransformationContext tree
TransformationListener listener = transformationListenerFactory();
ParseTreeWalker walker = new();
- walker.Walk(listener, tree);
+ Exception? thrownException = null;
+
+ try
+ {
+ walker.Walk(listener, tree);
+ }
+ catch (Exception ex)
+ {
+ thrownException = ex;
+ }
+
+ if (listener.Error is not null)
+ {
+ return listener.Error with
+ {
+ ThrownException = thrownException,
+ };
+ }
+
+ if (thrownException is not null)
+ {
+ return new FlowError(new TechnicalError("An unexpected error occurred.", Code: 5_000, thrownException));
+ }
List