Skip to content
Merged
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
10 changes: 5 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
</PropertyGroup>
<ItemGroup>
<!-- Cratis -->
<PackageVersion Include="Cratis.Arc.Screenplay" Version="20.68.0" />
<PackageVersion Include="Cratis.Chronicle.Connections" Version="16.11.0" />
<PackageVersion Include="Cratis.Chronicle.Contracts" Version="16.11.0" />
<PackageVersion Include="Cratis.Fundamentals" Version="7.16.6" />
<PackageVersion Include="Cratis.Arc.Screenplay" Version="21.0.1" />
<PackageVersion Include="Cratis.Chronicle.Connections" Version="16.19.1" />
<PackageVersion Include="Cratis.Chronicle.Contracts" Version="16.19.1" />
<PackageVersion Include="Cratis.Fundamentals" Version="7.17.1" />
<!-- Prologue -->
<PackageVersion Include="Cratis.Prologue.Configuration" Version="1.1.0" />
<PackageVersion Include="Cratis.Prologue.Contracts" Version="1.1.0" />
Expand Down Expand Up @@ -47,7 +47,7 @@
<!-- Testing -->
<PackageVersion Include="Cratis.Specifications" Version="4.0.0" />
<PackageVersion Include="Cratis.Specifications.XUnit" Version="4.0.0" />
<PackageVersion Include="Cratis.Chronicle.XUnit.Integration" Version="16.11.0" />
<PackageVersion Include="Cratis.Chronicle.XUnit.Integration" Version="16.19.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
Expand Down
54 changes: 46 additions & 8 deletions Documentation/reference/screenplay.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Pass `--file` to write it directly instead. The output is written as raw UTF-8,

| Argument | Description |
|---|---|
| `PATH` | Solution (`.slnx`, `.sln`), project (`.csproj`), or folder to read. Defaults to the current directory. |
| `PATH` | Solution (`.slnx`, `.sln`, `.slnf`), project (`.csproj`), or folder to read. Defaults to the current directory. |

### Options

Expand All @@ -37,6 +37,7 @@ Pass `--file` to write it directly instead. The output is written as raw UTF-8,
| `--domain <NAME>` | Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project, and to the solution name when several projects are read. |
| `--module <NAME>` | Name of the module every discovered feature is placed within. Defaults to the domain. |
| `--skip-segments <COUNT>` | Number of leading namespace segments to skip when inferring features and slices. |
| `--modules-from-namespace-roots` | Name the module of each feature after the outermost segment of its namespace, instead of placing every feature in one module. |

The output file uses `--file` rather than `-o`, because `-o/--output` is the global output *format* flag — see [Global Options](global-options.md).

Expand All @@ -47,20 +48,47 @@ cratis screenplay generate ./Source/MyApp/MyApp.csproj
cratis screenplay generate --domain Library --module Lending --file Library.play
```

### Naming the modules

A document places every discovered feature in one module, named after the domain. That is right for an application that *is* one module, and wrong for one whose namespaces already say what its modules are — `Library.Authors`, `Library.Inventory`, `Library.Lending` come back as a single `module Library` holding three features.

`--modules-from-namespace-roots` takes the module of each feature from the outermost segment of its namespace instead. When every slice shares a root namespace — as they do above — that outermost segment is the root, which names one module again, so pair it with `--skip-segments` to move the modules down to the segment that tells them apart:

```bash
cratis screenplay generate --modules-from-namespace-roots --skip-segments 1
```

```text
module Authors
module Inventory
module Lending
```

Naming a module with `--module` still collapses the document into that one, whichever of these is passed.

### Finding the solution or project

When `PATH` is a solution or project file, that file is read. When it is a folder — or is omitted entirely — the CLI looks in that folder and then in each parent folder in turn, stopping at the first one that holds a match. Within a folder it prefers `.slnx`, then `.sln`, then `.csproj`. Two candidates of the same kind in one folder is reported rather than guessed at.
When `PATH` is a solution or project file, that file is read. When it is a folder — or is omitted entirely — the CLI looks in that folder and then in each parent folder in turn, stopping at the first one that holds a match. Within a folder it prefers `.slnx`, then `.sln`, then `.slnf`, then `.csproj`. Two candidates of the same kind in one folder is reported rather than guessed at.

A solution filter (`.slnf`) is read as the solution it filters, which is how a repository holding more than one application points at the one to describe.

A Screenplay describes one application, and an application is regularly split across several projects — an executable alongside the libraries holding its slices. Every project of a solution therefore takes part in the same document, except the ones whose name ends in `.Specs`, `.Specifications`, `.Tests`, `.Test`, or `.IntegrationTests`.
### Which projects take part

A Screenplay describes one application, and an application is regularly split across several projects — an executable alongside the libraries holding its slices. Every project of a solution therefore takes part in the same document, except:

- **Projects that cannot declare anything the document is made of.** Every artifact is declared with an attribute the framework ships, so a project resolving neither the Arc nor the Chronicle one — a Roslyn analyzer, a build-time tool, a code-generation project — is left out. This is asked of what the project can *see*, not of what it is called.
- **Spec projects**, by name: the ones called, or ending in, `.Specs`, `.Specifications`, `.Tests`, `.Test`, `.IntegrationTests`, or `.Specs.AppHost`. Nothing about what a spec project can see tells it apart — it references the same framework the application does — so the name is what decides. `.Specs.AppHost` covers the host integration specs start the application in.

A project that targets several frameworks is read once. The workspace opens it once per target framework and names the results `MyApp(net10.0)`, `MyApp(net9.0)`; they all hold the same application, so one of them takes part.

Pass a `.csproj` instead of the solution to describe a single project — pointing at a project is the instruction to read it, so it is read whatever it can see.

The projects that were read are named in the result, so you can see what the document covers:

```text
Projects: Library.Api, Library.Domain, Library.ReadModels
```

Pass a `.csproj` instead of the solution to describe a single project.

### Diagnostics

Anything the generator cannot express in Screenplay is reported rather than silently dropped — a projection operator with no counterpart, a validator rule that has no equivalent, a construct only available as compiled metadata because it lives in a referenced package.
Expand All @@ -82,7 +110,15 @@ With `-o json` or `-o json-compact` the same diagnostics are written to standard

### Prerequisites

The command loads the project through MSBuild, so the **.NET SDK** must be installed — the same SDK you build the project with. Packages must be restorable; a project that cannot be restored cannot be read.
The command loads the project through MSBuild, so the **.NET SDK** must be installed — the same SDK you build the project with.

**Packages must already be restored.** An unrestored project still loads, and yields a compilation in which every framework type reads as missing — which would be reported as a page of unrecognizable artifacts and a document describing nobody's application. It is reported as the one thing that is actually wrong instead:

```text
errors (1):
error CLI0005: 'Library.Domain' has not been restored, so every type the application
references reads as missing — run 'dotnet restore' and generate again
```

The project does **not** have to have been built first. Sources MSBuild generates as part of a build — such as the strongly typed classes a `.resx` file declares with `<Generator>MSBuild:Compile</Generator>` — are produced while the project is read, so the model is derived from exactly what a real build compiles.

Expand All @@ -93,8 +129,10 @@ The project does **not** have to have been built first. Sources MSBuild generate
| `PATH` does not exist | Not-found error. |
| `PATH` is a file that is not a solution or project | Not-found error. |
| No solution or project found in `PATH` or any parent folder | Not-found error. |
| The solution holds no project that is not specs | Validation error. |
| A project cannot be read into a compilation | Validation error naming it; the remaining projects are still described. |
| The solution holds no project that is not specs | Validation error (`CLI0001`). |
| A project has not been restored | Validation error (`CLI0005`) naming it; nothing is generated. |
| No project of the solution can declare a command or an event type | Validation error (`CLI0006`). |
| A project cannot be read into a compilation | Validation error (`CLI0004`) naming it; the remaining projects are still described. |
| Generation reports one or more errors, with `--file` | Validation error; the document is written anyway. |
| Generation reports one or more errors, writing to standard output | Validation error; nothing is written. |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ [Fact] void should_pass_them_to_the_generation() => _generation.Received(1).Gene
Arg.Any<string>(),
Arg.Is<ScreenplayGenerationOptions>(options => options.Domain == "Library" && options.Module == "Lending" && options.SegmentsToSkip == 2),
Arg.Any<CancellationToken>());

[Fact] void should_leave_the_modules_named_by_one_name() => _generation.Received(1).Generate(
Arg.Any<string>(),
Arg.Is<ScreenplayGenerationOptions>(options => !options.ModulesFromNamespaceRoots),
Arg.Any<CancellationToken>());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating;

[Collection(CliSpecsCollection.Name)]
public class and_the_modules_are_asked_of_the_namespace_roots : given.a_generate_screenplay_command
{
void Establish() => _settings.ModulesFromNamespaceRoots = true;

async Task Because() => await Execute();

[Fact] void should_pass_it_to_the_generation() => _generation.Received(1).Generate(
Arg.Any<string>(),
Arg.Is<ScreenplayGenerationOptions>(options => options.ModulesFromNamespaceRoots),
Arg.Any<CancellationToken>());
}
41 changes: 41 additions & 0 deletions Source/Cli.Specs/for_ProjectRestoreState/given/a_project_folder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_ProjectRestoreState.given;

/// <summary>
/// A folder holding a project file, with nothing restored into it yet.
/// </summary>
public class a_project_folder : Specification
{
protected string _folder;
protected string _project;

void Establish()
{
_folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName;
_project = Path.Combine(_folder, "MyApp.csproj");
File.WriteAllText(_project, "<Project />");
}

/// <summary>
/// Writes an assets file into the given folder, as a restore does.
/// </summary>
/// <param name="folder">The intermediate output folder to restore into.</param>
/// <returns>The full path of the assets file.</returns>
protected static string Restore(string folder)
{
Directory.CreateDirectory(folder);
var assets = Path.Combine(folder, ProjectRestoreState.AssetsFileName);
File.WriteAllText(assets, "{}");
return assets;
}

void Destroy()
{
if (Directory.Exists(_folder))
{
Directory.Delete(_folder, true);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_ProjectRestoreState.when_deciding_whether_a_project_is_restored;

public class and_neither_path_is_known : Specification
{
bool _result;

void Because() => _result = ProjectRestoreState.IsRestored(null, null);

[Fact] void should_take_the_project_for_restored_rather_than_invent_a_failure() => _result.ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_ProjectRestoreState.when_deciding_whether_a_project_is_restored;

public class and_nothing_has_been_restored : given.a_project_folder
{
bool _result;

void Because() => _result = ProjectRestoreState.IsRestored(_project, Path.Combine(_folder, "obj", "Debug", "net10.0", "MyApp.dll"));

[Fact] void should_report_the_project_as_unrestored() => _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.

namespace Cratis.Cli.for_ProjectRestoreState.when_deciding_whether_a_project_is_restored;

public class and_the_assets_file_sits_beside_the_project : given.a_project_folder
{
bool _result;

void Establish() => Restore(Path.Combine(_folder, "obj"));

void Because() => _result = ProjectRestoreState.IsRestored(_project, Path.Combine(_folder, "obj", "Debug", "net10.0", "MyApp.dll"));

[Fact] void should_take_the_project_for_restored() => _result.ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -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.Cli.for_ProjectRestoreState.when_deciding_whether_a_project_is_restored;

/// <summary>
/// The artifacts output layout of the SDK moves the intermediate output folder out of the project folder, so the
/// assets file is nowhere near the <c>obj</c> beside the project and only the assembly says where it went.
/// </summary>
public class and_the_intermediate_output_folder_was_moved : given.a_project_folder
{
string _assembly;
bool _result;

void Establish()
{
var intermediate = Path.Combine(_folder, "artifacts", "obj", "MyApp");
Restore(intermediate);
_assembly = Path.Combine(intermediate, "debug", "MyApp.dll");
}

void Because() => _result = ProjectRestoreState.IsRestored(_project, _assembly);

[Fact] void should_take_the_project_for_restored() => _result.ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_ProjectRestoreState.when_describing_what_was_not_restored;

public class and_one_project_was_not : Specification
{
string _result;

void Because() => _result = ProjectRestoreState.MessageFor(["MyApp"]);

[Fact] void should_name_the_project() => _result.ShouldContain("'MyApp' has not been restored");
[Fact] void should_say_what_to_do_about_it() => _result.ShouldContain("dotnet restore");
}
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.

namespace Cratis.Cli.for_ProjectRestoreState.when_describing_what_was_not_restored;

public class and_several_projects_were_not : Specification
{
string _result;

void Because() => _result = ProjectRestoreState.MessageFor(["MyApp", "MyApp.Domain", "MyApp.Read"]);

[Fact] void should_name_the_first_project() => _result.ShouldContain("'MyApp'");
[Fact] void should_count_the_rest_rather_than_list_them() => _result.ShouldContain("and 2 more have not been restored");
[Fact] void should_say_what_to_do_about_it() => _result.ShouldContain("dotnet restore");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_ScreenplayProjectSelection.when_asking_what_a_compilation_can_declare;

public class and_it_resolves_neither_attribute : given.a_compilation_built_from_source
{
bool _result;

void Because() => _result = ScreenplayProjectSelection.CanDeclareAnArtifact(
Holding("namespace MyApp.Analyzers { public class Rule { } }"));

[Fact] void should_leave_it_out_of_the_application() => _result.ShouldBeFalse();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_ScreenplayProjectSelection.when_asking_what_a_compilation_can_declare;

public class and_it_resolves_the_command_attribute : given.a_compilation_built_from_source
{
bool _result;

void Because() => _result = ScreenplayProjectSelection.CanDeclareAnArtifact(
Holding("namespace Cratis.Arc.Commands.ModelBound { public class CommandAttribute { } }"));

[Fact] void should_take_it_for_part_of_the_application() => _result.ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_ScreenplayProjectSelection.when_asking_what_a_compilation_can_declare;

public class and_it_resolves_the_event_type_attribute : given.a_compilation_built_from_source
{
bool _result;

void Because() => _result = ScreenplayProjectSelection.CanDeclareAnArtifact(
Holding("namespace Cratis.Chronicle.Events { public class EventTypeAttribute { } }"));

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

namespace Cratis.Cli.for_ScreenplayProjectSelection.when_asking_what_a_compilation_can_declare.given;

/// <summary>
/// Builds a compilation from source, so that what it can resolve is stated rather than restored.
/// </summary>
public class a_compilation_built_from_source : Specification
{
/// <summary>
/// Builds a compilation holding the given source.
/// </summary>
/// <param name="source">The source the compilation is built from.</param>
/// <returns>The <see cref="Compilation"/>.</returns>
protected static Compilation Holding(string source) =>
CSharpCompilation.Create(
"Project",
[CSharpSyntaxTree.ParseText(source)],
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Cli.for_ScreenplayProjectSelection.when_deciding_whether_a_project_holds_specs;

public class and_it_is_a_multi_targeted_application_project : Specification
{
bool _result;

void Because() => _result = ScreenplayProjectSelection.IsSpecProject("MyApp.Domain(netstandard2.0)");

[Fact] void should_take_it_for_part_of_the_application() => _result.ShouldBeFalse();
}
Loading
Loading