diff --git a/.agents/skills/tunit/SKILL.md b/.agents/skills/tunit/SKILL.md
new file mode 100644
index 00000000..c224f6fb
--- /dev/null
+++ b/.agents/skills/tunit/SKILL.md
@@ -0,0 +1,61 @@
+---
+description: Write or troubleshoot tests using TUnit, TUnit.Assertions, or TUnit.Mocks, or migrate tests or mocks to TUnit or TUnit.Mocks. Routes to task-specific official documentation. Use only for tasks involving these packages or migration to them.
+license: MIT
+metadata:
+ github-path: skills/tunit
+ github-ref: refs/tags/v1.75.0
+ github-repo: https://github.com/thomhurst/TUnit
+ github-tree-sha: 0d594ad16156140ff224e389ac441149d7259d62
+name: tunit
+---
+# TUnit
+
+Use this skill to find the TUnit guidance needed for the current task. Fetch documentation with an available web tool or HTTP client; network access is required. The documentation contains the API details and examples; do not load the whole site.
+
+## Load only what the task needs
+
+1. Check the relevant project files for TUnit package versions, target frameworks, and the SDK/test runner in `global.json`. Include centrally managed package versions when present. Preserve the project's versions unless an upgrade is requested or necessary and explained.
+2. Choose the closest topic below and fetch that Markdown page before choosing APIs or commands. Start with one page; fetch another only when the task needs it. Skip pages already available in the conversation unless they need refreshing.
+3. For an unlisted topic or a broken route, fetch [the documentation index](https://tunit.dev/llms.txt), search it for the feature or diagnostic, then fetch only the matching page. Do not fetch `llms-full.txt` or crawl every link.
+4. Follow related links only to resolve a specific gap. Prefer the `.md` version: remove a trailing slash and append `.md` to an HTML documentation path, before any fragment. Keep existing `.md` paths unchanged. Resolve relative links against the fetched page's URL.
+5. Apply the relevant example to the existing project and run a focused build or test when making code changes. Cite the documentation used. If current documentation differs from the installed API, check version-matched source or local API evidence before proposing an upgrade. If fetching fails, use available local documentation and state what could not be verified; do not guess APIs.
+
+## Topic routes
+
+Choose the link that matches the task; these are alternatives, not a reading checklist.
+
+| Task | Documentation |
+| --- | --- |
+| Create or configure a project | [Installation](https://tunit.dev/docs/getting-started/installation.md) |
+| Write a basic test | [First test](https://tunit.dev/docs/getting-started/writing-your-first-test.md) |
+| Assertions, exceptions, or assertion composition | [Assertions](https://tunit.dev/docs/assertions/getting-started.md) |
+| Parameterized tests | [Arguments](https://tunit.dev/docs/writing-tests/arguments.md), [method data](https://tunit.dev/docs/writing-tests/method-data-source.md), or [matrix data](https://tunit.dev/docs/writing-tests/matrix-tests.md) |
+| Setup, teardown, or hooks | [Lifecycle](https://tunit.dev/docs/writing-tests/lifecycle.md) |
+| Fixtures, shared resources, or dependency injection | [Class data sources](https://tunit.dev/docs/writing-tests/class-data-source.md) or [dependency injection](https://tunit.dev/docs/writing-tests/dependency-injection.md) |
+| Concurrency or dependencies between tests | [Parallelism](https://tunit.dev/docs/execution/parallelism.md) or [ordering](https://tunit.dev/docs/writing-tests/ordering.md) |
+| Run tests or select tests | [Running tests](https://tunit.dev/docs/getting-started/running-your-tests.md) or [filters](https://tunit.dev/docs/execution/test-filters.md) |
+| Migrate an existing suite | [xUnit](https://tunit.dev/docs/migration/xunit.md), [NUnit](https://tunit.dev/docs/migration/nunit.md), or [MSTest](https://tunit.dev/docs/migration/mstest.md), matching the source framework |
+| Mocking | [TUnit.Mocks](https://tunit.dev/docs/writing-tests/mocking.md) |
+| Migrate mocks to TUnit.Mocks | [Moq](https://tunit.dev/docs/migration/mocking/moq.md), [NSubstitute](https://tunit.dev/docs/migration/mocking/nsubstitute.md), or [FakeItEasy](https://tunit.dev/docs/migration/mocking/fakeiteasy.md), matching the source library |
+| Aspire integration testing | [Aspire](https://tunit.dev/docs/examples/aspire.md) |
+| ASP.NET Core integration testing | [ASP.NET Core](https://tunit.dev/docs/examples/aspnet.md) |
+| Playwright browser testing | [Playwright](https://tunit.dev/docs/examples/playwright.md) |
+| Testcontainers and container fixtures | [ASP.NET Core integration examples](https://tunit.dev/docs/examples/aspnet.md) (see "With Testcontainers") |
+| Native AOT, trimming, or reflection mode | [AOT compatibility](https://tunit.dev/docs/writing-tests/aot.md) or [engine modes](https://tunit.dev/docs/execution/engine-modes.md) |
+| Custom assertions | [Assertion source generators](https://tunit.dev/docs/assertions/extensibility/source-generator-assertions.md) or [custom assertion implementations](https://tunit.dev/docs/assertions/extensibility/custom-assertions.md) |
+| Custom data sources | [Data source generators](https://tunit.dev/docs/extending/data-source-generators.md) |
+| Event receivers | [Event subscribing](https://tunit.dev/docs/writing-tests/event-subscribing.md) |
+| TestContext and test metadata | [Test context](https://tunit.dev/docs/writing-tests/test-context.md) |
+| Diagnostics, output, or tracing | [Logging](https://tunit.dev/docs/extending/logging.md), [test artifacts](https://tunit.dev/docs/writing-tests/artifacts.md), or [OpenTelemetry](https://tunit.dev/docs/examples/opentelemetry.md) |
+| Cancellation | [Cancelling a test](https://tunit.dev/docs/execution/cancellation.md) |
+| Discovery, build, or execution failures | [Troubleshooting](https://tunit.dev/docs/troubleshooting.md) |
+
+For other topics, such as analyzers or framework-specific integrations, use the index selectively.
+
+## Essential distinctions
+
+- Await TUnit assertions, for example `await Assert.That(actual).IsEqualTo(expected);`.
+- Tests run in parallel by default. Account for shared mutable state and resource lifetimes.
+- TUnit uses Microsoft.Testing.Platform and `--treenode-filter`. Match command syntax to the project's SDK and runner mode; do not substitute VSTest `--filter` or assume every `dotnet test` invocation needs a `--` separator.
+- Source-generated discovery is the default; reflection mode also exists. Check the relevant documentation when changing discovery or Native AOT behavior.
+- TUnit.Assertions and TUnit.Mocks also work with other test frameworks. Using them alone does not require changing the project's test runner.
diff --git a/.claude/skills/tunit/SKILL.md b/.claude/skills/tunit/SKILL.md
new file mode 100644
index 00000000..c224f6fb
--- /dev/null
+++ b/.claude/skills/tunit/SKILL.md
@@ -0,0 +1,61 @@
+---
+description: Write or troubleshoot tests using TUnit, TUnit.Assertions, or TUnit.Mocks, or migrate tests or mocks to TUnit or TUnit.Mocks. Routes to task-specific official documentation. Use only for tasks involving these packages or migration to them.
+license: MIT
+metadata:
+ github-path: skills/tunit
+ github-ref: refs/tags/v1.75.0
+ github-repo: https://github.com/thomhurst/TUnit
+ github-tree-sha: 0d594ad16156140ff224e389ac441149d7259d62
+name: tunit
+---
+# TUnit
+
+Use this skill to find the TUnit guidance needed for the current task. Fetch documentation with an available web tool or HTTP client; network access is required. The documentation contains the API details and examples; do not load the whole site.
+
+## Load only what the task needs
+
+1. Check the relevant project files for TUnit package versions, target frameworks, and the SDK/test runner in `global.json`. Include centrally managed package versions when present. Preserve the project's versions unless an upgrade is requested or necessary and explained.
+2. Choose the closest topic below and fetch that Markdown page before choosing APIs or commands. Start with one page; fetch another only when the task needs it. Skip pages already available in the conversation unless they need refreshing.
+3. For an unlisted topic or a broken route, fetch [the documentation index](https://tunit.dev/llms.txt), search it for the feature or diagnostic, then fetch only the matching page. Do not fetch `llms-full.txt` or crawl every link.
+4. Follow related links only to resolve a specific gap. Prefer the `.md` version: remove a trailing slash and append `.md` to an HTML documentation path, before any fragment. Keep existing `.md` paths unchanged. Resolve relative links against the fetched page's URL.
+5. Apply the relevant example to the existing project and run a focused build or test when making code changes. Cite the documentation used. If current documentation differs from the installed API, check version-matched source or local API evidence before proposing an upgrade. If fetching fails, use available local documentation and state what could not be verified; do not guess APIs.
+
+## Topic routes
+
+Choose the link that matches the task; these are alternatives, not a reading checklist.
+
+| Task | Documentation |
+| --- | --- |
+| Create or configure a project | [Installation](https://tunit.dev/docs/getting-started/installation.md) |
+| Write a basic test | [First test](https://tunit.dev/docs/getting-started/writing-your-first-test.md) |
+| Assertions, exceptions, or assertion composition | [Assertions](https://tunit.dev/docs/assertions/getting-started.md) |
+| Parameterized tests | [Arguments](https://tunit.dev/docs/writing-tests/arguments.md), [method data](https://tunit.dev/docs/writing-tests/method-data-source.md), or [matrix data](https://tunit.dev/docs/writing-tests/matrix-tests.md) |
+| Setup, teardown, or hooks | [Lifecycle](https://tunit.dev/docs/writing-tests/lifecycle.md) |
+| Fixtures, shared resources, or dependency injection | [Class data sources](https://tunit.dev/docs/writing-tests/class-data-source.md) or [dependency injection](https://tunit.dev/docs/writing-tests/dependency-injection.md) |
+| Concurrency or dependencies between tests | [Parallelism](https://tunit.dev/docs/execution/parallelism.md) or [ordering](https://tunit.dev/docs/writing-tests/ordering.md) |
+| Run tests or select tests | [Running tests](https://tunit.dev/docs/getting-started/running-your-tests.md) or [filters](https://tunit.dev/docs/execution/test-filters.md) |
+| Migrate an existing suite | [xUnit](https://tunit.dev/docs/migration/xunit.md), [NUnit](https://tunit.dev/docs/migration/nunit.md), or [MSTest](https://tunit.dev/docs/migration/mstest.md), matching the source framework |
+| Mocking | [TUnit.Mocks](https://tunit.dev/docs/writing-tests/mocking.md) |
+| Migrate mocks to TUnit.Mocks | [Moq](https://tunit.dev/docs/migration/mocking/moq.md), [NSubstitute](https://tunit.dev/docs/migration/mocking/nsubstitute.md), or [FakeItEasy](https://tunit.dev/docs/migration/mocking/fakeiteasy.md), matching the source library |
+| Aspire integration testing | [Aspire](https://tunit.dev/docs/examples/aspire.md) |
+| ASP.NET Core integration testing | [ASP.NET Core](https://tunit.dev/docs/examples/aspnet.md) |
+| Playwright browser testing | [Playwright](https://tunit.dev/docs/examples/playwright.md) |
+| Testcontainers and container fixtures | [ASP.NET Core integration examples](https://tunit.dev/docs/examples/aspnet.md) (see "With Testcontainers") |
+| Native AOT, trimming, or reflection mode | [AOT compatibility](https://tunit.dev/docs/writing-tests/aot.md) or [engine modes](https://tunit.dev/docs/execution/engine-modes.md) |
+| Custom assertions | [Assertion source generators](https://tunit.dev/docs/assertions/extensibility/source-generator-assertions.md) or [custom assertion implementations](https://tunit.dev/docs/assertions/extensibility/custom-assertions.md) |
+| Custom data sources | [Data source generators](https://tunit.dev/docs/extending/data-source-generators.md) |
+| Event receivers | [Event subscribing](https://tunit.dev/docs/writing-tests/event-subscribing.md) |
+| TestContext and test metadata | [Test context](https://tunit.dev/docs/writing-tests/test-context.md) |
+| Diagnostics, output, or tracing | [Logging](https://tunit.dev/docs/extending/logging.md), [test artifacts](https://tunit.dev/docs/writing-tests/artifacts.md), or [OpenTelemetry](https://tunit.dev/docs/examples/opentelemetry.md) |
+| Cancellation | [Cancelling a test](https://tunit.dev/docs/execution/cancellation.md) |
+| Discovery, build, or execution failures | [Troubleshooting](https://tunit.dev/docs/troubleshooting.md) |
+
+For other topics, such as analyzers or framework-specific integrations, use the index selectively.
+
+## Essential distinctions
+
+- Await TUnit assertions, for example `await Assert.That(actual).IsEqualTo(expected);`.
+- Tests run in parallel by default. Account for shared mutable state and resource lifetimes.
+- TUnit uses Microsoft.Testing.Platform and `--treenode-filter`. Match command syntax to the project's SDK and runner mode; do not substitute VSTest `--filter` or assume every `dotnet test` invocation needs a `--` separator.
+- Source-generated discovery is the default; reflection mode also exists. Check the relevant documentation when changing discovery or Native AOT behavior.
+- TUnit.Assertions and TUnit.Mocks also work with other test frameworks. Using them alone does not require changing the project's test runner.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9f64dcf9..3d73c02d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -57,6 +57,8 @@
+
+
diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
index 23a0c5ff..c0859ed0 100644
--- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
+++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
@@ -14,6 +14,7 @@
+
diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs
index 83a413c0..012ebf6a 100644
--- a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs
+++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs
@@ -65,6 +65,24 @@ public async Task GetListingAsync_DifferentFileExtension_AutoDiscoversFileExtens
await Assert.That(result.FileExtension).IsEqualTo("xml");
}
+ [Test]
+ public async Task GetListingAsync_BookContentFilename_ReturnsListing()
+ {
+ ListingSourceCodeService service = CreateService();
+
+ ListingSourceCodeResponse? result = await service.GetListingAsync(99, 1);
+ IReadOnlyList chapterListings = await service.GetListingsByChapterAsync(99);
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result.FileExtension).IsEqualTo("cs");
+ await Assert.That(result.Content).Contains("BookContent filename");
+ ListingSourceCodeResponse listing = await Assert.That(chapterListings).HasSingleItem();
+ await Assert.That(listing.ListingNumber).IsEqualTo(1);
+ }
+ }
+
[Test]
public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings()
{
diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter99/Listing99.01.Sample.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter99/Listing99.01.Sample.cs
new file mode 100644
index 00000000..faa796f5
--- /dev/null
+++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter99/Listing99.01.Sample.cs
@@ -0,0 +1,2 @@
+// BookContent filename
+Console.WriteLine("BookContent filename");
diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj
index f0a2c6e2..be1eb33d 100644
--- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj
+++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj
@@ -62,6 +62,7 @@
all
+
diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs
index 94eda71c..f08c0be1 100644
--- a/EssentialCSharp.Web/Program.cs
+++ b/EssentialCSharp.Web/Program.cs
@@ -46,13 +46,11 @@ private static void Main(string[] args)
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
- // OpenTelemetry — two mutually exclusive export paths:
- // Production: Azure Monitor (Application Insights) via APPLICATIONINSIGHTS_CONNECTION_STRING
- // Local/Aspire: OTLP to Aspire Dashboard via OTEL_EXPORTER_OTLP_ENDPOINT
- // Never both simultaneously — that would cause duplicate telemetry in App Insights.
+ // Prefer Aspire's OTLP endpoint when present; otherwise export to Azure Monitor.
+ // This keeps local traces in the dashboard even when a developer has an App Insights connection string configured.
string? appInsightsConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
- bool useAzureMonitor = !string.IsNullOrWhiteSpace(appInsightsConnectionString);
bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+ bool useAzureMonitor = !useOtlp && !string.IsNullOrWhiteSpace(appInsightsConnectionString);
bool profilerSupportedPlatform = OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
bool profilerSkippedUnsupportedPlatform = false;
diff --git a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs
index b9eb7694..f92a174b 100644
--- a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs
+++ b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs
@@ -49,14 +49,21 @@ public void Dispose()
return null;
}
- string pattern = $"{chapterNumber:D2}.{listingNumber:D2}.*";
+ Regex listingFileRegex = ListingFilePattern();
IFileInfo? matchingFile = directoryContents
.Where(f => !f.IsDirectory)
- .FirstOrDefault(f => IsMatch(f.Name, pattern));
+ .Select(f => new { File = f, Match = listingFileRegex.Match(f.Name) })
+ .Where(x => x.Match.Success
+ && int.Parse(x.Match.Groups[1].Value, CultureInfo.InvariantCulture) == chapterNumber
+ && int.Parse(x.Match.Groups[2].Value, CultureInfo.InvariantCulture) == listingNumber)
+ .OrderBy(x => string.Equals(Path.GetExtension(x.File.Name), ".cs", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
+ .ThenBy(x => x.File.Name, StringComparer.Ordinal)
+ .Select(x => x.File)
+ .FirstOrDefault();
if (matchingFile == null)
{
- LogListingFileNotFound(_Logger, pattern, chapterDirectory);
+ LogListingFileNotFound(_Logger, $"{chapterNumber:D2}.{listingNumber:D2}", chapterDirectory);
return null;
}
@@ -82,7 +89,7 @@ public async Task> GetListingsByChapter
return Array.Empty();
}
- // Regex to match files like "01.01.cs" or "23.15.xml"
+ // Match both legacy "01.01.cs" and BookContent "Listing01.01.Title.cs" names.
Regex listingFileRegex = ListingFilePattern();
var matchedFiles = directoryContents
@@ -122,14 +129,7 @@ private static async Task ReadFileContentAsync(IFileInfo file)
return await reader.ReadToEndAsync();
}
- private static bool IsMatch(string fileName, string pattern)
- {
- // Convert glob-like pattern to regex (simple version for our use case)
- string regexPattern = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$";
- return Regex.IsMatch(fileName, regexPattern);
- }
-
- [GeneratedRegex(@"^(\d{2})\.(\d{2})\.(\w+)$")]
+ [GeneratedRegex(@"^(?:Listing)?(\d{2})\.(\d{2})(?:\..+)?\.(\w+)$")]
private static partial Regex ListingFilePattern();
[LoggerMessage(Level = LogLevel.Information, Message = "Using listing source code from: {Path}")]