From fa711e0be9c5fa8a97c8649d54a5bec63509cb64 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 12 Aug 2026 16:59:00 +0200 Subject: [PATCH] Drop validation rules stated against a runtime value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Stage rule holds a fixed operand. Screenplay lets one be stated against a value the application only resolves while it runs — 'dueDate > today', or a threshold naming another property — and the conversion substituted 0 for the missing number and an empty string for the missing pattern rather than declining to carry the rule. Measured on the language's own invoicing.play, that turned 'dueDate > today' into "greater than zero" and 'olderThan < today' into "less than zero": rules asserting something the document never said, carried with the same confidence as the ones it did. Drop them instead, so what cannot be carried faithfully is not carried at all and a caller can report it. The converter had no specs; it has them now. --- .../Screenplay/ValidationRuleConverter.cs | 47 ++++++++++--------- .../given/a_validation_block.cs | 38 +++++++++++++++ .../and_an_operand_is_a_runtime_value.cs | 37 +++++++++++++++ .../and_every_operand_is_a_literal.cs | 32 +++++++++++++ .../and_one_property_states_both.cs | 43 +++++++++++++++++ 5 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 Source/Contracts/Screenplay/for_ValidationRuleConverter/given/a_validation_block.cs create mode 100644 Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_an_operand_is_a_runtime_value.cs create mode 100644 Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_every_operand_is_a_literal.cs create mode 100644 Source/Contracts/Screenplay/for_ValidationRuleConverter/when_converting_the_validation_of_a_command/and_one_property_states_both.cs 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); +}