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
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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 source generator writes partial members into a slice's namespace and emits them to disk under <c>obj/</c>. Those
/// files contribute symbols to the slice but say nothing about where its source - and therefore its screens - live, so
/// the slice is not spread over the folder they sit in and screen discovery must not scan it.
/// </summary>
public class a_slice_whose_generated_members_sit_under_obj : Specification
{
const string Source = """
using Cratis.Arc.Commands.ModelBound;
using Cratis.Chronicle.Events;

namespace Library.Authors.Registration;

[EventType]
public record AuthorRegistered(string Name);

[Command]
public partial record RegisterAuthor(string Name)
{
public AuthorRegistered Handle() => new(Name);
}
""";

const string Generated = """
namespace Library.Authors.Registration;

public partial record RegisterAuthor
{
}
""";

static readonly DeclaredUserInterfaceFiles _files = new(
"Library/Authors/Registration/AddAuthor.tsx");

ApplicationModelAnalysis _analysis;
IEnumerable<ScreenModel> _screens;

void Establish()
{
_analysis = Analyzed.Source(
_files,
("Library/Authors/Registration/Registration.cs", Source),
("Library/obj/Debug/net10.0/Generator/RegisterAuthor.Logging.g.cs", Generated));
_screens = _analysis.Slice().Screens;
}

[Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Registration/Registration.cs", Source), ("Library/obj/Debug/net10.0/Generator/RegisterAuthor.Logging.g.cs", Generated)).ShouldBeEmpty();
[Fact] void should_not_report_the_slice_as_spread_over_folders() => _analysis.Diagnostics.Select(_ => _.Code).ShouldNotContain(ScreenplayDiagnosticCodes.AmbiguousScreenFile);
[Fact] void should_still_recover_the_screen_from_the_authored_folder() => _screens.Select(_ => _.Name).ShouldContainOnly(["AddAuthor"]);
[Fact] void should_point_at_the_file_realizing_the_screen() => _screens.Single().FilePath.ShouldEqual("Authors/Registration/AddAuthor.tsx");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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>
/// FluentValidation lets a message be a value or a lambda producing one, and keeping messages in one place behind a
/// constant - <c>WithMessage(_ =&gt; Messages.NameRequired)</c> - is the common way an application writes them. The
/// lambda body is a plain reference the semantic model reads a compile-time constant from, so the message is
/// recovered exactly as it would be from a value written inline.
/// </summary>
public class a_validator_whose_message_is_a_lambda_returning_a_constant : Specification
{
const string Source = """
using Cratis.Arc.Commands;
using Cratis.Arc.Commands.ModelBound;
using FluentValidation;

namespace Library.Authors.Registration;

public static class AuthorMessages
{
public const string NameRequired = "An author must have a name";
}

[Command]
public record RegisterAuthor(string Name, string Email)
{
public void Handle()
{
}
}

public class RegisterAuthorValidator : CommandValidator<RegisterAuthor>
{
public RegisterAuthorValidator()
{
RuleFor(_ => _.Name).NotEmpty().WithMessage(_ => AuthorMessages.NameRequired);
RuleFor(_ => _.Email).EmailAddress().WithMessage(_ => "An email must look like one");
}
}
""";

ApplicationModelAnalysis _analysis;
IEnumerable<ValidationRuleModel> _rules;

void Establish()
{
_analysis = Analyzed.Source(Source);
_rules = _analysis.Slice().Commands.First().Validations;
}

ValidationRuleModel Rule(ValidationRuleKind kind) => _rules.First(_ => _.Kind == kind);

[Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
[Fact] void should_recover_a_message_from_a_lambda_returning_a_constant() => Rule(ValidationRuleKind.NotEmpty).Message.ShouldEqual("An author must have a name");
[Fact] void should_recover_a_message_from_a_lambda_returning_a_literal() => Rule(ValidationRuleKind.Matches).Message.ShouldEqual("An email must look like one");
[Fact] void should_leave_no_message_unrecovered() => _analysis.Diagnostics.Select(_ => _.Code).ShouldNotContain(ScreenplayDiagnosticCodes.UnmappableValidationRule);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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 message a lambda computes at runtime - an interpolation, a call, a culture-dependent lookup - has no
/// compile-time constant to read, so there is nothing to write into the document. It stays reported rather than
/// guessed at, which is what keeps the recovery of the constant forms honest.
/// </summary>
public class a_validator_whose_message_is_computed : Specification
{
const string Source = """
using Cratis.Arc.Commands;
using Cratis.Arc.Commands.ModelBound;
using FluentValidation;

namespace Library.Authors.Registration;

[Command]
public record RegisterAuthor(string Name)
{
public void Handle()
{
}
}

public class RegisterAuthorValidator : CommandValidator<RegisterAuthor>
{
public RegisterAuthorValidator()
{
RuleFor(_ => _.Name).NotEmpty().WithMessage(_ => $"The name '{_.Name}' is not allowed");
}
}
""";

ApplicationModelAnalysis _analysis;
IEnumerable<ValidationRuleModel> _rules;

void Establish()
{
_analysis = Analyzed.Source(Source);
_rules = _analysis.Slice().Commands.First().Validations;
}

[Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
[Fact] void should_leave_the_rule_without_a_message() => _rules.First(_ => _.Kind == ValidationRuleKind.NotEmpty).Message.ShouldBeNull();
[Fact] void should_report_the_message_it_could_not_recover() => _analysis.Diagnostics.Select(_ => _.Code).ShouldContain(ScreenplayDiagnosticCodes.UnmappableValidationRule);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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 record is referred to by name and never declared, so nothing inside it is ever named on its own. Collecting only
/// the types written straight onto an artifact therefore lost every concept reached through one - and a concept marked
/// as personal data lost that way leaves a document understating what the application holds about people, which is the
/// one thing declaring concepts is most for. A concept can be declared wherever it was reached from, so it is.
/// </summary>
public class concepts_carried_only_inside_a_record : Specification
{
const string Source = """
using System;
using System.Collections.Generic;
using Cratis.Arc.Queries.ModelBound;
using Cratis.Chronicle.Compliance.GDPR;
using Cratis.Chronicle.Events;
using Cratis.Concepts;

namespace Library.Authors.Registration;

public record AuthorId(Guid Value) : ConceptAs<Guid>(Value);

[PII("The name of a person")]
public record FirstName(string Value) : ConceptAs<string>(Value);

public record MentorNote(string Value) : ConceptAs<string>(Value);

public record ShelfCode(string Value) : ConceptAs<string>(Value);

public enum ContactPreference { Email, Phone }

public record PersonalDetails(FirstName First, ContactPreference Preference);

public record Mentorship(MentorNote Note, Mentorship? Next);

[EventType]
public record AuthorRegistered(AuthorId Id, PersonalDetails Details, IEnumerable<Mentorship> Mentors);

[ReadModel]
public record Author
{
public string Id { get; init; } = string.Empty;

public ShelfCode Shelf { get; init; } = new(string.Empty);

public static IEnumerable<Author> All() => [];
}
""";

ApplicationModelAnalysis _analysis;

void Establish() => _analysis = Analyzed.Source(Source);

ConceptModel Concept(string name) => _analysis.Model.Concepts.First(_ => _.Name == name);

IEnumerable<ScreenplayDiagnostic> Shapes =>
_analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UndeclarableShape);

[Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
[Fact] void should_declare_a_concept_carried_inside_a_record() => Concept("FirstName").Primitive.ShouldEqual(ScreenplayPrimitive.String);
[Fact] void should_keep_what_that_concept_says_about_personal_data() => Concept("FirstName").IsPii.ShouldBeTrue();
[Fact] void should_declare_an_enumeration_carried_inside_a_record() => Concept("ContactPreference").EnumValues.ShouldContainOnly(["Email", "Phone"]);
[Fact] void should_declare_a_concept_carried_inside_a_record_a_collection_holds() => Concept("MentorNote").Primitive.ShouldEqual(ScreenplayPrimitive.String);
[Fact] void should_declare_a_concept_only_a_read_model_carries() => Concept("ShelfCode").Primitive.ShouldEqual(ScreenplayPrimitive.String);
[Fact] void should_walk_a_record_referring_to_itself_only_once() => _analysis.Model.Concepts.Select(_ => _.Name).ShouldContainOnly(["AuthorId", "ContactPreference", "FirstName", "MentorNote", "ShelfCode"]);
[Fact] void should_say_the_shape_of_a_record_a_property_carries_is_not_declared() => Shapes.Count().ShouldEqual(2);
[Fact] void should_name_the_records_it_could_not_declare() => Shapes.All(_ => _.Message.Contains("PersonalDetails", StringComparison.Ordinal) || _.Message.Contains("Mentorship", StringComparison.Ordinal)).ShouldBeTrue();
[Fact] void should_not_say_it_of_a_read_model_the_slice_describes() => Shapes.Any(_ => _.Message.Contains("Author'", StringComparison.Ordinal)).ShouldBeFalse();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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_GeneratedSource.when_checking_if_a_path_is_generated;

public class with_a_bin_segment : Specification
{
bool _result;

void Because() => _result = GeneratedSource.Is("/src/Core/bin/Release/net10.0/Slice.cs");

[Fact] void should_recognize_it_as_generated() => _result.ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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_GeneratedSource.when_checking_if_a_path_is_generated;

public class with_a_generated_suffix : Specification
{
bool _result;

void Because() => _result = GeneratedSource.Is("/src/Core/Feature/Slice.g.cs");

[Fact] void should_recognize_it_as_generated() => _result.ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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_GeneratedSource.when_checking_if_a_path_is_generated;

public class with_an_authored_path : Specification
{
bool _result;

void Because() => _result = GeneratedSource.Is("/src/Core/Feature/Slice/Slice.cs");

[Fact] void should_not_recognize_it_as_generated() => _result.ShouldBeFalse();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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_GeneratedSource.when_checking_if_a_path_is_generated;

public class with_an_obj_segment : Specification
{
bool _result;

void Because() => _result = GeneratedSource.Is("/src/Core/obj/Debug/net10.0/Generator/Slice.g.cs");

[Fact] void should_recognize_it_as_generated() => _result.ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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.for_ScreenplayNaming.when_making_a_declaration_name;

/// <summary>
/// A runtime name is idiomatically written with separators - a Chronicle constraint named <c>unique-timesheet-start</c>
/// is the common case - and a Screenplay identifier cannot hold one. The separators mark word boundaries, so the
/// segments they mark are PascalCased and joined rather than run together into an unreadable identifier. A name that
/// already looks like an identifier carries no separator and is left exactly as it was.
/// </summary>
public class from_names_carrying_separators : given.a_naming
{
string _kebabCased;
string _snakeCased;
string _spaceSeparated;
string _alreadyPascalCased;
string _acronym;

void Because()
{
_kebabCased = _naming.ToDeclarationName("unique-timesheet-start");
_snakeCased = _naming.ToDeclarationName("unique_invitation_email");
_spaceSeparated = _naming.ToDeclarationName("unique timesheet start");
_alreadyPascalCased = _naming.ToDeclarationName("TimesheetStarted");
_acronym = _naming.ToDeclarationName("ISBNValue");
}

[Fact] void should_pascal_case_a_kebab_cased_name() => _kebabCased.ShouldEqual("UniqueTimesheetStart");
[Fact] void should_pascal_case_a_snake_cased_name() => _snakeCased.ShouldEqual("UniqueInvitationEmail");
[Fact] void should_pascal_case_a_space_separated_name() => _spaceSeparated.ShouldEqual("UniqueTimesheetStart");
[Fact] void should_leave_an_already_pascal_cased_name_alone() => _alreadyPascalCased.ShouldEqual("TimesheetStarted");
[Fact] void should_leave_an_acronym_without_separators_alone() => _acronym.ShouldEqual("ISBNValue");
}
8 changes: 8 additions & 0 deletions Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ static void ReportTypesTheDocumentCannotName(ArtifactReaders readers, Screenplay
$"'{type}' shares its simple name with a concept the document already declares, so what it is is described by the first one instead",
location);
}

foreach (var shape in readers.Types.Shapes)
{
diagnostics.Information(
ScreenplayDiagnosticCodes.UndeclarableShape,
$"'{shape}' is a record an artifact carries, and there is no way to declare what a record holds, so the document names it without saying what is in it - the concepts it carries are declared, the shape itself is not",
location);
}
}

/// <summary>
Expand Down
Loading
Loading