diff --git a/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs b/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs new file mode 100644 index 000000000..ce10be200 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs @@ -0,0 +1,111 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay; + +/// +/// Declares the testing surface a Chronicle integration specification is written against, as source. +/// +/// +/// The generator recognizes these by the fully qualified name of the type declaring each member, which is exactly +/// what lets it read a specification without the testing packages being referenced. Declaring them here rather than +/// referencing them keeps every specification about reading them hermetic, and asks the recognition the only +/// question worth asking of it - whether the names alone are enough. +/// +public static class IntegrationTesting +{ + /// + /// The path the surface is compiled as. + /// + public const string Path = "Library/Testing/IntegrationTesting.cs"; + + /// + /// The source of the surface. + /// + public const string Source = """ + using System.Net.Http; + using System.Threading.Tasks; + using Cratis.Chronicle.Events; + using Cratis.Chronicle.EventSequences; + + namespace Cratis.Arc.Testing.Commands + { + public class CommandScenario + { + public Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioChronicleGivenBuilder Given => new(); + + public IEventSequence EventSequence => null!; + + public Task Execute(TCommand command) => Task.FromResult(new Result()); + + public Task Validate(TCommand command) => Task.FromResult(new Result()); + } + + public class Result + { + public bool IsSuccess => true; + } + } + + namespace Cratis.Arc.Chronicle.Testing.Commands + { + public class CommandScenarioChronicleGivenBuilder + { + public CommandScenarioSourceGivenBuilder ForEventSource(EventSourceId eventSourceId) => new(); + } + + public class CommandScenarioSourceGivenBuilder + { + public void Events(params object[] events) + { + } + + public void ReadModel(TReadModel readModel) + where TReadModel : class + { + } + } + } + + namespace Cratis.Chronicle.XUnit.Integration + { + public static class HttpClientExtensions + { + public static Task ExecuteCommand( + this HttpClient client, + string requestUri, + TCommand command) => Task.FromResult(new Cratis.Arc.Testing.Commands.Result()); + } + } + + namespace Cratis.Chronicle.Testing.EventSequences + { + public static class EventSequenceShouldExtensions + { + public static void ShouldHaveAppendedEvent(this IEventSequence sequence, EventSourceId eventSourceId) + { + } + + public static void ShouldHaveTailSequenceNumber(this IEventSequence sequence, int expected) + { + } + + public static void ShouldBeSuccessful(this Cratis.Arc.Testing.Commands.Result result) + { + } + + public static void ShouldNotBeSuccessful(this Cratis.Arc.Testing.Commands.Result result) + { + } + + public static void ShouldHaveConstraintViolationFor(this Cratis.Arc.Testing.Commands.Result result, string constraintName) + { + } + + public static void ShouldBeFalse(this bool value) + { + } + } + } + """; +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs new file mode 100644 index 000000000..f3e6a15e2 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs @@ -0,0 +1,87 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// The other shape Arc documents drives a running host rather than the pipeline in process: what the event log +/// already held is appended to it directly, the command goes over HTTP, and the steps live on a nested context with +/// only the assertions left outside. It says the same thing as the in-process shape and has to read as the same +/// thing, or a document would describe an application differently depending on how it was specified. +/// +public class a_specification_against_a_running_host : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(string Name) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System.Net.Http; + using System.Threading.Tasks; + using Cratis.Chronicle.EventSequences; + using Cratis.Chronicle.Testing.EventSequences; + using Cratis.Chronicle.XUnit.Integration; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_name_is_already_taken + { + public class context + { + public const string AuthorName = "Jane Austen"; + + protected IEventLog EventLog = null!; + protected HttpClient Client = null!; + + async Task Establish() => await EventLog.Append("author", new AuthorRegistered(AuthorName)); + + async Task Because() => await Client.ExecuteCommand("/api/authors/register", new RegisterAuthor(AuthorName)); + } + + readonly IEventSequence _sequence = null!; + + [Fact] void should_register_the_author() => _sequence.ShouldHaveAppendedEvent("author"); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_name_is_already_taken.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + SpecificationModel _specification; + + void Establish() + { + _analysis = Analyzed.Source(_sources); + _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single(); + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_read_the_steps_written_on_the_nested_context() => _specification.Given.Single().Name.ShouldEqual("AuthorRegistered"); + [Fact] void should_state_the_value_appended_to_the_log() => _specification.Given.Single().Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]); + [Fact] void should_issue_the_command_sent_over_http() => _specification.When.Name.ShouldEqual("RegisterAuthor"); + [Fact] void should_state_the_values_the_command_was_sent_with() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]); + [Fact] void should_read_the_assertions_left_outside_the_context() => _specification.Then.Single().Name.ShouldEqual("AuthorRegistered"); + [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs new file mode 100644 index 000000000..4d21f9698 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs @@ -0,0 +1,93 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A command being turned down is half of what a slice is specified for, and the language says it as a rejection +/// with a reason. A source naming the reason - the constraint that held - has it carried across; a source saying +/// only that the command did not go through has no reason to carry, and a made up sentence would describe an +/// application nobody wrote. The scenario is named after the words the source uses for it, so the reason is already +/// said there. +/// +public class a_specification_expecting_a_rejection : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(string Name) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_name_is_taken + { + public const string UniqueAuthorName = "unique-author-name"; + + readonly CommandScenario _scenario = new(); + Result _result = null!; + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Jane Austen")); + + [Fact] void should_not_register_the_author() => _result.ShouldHaveConstraintViolationFor(UniqueAuthorName); + } + + public class and_nothing_was_given + { + readonly CommandScenario _scenario = new(); + Result _result = null!; + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Jane Austen")); + + [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); + [Fact] void should_say_so_on_the_result() => _result.IsSuccess.ShouldBeFalse(); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_name_is_taken.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + SpecificationModel _named; + SpecificationModel _unnamed; + + void Establish() + { + _analysis = Analyzed.Source(_sources); + var specifications = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ToList(); + _named = specifications.Single(_ => _.Name.EndsWith("and_the_name_is_taken", StringComparison.Ordinal)); + _unnamed = specifications.Single(_ => _.Name.EndsWith("and_nothing_was_given", StringComparison.Ordinal)); + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_read_both_scenarios_written_in_one_file() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Count().ShouldEqual(2); + [Fact] void should_carry_across_the_reason_the_source_names() => _named.Errors.ShouldContainOnly(["unique-author-name"]); + [Fact] void should_expect_no_event_alongside_a_rejection() => _named.Then.ShouldBeEmpty(); + [Fact] void should_state_a_rejection_the_source_names_no_reason_for() => _unnamed.Errors.ShouldContainOnly([string.Empty]); + [Fact] void should_state_two_ways_of_saying_the_same_rejection_once() => _unnamed.Errors.Count().ShouldEqual(1); + [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs new file mode 100644 index 000000000..e7e32794e --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs @@ -0,0 +1,78 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// Most of the specifications an application carries stand a collaborator up behind a substitute and say what it was +/// asked to do. That is a statement about the inside of a slice rather than about its behavior, so the language has +/// nowhere to put it - and reporting each one would say a gap exists where none does. They are passed over entirely, +/// which is only safe because what is read is decided by what a specification touches rather than by where it sits. +/// +public class a_specification_of_a_collaborator : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(string Name) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public interface INameFormatter + { + string Format(string name); + } + + public class and_the_name_is_formatted + { + string _result = string.Empty; + + void Because() => _result = new Formatter().Format("jane austen"); + + [Fact] void should_capitalize_the_name() => _result.ShouldEqualTheExpected("Jane Austen"); + } + + public class Formatter : INameFormatter + { + public string Format(string name) => name; + } + + public static class Assertions + { + public static void ShouldEqualTheExpected(this string actual, string expected) + { + } + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_name_is_formatted.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source(_sources); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_specify_the_slice_by_nothing() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ShouldBeEmpty(); + [Fact] void should_report_no_scenario_left_out() => _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecification).ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs new file mode 100644 index 000000000..bc8d41d10 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs @@ -0,0 +1,91 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// The scenarios a slice is specified by sit in a folder beneath it, which is a namespace beneath its own, and are +/// written to a convention rigid enough to read: what had happened is stated against the scenario, the command is +/// issued through it, and each assertion says one thing about what followed. Reading them is what turns a document +/// stating what a slice does into one carrying the examples proving it. +/// +public class a_specification_of_a_slice : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Arc.Queries.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [ReadModel] + public record Author(string Id, string Name); + + [Command] + public record RegisterAuthor(string Name, int Age) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_author_is_new + { + readonly CommandScenario _scenario = new(); + + void Establish() + { + _scenario.Given.ForEventSource("author").Events(new AuthorRegistered("Jane Austen")); + _scenario.Given.ForEventSource("author").ReadModel(new Author("author", "Jane Austen")); + } + + async Task Because() => await _scenario.Execute(new RegisterAuthor("Mary Shelley", 42)); + + [Fact] void should_register_the_author() => _scenario.EventSequence.ShouldHaveAppendedEvent("author"); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_author_is_new.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + SpecificationModel _specification; + + void Establish() + { + _analysis = Analyzed.Source(_sources); + _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single(); + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_put_it_under_the_slice_it_specifies() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Count().ShouldEqual(1); + [Fact] void should_name_it_after_every_word_between_the_slice_and_itself() => _specification.Name.ShouldEqual("when_registering_and_the_author_is_new"); + [Fact] void should_start_from_the_event_that_had_happened() => _specification.Given.First().Name.ShouldEqual("AuthorRegistered"); + [Fact] void should_know_the_event_it_starts_from_is_an_event() => _specification.Given.First().Kind.ShouldEqual(SpecificationStateKind.Event); + [Fact] void should_state_the_values_of_the_event_it_starts_from() => _specification.Given.First().Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]); + [Fact] void should_start_from_the_read_model_that_was_pinned() => _specification.Given.Last().Kind.ShouldEqual(SpecificationStateKind.ReadModel); + [Fact] void should_state_the_values_of_the_read_model() => _specification.Given.Last().Values.Select(_ => _.Property).ShouldContainOnly(["Id", "Name"]); + [Fact] void should_issue_the_command() => _specification.When.Name.ShouldEqual("RegisterAuthor"); + [Fact] void should_state_the_values_the_command_was_issued_with() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Mary Shelley")), new PropertyMappingModel("Age", new LiteralSource(42))]); + [Fact] void should_expect_the_event_the_assertion_names() => _specification.Then.Single().Name.ShouldEqual("AuthorRegistered"); + [Fact] void should_expect_no_rejection() => _specification.Errors.ShouldBeEmpty(); + [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs new file mode 100644 index 000000000..70f7c6d4e --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A scenario written in the host language routinely rests an identity on a value made at run time, which is exactly +/// what a document stating values has no way to name. Unlike a step, a value stands on its own: leaving one out +/// leaves the rest of the scenario saying what the source says, so it is left out and said rather than taking the +/// scenario with it. +/// +public class a_specification_stating_a_value_that_is_code : Specification +{ + const string Slice = """ + using System; + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(Guid Id, string Name, int Age) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System; + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_identity_is_made_at_run_time + { + readonly CommandScenario _scenario = new(); + readonly Guid _id = Guid.NewGuid(); + Result _result = null!; + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_id, "Jane Austen", 41)); + + [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_identity_is_made_at_run_time.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + SpecificationModel _specification; + + void Establish() + { + _analysis = Analyzed.Source(_sources); + _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single(); + } + + ScreenplayDiagnostic LeftOut() => + _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecificationValue); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_keep_the_scenario() => _specification.When.Name.ShouldEqual("RegisterAuthor"); + [Fact] void should_state_every_value_it_can_read() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen")), new PropertyMappingModel("Age", new LiteralSource(41))]); + [Fact] void should_leave_out_the_one_it_cannot() => _specification.When.Values.Select(_ => _.Property).ShouldNotContain("Id"); + [Fact] void should_say_which_value_it_left_out() => LeftOut().Message.ShouldEqual("The value 'when_registering_and_the_identity_is_made_at_run_time' states for 'RegisterAuthor.Id' is code rather than a constant, so the scenario states everything but that value"); + [Fact] void should_say_where_it_left_it_out() => LeftOut().Location.ShouldEqual("Library.Authors.Registration.when_registering.and_the_identity_is_made_at_run_time"); + [Fact] void should_report_it_as_information() => LeftOut().Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Information); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs new file mode 100644 index 000000000..1bace393b --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs @@ -0,0 +1,110 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A scenario is one example, and an example missing the state it started from, the command it issued or the outcome +/// it expects is a different example from the one the source states. Each of the three is left out whole and said +/// so, which is the difference between a document with a known gap and one that is quietly wrong about what an +/// application was specified against. +/// +public class a_specification_whose_steps_cannot_be_read : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(string Name) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_command_is_held_in_a_field + { + readonly CommandScenario _scenario = new(); + RegisterAuthor _command = null!; + Result _result = null!; + + void Establish() => _command = new RegisterAuthor("Jane Austen"); + + async Task Because() => _result = await _scenario.Execute(_command); + + [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); + } + + public class and_what_it_starts_from_depends_on_a_condition + { + readonly CommandScenario _scenario = new(); + Result _result = null!; + + protected virtual bool AuthorAlreadyRegistered => true; + + void Establish() + { + if (AuthorAlreadyRegistered) + { + _scenario.Given.ForEventSource("author").Events(new AuthorRegistered("Jane Austen")); + } + } + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Mary Shelley")); + + [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); + } + + public class and_nothing_it_expects_has_a_place_in_the_language + { + readonly CommandScenario _scenario = new(); + Result _result = null!; + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Mary Shelley")); + + [Fact] void should_succeed() => _result.ShouldBeSuccessful(); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_command_is_held_in_a_field.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source(_sources); + + IEnumerable LeftOut() => + _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecification); + + bool SaidOf(string scenario, string reason) => + LeftOut().Any(_ => _.Message.Contains(scenario, StringComparison.Ordinal) && _.Message.Contains(reason, StringComparison.Ordinal)); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_leave_out_every_scenario_it_cannot_read() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ShouldBeEmpty(); + [Fact] void should_report_each_of_them() => LeftOut().Count().ShouldEqual(3); + [Fact] void should_say_a_command_put_together_elsewhere_cannot_be_read() => SaidOf("and_the_command_is_held_in_a_field", "the command it issues is put together somewhere this cannot read").ShouldBeTrue(); + [Fact] void should_say_a_starting_point_under_a_condition_cannot_be_read() => SaidOf("and_what_it_starts_from_depends_on_a_condition", "only happens under a condition").ShouldBeTrue(); + [Fact] void should_say_an_outcome_the_language_cannot_hold_cannot_be_read() => SaidOf("and_nothing_it_expects_has_a_place_in_the_language", "it expects no event and no rejection").ShouldBeTrue(); + [Fact] void should_say_where_each_of_them_lives() => LeftOut().Select(_ => _.Location).ShouldContain("Library.Authors.Registration.when_registering.and_the_command_is_held_in_a_field"); + [Fact] void should_report_them_as_warnings() => LeftOut().Select(_ => _.Severity).ShouldContainOnly([ScreenplayDiagnosticSeverity.Warning, ScreenplayDiagnosticSeverity.Warning, ScreenplayDiagnosticSeverity.Warning]); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs new file mode 100644 index 000000000..336b40592 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs @@ -0,0 +1,96 @@ +// 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; + +namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating; + +/// +/// A scenario recovered from source has to survive being written down: the name it is declared under, the events it +/// starts from, the command it issues and the rejection it expects are each a line the language reads back, and a +/// scenario is the one construct where the value of a property and the name of a step sit one indentation apart. +/// This asks the whole way through, from source to printed text and back again through the compiler. +/// +public class from_source_carrying_the_specifications_of_a_slice : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Arc.Queries.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Invoicing.Issuing; + + [EventType] + public record InvoiceIssued(string Number, int Lines); + + [ReadModel] + public record Invoice(string Id, string Number); + + [Command] + public record IssueInvoice(string Number, int Lines) + { + public InvoiceIssued Handle() => new(Number, Lines); + } + """; + + const string Scenario = """ + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Invoicing.Issuing; + using Xunit; + + namespace Library.Invoicing.Issuing.when_issuing; + + public class and_the_invoice_has_no_lines + { + public const string OneInvoicePerNumber = "one-invoice-per-number"; + + readonly CommandScenario _scenario = new(); + Result _result = null!; + + void Establish() + { + _scenario.Given.ForEventSource("invoice").Events(new InvoiceIssued("2026-1", 3)); + _scenario.Given.ForEventSource("invoice").ReadModel(new Invoice("invoice", "2026-1")); + } + + async Task Because() => _result = await _scenario.Execute(new IssueInvoice("2026-2", 0)); + + [Fact] void should_not_issue_the_invoice() => _result.ShouldHaveConstraintViolationFor(OneInvoicePerNumber); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Invoicing/Issuing/Issuing.cs", Slice), + ("Library/Invoicing/Issuing/when_issuing/and_the_invoice_has_no_lines.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ScreenplayGenerationResult _result; + CompilationResult _compiled; + string _reprinted; + + void Because() + { + _result = new ScreenplayGenerator().Generate(Analyzed.Compile(_sources), new ScreenplayOptions()); + _compiled = new ScreenplayCompiler().Compile(_result.Source); + _reprinted = _compiled.Value is null ? string.Empty : new Cratis.Screenplay.Printing.ScreenplayPrinter().Print(_compiled.Value); + } + + bool Says(string text) => _result.Source.Contains(text, StringComparison.Ordinal); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue(); + [Fact] void should_produce_a_document_the_compiler_says_nothing_about() => _compiled.Diagnostics.ShouldBeEmpty(); + [Fact] void should_print_the_same_text_on_a_second_pass() => _reprinted.ShouldEqual(_result.Source); + [Fact] void should_declare_the_scenario_under_the_words_the_source_wrote() => Says("specification WhenIssuingAndTheInvoiceHasNoLines").ShouldBeTrue(); + [Fact] void should_state_the_event_it_starts_from() => Says("given InvoiceIssued").ShouldBeTrue(); + [Fact] void should_state_the_read_model_it_starts_from() => Says("given readmodel Invoice").ShouldBeTrue(); + [Fact] void should_state_the_values_it_starts_from() => Says("number = \"2026-1\"").ShouldBeTrue(); + [Fact] void should_state_the_command_it_issues() => Says("when IssueInvoice").ShouldBeTrue(); + [Fact] void should_state_the_values_the_command_carries() => Says("lines = 0").ShouldBeTrue(); + [Fact] void should_state_the_rejection_and_the_reason_named_for_it() => Says("then error \"one-invoice-per-number\"").ShouldBeTrue(); + [Fact] void should_be_successful() => _result.IsSuccess.ShouldBeTrue(); +} diff --git a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs index 70d00fc9f..300f172a8 100644 --- a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs +++ b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs @@ -63,7 +63,8 @@ public ApplicationModelAnalysis Analyze(IReadOnlyList compilations, whole.Elsewhere.Report(diagnostics); TypesTheDocumentCannotName.Report(whole.Types, diagnostics, domain); - var slices = SliceUnion.Of(projects.SelectMany(_ => _.Slices), diagnostics); + var joined = SliceUnion.Of(projects.SelectMany(_ => _.Slices), diagnostics); + var slices = Specified(projects, joined, diagnostics); var imports = ExternalEvents.Resolve(ordered, slices, diagnostics); NamespacesWithoutStructure.Report(slices, diagnostics, options.SegmentsToSkip ?? 0); @@ -100,6 +101,42 @@ public ApplicationModelAnalysis Analyze(IReadOnlyList compilations, diagnostics.All); } + /// + /// Puts every scenario the application specifies its slices by under the slice it belongs to. + /// + /// The projects the application is written as, in the order they were read. + /// The slices of the application, joined across every project. + /// The diagnostics to report to. + /// The slices, each carrying the scenarios it is specified by. + /// + /// A scenario is written in one project and reads the symbols of that project's compilation, so it is recovered + /// per project exactly as an artifact is. Which slice it belongs to is a different question: a scenario names no + /// slice, it is placed by the namespace above it that declares one, and nothing says the project declaring that + /// slice is the project the scenario was written in - a bounded context handling its commands beside the contracts + /// project publishing its events is specified from the project holding the handlers. So placement is decided + /// against the slices of the whole application rather than the slices of the project the scenario came from, which + /// is why this reads them once the union is in rather than within each project. + /// + static IReadOnlyList Specified( + IReadOnlyList projects, + IReadOnlyList slices, + ScreenplayDiagnostics diagnostics) + { + var namespaces = slices.Select(_ => _.Namespace).ToList(); + var catalogs = projects.Select(_ => _.Specifications(namespaces, diagnostics)).ToList(); + + return + [ + .. slices.Select(slice => slice with + { + Specifications = SliceUnion.Specifications( + catalogs.SelectMany(_ => _.For(slice.Namespace)), + slice.Namespace, + diagnostics) + }) + ]; + } + /// /// Gets everything within a slice that requires something of the caller. /// diff --git a/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs b/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs index 7fed1403b..0b8dc1a4c 100644 --- a/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs +++ b/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs @@ -3,6 +3,7 @@ using Cratis.Arc.Screenplay.Analysis.Screens; using Cratis.Arc.Screenplay.Analysis.Slices; +using Cratis.Arc.Screenplay.Analysis.Specifications; using Cratis.Arc.Screenplay.Model; using Microsoft.CodeAnalysis; @@ -14,9 +15,9 @@ namespace Cratis.Arc.Screenplay.Analysis; /// /// Everything within this reads symbols and syntax of a single compilation, which is what makes it the unit an /// application of several projects is read in. What comes out is joined to what the other projects yielded, and the -/// two questions that can only be answered once every project has been read - what a validator declares about a -/// concept another project holds, and how much the errors of this project left behind - are asked of this afterwards -/// rather than kept inside it. +/// questions that can only be answered once every project has been read - what a validator declares about a concept +/// another project holds, which slice of the whole application a scenario written here specifies, and how much the +/// errors of this project left behind - are asked of this afterwards rather than kept inside it. /// public class CompilationAnalysis { @@ -82,6 +83,19 @@ public static CompilationAnalysis Of( return new(compilation, catalog, readers, recovered, slices); } + /// + /// Reads every scenario this project specifies a slice of the application by. + /// + /// The namespaces a slice was recovered from, across every project. + /// The diagnostics to report to. + /// The scenarios, arranged under the slice each belongs to. + /// + /// This waits until every project has been read, because a scenario is placed by the nearest namespace above it + /// that declares a slice - and the project declaring that slice need not be the one the scenario is written in. + /// + public SpecificationCatalog Specifications(IEnumerable slices, ScreenplayDiagnostics diagnostics) => + SpecificationCatalog.Read(Compilation, _catalog, slices, diagnostics); + /// /// Attaches the rules the validators of this project declare to the concepts they validate. /// diff --git a/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs b/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs index 335bf9efa..647890fbb 100644 --- a/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs +++ b/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs @@ -36,6 +36,25 @@ .. slices .Select(group => Join([.. group], diagnostics)) ]; + /// + /// Joins the scenarios every project specifies one slice by. + /// + /// The scenarios each project placed under the slice, in the order they were read. + /// The namespace of the slice. + /// The diagnostics to report to. + /// The scenarios kept, ordered by name. + /// + /// Scenarios arrive apart from the rest of a slice, because which slice one belongs to is only known once every + /// project has been read, so they are joined here rather than in . What holds for every other + /// part of a slice holds for them: a document saying specification and_the_name_is_taken twice in one slice + /// says the same word twice and means it differently, so the first is kept. + /// + public static IReadOnlyList Specifications( + IEnumerable specifications, + string @namespace, + ScreenplayDiagnostics diagnostics) => + Once(specifications, _ => _.Name, "specification", @namespace, diagnostics); + /// /// Joins the parts of one slice. /// diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs b/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs new file mode 100644 index 000000000..f4bccd55f --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs @@ -0,0 +1,96 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Gets the expressions a call gives to one of its parameters. +/// +/// +/// What a specification states sits in one argument of a call, and which one it is has to be resolved by name rather +/// than by position - the calls carrying it declare everything after it as optional, take it through a parameter +/// array, or are extension methods whose first parameter is the receiver. A parameter array and a parameter taking a +/// collection are both flattened, so that stating three events in one call and stating them in three reads the same +/// way. +/// +public static class CallArguments +{ + /// + /// Gets the expressions a call gives to a parameter. + /// + /// The call to read. + /// The method being called. + /// The name of the parameter to read. + /// The expressions, in the order the call declares them. + public static IEnumerable For(InvocationExpressionSyntax invocation, IMethodSymbol method, string parameter) + { + var arguments = invocation.ArgumentList.Arguments; + var given = new List(); + + for (var index = 0; index < arguments.Count; index++) + { + if (string.Equals(NameOf(arguments[index], method, index), parameter, StringComparison.Ordinal)) + { + given.AddRange(Flatten(arguments[index].Expression)); + } + } + + return given; + } + + /// + /// Gets the name of the parameter an argument fills in. + /// + /// The argument to name. + /// The method being called. + /// The position of the argument. + /// The parameter name, or when it cannot be resolved. + static string? NameOf(ArgumentSyntax argument, IMethodSymbol method, int index) + { + if (argument.NameColon is { } named) + { + return named.Name.Identifier.ValueText; + } + + if (index < method.Parameters.Length) + { + return method.Parameters[index].Name; + } + + return method.Parameters is [.., { IsParams: true } last] ? last.Name : null; + } + + /// + /// Opens up an expression that holds several values into the values it holds. + /// + /// The expression to open up. + /// The values, or the expression itself when it holds one. + static IEnumerable Flatten(ExpressionSyntax expression) => expression switch + { + CollectionExpressionSyntax collection => collection.Elements.Select(ValueOf), + ImplicitArrayCreationExpressionSyntax implicitly => implicitly.Initializer.Expressions, + ArrayCreationExpressionSyntax array when array.Initializer is { } initializer => initializer.Expressions, + _ => [expression] + }; + + /// + /// Gets the expression one element of a collection stands for. + /// + /// The element to read. + /// The expression. + /// + /// An element spreading another collection into this one stands for however many values that collection holds, + /// which is not something a source text says. The expression being spread is returned as it is, so that whoever + /// asked for the values finds one it cannot read rather than a list quietly missing them. + /// + static ExpressionSyntax ValueOf(CollectionElementSyntax element) => element switch + { + ExpressionElementSyntax value => value.Expression, + SpreadElementSyntax spread => spread.Expression, + _ => SyntaxFactory.IdentifierName(element.ToString()) + }; +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs new file mode 100644 index 000000000..16aaba1e7 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs @@ -0,0 +1,95 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Recognizes what an assertion of a specification says the outcome was. +/// +/// +/// Assertions are matched on the name of the helper alone. The same helper is declared several times over across the +/// testing packages - once for the result of a command, once for the result of an append, once for the scenario +/// itself - and a specification calls whichever of them its own shape reaches, so matching the declaring type would +/// recognize the same sentence in one specification and not in the next. +/// +/// Only the two outcomes Screenplay can hold are recognized: an event was appended, and the command was rejected. +/// Everything else a specification asserts - how far the sequence got, that no exception was thrown, that the result +/// was valid - says nothing the language has a place for and is passed over rather than reported, in the same way a +/// unit level specification is. +/// +/// +public static class SpecificationAssertions +{ + /// The assertion naming an event the command appended. + public const string AppendedEventAssertion = "ShouldHaveAppendedEvent"; + + /// The assertion saying a value is false, which is a rejection when the value is the success of a result. + public const string FalseAssertion = "ShouldBeFalse"; + + /// The value of a result saying whether the command was carried out. + public const string SuccessProperty = "IsSuccess"; + + /// The assertions saying the command was rejected, without naming why. + public static readonly string[] Rejections = + [ + "ShouldHaveExceptions", + "ShouldHaveValidationErrors", + "ShouldNotBeAuthorized", + "ShouldNotBeSuccessful" + ]; + + /// The assertions saying the command was rejected and naming the reason. + public static readonly string[] NamedRejections = + [ + "ShouldHaveConstraintViolation", + "ShouldHaveConstraintViolationFor", + "ShouldHaveValidationErrorFor" + ]; + + /// + /// Gets the event an assertion says was appended. + /// + /// The method being called. + /// The event type, or when the assertion is about something else. + /// + /// The event is the last type argument rather than the only one, because the helper taking the scenario also + /// takes the command it is a scenario for, which has to be named ahead of it. + /// + public static ITypeSymbol? AppendedEventOf(IMethodSymbol method) => + string.Equals(method.Name, AppendedEventAssertion, StringComparison.Ordinal) + ? method.TypeArguments.LastOrDefault() + : null; + + /// + /// Determines whether an assertion says the command was rejected. + /// + /// The assertion to read. + /// The method being called. + /// True when the assertion is a rejection. + public static bool IsRejection(InvocationExpressionSyntax invocation, IMethodSymbol method) => + Array.Exists(Rejections, _ => string.Equals(_, method.Name, StringComparison.Ordinal)) || + IsNamedRejection(method) || + IsUnsuccessful(invocation, method); + + /// + /// Determines whether an assertion names the reason the command was rejected. + /// + /// The method being called. + /// True when the assertion names a reason. + public static bool IsNamedRejection(IMethodSymbol method) => + Array.Exists(NamedRejections, _ => string.Equals(_, method.Name, StringComparison.Ordinal)); + + /// + /// Determines whether an assertion says the result of the command was not a success. + /// + /// The assertion to read. + /// The method being called. + /// True when the assertion is about the success of a result. + static bool IsUnsuccessful(InvocationExpressionSyntax invocation, IMethodSymbol method) => + string.Equals(method.Name, FalseAssertion, StringComparison.Ordinal) && + invocation.Expression is MemberAccessExpressionSyntax { Expression: MemberAccessExpressionSyntax subject } && + string.Equals(subject.Name.Identifier.ValueText, SuccessProperty, StringComparison.Ordinal); +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs new file mode 100644 index 000000000..60cad5db5 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs @@ -0,0 +1,129 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Recognizes the calls a Chronicle integration specification is written with. +/// +/// +/// Two ways of writing one are recognized, because Arc documents both. A specification driving the command pipeline +/// in process states what had happened through the scenario it holds and issues the command through the same +/// scenario; one driving a running host states it against the event log and issues the command over HTTP. Everything +/// is matched on the fully qualified metadata name of the type declaring the method, so neither testing package has +/// to be referenced for either to be read. +/// +public static class SpecificationCalls +{ + /// The method appending one event to a sequence. + public const string AppendMethod = "Append"; + + /// The method appending several events to a sequence as one transaction. + public const string AppendManyMethod = "AppendMany"; + + /// The method stating the events that had happened for one event source. + public const string EventsMethod = "Events"; + + /// The method pinning the read model one event source resolves to. + public const string ReadModelMethod = "ReadModel"; + + /// The method issuing a command through the in-process pipeline. + public const string ExecuteMethod = "Execute"; + + /// The method taking a command as far as the pipeline validates it. + public const string ValidateMethod = "Validate"; + + /// The method issuing a command over HTTP. + public const string ExecuteCommandMethod = "ExecuteCommand"; + + /// The parameter carrying the single event an append is given. + public const string EventParameter = "event"; + + /// The parameter carrying the events an append or a scenario is given. + public const string EventsParameter = "events"; + + /// The parameter carrying the read model a scenario is pinned with. + public const string ReadModelParameter = "readModel"; + + /// The parameter carrying the command issued over HTTP. + public const string CommandParameter = "command"; + + /// + /// Determines whether a call states the events a specification starts from. + /// + /// The method being called. + /// True when the call states events. + public static bool IsGivenEvents(IMethodSymbol method) => + (IsOn(method, WellKnownTypeNames.EventSequence) && (Named(method, AppendMethod) || Named(method, AppendManyMethod))) || + (IsOn(method, WellKnownTypeNames.CommandScenarioSourceGivenBuilder) && Named(method, EventsMethod)); + + /// + /// Determines whether a call pins the read model a specification starts from. + /// + /// The method being called. + /// True when the call pins a read model. + public static bool IsGivenReadModel(IMethodSymbol method) => + IsOn(method, WellKnownTypeNames.CommandScenarioSourceGivenBuilder) && Named(method, ReadModelMethod); + + /// + /// Determines whether a call issues the command a specification is about. + /// + /// The method being called. + /// True when the call issues a command. + public static bool IsExecution(IMethodSymbol method) => + (IsOn(method, WellKnownTypeNames.CommandScenario) && (Named(method, ExecuteMethod) || Named(method, ValidateMethod))) || + (IsOn(method, WellKnownTypeNames.HttpClientExtensions) && Named(method, ExecuteCommandMethod)); + + /// + /// Gets the name of the parameter carrying what a recognized call is given. + /// + /// The method being called. + /// The parameter name, or when the call carries nothing this reads. + public static string? PayloadParameterOf(IMethodSymbol method) + { + if (IsGivenReadModel(method)) + { + return ReadModelParameter; + } + + if (IsExecution(method)) + { + return Named(method, ExecuteCommandMethod) ? CommandParameter : method.Parameters.FirstOrDefault()?.Name; + } + + if (!IsGivenEvents(method)) + { + return null; + } + + return Named(method, AppendMethod) ? EventParameter : EventsParameter; + } + + /// + /// Determines whether a method carries a name. + /// + /// The method to check. + /// The name to match. + /// True when the names are the same. + static bool Named(IMethodSymbol method, string name) => string.Equals(method.Name, name, StringComparison.Ordinal); + + /// + /// Determines whether a method belongs to a named type, following the type it was reduced from. + /// + /// The method to check. + /// The fully qualified metadata name of the declaring type. + /// True when the method belongs to that type. + /// + /// A specialization of an interface inherits its members without redeclaring them, so the interface a member is + /// declared on is matched as well as the one it is reached through - appending to the event log is appending to a + /// sequence, whichever of the two the call site names. + /// + static bool IsOn(IMethodSymbol method, string fullMetadataName) + { + var declaring = (method.ReducedFrom ?? method).ContainingType; + + return declaring.Is(fullMetadataName) || declaring.FindInterface(fullMetadataName) is not null; + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs new file mode 100644 index 000000000..64567b0d4 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs @@ -0,0 +1,88 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Holds every scenario the application specifies its slices by, arranged under the slice each belongs to. +/// +/// +/// Specifications are read once the slices are in, because which slice a scenario belongs to is answered by which +/// namespaces turned out to declare one. Reading them alongside would mean deciding that against namespaces still +/// being read, and a scenario would land under a different slice depending on the order the compilation was walked. +/// +public class SpecificationCatalog +{ + readonly Dictionary> _bySlice; + + SpecificationCatalog(Dictionary> bySlice) => _bySlice = bySlice; + + /// + /// Reads every specification the compilation declares. + /// + /// The compilation being analyzed. + /// The catalogue of everything the compilation declares. + /// The namespaces a slice was recovered from. + /// The anything unreadable is reported to. + /// The . + public static SpecificationCatalog Read( + Compilation compilation, + ArtifactCatalog catalog, + IEnumerable slices, + ScreenplayDiagnostics diagnostics) + { + var reader = new SpecificationReader(compilation, diagnostics); + var placement = new SpecificationPlacement(slices); + var bySlice = new Dictionary>(StringComparer.Ordinal); + + foreach (var type in catalog.Types.Where(reader.IsSpecification)) + { + if (placement.SliceOf(type) is not { } slice) + { + diagnostics.Warning( + ScreenplayDiagnosticCodes.UnreadableSpecification, + $"The scenario '{type.Name}' was left out because no namespace above it declares a slice for it to specify", + type.ToDisplayString()); + + continue; + } + + if (reader.Read(type, SpecificationPlacement.NameOf(type, slice)) is { } specification) + { + Add(bySlice, slice, specification); + } + } + + return new(bySlice); + } + + /// + /// Gets the specifications belonging to a slice. + /// + /// The namespace of the slice. + /// The specifications, ordered by name. + public IEnumerable For(string @namespace) => + _bySlice.TryGetValue(@namespace, out var specifications) + ? specifications.OrderBy(_ => _.Name, StringComparer.Ordinal) + : []; + + /// + /// Adds a specification under the slice it belongs to. + /// + /// The specifications collected so far. + /// The namespace of the slice. + /// The specification to add. + static void Add(Dictionary> bySlice, string slice, SpecificationModel specification) + { + if (!bySlice.TryGetValue(slice, out var specifications)) + { + specifications = []; + bySlice[slice] = specifications; + } + + specifications.Add(specification); + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs new file mode 100644 index 000000000..c868c0bc1 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Collects a specification while the type declaring it is being read. +/// +/// +/// A scenario is one example, so the first step of it that cannot be read decides the outcome for all of them. The +/// reason is kept rather than the fact, because what made a scenario unreadable is the only part of it worth +/// reporting - and the first reason is kept rather than the last, since every reason after it is read from a +/// scenario already known to be incomplete. +/// +public class SpecificationDraft +{ + /// + /// Gets what had already happened when the command was issued. + /// + public IList Given { get; } = []; + + /// + /// Gets the events and read model states that followed. + /// + public IList Then { get; } = []; + + /// + /// Gets the rejections that followed, each named by the reason the source gives for it. + /// + public IList Errors { get; } = []; + + /// + /// Gets or sets the command that was issued. + /// + public SpecificationStateModel? When { get; set; } + + /// + /// Gets why the scenario could not be read, or while all of it still can be. + /// + public string? Unreadable { get; private set; } + + /// + /// Records why the scenario cannot be read. + /// + /// What made it unreadable. + public void CannotRead(string reason) => Unreadable ??= reason; +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs new file mode 100644 index 000000000..56f761fc4 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs @@ -0,0 +1,114 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Finds the parts of a specification within the chain of types it is written across. +/// +/// +/// A specification inherits the world it starts from. Every type in the chain may set part of it up, and the +/// specification framework calls each of those in turn from the base down, so the chain is walked in that same order +/// and what it states comes out in the order it really happens in. +/// +/// Where the steps are written differs between the two shapes Arc documents. A specification driving the pipeline in +/// process writes them on itself; one driving a running host writes them on a nested type the fixture is handed to, +/// and keeps only its assertions outside. Which of the two a specification is follows from where the steps are, not +/// from what it derives from, so a chain no package declares reads exactly the same way. +/// +/// +public static class SpecificationMembers +{ + /// The method setting up the world a specification starts from. + public const string EstablishMethod = "Establish"; + + /// The method performing the single action a specification is about. + public const string BecauseMethod = "Because"; + + /// The nested type the steps of a specification against a running host are written on. + public const string ContextType = "context"; + + /// + /// Gets the type the steps of a specification are written on. + /// + /// The type declaring the specification. + /// The nested context when the specification has one, otherwise the type itself. + public static INamedTypeSymbol StepsOf(INamedTypeSymbol type) => + type.GetTypeMembers(ContextType).FirstOrDefault(_ => Declares(_, BecauseMethod) || Declares(_, EstablishMethod)) ?? type; + + /// + /// Gets the chain of types a type is written across, from the base down. + /// + /// The type to walk. + /// The chain, base first. + public static IEnumerable ChainOf(INamedTypeSymbol type) + { + var chain = new List(); + for (var current = type; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType) + { + chain.Insert(0, current); + } + + return chain; + } + + /// + /// Gets every declaration of a method in a chain of types. + /// + /// The type to walk from. + /// The name of the method. + /// The methods, from the base down. + public static IEnumerable MethodsIn(INamedTypeSymbol type, string name) => + ChainOf(type).SelectMany(_ => _.GetMembers(name)).OfType().Where(_ => _.MethodKind == MethodKind.Ordinary); + + /// + /// Gets every assertion a specification makes. + /// + /// The type declaring the specification. + /// The assertions, ordered by name so that the same source always reads the same way. + public static IEnumerable AssertionsIn(INamedTypeSymbol type) => + ChainOf(type) + .SelectMany(_ => _.GetMembers()) + .OfType() + .Where(_ => _.HasAttribute(WellKnownTypeNames.FactAttribute)) + .OrderBy(_ => _.Name, StringComparer.Ordinal) + .ThenBy(_ => _.ToDisplayString(), StringComparer.Ordinal); + + /// + /// Determines whether a type holds a scenario the command pipeline is driven through. + /// + /// The type to check. + /// True when the type or one of its bases holds a scenario. + /// + /// Holding one is what makes a specification an integration specification of the slice rather than a unit level + /// one about a collaborator, and it holds whether or not the command is issued somewhere this can read. That is + /// exactly why it is asked: a specification that holds a scenario and issues its command through a helper is one + /// this cannot read, and saying so is the difference between a known gap and a silent one. + /// + public static bool HoldsAScenario(INamedTypeSymbol type) => + ChainOf(type) + .SelectMany(_ => _.GetMembers()) + .Any(member => TypeOf(member).Is(WellKnownTypeNames.CommandScenario)); + + /// + /// Determines whether a type declares a method itself. + /// + /// The type to check. + /// The name of the method. + /// True when the type declares it. + static bool Declares(INamedTypeSymbol type, string name) => type.GetMembers(name).OfType().Any(); + + /// + /// Gets the type a member holds a value of. + /// + /// The member to read. + /// The type, or when the member holds no value. + static ITypeSymbol? TypeOf(ISymbol member) => member switch + { + IFieldSymbol field => field.Type, + IPropertySymbol property => property.Type, + _ => null + }; +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs new file mode 100644 index 000000000..a41fcc5da --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs @@ -0,0 +1,151 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis.Commands; +using Cratis.Arc.Screenplay.Analysis.Events; +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Reads what a specification says followed from the command it issued. +/// +/// The compilation being analyzed. +/// The anything unreadable is reported to. +/// +/// Each assertion is one sentence about the outcome, and several of them routinely say the same sentence about a +/// different part of the same event - once for each value it carries. Screenplay says an event followed once, so a +/// sentence already said is passed over rather than repeated. +/// +public class SpecificationOutcomeReader(Compilation compilation, ScreenplayDiagnostics diagnostics) +{ + /// + /// Reads what a specification says followed. + /// + /// The type declaring the specification. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives, for use in diagnostics. + public void Read(INamedTypeSymbol type, SpecificationDraft draft, string name, string location) + { + foreach (var assertion in SpecificationMembers.AssertionsIn(type)) + { + foreach (var body in HandlerBodies.Of(assertion)) + { + ReadBody(body, compilation.GetSemanticModel(body.SyntaxTree), draft, name, location); + } + } + } + + /// + /// Adds an event a specification says followed, or records that it cannot be read. + /// + /// The type the assertion names. + /// The scenario collected so far. + static void AddEvent(ITypeSymbol appended, SpecificationDraft draft) + { + if (!EventReader.IsEvent(appended)) + { + draft.CannotRead($"it expects '{appended.Name}' to follow, which is not declared as an event"); + return; + } + + if (!draft.Then.Any(_ => string.Equals(_.Name, appended.Name, StringComparison.Ordinal))) + { + draft.Then.Add(new(appended.Name, SpecificationStateKind.Event, [])); + } + } + + /// + /// Reads what one assertion says followed. + /// + /// The body of the assertion. + /// The semantic model of the tree the body lives in. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives. + void ReadBody(SyntaxNode body, SemanticModel semanticModel, SpecificationDraft draft, string name, string location) + { + foreach (var invocation in body.DescendantNodesAndSelf().OfType()) + { + if (semanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method) + { + continue; + } + + var appended = SpecificationAssertions.AppendedEventOf(method); + var rejection = SpecificationAssertions.IsRejection(invocation, method); + if (appended is null && !rejection) + { + continue; + } + + if (!StepsTaken.Always(invocation, body)) + { + draft.CannotRead("what it expects to follow is only expected under a condition, and a scenario says what followed"); + return; + } + + if (appended is not null) + { + AddEvent(appended, draft); + continue; + } + + AddError(invocation, method, semanticModel, draft, name, location); + } + } + + /// + /// Adds a rejection a specification says followed, named by the reason the source gives for it. + /// + /// The assertion to read. + /// The method being called. + /// The semantic model of the tree the assertion lives in. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives. + void AddError( + InvocationExpressionSyntax invocation, + IMethodSymbol method, + SemanticModel semanticModel, + SpecificationDraft draft, + string name, + string location) + { + var reason = SpecificationAssertions.IsNamedRejection(method) + ? ReasonOf(invocation, semanticModel, name, location) + : string.Empty; + + if (!draft.Errors.Contains(reason, StringComparer.Ordinal)) + { + draft.Errors.Add(reason); + } + } + + /// + /// Gets the reason an assertion names for a rejection. + /// + /// The assertion to read. + /// The semantic model of the tree the assertion lives in. + /// The name of the specification. + /// Where the specification lives. + /// The reason, empty when the source names one this cannot read. + string ReasonOf(InvocationExpressionSyntax invocation, SemanticModel semanticModel, string name, string location) + { + var named = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression; + if (named is not null && semanticModel.GetConstantValue(named) is { HasValue: true, Value: { } value }) + { + return value.ToString() ?? string.Empty; + } + + diagnostics.Information( + ScreenplayDiagnosticCodes.UnreadableSpecificationValue, + $"The reason '{name}' gives for a rejection is code rather than a constant, so the rejection is stated without one", + location); + + return string.Empty; + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs new file mode 100644 index 000000000..8663601f3 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Decides which slice a specification belongs to and what it is called there. +/// +/// The namespaces a slice was recovered from. +/// +/// A specification sits in a folder beneath the slice it is about, which is a namespace beneath the slice's own. The +/// slice it belongs to is therefore the nearest namespace above it that declares one - nearest rather than any, +/// because a slice within a slice is exactly what a feature holding both a behavior and the behaviors beneath it +/// looks like. +/// +/// The name is every word between the slice and the specification, in the order the source wrote them. That is what +/// the convention was built to read as - a folder saying when something happens and a file saying what else was true +/// at the time - so keeping all of it is what makes the specification say in the document what it says in the source. +/// +/// +public class SpecificationPlacement(IEnumerable slices) +{ + /// The namespace segment holding the world several specifications share. + public const string SharedContextSegment = "given"; + + readonly HashSet _slices = [.. slices]; + + /// + /// Gets the name a specification is declared under. + /// + /// The type declaring the specification. + /// The namespace of the slice it belongs to. + /// The name, as the words the source wrote joined together. + public static string NameOf(INamedTypeSymbol type, string slice) + { + var below = Segments(type)[SegmentsIn(slice)..]; + + return string.Join( + '_', + below.Where(_ => !string.Equals(_, SharedContextSegment, StringComparison.Ordinal)).Append(type.Name)); + } + + /// + /// Gets the namespace of the slice a specification belongs to. + /// + /// The type declaring the specification. + /// The namespace, or when no slice above it declares anything. + public string? SliceOf(INamedTypeSymbol type) + { + var segments = Segments(type); + + for (var depth = segments.Length - 1; depth > 0; depth--) + { + var candidate = string.Join('.', segments[..depth]); + if (_slices.Contains(candidate)) + { + return candidate; + } + } + + return null; + } + + /// + /// Gets the segments of the namespace a type lives in, with the type left out. + /// + /// The type to read. + /// The segments. + static string[] Segments(INamedTypeSymbol type) => type.Namespace().Split('.', StringSplitOptions.RemoveEmptyEntries); + + /// + /// Counts the segments of a namespace. + /// + /// The namespace to count. + /// The number of segments. + static int SegmentsIn(string @namespace) => @namespace.Split('.', StringSplitOptions.RemoveEmptyEntries).Length; +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs new file mode 100644 index 000000000..efb5b396d --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs @@ -0,0 +1,133 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis.Commands; +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Reads the scenario one type specifies a slice by. +/// +/// The compilation being analyzed. +/// The anything unreadable is reported to. +/// +/// Only a specification driving a command through the real pipeline is read. A unit level one stands a collaborator +/// up behind a substitute and says what that collaborator was asked to do, which is a statement about the inside of +/// the slice rather than about its behavior, and Screenplay has nowhere to put it. Whether a specification is one or +/// the other is decided by what it touches: holding a scenario the pipeline runs in, or reaching the event log, is +/// what an integration specification does and nothing else does. +/// +public class SpecificationReader(Compilation compilation, ScreenplayDiagnostics diagnostics) +{ + /// + /// Determines whether a type specifies a slice by driving a command through the pipeline. + /// + /// The type to check. + /// True when the type is a specification of the kind this reads. + public bool IsSpecification(INamedTypeSymbol type) + { + if (type is not { TypeKind: TypeKind.Class, IsAbstract: false, ContainingType: null }) + { + return false; + } + + var steps = SpecificationMembers.StepsOf(type); + + return SpecificationMembers.MethodsIn(steps, SpecificationMembers.BecauseMethod).Any() && + (SpecificationMembers.HoldsAScenario(steps) || DrivesASlice(steps)); + } + + /// + /// Reads the scenario a type specifies a slice by. + /// + /// The type declaring the specification. + /// The name the specification is declared under. + /// The , or when the scenario cannot be read. + /// + /// What was left out of a scenario is held back until the scenario is known to survive. Saying that a value was + /// left out of a scenario that was itself left out describes a document that says something it does not say, and + /// there are far more values than scenarios - so the noise would be the loudest thing in the report. + /// + public SpecificationModel? Read(INamedTypeSymbol type, string name) + { + var location = type.ToDisplayString(); + var steps = SpecificationMembers.StepsOf(type); + var draft = new SpecificationDraft(); + var stated = new ScreenplayDiagnostics(); + + var reader = new SpecificationStepReader(compilation, new(stated)); + reader.ReadGiven(steps, draft, name, location); + reader.ReadWhen(steps, draft, name, location); + new SpecificationOutcomeReader(compilation, stated).Read(type, draft, name, location); + + if (draft.When is null) + { + draft.CannotRead("the command it issues is put together somewhere this cannot read"); + } + else if (draft.Then.Count == 0 && draft.Errors.Count == 0) + { + draft.CannotRead("it expects no event and no rejection, and those are the outcomes the language holds"); + } + + if (draft.Unreadable is not null) + { + diagnostics.Warning( + ScreenplayDiagnosticCodes.UnreadableSpecification, + $"The scenario '{name}' was left out because {draft.Unreadable}", + location); + + return null; + } + + diagnostics.AddRange(stated.All); + + return new(name, [.. draft.Given], draft.When, [.. draft.Then], [.. draft.Errors]); + } + + /// + /// Determines whether the steps of a specification drive a slice rather than exercise a part of one. + /// + /// The type the steps are written on. + /// True when the specification issues a command, or says what the event store already held. + /// + /// A specification against a running host holds no scenario of its own - the host is the scenario - so what makes + /// it one is that it hands a command to the host, or says what the event log already held before doing so. + /// + /// Where the two are looked for differs, and deliberately. Issuing a command is what a slice does, wherever it is + /// written. Appending to a sequence is only the world a scenario starts in when it happens before the action, and + /// a specification appending as its action is one about the event store itself - a constraint holding, an append + /// being rejected - which is a part of a slice rather than the behavior of one. + /// + /// + bool DrivesASlice(INamedTypeSymbol steps) => + Calls(steps, SpecificationMembers.EstablishMethod) + .Any(_ => SpecificationCalls.IsGivenEvents(_) || SpecificationCalls.IsGivenReadModel(_) || SpecificationCalls.IsExecution(_)) || + Calls(steps, SpecificationMembers.BecauseMethod).Any(SpecificationCalls.IsExecution); + + /// + /// Gets every call a method of a chain resolves to. + /// + /// The type the steps are written on. + /// The name of the method to read. + /// The methods called. + IEnumerable Calls(INamedTypeSymbol steps, string method) => + SpecificationMembers.MethodsIn(steps, method).SelectMany(HandlerBodies.Of).SelectMany(CallsIn); + + /// + /// Gets every call a body resolves to. + /// + /// The body to walk. + /// The methods called. + IEnumerable CallsIn(SyntaxNode body) + { + var semanticModel = compilation.GetSemanticModel(body.SyntaxTree); + + return body.DescendantNodesAndSelf() + .OfType() + .Select(invocation => semanticModel.GetSymbolInfo(invocation).Symbol) + .OfType(); + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs new file mode 100644 index 000000000..cf516032b --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs @@ -0,0 +1,154 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis.Commands; +using Cratis.Arc.Screenplay.Analysis.Events; +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Reads what a specification starts from and the command it issues, from the bodies stating them. +/// +/// The compilation being analyzed. +/// The reading the values each step states. +public class SpecificationStepReader(Compilation compilation, SpecificationValues values) +{ + /// + /// Reads what a specification had already seen when it issued its command. + /// + /// The type the steps are written on. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives, for use in diagnostics. + public void ReadGiven(INamedTypeSymbol steps, SpecificationDraft draft, string name, string location) + { + foreach (var (invocation, method, semanticModel, always) in CallsIn(steps, SpecificationMembers.EstablishMethod)) + { + if (!SpecificationCalls.IsGivenEvents(method) && !SpecificationCalls.IsGivenReadModel(method)) + { + continue; + } + + if (!always) + { + draft.CannotRead("what it starts from only happens under a condition, and a scenario says what happened"); + return; + } + + var kind = SpecificationCalls.IsGivenReadModel(method) ? SpecificationStateKind.ReadModel : SpecificationStateKind.Event; + + foreach (var stated in CallArguments.For(invocation, method, SpecificationCalls.PayloadParameterOf(method) ?? string.Empty)) + { + Add(draft.Given, stated, kind, semanticModel, draft, name, location); + } + } + } + + /// + /// Reads the command a specification issues. + /// + /// The type the steps are written on. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives, for use in diagnostics. + public void ReadWhen(INamedTypeSymbol steps, SpecificationDraft draft, string name, string location) + { + foreach (var (invocation, method, semanticModel, always) in CallsIn(steps, SpecificationMembers.BecauseMethod)) + { + if (!SpecificationCalls.IsExecution(method)) + { + continue; + } + + if (!always) + { + draft.CannotRead("the command it issues is only issued under a condition, and a scenario says what happened"); + return; + } + + if (draft.When is not null) + { + draft.CannotRead("it issues more than one command, and a scenario is about one"); + return; + } + + var stated = CallArguments.For(invocation, method, SpecificationCalls.PayloadParameterOf(method) ?? string.Empty).ToList(); + if (stated is not [BaseObjectCreationExpressionSyntax creation] || + semanticModel.GetTypeInfo(creation).Type is not INamedTypeSymbol command) + { + draft.CannotRead("the command it issues is put together somewhere this cannot read"); + return; + } + + if (!CommandReader.IsCommand(command)) + { + draft.CannotRead($"'{command.Name}' is not a command the document declares"); + return; + } + + draft.When = new(command.Name, SpecificationStateKind.Command, values.Read(creation, semanticModel, command, name, location)); + } + } + + /// + /// Gets every call a body makes that resolves to a method. + /// + /// The body to walk. + /// The semantic model of the tree the body lives in. + /// The calls, in the order the body makes them. + static IEnumerable Calls(SyntaxNode body, SemanticModel semanticModel) => + body.DescendantNodesAndSelf() + .OfType() + .Select(invocation => (invocation, Method: semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol, semanticModel, body)) + .Where(call => call.Method is not null) + .Select(call => new Step(call.invocation, call.Method!, call.semanticModel, StepsTaken.Always(call.invocation, call.body))); + + /// + /// Gets every call a method of a chain makes, with the model the tree it lives in is read through. + /// + /// The type to walk from. + /// The name of the method to read. + /// The calls, from the base down and in the order each body makes them. + IEnumerable CallsIn(INamedTypeSymbol type, string method) => + SpecificationMembers.MethodsIn(type, method) + .SelectMany(HandlerBodies.Of) + .SelectMany(body => Calls(body, compilation.GetSemanticModel(body.SyntaxTree))); + + /// + /// Adds one state a specification starts from, or records that it cannot be read. + /// + /// The states collected so far. + /// The expression stating it. + /// What the state is. + /// The semantic model of the tree the expression lives in. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives. + void Add( + IList states, + ExpressionSyntax stated, + SpecificationStateKind kind, + SemanticModel semanticModel, + SpecificationDraft draft, + string name, + string location) + { + if (stated is not BaseObjectCreationExpressionSyntax creation || + semanticModel.GetTypeInfo(creation).Type is not INamedTypeSymbol type) + { + draft.CannotRead("what it starts from is put together somewhere this cannot read"); + return; + } + + if (kind == SpecificationStateKind.Event && !EventReader.IsEvent(type)) + { + draft.CannotRead($"it starts from '{type.Name}', which is not declared as an event"); + return; + } + + states.Add(new(type.Name, kind, values.Read(creation, semanticModel, type, name, location))); + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs new file mode 100644 index 000000000..18a383d8c --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs @@ -0,0 +1,135 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis.Commands; +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Reads the values a specification states for one artifact, from the construction stating them. +/// +/// The anything unreadable is reported to. +/// +/// A specification is closed - it refers to nothing outside itself - so a constant is the only source of a value +/// there is. That is the same discipline a produces mapping is read with, one source shorter: a produces mapping may +/// also name the input of the command being handled, and a scenario has no input to name. +/// +public class SpecificationValues(ScreenplayDiagnostics diagnostics) +{ + readonly MappingSourceReader _sources = new(diagnostics); + + /// + /// Reads the values one construction states. + /// + /// The construction to read. + /// The semantic model of the tree the construction lives in. + /// The type being constructed. + /// The name of the specification, for use in diagnostics. + /// Where the specification lives, for use in diagnostics. + /// The values, in the order the source declares them. + public IEnumerable Read( + BaseObjectCreationExpressionSyntax creation, + SemanticModel semanticModel, + ITypeSymbol type, + string specification, + string location) + { + var values = new List(); + var constructor = semanticModel.GetSymbolInfo(creation).Symbol as IMethodSymbol; + + foreach (var (name, expression) in Stated(creation, constructor)) + { + Add(values, PropertyOf(type, name), expression, semanticModel, type, specification, location); + } + + return values; + } + + /// + /// Gets every value a construction states, from its arguments and from its initializer. + /// + /// The construction to read. + /// The constructor being called. + /// The values, each with the name it fills in, in the order the source declares them. + static IEnumerable<(string Name, ExpressionSyntax Expression)> Stated( + BaseObjectCreationExpressionSyntax creation, + IMethodSymbol? constructor) + { + var arguments = creation.ArgumentList?.Arguments ?? []; + for (var index = 0; index < arguments.Count; index++) + { + if (NameOf(arguments[index], constructor, index) is { } name) + { + yield return (name, arguments[index].Expression); + } + } + + foreach (var assignment in creation.Initializer?.Expressions.OfType() ?? []) + { + if (assignment.Left is IdentifierNameSyntax identifier) + { + yield return (identifier.Identifier.ValueText, assignment.Right); + } + } + } + + /// + /// Gets the name of the property an argument fills in. + /// + /// The argument to name. + /// The constructor being called. + /// The position of the argument. + /// The parameter name, or when it cannot be resolved. + static string? NameOf(ArgumentSyntax argument, IMethodSymbol? constructor, int index) + { + if (argument.NameColon is { } named) + { + return named.Name.Identifier.ValueText; + } + + return constructor is not null && index < constructor.Parameters.Length ? constructor.Parameters[index].Name : null; + } + + /// + /// Resolves the declared casing of a property from the name an argument used. + /// + /// The type being constructed. + /// The name to resolve. + /// The property name. + static string PropertyOf(ITypeSymbol type, string name) => + type.DeclaredProperties().FirstOrDefault(_ => string.Equals(_.Name, name, StringComparison.OrdinalIgnoreCase))?.Name ?? name; + + /// + /// Adds a value, reporting one that is code rather than stating it as something it is not. + /// + /// The values collected so far. + /// The property being filled in. + /// The expression filling it in. + /// The semantic model of the tree the expression lives in. + /// The type being constructed. + /// The name of the specification. + /// Where the specification lives. + void Add( + List values, + string property, + ExpressionSyntax expression, + SemanticModel semanticModel, + ITypeSymbol type, + string specification, + string location) + { + if (_sources.Read(expression, semanticModel, type, location) is LiteralSource literal) + { + values.Add(new(property, literal)); + return; + } + + diagnostics.Information( + ScreenplayDiagnosticCodes.UnreadableSpecificationValue, + $"The value '{specification}' states for '{type.Name}.{property}' is code rather than a constant, so the scenario states everything but that value", + location); + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs b/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs new file mode 100644 index 000000000..ce9a3b423 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Represents one call a specification makes, with everything needed to read what it says. +/// +/// The call as it is written. +/// The method it resolves to. +/// The semantic model of the tree it lives in. +/// Whether the body it is written in always makes the call, exactly once. +public record Step( + InvocationExpressionSyntax Invocation, + IMethodSymbol Method, + SemanticModel SemanticModel, + bool Always); diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs b/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs new file mode 100644 index 000000000..e0c312396 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Answers whether a step written in a body is one the body always takes. +/// +/// +/// A scenario says what happened, once, in order. A step written inside a branch happened in some runs of the +/// specification and not in others, and one written inside a loop happened as many times as the loop went round - +/// neither of which the source text says. Reading such a step as if it always happened states a world the +/// application was never specified against, which is worse than saying nothing: it is the one failure mode a reader +/// has no way of catching. +/// +/// Which condition a branch stood on is routinely knowable to the compiler - a virtual property a derived +/// specification overrides with a constant is the usual shape - but knowing it means following values through +/// dispatch rather than reading what was written, which is a different discipline from the one everything else here +/// keeps to. So a conditional step is left unread and said so, and a scenario that has one is left out whole. +/// +/// +public static class StepsTaken +{ + /// + /// Determines whether a body always takes a step, exactly once. + /// + /// The call the step is written as. + /// The body the step is written in. + /// True when nothing between the two makes the step conditional or repeated. + public static bool Always(SyntaxNode call, SyntaxNode body) + { + for (var current = call.Parent; current is not null && current != body; current = current.Parent) + { + if (IsConditional(current)) + { + return false; + } + } + + return true; + } + + /// + /// Determines whether a construct makes what is written inside it conditional, repeated or deferred. + /// + /// The construct to check. + /// True when it does. + static bool IsConditional(SyntaxNode node) => node switch + { + IfStatementSyntax or SwitchStatementSyntax or SwitchExpressionSyntax => true, + ForStatementSyntax or ForEachStatementSyntax or WhileStatementSyntax or DoStatementSyntax => true, + ConditionalExpressionSyntax or ConditionalAccessExpressionSyntax => true, + CatchClauseSyntax or FinallyClauseSyntax => true, + AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax => true, + BinaryExpressionSyntax binary => binary.IsKind(SyntaxKind.LogicalAndExpression) || + binary.IsKind(SyntaxKind.LogicalOrExpression) || + binary.IsKind(SyntaxKind.CoalesceExpression), + _ => false + }; +} diff --git a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs index 62fc50fb4..4bd288e83 100644 --- a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs +++ b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs @@ -123,6 +123,21 @@ public static class WellKnownTypeNames /// The everything a query is performed with, which the host fills in. public const string QueryContext = "Cratis.Arc.Queries.QueryContext"; + /// The sequence a specification appends the events it starts from to. + public const string EventSequence = "Cratis.Chronicle.EventSequences.IEventSequence"; + + /// The scenario a specification issues a command through in process. + public const string CommandScenario = "Cratis.Arc.Testing.Commands.CommandScenario`1"; + + /// The builder a specification states the state of one event source with. + public const string CommandScenarioSourceGivenBuilder = "Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioSourceGivenBuilder`1"; + + /// The extensions a specification issues a command through over HTTP. + public const string HttpClientExtensions = "Cratis.Chronicle.XUnit.Integration.HttpClientExtensions"; + + /// The attribute marking a method as one assertion of a specification. + public const string FactAttribute = "Xunit.FactAttribute"; + /// The page of a result a query is performed for, which the host fills in from the request. public const string Paging = "Cratis.Arc.Queries.Paging"; diff --git a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs index 403ca5534..f8d9986ae 100644 --- a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs +++ b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs @@ -12,6 +12,7 @@ using Cratis.Arc.Screenplay.Emission.Reactors; using Cratis.Arc.Screenplay.Emission.Screens; using Cratis.Arc.Screenplay.Emission.Slices; +using Cratis.Arc.Screenplay.Emission.Specifications; using Cratis.Arc.Screenplay.Emission.Types; using Cratis.Arc.Screenplay.Emission.Validation; using Cratis.Arc.Screenplay.Model; @@ -145,7 +146,8 @@ SliceSyntaxBuilder CreateSliceBuilder() => new ConstraintSyntaxBuilder(naming), new ReactorSyntaxBuilder(naming, diagnostics), new ProjectionSyntaxBuilder(naming, diagnostics, _names), - new ScreenSyntaxBuilder(naming, _types)); + new ScreenSyntaxBuilder(naming, _types), + new SpecificationSyntaxBuilder(naming)); /// /// Sanitizes a document level name, falling back when it yields nothing usable. diff --git a/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs index e5ab049d2..585d50edc 100644 --- a/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs +++ b/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs @@ -9,6 +9,7 @@ using Cratis.Arc.Screenplay.Emission.Queries; using Cratis.Arc.Screenplay.Emission.Reactors; using Cratis.Arc.Screenplay.Emission.Screens; +using Cratis.Arc.Screenplay.Emission.Specifications; using Cratis.Arc.Screenplay.Model; using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Syntax; @@ -27,6 +28,7 @@ namespace Cratis.Arc.Screenplay.Emission.Slices; /// The for the reactors of the slice. /// The for the projection of the slice. /// The for the screens of the slice. +/// The for the scenarios the slice is specified by. public class SliceSyntaxBuilder( IScreenplayNaming naming, CommandSyntaxBuilder commands, @@ -35,7 +37,8 @@ public class SliceSyntaxBuilder( ConstraintSyntaxBuilder constraints, ReactorSyntaxBuilder reactors, ProjectionSyntaxBuilder projections, - ScreenSyntaxBuilder screens) + ScreenSyntaxBuilder screens, + SpecificationSyntaxBuilder specifications) { /// /// The name given to a slice whose own name yields nothing usable. @@ -73,7 +76,7 @@ .. slice.Constraints .Select(_ => constraints.Build(_, slice.Namespace)) .OrderBy(_ => _.Name, StringComparer.Ordinal) ], - [], + [.. specifications.Build(slice.Specifications)], SourceLocation.Start, naming.ToStringLiteral(slice.Description)); diff --git a/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs new file mode 100644 index 000000000..89b941387 --- /dev/null +++ b/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Emission.Expressions; +using Cratis.Arc.Screenplay.Emission.Naming; +using Cratis.Arc.Screenplay.Model; +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Specifications; + +namespace Cratis.Arc.Screenplay.Emission.Specifications; + +/// +/// Builds the Screenplay specification blocks of a slice. +/// +/// The used for name conversion. +/// +/// Nothing a step states is ever left out over its name. Every other block decides what a line is from its first +/// word, so a property called after a directive collides with it; the values of a step are written one level deeper +/// than the step itself and the step takes every line beneath it as a value, whatever its first word says. +/// +public class SpecificationSyntaxBuilder(IScreenplayNaming naming) +{ + readonly MappingSourceConverter _sources = new(naming); + + /// + /// Builds the specifications of a slice. + /// + /// The scenarios the slice is specified by. + /// The specifications, ordered by name. + public IEnumerable Build(IEnumerable specifications) => + [ + .. specifications + .Select(Build) + .OrderBy(_ => _.Name, StringComparer.Ordinal) + ]; + + /// + /// Builds one specification. + /// + /// The scenario to build for. + /// The . + SpecificationSyntax Build(SpecificationModel specification) => + new( + naming.ToDeclarationName(specification.Name), + [.. Events(specification.Given)], + new SpecificationCommandSyntax( + naming.ToDeclarationName(specification.When.Name), + [.. Values(specification.When)], + SourceLocation.Start), + [.. Events(specification.Then)], + [.. specification.Errors.Select(_ => new SpecificationErrorSyntax(naming.ToStringLiteral(_) ?? string.Empty, SourceLocation.Start))], + SourceLocation.Start, + [.. ReadModels(specification.Given)], + [.. ReadModels(specification.Then)]); + + /// + /// Builds the states of a step that name an event. + /// + /// The states to build from. + /// The events. + IEnumerable Events(IEnumerable states) => + states + .Where(_ => _.Kind == SpecificationStateKind.Event) + .Select(_ => new SpecificationEventSyntax(naming.ToDeclarationName(_.Name), [.. Values(_)], SourceLocation.Start)); + + /// + /// Builds the states of a step that name a read model. + /// + /// The states to build from. + /// The read models. + IEnumerable ReadModels(IEnumerable states) => + states + .Where(_ => _.Kind == SpecificationStateKind.ReadModel) + .Select(_ => new SpecificationReadModelSyntax(naming.ToDeclarationName(_.Name), [.. Values(_)], SourceLocation.Start)); + + /// + /// Builds the values a step states. + /// + /// The state to build from. + /// The values. + IEnumerable Values(SpecificationStateModel state) => + state.Values.Select(_ => new PropertyMappingSyntax(naming.ToPropertyName(_.Property), _sources.Convert(_.Source), SourceLocation.Start)); +} diff --git a/Source/DotNET/Screenplay/Model/SliceModel.cs b/Source/DotNET/Screenplay/Model/SliceModel.cs index 9aa5a0838..aeb944390 100644 --- a/Source/DotNET/Screenplay/Model/SliceModel.cs +++ b/Source/DotNET/Screenplay/Model/SliceModel.cs @@ -37,6 +37,16 @@ public record SliceModel( /// public IEnumerable Screens { get; init; } = []; + /// + /// Gets the scenarios the slice is specified by. + /// + /// + /// A scenario is recovered from a namespace beneath the slice's own rather than from the slice's, so it arrives + /// once every namespace has been read and which of them declare a slice is known - which is after the slice + /// itself exists. + /// + public IEnumerable Specifications { get; init; } = []; + /// /// Creates a slice that declares nothing, for use as a starting point. /// diff --git a/Source/DotNET/Screenplay/Model/SpecificationModel.cs b/Source/DotNET/Screenplay/Model/SpecificationModel.cs new file mode 100644 index 000000000..fa68980f1 --- /dev/null +++ b/Source/DotNET/Screenplay/Model/SpecificationModel.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.Model; + +/// +/// Represents one scenario a slice is specified by - what had happened, the command that was issued and what +/// followed. +/// +/// The name of the specification. +/// What had already happened when the command was issued. +/// The command that was issued. +/// The events and read model states that followed. +/// The rejections that followed, each named by the reason the source gives for it. +/// +/// A rejection the source names no reason for carries an empty one. The scenario is named after the words the +/// source itself uses for it, so what the rejection was about is already said by the name and inventing a sentence +/// to repeat it would be describing an application nobody wrote. +/// +public record SpecificationModel( + string Name, + IEnumerable Given, + SpecificationStateModel When, + IEnumerable Then, + IEnumerable Errors); diff --git a/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs b/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs new file mode 100644 index 000000000..6bf316bd5 --- /dev/null +++ b/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.Model; + +/// +/// Represents what a state a specification names actually is. +/// +public enum SpecificationStateKind +{ + /// + /// An event, which is what a given and a then name by default. + /// + Event = 0, + + /// + /// A read model, which a given and a then name behind the readmodel keyword. + /// + ReadModel = 1, + + /// + /// A command, which is what the single when of a specification names. + /// + Command = 2 +} diff --git a/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs b/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs new file mode 100644 index 000000000..0bdaf3dec --- /dev/null +++ b/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.Model; + +/// +/// Represents one artifact a specification names, together with the values it states for it. +/// +/// The name of the event, read model or command being named. +/// What the named artifact is. +/// The values the specification states, in the order the source declares them. +/// +/// A given, a when and a then are the same shape - a name and a list of values - so one model +/// covers all three and the step it belongs to says which of them it is. +/// +public record SpecificationStateModel(string Name, SpecificationStateKind Kind, IEnumerable Values); diff --git a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs index 2969ec1b1..19e68754c 100644 --- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs @@ -341,4 +341,30 @@ public static class ScreenplayDiagnosticCodes /// /// public const string ProjectsWithoutASharedRoot = "SP0038"; + + /// + /// A scenario a slice is specified by states a step that could not be read, so the whole scenario was left out. + /// + /// + /// A specification is one concrete example, and an example missing the command it issues, the state it started + /// from or the outcome it expects is not that example - it is a different one nobody wrote. So unlike a mapping, + /// which stands on its own and can be left out while the rest of the block still says something true, a step that + /// cannot be read takes the scenario with it. What made it unreadable is said, because the difference between a + /// scenario resting on a value computed at run time and one resting on a helper the reader could inline is the + /// difference between a gap that will always be there and one an afternoon closes. + /// + public const string UnreadableSpecification = "SP0039"; + + /// + /// A value a scenario states is code rather than a constant, so the scenario states everything but that value. + /// + /// + /// A scenario is written in the host language, where the identity two steps agree on is routinely a fresh + /// identifier held in a field rather than something written down twice. Screenplay states values and has no way + /// to name one, so such a value is left out and the rest of the scenario stands: which events had happened, which + /// command was issued and what followed are all still exactly what the source says. This is reported per value + /// rather than per scenario for the same reason a produces mapping is - a reader counting what a scenario states + /// against what the source states otherwise has no way of knowing which of the two it is looking at. + /// + public const string UnreadableSpecificationValue = "SP0040"; }