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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ section is the GitHub release's notes.
- Analyzers: CMTK0009 warns at `FirstOrNone`, `SingleOrNone` or `FirstOrError` on a query given a `Func` variable or a method group, which binds to the `IEnumerable<T>` overload and reads every row; a lambda at the call or an `Expression` stays silent ([A condition that reads every row](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#a-condition-that-reads-every-row)).
- Docs: the guides, the package READMEs, the changelog and the API of every run-time package as one site with a menu and search, built from the latest release ([caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/)).
- Analyzers: a validated value object without `Create` gets a code fix, **Add Create**, that writes a `Create` accepting every value, ready for your rules ([Add Create](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#add-create)).
- Docs: a walkthrough follows one orders service from request to response, saying what changes at each boundary; the service is a sample that CI builds from the packages and runs, and the answers the page states are checked against it ([Walkthrough](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/walkthrough.md)).
- EF Core: queries, keys and round trips are tested on a real SQL Server, and the README says what differs there from memory: text compared ignoring case, Guids sorted by their last group, `decimal(18,2)` by default, a `TimeSpan` under a day, `Greatest` from compatibility level 160 ([On SQL Server](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server)).

### Changed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ It shows the latest release.

| Page | Covers |
|---|---|
| [Walkthrough](docs/walkthrough.md) | One orders service from request to response: value objects bound, validated, stored, queried, patched, logged and described, and what changes at each boundary |
| [Value objects](docs/value-objects.md) | Declare one, pick a struct or a class, validate input, design its errors, pick the right factory, write your own members, generic code |
| [Closed sets](docs/closed-sets.md) | Smart enums that carry data: declare one, handle every member with `Match`, send it as JSON, store it as a text column, read it from configuration, use one as an error type, and when an enum is still the right choice |
| [Option](docs/option.md) | A value or nothing: create one, get the value out, combine options, convert at the boundary, and every public member |
Expand Down
3 changes: 2 additions & 1 deletion design/native-aot.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ host, EF Core through `dotnet ef dbcontext optimize --precompile-queries --nativ
unchanged (measured with `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`, which leaves the
`System.Globalization.Invariant` switch unset). `AsText()` asks once whether a decomposed letter
composes, and throws `PlatformNotSupportedException` where it does not; `InvariantGlobalizationTests`
runs the test assembly again in that mode. The smoke test's consumers do not call `AsText()`.
runs the test assembly again in that mode. The smoke test's `aot` consumers do not call `AsText()`; its
`walkthrough` consumer does, on the JIT, without that mode.
- **Third-party packages** report their own warnings (NodaTime.Serialization.SystemTextJson 1.4.0,
EF Core). The smoke test asserts only that none names a CodoMetis package or a woven value object.
4 changes: 3 additions & 1 deletion docs/value-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ This page helps you design them: pick a struct or a class, write the validation,
error carries, and call the right factory. The
[Generators README](../src/CodoMetis.TypeKit.Generators/README.md) lists everything that is
generated, member by member, and every build error. The interfaces themselves are in the
[base README](../src/CodoMetis.TypeKit/README.md#value-objects).
[base README](../src/CodoMetis.TypeKit/README.md#value-objects). To see value objects cross every
boundary of one service, from the request to the database, the log and back, read the
[Walkthrough](walkthrough.md).

Reference `CodoMetis.TypeKit.Generators` in the project that declares value objects. The examples
import `CodoMetis.TypeKit` and `CodoMetis.TypeKit.ValueObjects`.
Expand Down
369 changes: 369 additions & 0 deletions docs/walkthrough.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion site/docfx.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
{ "src": "..", "files": [ "README.md", "CHANGELOG.md", "docs/*.md", "src/*/README.md" ] }
],
"resource": [
{ "src": "..", "dest": "_repository", "files": [ "design/**", "CONTRIBUTING.md", "SECURITY.md", "AGENTS.md", "LICENSE" ] }
{ "src": "..", "dest": "_repository", "files": [ "design/**", "CONTRIBUTING.md", "SECURITY.md", "AGENTS.md", "LICENSE", "tools/ConsumerSmokeTest/Consumers/walkthrough/*.cs" ] }
],
"output": "obj/_site",
"template": [ "default", "modern", "template" ],
Expand Down
2 changes: 2 additions & 0 deletions site/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
href: ../README.md
- name: Guides
items:
- name: Walkthrough
href: ../docs/walkthrough.md
- name: Value objects
href: ../docs/value-objects.md
- name: Closed sets
Expand Down
38 changes: 35 additions & 3 deletions test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ namespace CodoMetis.TypeKit.Conventions.Tests;

/// <summary>
/// Every C# block in the READMEs and the topic pages under <c>docs/</c> is compiled code: it equals,
/// after indentation, exactly one sample region in <c>test/CodoMetis.TypeKit.Samples</c>, and every
/// region there is shown in the page its name starts with. A README edit that is not mirrored in the samples fails here, and a sample that
/// after indentation, exactly one sample region in <c>test/CodoMetis.TypeKit.Samples</c>, or in the sources a
/// page names for itself (<see cref="ReadmeSamples.RegionSources"/>: the walkthrough's sample application),
/// and every region is shown in the page its name starts with. A README edit that is not mirrored in the samples fails here, and a sample that
/// stops compiling fails the build.
/// </summary>
/// <remarks>
Expand Down Expand Up @@ -44,6 +45,7 @@ public void The_docs_pages_are_discovered() =>
[InlineData("README.md", "README")]
[InlineData("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md", "CodoMetis.TypeKit.EntityFrameworkCore")]
[InlineData("docs/ef-core.md", "ef-core")]
[InlineData("docs/walkthrough.md", "walkthrough")]
public void A_page_s_samples_are_named_after_it(string page, string prefix) => ReadmeSamples.Prefix(page).ShouldBe(prefix);

/// <summary>
Expand Down Expand Up @@ -92,6 +94,36 @@ public void The_csharp_blocks_and_the_samples_are_discovered()
Samples.Value.Count.ShouldBeGreaterThanOrEqualTo(30);
}

/// <summary>
/// The walkthrough's sample application is found: a directory renamed would leave its page's blocks matching
/// nothing, which the theories report, but a page that lost its blocks with it would pass.
/// </summary>
[Fact]
public void Each_page_s_own_sources_hold_its_samples()
{
foreach (var (prefix, directory) in ReadmeSamples.RegionSources)
{
Directory.Exists(Path.Combine(Repository.Root, directory)).ShouldBeTrue($"{directory}, where the '{prefix}' samples live, does not exist.");
Samples.Value.Count(sample => sample.ReadmePrefix == prefix).ShouldBeGreaterThanOrEqualTo(10, $"{directory} holds too few '{prefix}/' samples.");
}
}

/// <summary>
/// A page's region lives in the sources its prefix names, and nowhere else: a walkthrough region in the samples
/// project would be compiled where the packed packages never build it, and a samples region among the
/// walkthrough's sources would be shipped to its consumer.
/// </summary>
[Theory]
[MemberData(nameof(SampleNames))]
public void A_sample_lives_in_the_sources_of_its_page(string name)
{
var sample = Samples.Value.Single(candidate => candidate.Name == name);
var expected = ReadmeSamples.SourcesOf(sample.ReadmePrefix);

sample.File.ShouldStartWith(expected + "/", Case.Sensitive,
$"'{name}' ({sample.Location}) belongs to the page '{sample.ReadmePrefix}', whose samples live in {expected}.");
}

[Theory]
[MemberData(nameof(CompiledBlocks))]
public void A_csharp_block_in_a_README_is_a_compiled_sample(string location)
Expand All @@ -102,7 +134,7 @@ public void A_csharp_block_in_a_README_is_a_compiled_sample(string location)
if (sample is null)
{
throw new ShouldAssertException(
$"{location} is not compiled: no sample in {ReadmeSamples.SamplesProject} holds this code. Mirror the change in the sample region, " +
$"{location} is not compiled: no sample in {ReadmeSamples.SourcesOf(ReadmeSamples.Prefix(block.Readme))} holds this code. Mirror the change in the sample region, " +
$"or mark a block that cannot compile on purpose with <!-- not compiled: {{reason}} --> above its fence.{Environment.NewLine}" +
Closest(block.Code, Samples.Value.Select(candidate => (candidate.Name + " at " + candidate.Location, candidate.Code)), "sample", "README"));
}
Expand Down
22 changes: 18 additions & 4 deletions test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ internal static partial class ReadmeSamples

public const string DocsDirectory = "docs";

/// <summary>
/// The pages whose regions live outside the samples project, each with the directory that holds them. The
/// walkthrough's are the sources of the smoke test's <c>walkthrough</c> consumer: a sample application that
/// the packed packages build and run, and whose answers the page states.
/// </summary>
public static readonly IReadOnlyDictionary<string, string> RegionSources = new Dictionary<string, string>(StringComparer.Ordinal)
{
["walkthrough"] = "tools/ConsumerSmokeTest/Consumers/walkthrough",
};

/// <summary>Where the regions of the page with this prefix live: its own sources, or the samples project.</summary>
public static string SourcesOf(string prefix) => RegionSources.GetValueOrDefault(prefix, SamplesProject);

/// <summary>
/// Every page whose C# is compiled: the root README, every shipping package's, discovered from
/// <c>src/</c>, and every topic page in <c>docs/</c> (<see cref="DocsPages"/>).
Expand Down Expand Up @@ -93,12 +106,13 @@ public static IReadOnlyList<string> DocumentationLinks(string markdown)
public static IReadOnlyList<CodeBlock> CodeBlocks() =>
[.. Readmes().SelectMany(readme => CodeBlocks(readme, File.ReadAllText(Path.Combine(Repository.Root, readme))))];

/// <summary>Every region of the samples project and of each page's own sources (<see cref="RegionSources"/>).</summary>
public static IReadOnlyList<SampleRegion> Regions()
{
var project = Path.Combine(Repository.Root, SamplesProject);

var regions = Directory.EnumerateFiles(project, "*.cs", SearchOption.AllDirectories)
.Where(path => !Path.GetRelativePath(project, path).Split(Path.DirectorySeparatorChar).Any(part => part is "bin" or "obj"))
var regions = new[] { SamplesProject }.Concat(RegionSources.Values)
.Select(directory => Path.Combine(Repository.Root, directory))
.SelectMany(directory => Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories)
.Where(path => !Path.GetRelativePath(directory, path).Split(Path.DirectorySeparatorChar).Any(part => part is "bin" or "obj")))
.Order(StringComparer.Ordinal)
.SelectMany(path => Regions(Path.GetRelativePath(Repository.Root, path).Replace('\\', '/'), File.ReadAllText(path)))
.ToArray();
Expand Down
1 change: 1 addition & 0 deletions test/CodoMetis.TypeKit.Oracles.Tests/OracleCatalogue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ internal static partial class OracleCatalogue
"docs/result.md",
"docs/ef-core.md",
"docs/type-constraints.md",
"docs/walkthrough.md",
"src/CodoMetis.TypeKit.AspNetCore/README.md",
"src/CodoMetis.TypeKit.EntityFrameworkCore/README.md",
"src/CodoMetis.TypeKit.Generators/README.md",
Expand Down
11 changes: 11 additions & 0 deletions test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" />
</ItemGroup>

<!--
docs/walkthrough.md shows a sample application, whose sources are the consumer smoke test's walkthrough
consumer, so that the packed packages build it too. Its regions are read there; this project compiles them
for WalkthroughTests, which runs the service in memory and reads the page with the smoke test's own reader.
Program.cs, the host, stays the consumer's.
-->
<ItemGroup>
<Compile Include="..\..\tools\ConsumerSmokeTest\Consumers\walkthrough\*.cs" Exclude="..\..\tools\ConsumerSmokeTest\Consumers\walkthrough\Program.cs" LinkBase="Walkthrough" />
<Compile Include="..\..\tools\ConsumerSmokeTest\WalkthroughPage.cs" LinkBase="Walkthrough" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\CodoMetis.TypeKit.Generators\CodoMetis.TypeKit.Generators.csproj" />
<ProjectReference Include="..\..\src\CodoMetis.TypeKit.EntityFrameworkCore\CodoMetis.TypeKit.EntityFrameworkCore.csproj" />
Expand Down
110 changes: 110 additions & 0 deletions test/CodoMetis.TypeKit.Samples/Docs/WalkthroughTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Collections.Concurrent;
using CodoMetis.TypeKit.AspNetCore;
using ConsumerSmokeTest;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using Walkthrough;

namespace ReadmeSamples.Docs;

/// <summary>
/// The walkthrough's orders service, run in memory from the project references: every answer, log line and SQL
/// statement <c>docs/walkthrough.md</c> states in a comment is what the service gives. The consumer smoke test
/// runs the same service from the packed packages (<c>tools/ConsumerSmokeTest</c>, its <c>walkthrough</c>
/// consumer, whose sources this project compiles too); this runs it on every pull request's test leg.
/// </summary>
/// <remarks>
/// The expected text is read from the page by <see cref="WalkthroughPage"/>, the smoke test's own reader, compiled
/// here too: never written into this test, so a changed answer is corrected in the page's comment.
/// </remarks>
[Collection(nameof(Serilog.ConsoleOutput))]
public sealed class WalkthroughTests
{
private static readonly string Page = SampleOutputs.Readme(WalkthroughPage.Path);

[Fact]
public async Task The_service_answers_what_the_page_states()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
var sql = new ConcurrentQueue<string>();

// The setup Program.cs has, but for the host's address and the SQL, which is kept here rather than printed.
// The log is written to the console as there, with the console captured: the collection runs alone.
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production });
builder.WebHost.UseTestServer();
builder.Logging.ClearProviders();
builder.Services.AddOpenApi(options => options.AddTypeKit());
builder.Services.AddFaultResponses<OrderFault>(fault => fault switch
{
OrderFault.NotFound => StatusCodes.Status404NotFound,
OrderFault.AlreadyShipped or OrderFault.Cancelled => StatusCodes.Status409Conflict,
OrderFault.Cleared => StatusCodes.Status422UnprocessableEntity,
_ => throw new ArgumentOutOfRangeException(nameof(fault)),
});
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.RespectRequiredConstructorParameters = true);
builder.Services.AddDbContext<OrdersDb>(options => options.UseSqlite(connection).UseTypeKit()
.LogTo(sql.Enqueue, [RelationalEventId.CommandExecuted]));
builder.Services.AddSingleton<global::Serilog.ILogger>(new LoggerConfiguration()
.Destructure.WithTypeKit()
.WriteTo.Console(outputTemplate: "[{Level:u3}] {Message:lj}{NewLine}")
.CreateLogger());

await using var app = builder.Build();
app.UseStatusCodePages();
app.MapOpenApi();
app.MapOrders();
using (var scope = app.Services.CreateScope()) scope.ServiceProvider.GetRequiredService<OrdersDb>().Database.EnsureCreated();
await app.StartAsync(TestContext.Current.CancellationToken);

var reported = new List<(string Name, string Value)>();
var console = new StringWriter();
var original = Console.Out;
Console.SetOut(console);
try
{
await Tour.RunAsync(app.GetTestClient(), (name, value) => reported.Add((name, value)));
}
finally
{
Console.SetOut(original);
}
await app.StopAsync(TestContext.Current.CancellationToken);

var problems = new List<string>();
foreach (var (name, value) in reported)
{
var stated = WalkthroughPage.Stated(Page, WalkthroughPage.Answer(name));
if (!WalkthroughPage.Is(stated, value)) problems.Add($"the page states '{stated.Single()}' for {name}, but it is '{value}'");
}

WalkthroughPage.Declarations(Page).Except(reported.Select(answer => answer.Name))
.ShouldBeEmpty("The page states these answers, which the tour never reports.");

var logged = console.ToString().ReplaceLineEndings("\n").Split('\n').Where(line => line.StartsWith("[INF] ", StringComparison.Ordinal)).ToArray();
Within(problems, "log.Information(\"Placed", logged, "the log");

var statements = sql.SelectMany(message => message.Split('\n')).ToArray();
Within(problems, "order.Quantity.Value >= minimum.Value", statements, "the SQL");
Within(problems, "Database.EnsureCreated()", statements, "the SQL");

logged.ShouldNotContain(line => line.Contains("ann@example.com", StringComparison.Ordinal), "The e-mail address is masked in every log line.");
problems.ShouldBeEmpty();
}

private static void Within(List<string> problems, string fragment, IReadOnlyList<string> lines, string what)
{
var stated = WalkthroughPage.Stated(Page, fragment);
if (!WalkthroughPage.Within(stated, lines))
problems.Add($"the page states '{string.Join(" / ", stated)}' for '{fragment}', which {what} does not hold: {string.Join(" | ", lines)}");
}
}
Loading
Loading