diff --git a/Source/aweXpect/Results/IsParsableResult.cs b/Source/aweXpect/Results/IsParsableResult.cs new file mode 100644 index 000000000..822603d04 --- /dev/null +++ b/Source/aweXpect/Results/IsParsableResult.cs @@ -0,0 +1,34 @@ +#if NET8_0_OR_GREATER +using System; +using aweXpect.Core; + +namespace aweXpect.Results; + +/// +/// The result for verifying that the subject is parsable into . +/// +public class IsParsableResult( + ExpectationBuilder expectationBuilder, + IThat subject, + IFormatProvider? formatProvider) + : AndOrResult>(expectationBuilder, subject) + where TType : IParsable +{ + private readonly ExpectationBuilder _expectationBuilder = expectationBuilder; + + /// + /// Gives access to the parsed value. + /// + public IThat Which + => new ThatSubject(_expectationBuilder + .ForWhich(d => + { + if (TType.TryParse(d, formatProvider, out TType? result)) + { + return result; + } + + return default; + }, " which ")); +} +#endif diff --git a/Source/aweXpect/That/Strings/ThatString.IsParsableInto.cs b/Source/aweXpect/That/Strings/ThatString.IsParsableInto.cs new file mode 100644 index 000000000..dbf70eccb --- /dev/null +++ b/Source/aweXpect/That/Strings/ThatString.IsParsableInto.cs @@ -0,0 +1,129 @@ +#if NET8_0_OR_GREATER +using System; +using aweXpect.Core; +using aweXpect.Core.Constraints; +using aweXpect.Helpers; +using aweXpect.Results; + +namespace aweXpect; + +public static partial class ThatString +{ + /// + /// Verifies that the subject is parsable into type . + /// + /// + /// The optional parameter provides culture-specific formatting information + /// in the call to . + /// + public static IsParsableResult IsParsableInto( + this IThat source, + IFormatProvider? formatProvider = null) + where TType : IParsable + => new(source.Get().ExpectationBuilder.AddConstraint((it, grammars) + => new IsParsableIntoConstraint(it, grammars, formatProvider)), + source, + formatProvider); + + /// + /// Verifies that the subject is not parsable into type . + /// + /// + /// The optional parameter provides culture-specific formatting information + /// in the call to . + /// + public static AndOrResult> IsNotParsableInto( + this IThat source, + IFormatProvider? formatProvider = null) + where TType : IParsable + => new(source.Get().ExpectationBuilder.AddConstraint((it, grammars) + => new IsParsableIntoConstraint(it, grammars, formatProvider).Invert()), + source); + + private sealed class IsParsableIntoConstraint : ConstraintResult.WithValue, + IValueConstraint + where TType : IParsable + { + private readonly IFormatProvider? _formatProvider; + private readonly string _it; + private string? _exceptionMessage; + + public IsParsableIntoConstraint(string it, + ExpectationGrammars grammars, + IFormatProvider? formatProvider) : base(grammars) + { + _it = it; + _formatProvider = formatProvider; + FurtherProcessingStrategy = FurtherProcessingStrategy.IgnoreResult; + } + + public ConstraintResult IsMetBy(string? actual) + { + Actual = actual; + if (actual is null) + { + Outcome = Outcome.Failure; + return this; + } + + try + { + _ = TType.Parse(actual, _formatProvider); + Outcome = Outcome.Success; + } + catch (Exception ex) + { + if (string.IsNullOrEmpty(ex.Message) || ex.Message.Length < 2) + { + _exceptionMessage = "an unknown error occurred"; + } + else + { + _exceptionMessage = char.ToLowerInvariant(ex.Message[0]) + ex.Message[1..^1]; + } + + Outcome = Outcome.Failure; + } + + return this; + } + + protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) + { + stringBuilder.Append("is parsable into "); + Formatter.Format(stringBuilder, typeof(TType)); + if (_formatProvider is not null) + { + stringBuilder.Append(" using "); + Formatter.Format(stringBuilder, _formatProvider); + } + } + + protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null) + { + if (Actual is null) + { + stringBuilder.ItWasNull(_it); + } + else + { + stringBuilder.Append(_it).Append(" was not because ").Append(_exceptionMessage); + } + } + + protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null) + { + stringBuilder.Append("is not parsable into "); + Formatter.Format(stringBuilder, typeof(TType)); + if (_formatProvider is not null) + { + stringBuilder.Append(" using "); + Formatter.Format(stringBuilder, _formatProvider); + } + } + + protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append(_it).Append(" was"); + } +} +#endif diff --git a/Tests/aweXpect.Api.Tests/Expected/aweXpect_net8.0.txt b/Tests/aweXpect.Api.Tests/Expected/aweXpect_net8.0.txt index 90a0859a4..9dbef024a 100644 --- a/Tests/aweXpect.Api.Tests/Expected/aweXpect_net8.0.txt +++ b/Tests/aweXpect.Api.Tests/Expected/aweXpect_net8.0.txt @@ -1118,12 +1118,16 @@ namespace aweXpect public static aweXpect.Results.AndOrResult> IsNotNullOrWhiteSpace(this aweXpect.Core.IThat source) { } public static aweXpect.Results.StringEqualityTypeResult> IsNotOneOf(this aweXpect.Core.IThat source, System.Collections.Generic.IEnumerable unexpected) { } public static aweXpect.Results.StringEqualityTypeResult> IsNotOneOf(this aweXpect.Core.IThat source, params string?[] unexpected) { } + public static aweXpect.Results.AndOrResult> IsNotParsableInto(this aweXpect.Core.IThat source, System.IFormatProvider? formatProvider = null) + where TType : System.IParsable { } public static aweXpect.Results.AndOrResult> IsNotUpperCased(this aweXpect.Core.IThat source) { } public static aweXpect.Results.AndOrResult> IsNull(this aweXpect.Core.IThat source) { } public static aweXpect.Results.AndOrResult> IsNullOrEmpty(this aweXpect.Core.IThat source) { } public static aweXpect.Results.AndOrResult> IsNullOrWhiteSpace(this aweXpect.Core.IThat source) { } public static aweXpect.Results.StringEqualityTypeResult> IsOneOf(this aweXpect.Core.IThat source, System.Collections.Generic.IEnumerable expected) { } public static aweXpect.Results.StringEqualityTypeResult> IsOneOf(this aweXpect.Core.IThat source, params string?[] expected) { } + public static aweXpect.Results.IsParsableResult IsParsableInto(this aweXpect.Core.IThat source, System.IFormatProvider? formatProvider = null) + where TType : System.IParsable { } public static aweXpect.Results.AndOrResult> IsUpperCased(this aweXpect.Core.IThat source) { } public static aweXpect.Results.StringEqualityTypeResult> StartsWith(this aweXpect.Core.IThat source, string expected) { } } @@ -1246,6 +1250,12 @@ namespace aweXpect.Results aweXpect.Results.EventTriggerResult WithParameter(string expression, int? position, System.Func predicate); } } + public class IsParsableResult : aweXpect.Results.AndOrResult> + where TType : System.IParsable + { + public IsParsableResult(aweXpect.Core.ExpectationBuilder expectationBuilder, aweXpect.Core.IThat subject, System.IFormatProvider? formatProvider) { } + public aweXpect.Core.IThat Which { get; } + } public class ObjectCollectionBeContainedInResult : aweXpect.Results.ObjectCollectionMatchResult { public ObjectCollectionBeContainedInResult(aweXpect.Core.ExpectationBuilder expectationBuilder, TThat returnValue, aweXpect.Options.ObjectEqualityOptions options, aweXpect.Options.CollectionMatchOptions collectionMatchOptions) { } diff --git a/Tests/aweXpect.Tests/Strings/ThatString.IsNotParsableInto.Tests.cs b/Tests/aweXpect.Tests/Strings/ThatString.IsNotParsableInto.Tests.cs new file mode 100644 index 000000000..77aa2115b --- /dev/null +++ b/Tests/aweXpect.Tests/Strings/ThatString.IsNotParsableInto.Tests.cs @@ -0,0 +1,70 @@ +#if NET8_0_OR_GREATER +using System.Globalization; + +namespace aweXpect.Tests; + +public sealed partial class ThatString +{ + public class IsNotParsableInto + { + public sealed class Tests + { + [Fact] + public async Task WhenNull_ShouldSucceed() + { + string? subject = null; + + async Task Act() + => await That(subject).IsNotParsableInto(); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenStringIsNotParsable_ShouldSucceed() + { + string subject = "abc"; + + async Task Act() + => await That(subject).IsNotParsableInto(); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenStringIsParsable_ShouldFail() + { + string subject = "42"; + + async Task Act() + => await That(subject).IsNotParsableInto(); + + await That(Act).Throws() + .WithMessage(""" + Expected that subject + is not parsable into int, + but it was + """); + } + + [Theory] + [InlineData("12,34", "de-AT")] + [InlineData("12.34", "en-US")] + public async Task WithFormatProvider_ShouldBeUsed(string subject, string cultureName) + { + IFormatProvider formatProvider = new CultureInfo(cultureName); + + async Task Act() + => await That(subject).IsNotParsableInto(formatProvider); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + is not parsable into decimal using {cultureName}, + but it was + """); + } + } + } +} +#endif diff --git a/Tests/aweXpect.Tests/Strings/ThatString.IsParsableInto.Tests.cs b/Tests/aweXpect.Tests/Strings/ThatString.IsParsableInto.Tests.cs new file mode 100644 index 000000000..7fddc0c55 --- /dev/null +++ b/Tests/aweXpect.Tests/Strings/ThatString.IsParsableInto.Tests.cs @@ -0,0 +1,147 @@ +#if NET8_0_OR_GREATER +using System.Globalization; + +namespace aweXpect.Tests; + +public sealed partial class ThatString +{ + public class IsParsableInto + { + public sealed class Tests + { + [Fact] + public async Task WhenNull_ShouldFail() + { + string? subject = null; + + async Task Act() + => await That(subject).IsParsableInto().Because("null should fail"); + + await That(Act).Throws() + .WithMessage(""" + Expected that subject + is parsable into int, because null should fail, + but it was + """); + } + + [Fact] + public async Task WhenStringIsNotParsable_ShouldFail() + { + string subject = "abc"; + + async Task Act() + => await That(subject).IsParsableInto(); + + await That(Act).Throws() + .WithMessage(""" + Expected that subject + is parsable into int, + but it was not because the input string 'abc' was not in a correct format + """); + } + + [Fact] + public async Task WhenStringIsParsable_ShouldSucceed() + { + string subject = "42"; + + async Task Act() + => await That(subject).IsParsableInto(); + + await That(Act).DoesNotThrow(); + } + + [Theory] + [InlineData("-12.34", "de-AT")] + [InlineData("-12,34", "en-US")] + public async Task WithFormatProvider_WhenFormatDoesNotMatch_ShouldFail(string subject, string cultureName) + { + IFormatProvider formatProvider = new CultureInfo(cultureName); + + async Task Act() + => await That(subject).IsParsableInto(formatProvider); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + is parsable into uint using {cultureName}, + but it was not because the input string '{subject}' was not in a correct format + """); + } + + [Theory] + [InlineData("12,34", "de-AT")] + [InlineData("12.34", "en-US")] + public async Task WithFormatProvider_WhenFormatMatches_ShouldSucceed(string subject, string cultureName) + { + IFormatProvider formatProvider = new CultureInfo(cultureName); + + async Task Act() + => await That(subject).IsParsableInto(formatProvider); + + await That(Act).DoesNotThrow(); + } + } + + public sealed class WhichTests + { + [Fact] + public async Task WhenNull_ShouldFail() + { + string? subject = null; + + async Task Act() + => await That(subject).IsParsableInto().Which.IsGreaterThan(2).And.IsLessThan(3); + + await That(Act).Throws() + .WithMessage(""" + Expected that subject + is parsable into int which is greater than 2 and is less than 3, + but it was + """); + } + + [Fact] + public async Task WhenStringIsNotParsable_ShouldFail() + { + string subject = "abc"; + + async Task Act() + => await That(subject).IsParsableInto().Which.IsLessThan(10.Seconds()); + + await That(Act).Throws() + .WithMessage(""" + Expected that subject + is parsable into TimeSpan which is less than 0:10, + but it was not because string 'abc' was not recognized as a valid TimeSpan + """); + } + + [Fact] + public async Task WhenStringIsParsable_ShouldSucceed() + { + string subject = "42"; + + async Task Act() + => await That(subject).IsParsableInto().Which.IsBetween(41).And(43); + + await That(Act).DoesNotThrow(); + } + + [Theory] + [InlineData("12,34", "de-AT")] + [InlineData("12.34", "en-US")] + public async Task WithFormatProvider_ShouldBeUsed(string subject, string cultureName) + { + IFormatProvider formatProvider = new CultureInfo(cultureName); + + async Task Act() + => await That(subject).IsParsableInto(formatProvider).Which.IsEqualTo(12.34M); + + await That(Act).DoesNotThrow(); + } + } + } +} +#endif