diff --git a/Source/Contracts/Screenplay/ValidationRuleConverter.cs b/Source/Contracts/Screenplay/ValidationRuleConverter.cs index abfee5b..2bb4c14 100644 --- a/Source/Contracts/Screenplay/ValidationRuleConverter.cs +++ b/Source/Contracts/Screenplay/ValidationRuleConverter.cs @@ -10,7 +10,8 @@ namespace Cratis.Stage.Contracts.Screenplay; /// /// Converts the declarative validation rules of a Screenplay command into Stage's per-property /// . Only blocks are translated; inline -/// validate csharp blocks have no Stage rule equivalent and are skipped. +/// validate csharp blocks have no Stage rule equivalent and are skipped, as is any rule whose operand is not a +/// literal — a Stage rule holds a fixed value, and there is none to hold for one stated against a runtime value. /// public static class ValidationRuleConverter { @@ -48,37 +49,41 @@ public static IReadOnlyList Convert(IEnumerable new CommandPropertyRules(group.Property, group.Rules))]; } + // A Stage rule holds a fixed operand, and Screenplay lets one be stated against a value the application only + // resolves while it runs — 'dueDate > today', or a threshold naming another property. There is no fixed value to + // store for those, so the rule is dropped rather than stored with a stand-in: a rule carrying a substituted + // operand asserts something the document never said, which is worse than carrying no rule at all. static RuleDefinition? Convert(ValidationRuleSyntax rule) => rule.Rule switch { ValidationRuleKind.NotEmpty => new NotEmpty(rule.Message), - ValidationRuleKind.Max => new MaxLength(IntOperand(rule), rule.Message), - ValidationRuleKind.Min => new MinLength(IntOperand(rule), rule.Message), - ValidationRuleKind.Length => new Length(IntOperand(rule), IntOperand(rule), rule.Message), - ValidationRuleKind.Matches => new Matches(StringOperand(rule), rule.Message), - ValidationRuleKind.GreaterThan => new GreaterThan(DoubleOperand(rule), rule.Message), - ValidationRuleKind.GreaterThanOrEqual => new GreaterThanOrEqual(DoubleOperand(rule), rule.Message), - ValidationRuleKind.LessThan => new LessThan(DoubleOperand(rule), rule.Message), - ValidationRuleKind.LessThanOrEqual => new LessThanOrEqual(DoubleOperand(rule), rule.Message), + ValidationRuleKind.Max when IntOperand(rule) is { } max => new MaxLength(max, rule.Message), + ValidationRuleKind.Min when IntOperand(rule) is { } min => new MinLength(min, rule.Message), + ValidationRuleKind.Length when IntOperand(rule) is { } length => new Length(length, length, rule.Message), + ValidationRuleKind.Matches when StringOperand(rule) is { } pattern => new Matches(pattern, rule.Message), + ValidationRuleKind.GreaterThan when DoubleOperand(rule) is { } threshold => new GreaterThan(threshold, rule.Message), + ValidationRuleKind.GreaterThanOrEqual when DoubleOperand(rule) is { } threshold => new GreaterThanOrEqual(threshold, rule.Message), + ValidationRuleKind.LessThan when DoubleOperand(rule) is { } threshold => new LessThan(threshold, rule.Message), + ValidationRuleKind.LessThanOrEqual when DoubleOperand(rule) is { } threshold => new LessThanOrEqual(threshold, rule.Message), - // "all >" / "all >=" over a collection have no dedicated Stage rule; approximate with the scalar comparison. - ValidationRuleKind.AllGreaterThan => new GreaterThan(DoubleOperand(rule), rule.Message), - ValidationRuleKind.AllGreaterThanOrEqual => new GreaterThanOrEqual(DoubleOperand(rule), rule.Message), + // "all >" / "all >=" over a collection have no dedicated Stage rule; the property path names the element + // ('lines.quantity'), so the scalar comparison carries the same intent applied per element. + ValidationRuleKind.AllGreaterThan when DoubleOperand(rule) is { } threshold => new GreaterThan(threshold, rule.Message), + ValidationRuleKind.AllGreaterThanOrEqual when DoubleOperand(rule) is { } threshold => new GreaterThanOrEqual(threshold, rule.Message), // Equality has no Stage rule vocabulary equivalent — skip it. _ => null }; - static int IntOperand(ValidationRuleSyntax rule) => - rule.Value is LiteralExpressionSyntax { Value: { } value } && double.TryParse(System.Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var number) - ? (int)number - : 0; + static int? IntOperand(ValidationRuleSyntax rule) => DoubleOperand(rule) is { } number ? (int)number : null; - static double DoubleOperand(ValidationRuleSyntax rule) => - rule.Value is LiteralExpressionSyntax { Value: { } value } && double.TryParse(System.Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var number) + static double? DoubleOperand(ValidationRuleSyntax rule) => + StringOperand(rule) is { } text && double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var number) ? number - : 0; + : null; - static string StringOperand(ValidationRuleSyntax rule) => - rule.Value is LiteralExpressionSyntax { Value: { } value } ? System.Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty : string.Empty; + static string? StringOperand(ValidationRuleSyntax rule) => + rule.Value is LiteralExpressionSyntax { Value: { } value } + ? System.Convert.ToString(value, CultureInfo.InvariantCulture) + : null; } diff --git a/Source/Contracts/Screenplay/for_ValidationRuleConverter/given/a_validation_block.cs b/Source/Contracts/Screenplay/for_ValidationRuleConverter/given/a_validation_block.cs new file mode 100644 index 0000000..f2ee0cb --- /dev/null +++ b/Source/Contracts/Screenplay/for_ValidationRuleConverter/given/a_validation_block.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Specifications; + +namespace Cratis.Stage.Contracts.Screenplay.for_ValidationRuleConverter.given; + +public class a_validation_block : Specification +{ + // Every rule kind that takes an operand, so a spec can offer the same set twice — once stating a literal and + // once stating a value the application only resolves while it runs — and compare what survives each time. + protected static readonly ValidationRuleKind[] KindsTakingAnOperand = + [ + ValidationRuleKind.Min, + ValidationRuleKind.Max, + ValidationRuleKind.Length, + ValidationRuleKind.Matches, + ValidationRuleKind.GreaterThan, + ValidationRuleKind.GreaterThanOrEqual, + ValidationRuleKind.LessThan, + ValidationRuleKind.LessThanOrEqual, + ValidationRuleKind.AllGreaterThan, + ValidationRuleKind.AllGreaterThanOrEqual + ]; + + // The property carries the kind's name so a converted rule can be traced back to the kind that produced it. + protected static ValidateSyntax Block(Func operand) => + new DeclarativeValidateSyntax( + [.. KindsTakingAnOperand.Select(kind => new ValidationRuleSyntax(kind.ToString(), kind, operand(kind), null, SourceLocation.Start))], + SourceLocation.Start); + + // 'matches' takes a pattern rather than a number, so it gets one — the point of the spec is the operand being + // fixed, not what shape of fixed value each kind happens to want. + protected static ExpressionSyntax Literal(ValidationRuleKind kind) => + new LiteralExpressionSyntax(kind == ValidationRuleKind.Matches ? "^INV-[0-9]{6}$" : 5d, SourceLocation.Start); +} diff --git a/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_an_operand_is_a_runtime_value.cs b/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_an_operand_is_a_runtime_value.cs new file mode 100644 index 0000000..854ce45 --- /dev/null +++ b/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_an_operand_is_a_runtime_value.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Specifications; +using Cratis.Stage.Contracts.Rules; +using Xunit; + +namespace Cratis.Stage.Contracts.Screenplay.for_ValidationRuleConverter.when_converting_the_validation_of_a_command; + +/// +/// Screenplay lets a rule be stated against a value only known while the application runs — dueDate > today, +/// or a threshold naming another property. A Stage rule holds a fixed operand and there is none to hold for these, so +/// they are dropped. +/// +/// +/// The conversion used to substitute 0 for the missing number and an empty string for the missing pattern, which +/// is the failure worth pinning: dueDate > today became "greater than zero" and matches somePattern +/// became "matches the empty pattern" — rules that assert something the document never said, carried with the same +/// confidence as the ones it did. A rule that is not carried can be reported; a rule carried with an invented operand +/// reads as faithful and is not. +/// +public class and_an_operand_is_a_runtime_value : given.a_validation_block +{ + IReadOnlyList _converted = null!; + + void Because() => _converted = ValidationRuleConverter.Convert([Block(_ => new PathExpressionSyntax("today", SourceLocation.Start))]); + + [Fact] void should_carry_no_rule_at_all() => _converted.ShouldBeEmpty(); + + [Fact] void should_not_substitute_a_threshold() => + _converted.SelectMany(rules => rules.Rules).OfType().ShouldBeEmpty(); + + [Fact] void should_not_substitute_a_pattern() => + _converted.SelectMany(rules => rules.Rules).OfType().ShouldBeEmpty(); +} diff --git a/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_every_operand_is_a_literal.cs b/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_every_operand_is_a_literal.cs new file mode 100644 index 0000000..1b6be9a --- /dev/null +++ b/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_every_operand_is_a_literal.cs @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Syntax; +using Cratis.Specifications; +using Cratis.Stage.Contracts.Rules; +using Xunit; + +namespace Cratis.Stage.Contracts.Screenplay.for_ValidationRuleConverter.when_converting_the_validation_of_a_command; + +/// +/// The baseline the dropping specs are read against: every rule kind that takes an operand, each stating a fixed +/// value, is carried. Without it, a converter that dropped everything would satisfy the specs that assert what is +/// dropped, and nothing would notice. +/// +public class and_every_operand_is_a_literal : given.a_validation_block +{ + IReadOnlyList _converted = null!; + + void Because() => _converted = ValidationRuleConverter.Convert([Block(Literal)]); + + [Fact] void should_carry_every_kind_that_takes_an_operand() => + _converted.Select(rules => rules.PropertyName).ShouldContainOnly(KindsTakingAnOperand.Select(kind => kind.ToString())); + + [Fact] void should_carry_the_stated_threshold() => + _converted.Single(rules => rules.PropertyName == nameof(ValidationRuleKind.GreaterThan)) + .Rules.OfType().Single().Threshold.ShouldEqual(5d); + + [Fact] void should_carry_the_stated_pattern() => + _converted.Single(rules => rules.PropertyName == nameof(ValidationRuleKind.Matches)) + .Rules.OfType().Single().Pattern.ShouldEqual("^INV-[0-9]{6}$"); +} diff --git a/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_one_property_states_both.cs b/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_one_property_states_both.cs new file mode 100644 index 0000000..c0ddd23 --- /dev/null +++ b/Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_one_property_states_both.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Specifications; +using Cratis.Stage.Contracts.Rules; +using Xunit; + +namespace Cratis.Stage.Contracts.Screenplay.for_ValidationRuleConverter.when_converting_the_validation_of_a_command; + +/// +/// The shape a real document takes: one property validated by several rules, only some of which state a fixed value. +/// Dropping the one that cannot be carried must not take the ones that can with it, and must not leave an empty group +/// behind that reads as a property with no invariants rather than a property with fewer. +/// +public class and_one_property_states_both : Specification +{ + IReadOnlyList _converted = null!; + + void Because() => _converted = ValidationRuleConverter.Convert( + [ + new DeclarativeValidateSyntax( + [ + Rule("amount", ValidationRuleKind.GreaterThanOrEqual, new LiteralExpressionSyntax(0d, SourceLocation.Start)), + Rule("amount", ValidationRuleKind.LessThan, new PathExpressionSyntax("creditLimit", SourceLocation.Start)), + Rule("dueDate", ValidationRuleKind.GreaterThan, new PathExpressionSyntax("today", SourceLocation.Start)) + ], + SourceLocation.Start) + ]); + + [Fact] void should_only_carry_the_property_that_has_a_rule_left() => + _converted.Select(rules => rules.PropertyName).ShouldContainOnly(["amount"]); + + [Fact] void should_carry_the_rule_stating_a_fixed_value() => + _converted.Single().Rules.OfType().Single().Threshold.ShouldEqual(0d); + + [Fact] void should_not_carry_the_rule_stating_another_property() => + _converted.Single().Rules.OfType().ShouldBeEmpty(); + + static ValidationRuleSyntax Rule(string property, ValidationRuleKind kind, ExpressionSyntax value) => + new(property, kind, value, null, SourceLocation.Start); +}