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);
+}