Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions Source/DotNET/Screenplay.Specs/IntegrationTesting.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Declares the testing surface a Chronicle integration specification is written against, as source.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class IntegrationTesting
{
/// <summary>
/// The path the surface is compiled as.
/// </summary>
public const string Path = "Library/Testing/IntegrationTesting.cs";

/// <summary>
/// The source of the surface.
/// </summary>
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<TCommand>
{
public Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioChronicleGivenBuilder<TCommand> Given => new();

public IEventSequence EventSequence => null!;

public Task<Result> Execute(TCommand command) => Task.FromResult(new Result());

public Task<Result> Validate(TCommand command) => Task.FromResult(new Result());
}

public class Result
{
public bool IsSuccess => true;
}
}

namespace Cratis.Arc.Chronicle.Testing.Commands
{
public class CommandScenarioChronicleGivenBuilder<TCommand>
{
public CommandScenarioSourceGivenBuilder<TCommand> ForEventSource(EventSourceId eventSourceId) => new();
}

public class CommandScenarioSourceGivenBuilder<TCommand>
{
public void Events(params object[] events)
{
}

public void ReadModel<TReadModel>(TReadModel readModel)
where TReadModel : class
{
}
}
}

namespace Cratis.Chronicle.XUnit.Integration
{
public static class HttpClientExtensions
{
public static Task<Cratis.Arc.Testing.Commands.Result> ExecuteCommand<TCommand>(
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<TEvent>(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)
{
}
}
}
""";
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<AuthorRegistered>("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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<RegisterAuthor> _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<RegisterAuthor> _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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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();
}
Loading
Loading