From edd4ef9bae1c9047332b082ab2cfa30cf8bfdeae Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 5 Feb 2026 12:23:34 -0800 Subject: [PATCH 01/16] Implement ListingSourceCode feature with controller, service, and tests --- .gitignore | 1 + .../EssentialCSharp.Web.Tests.csproj | 12 ++ .../ListingSourceCodeControllerTests.cs | 107 +++++++++++++ .../ListingSourceCodeServiceTests.cs | 147 ++++++++++++++++++ .../ListingSourceCode/src/Chapter01/01.01.cs | 10 ++ .../ListingSourceCode/src/Chapter01/01.02.xml | 7 + .../ListingSourceCode/src/Chapter01/01.03.cs | 8 + .../ListingSourceCode/src/Chapter10/10.01.cs | 6 + .../ListingSourceCode/src/Chapter10/10.02.cs | 5 + .../src/Chapter10/Employee.cs | 6 + EssentialCSharp.Web.Tests/TestData/README.md | 46 ++++++ .../ListingSourceCodeController.cs | 42 +++++ .../EssentialCSharp.Web.csproj | 6 + .../Models/ListingSourceCodeResponse.cs | 9 ++ EssentialCSharp.Web/Program.cs | 1 + .../Services/IListingSourceCodeService.cs | 9 ++ .../Services/ListingSourceCodeService.cs | 116 ++++++++++++++ 17 files changed, 538 insertions(+) create mode 100644 EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs create mode 100644 EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs create mode 100644 EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.01.cs create mode 100644 EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.02.xml create mode 100644 EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.03.cs create mode 100644 EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.01.cs create mode 100644 EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.02.cs create mode 100644 EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/Employee.cs create mode 100644 EssentialCSharp.Web.Tests/TestData/README.md create mode 100644 EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs create mode 100644 EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs create mode 100644 EssentialCSharp.Web/Services/IListingSourceCodeService.cs create mode 100644 EssentialCSharp.Web/Services/ListingSourceCodeService.cs diff --git a/.gitignore b/.gitignore index bc7b55e0..b5450468 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ wwwroot/Chapters EssentialCSharp.Web/wwwroot/Chapters EssentialCSharp.Web/wwwroot/sitemap.xml EssentialCSharp.Web/Chapters/ +EssentialCSharp.Web/ListingSourceCode Utilities/EssentialCSharp.Web/Chapters/ Utilities/EssentialCSharp.Web/wwwroot/sitemap.xml Utilities/EssentialCSharp.Web/wwwroot/Chapters/ diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj index cde07e16..8ca8e026 100644 --- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj +++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj @@ -15,6 +15,7 @@ + @@ -35,4 +36,15 @@ + + + + + + + + PreserveNewest + + + diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs new file mode 100644 index 00000000..3fd6f4c1 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Http.Json; +using EssentialCSharp.Web.Models; + +namespace EssentialCSharp.Web.Tests; + +public class ListingSourceCodeControllerTests +{ + [Fact] + public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1/1"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + ListingSourceCodeResponse? result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(1, result.ChapterNumber); + Assert.Equal(1, result.ListingNumber); + Assert.NotEmpty(result.FileExtension); + Assert.NotEmpty(result.Content); + } + + + [Fact] + public async Task GetListing_WithInvalidChapter_Returns404() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/999/1"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetListing_WithInvalidListing_Returns404() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1/999"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + List? results = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(results); + Assert.NotEmpty(results); + + // Verify all results are from chapter 1 + Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); + + // Verify results are ordered by listing number + Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); + + // Verify each listing has required properties + Assert.All(results, r => + { + Assert.NotEmpty(r.FileExtension); + Assert.NotEmpty(r.Content); + }); + } + + [Fact] + public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/999"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + List? results = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(results); + Assert.Empty(results); + } +} diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs new file mode 100644 index 00000000..7f42210d --- /dev/null +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -0,0 +1,147 @@ +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Moq; + +namespace EssentialCSharp.Web.Tests; + +public class ListingSourceCodeServiceTests +{ + [Fact] + public async Task GetListingAsync_WithValidChapterAndListing_ReturnsCorrectListing() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + ListingSourceCodeResponse? result = await service.GetListingAsync(1, 1); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.ChapterNumber); + Assert.Equal(1, result.ListingNumber); + Assert.Equal("cs", result.FileExtension); + Assert.NotEmpty(result.Content); + } + + [Fact] + public async Task GetListingAsync_WithInvalidChapter_ReturnsNull() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + ListingSourceCodeResponse? result = await service.GetListingAsync(999, 1); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetListingAsync_WithInvalidListing_ReturnsNull() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + ListingSourceCodeResponse? result = await service.GetListingAsync(1, 999); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetListingAsync_DifferentFileExtension_AutoDiscoversFileExtension() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act - Get an XML file (01.02.xml exists in Chapter 1) + ListingSourceCodeResponse? result = await service.GetListingAsync(1, 2); + + // Assert + Assert.NotNull(result); + Assert.Equal("xml", result.FileExtension); + } + + [Fact] + public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + IReadOnlyList results = await service.GetListingsByChapterAsync(1); + + // Assert + Assert.NotEmpty(results); + Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); + Assert.All(results, r => Assert.NotEmpty(r.Content)); + Assert.All(results, r => Assert.NotEmpty(r.FileExtension)); + + // Verify results are ordered + Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); + } + + [Fact] + public async Task GetListingsByChapterAsync_DirectoryContainsNonListingFiles_ExcludesNonListingFiles() + { + // Arrange - Chapter 10 has Employee.cs which doesn't match the pattern + ListingSourceCodeService service = CreateService(); + + // Act + IReadOnlyList results = await service.GetListingsByChapterAsync(10); + + // Assert + Assert.NotEmpty(results); + + // Ensure all results match the {CC}.{LL}.{ext} pattern + Assert.All(results, r => + { + Assert.Equal(10, r.ChapterNumber); + Assert.InRange(r.ListingNumber, 1, 99); + }); + } + + [Fact] + public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + IReadOnlyList results = await service.GetListingsByChapterAsync(999); + + // Assert + Assert.Empty(results); + } + + private static ListingSourceCodeService CreateService() + { + string testDataRoot = GetTestDataPath(); + + var mockWebHostEnvironment = new Mock(); + mockWebHostEnvironment.Setup(m => m.ContentRootPath).Returns(testDataRoot); + mockWebHostEnvironment.Setup(m => m.ContentRootFileProvider).Returns(new PhysicalFileProvider(testDataRoot)); + + var mockLogger = new Mock>(); + + return new ListingSourceCodeService(mockWebHostEnvironment.Object, mockLogger.Object); + } + + private static string GetTestDataPath() + { + // Get the test project directory and navigate to TestData folder + string currentDirectory = Directory.GetCurrentDirectory(); + string testDataPath = Path.Combine(currentDirectory, "TestData"); + + if (!Directory.Exists(testDataPath)) + { + throw new InvalidOperationException($"TestData directory not found at: {testDataPath}"); + } + + return testDataPath; + } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.01.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.01.cs new file mode 100644 index 00000000..1d0e12f4 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.01.cs @@ -0,0 +1,10 @@ +// Test listing 01.01 +using System; + +class Program +{ + static void Main() + { + Console.WriteLine("Hello, World!"); + } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.02.xml b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.02.xml new file mode 100644 index 00000000..a0ba1622 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.02.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.03.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.03.cs new file mode 100644 index 00000000..b03ff26b --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.03.cs @@ -0,0 +1,8 @@ +// Test listing 01.03 +namespace TestNamespace +{ + public class TestClass + { + public int TestProperty { get; set; } + } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.01.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.01.cs new file mode 100644 index 00000000..9d73d9cd --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.01.cs @@ -0,0 +1,6 @@ +// Test listing 10.01 +public class Employee +{ + public string Name { get; set; } + public int Id { get; set; } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.02.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.02.cs new file mode 100644 index 00000000..1ddb62b1 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.02.cs @@ -0,0 +1,5 @@ +// Test listing 10.02 +public class Manager : Employee +{ + public List DirectReports { get; set; } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/Employee.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/Employee.cs new file mode 100644 index 00000000..6cdb6753 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/Employee.cs @@ -0,0 +1,6 @@ +// This file should NOT be picked up by the listing pattern +// It doesn't match {CC}.{LL}.{ext} format +public class EmployeeHelper +{ + public static void DoSomething() { } +} diff --git a/EssentialCSharp.Web.Tests/TestData/README.md b/EssentialCSharp.Web.Tests/TestData/README.md new file mode 100644 index 00000000..80baafc5 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/README.md @@ -0,0 +1,46 @@ +# Test Data Directory + +This directory contains test fixtures used by the test suite to ensure tests are isolated and independent of production data. + +## Structure + +``` +TestData/ +└── ListingSourceCode/ + └── src/ + ├── Chapter01/ + │ ├── 01.01.cs + │ ├── 01.02.xml + │ └── 01.03.cs + └── Chapter10/ + ├── 10.01.cs + ├── 10.02.cs + └── Employee.cs # Non-listing file to test filtering +``` + +## Purpose + +Test files in this directory: +- Provide controlled, predictable test data +- Isolate tests from changes to production listing files +- Enable testing of edge cases and error conditions +- Are minimal in size for fast test execution +- Are automatically copied to the output directory during build + +## File Naming Convention + +Listing files follow the pattern: `{CC}.{LL}.{ext}` +- `CC`: Two-digit chapter number (e.g., "01", "10") +- `LL`: Two-digit listing number (e.g., "01", "15") +- `ext`: File extension (e.g., "cs", "xml") + +Files not matching this pattern (like `Employee.cs`) are used to test that the service correctly excludes non-listing files. + +## Build Configuration + +These files are: +- Excluded from compilation via `` +- Included as content via `` +- Copied to output directory with `CopyToOutputDirectory.PreserveNewest` + +See [EssentialCSharp.Web.Tests.csproj](../EssentialCSharp.Web.Tests.csproj) for the full build configuration. diff --git a/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs b/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs new file mode 100644 index 00000000..b8d57a41 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs @@ -0,0 +1,42 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace EssentialCSharp.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ListingSourceCodeController : ControllerBase +{ + private readonly IListingSourceCodeService _ListingSourceCodeService; + private readonly ILogger _Logger; + + public ListingSourceCodeController( + IListingSourceCodeService listingSourceCodeService, + ILogger logger) + { + _ListingSourceCodeService = listingSourceCodeService; + _Logger = logger; + } + + [HttpGet("{chapterNumber}/{listingNumber}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] + public async Task GetListing(int chapterNumber, int listingNumber) + { + var result = await _ListingSourceCodeService.GetListingAsync(chapterNumber, listingNumber); + + if (result == null) + { + return NotFound(new { error = $"Listing {chapterNumber}.{listingNumber} not found." }); + } + + return Ok(result); + } + + [HttpGet("{chapterNumber}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] + public async Task GetListingsByChapter(int chapterNumber) + { + var results = await _ListingSourceCodeService.GetListingsByChapterAsync(chapterNumber); + return Ok(results); + } +} diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index cf53c6ff..6987cc79 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -2,6 +2,12 @@ net9.0 + + + + + + diff --git a/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs b/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs new file mode 100644 index 00000000..95b2c742 --- /dev/null +++ b/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs @@ -0,0 +1,9 @@ +namespace EssentialCSharp.Web.Models; + +public class ListingSourceCodeResponse +{ + public int ChapterNumber { get; set; } + public int ListingNumber { get; set; } + public string FileExtension { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 1b83f672..f3d8977c 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -152,6 +152,7 @@ private static void Main(string[] args) builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); diff --git a/EssentialCSharp.Web/Services/IListingSourceCodeService.cs b/EssentialCSharp.Web/Services/IListingSourceCodeService.cs new file mode 100644 index 00000000..28aa04a0 --- /dev/null +++ b/EssentialCSharp.Web/Services/IListingSourceCodeService.cs @@ -0,0 +1,9 @@ +using EssentialCSharp.Web.Models; + +namespace EssentialCSharp.Web.Services; + +public interface IListingSourceCodeService +{ + Task GetListingAsync(int chapterNumber, int listingNumber); + Task> GetListingsByChapterAsync(int chapterNumber); +} diff --git a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs new file mode 100644 index 00000000..b858af71 --- /dev/null +++ b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using EssentialCSharp.Web.Models; +using Microsoft.Extensions.FileProviders; + +namespace EssentialCSharp.Web.Services; + +public partial class ListingSourceCodeService : IListingSourceCodeService +{ + private readonly IWebHostEnvironment _WebHostEnvironment; + private readonly ILogger _Logger; + + public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger logger) + { + _WebHostEnvironment = webHostEnvironment; + _Logger = logger; + } + + public async Task GetListingAsync(int chapterNumber, int listingNumber) + { + string chapterDirectory = $"ListingSourceCode/src/Chapter{chapterNumber:D2}"; + IFileProvider fileProvider = _WebHostEnvironment.ContentRootFileProvider; + IDirectoryContents directoryContents = fileProvider.GetDirectoryContents(chapterDirectory); + + if (!directoryContents.Exists) + { + _Logger.LogWarning("Chapter directory not found: {ChapterDirectory}", chapterDirectory); + return null; + } + + string pattern = $"{chapterNumber:D2}.{listingNumber:D2}.*"; + IFileInfo? matchingFile = directoryContents + .Where(f => !f.IsDirectory) + .FirstOrDefault(f => IsMatch(f.Name, pattern)); + + if (matchingFile == null) + { + _Logger.LogWarning("Listing file not found: {Pattern} in {ChapterDirectory}", pattern, chapterDirectory); + return null; + } + + string content = await ReadFileContentAsync(matchingFile); + string extension = Path.GetExtension(matchingFile.Name).TrimStart('.'); + + return new ListingSourceCodeResponse + { + ChapterNumber = chapterNumber, + ListingNumber = listingNumber, + FileExtension = extension, + Content = content + }; + } + + public async Task> GetListingsByChapterAsync(int chapterNumber) + { + string chapterDirectory = $"ListingSourceCode/src/Chapter{chapterNumber:D2}"; + IFileProvider fileProvider = _WebHostEnvironment.ContentRootFileProvider; + IDirectoryContents directoryContents = fileProvider.GetDirectoryContents(chapterDirectory); + + if (!directoryContents.Exists) + { + _Logger.LogWarning("Chapter directory not found: {ChapterDirectory}", chapterDirectory); + return Array.Empty(); + } + + // Regex to match files like "01.01.cs" or "23.15.xml" + Regex listingFileRegex = ListingFilePattern(); + + var matchedFiles = directoryContents + .Where(f => !f.IsDirectory) + .Select(f => new { File = f, Match = listingFileRegex.Match(f.Name) }) + .Where(x => x.Match.Success) + .Select(x => new + { + x.File, + ChapterNumber = int.Parse(x.Match.Groups[1].Value, CultureInfo.InvariantCulture), + ListingNumber = int.Parse(x.Match.Groups[2].Value, CultureInfo.InvariantCulture), + Extension = x.Match.Groups[3].Value + }) + .Where(x => x.ChapterNumber == chapterNumber); + + var results = new List(); + + foreach (var item in matchedFiles) + { + string content = await ReadFileContentAsync(item.File); + + results.Add(new ListingSourceCodeResponse + { + ChapterNumber = item.ChapterNumber, + ListingNumber = item.ListingNumber, + FileExtension = item.Extension, + Content = content + }); + } + + return results.OrderBy(r => r.ListingNumber).ToList(); + } + + private static async Task ReadFileContentAsync(IFileInfo file) + { + using Stream stream = file.CreateReadStream(); + using StreamReader reader = new(stream); + 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+)$")] + private static partial Regex ListingFilePattern(); +} From 30402fafe2c20666121107622640854587951be8 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Wed, 11 Feb 2026 11:27:52 -0800 Subject: [PATCH 02/16] feat: add TryDotNet integration for interactive code execution - Updated appsettings.json to include TryDotNet origin configuration. - Introduced code-runner.css for styling the interactive code execution panel. - Implemented trydotnet-module.js for managing TryDotNet functionality, including session management, code execution, and error handling. - Enhanced site.js to initialize TryDotNet functionality alongside chat widget. --- .github/workflows/Build-Test-And-Deploy.yml | 8 +- .../Views/Shared/_Layout.cshtml | 121 +++ EssentialCSharp.Web/appsettings.json | 3 + .../wwwroot/css/code-runner.css | 686 ++++++++++++++++++ EssentialCSharp.Web/wwwroot/js/site.js | 7 +- .../wwwroot/js/trydotnet-module.js | 620 ++++++++++++++++ 6 files changed, 1442 insertions(+), 3 deletions(-) create mode 100644 EssentialCSharp.Web/wwwroot/css/code-runner.css create mode 100644 EssentialCSharp.Web/wwwroot/js/trydotnet-module.js diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml index 62f3035b..028404e0 100644 --- a/.github/workflows/Build-Test-And-Deploy.yml +++ b/.github/workflows/Build-Test-And-Deploy.yml @@ -138,6 +138,7 @@ jobs: ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }} ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }} AZURECLIENTID: ${{ secrets.IDENTITY_CLIENT_ID }} + TRYDOTNET_ORIGIN: ${{ vars.TRYDOTNET_ORIGIN }} with: inlineScript: | az containerapp identity assign -n ${{ vars.CONTAINER_APP_NAME }} -g ${{ vars.RESOURCEGROUP }} --user-assigned ${{ vars.CONTAINER_APP_IDENTITY }} @@ -157,7 +158,8 @@ jobs: AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Staging \ AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \ AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \ - AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring + AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \ + TryDotNet__Origin=$TRYDOTNET_ORIGIN - name: Logout of Azure CLI if: always() @@ -230,6 +232,7 @@ jobs: ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }} ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }} AZURECLIENTID: ${{ secrets.IDENTITY_CLIENT_ID }} + TRYDOTNET_ORIGIN: ${{ vars.TRYDOTNET_ORIGIN }} with: inlineScript: | az containerapp identity assign -n ${{ vars.CONTAINER_APP_NAME }} -g ${{ vars.RESOURCEGROUP }} --user-assigned ${{ vars.CONTAINER_APP_IDENTITY }} @@ -249,7 +252,8 @@ jobs: AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Production \ AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \ AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \ - AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring + AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \ + TryDotNet__Origin=$TRYDOTNET_ORIGIN - name: Logout of Azure CLI diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 3c405fc3..60cf43fc 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -6,9 +6,11 @@ @using Microsoft.AspNetCore.Identity @using EssentialCSharp.Web.Areas.Identity.Data @using Microsoft.Extensions.Options +@using Microsoft.Extensions.Configuration @inject ISiteMappingService _SiteMappings @inject SignInManager SignInManager @inject IOptions CaptchaOptions +@inject IConfiguration Configuration @using Microsoft.AspNetCore.Components @{ var prodMap = new ImportMapDefinition( @@ -57,6 +59,7 @@ + @*Font Family*@ @@ -468,6 +471,123 @@ } + + +
@* Recursive vue component template for rendering the table of contents. *@ diff --git a/EssentialCSharp.Web/appsettings.json b/EssentialCSharp.Web/appsettings.json index 8e8cdd33..c6c3b60e 100644 --- a/EssentialCSharp.Web/appsettings.json +++ b/EssentialCSharp.Web/appsettings.json @@ -27,5 +27,8 @@ }, "SiteSettings": { "BaseUrl": "https://essentialcsharp.com" + }, + "TryDotNet": { + "Origin": "" } } \ No newline at end of file diff --git a/EssentialCSharp.Web/wwwroot/css/code-runner.css b/EssentialCSharp.Web/wwwroot/css/code-runner.css new file mode 100644 index 00000000..0354deb3 --- /dev/null +++ b/EssentialCSharp.Web/wwwroot/css/code-runner.css @@ -0,0 +1,686 @@ +/* Code Runner Panel - Interactive Code Execution Styles */ + +/* CSS Custom Properties */ +:root { + /* Primary palette */ + --code-runner-primary: #388e3c; + --code-runner-primary-mid: #2e7d32; + --code-runner-primary-dark: #1b5e20; + --code-runner-primary-darker: #0d3d0f; + --code-runner-primary-shadow: rgba(56, 142, 60, 0.3); + --code-runner-primary-shadow-strong: rgba(56, 142, 60, 0.4); + + /* Gradients */ + --code-runner-gradient: linear-gradient(135deg, var(--code-runner-primary) 0%, var(--code-runner-primary-mid) 50%, var(--code-runner-primary-dark) 100%); + --code-runner-gradient-hover: linear-gradient(135deg, var(--code-runner-primary-mid) 0%, var(--code-runner-primary-dark) 50%, var(--code-runner-primary-darker) 100%); + + /* Surface & text */ + --code-runner-surface: #ffffff; + --code-runner-surface-alt: #fafafa; + --code-runner-surface-alt2: #f5f5f5; + --code-runner-surface-header: #f8f9fa; + --code-runner-surface-header-end: #f1f3f4; + --code-runner-text: rgba(0, 0, 0, 0.87); + --code-runner-text-secondary: rgba(0, 0, 0, 0.6); + --code-runner-text-tertiary: rgba(0, 0, 0, 0.54); + --code-runner-border: rgba(0, 0, 0, 0.08); + --code-runner-border-light: rgba(0, 0, 0, 0.06); + --code-runner-hover-bg: rgba(0, 0, 0, 0.04); + --code-runner-hover-bg-strong: rgba(0, 0, 0, 0.08); + + /* Console / output */ + --code-runner-console-bg: #1e1e1e; + --code-runner-console-header-bg: #252526; + --code-runner-console-border: #333333; + --code-runner-console-text: #d4d4d4; + --code-runner-console-text-secondary: rgba(255, 255, 255, 0.7); + --code-runner-console-text-tertiary: rgba(255, 255, 255, 0.5); + --code-runner-console-scroll-thumb: rgba(255, 255, 255, 0.15); + --code-runner-console-scroll-thumb-hover: rgba(255, 255, 255, 0.25); + + /* Error */ + --code-runner-error-color: #f44336; + --code-runner-error-dark: #d32f2f; + --code-runner-error-darker: #c62828; + --code-runner-error-bg-start: #ffebee; + --code-runner-error-bg-end: #ffcdd2; + --code-runner-error-output: #f48771; + --code-runner-error-close-hover-bg: rgba(244, 67, 54, 0.08); + + /* Focus */ + --code-runner-focus-ring: rgba(56, 142, 60, 0.3); + --code-runner-focus-ring-light: rgba(255, 255, 255, 0.3); + + /* Layout */ + --code-runner-panel-width: 600px; + + /* Z-index layers — relative to site overlay layer */ + --code-runner-z-overlay: 1100; + --code-runner-z-panel: 1101; + + /* Easing */ + --code-runner-ease: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Box-sizing scope */ +.code-runner-panel *, +.code-runner-panel *::before, +.code-runner-panel *::after { + box-sizing: border-box; +} + +/* Run button (inline, placed next to listings) */ +.code-runner-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 14px; + margin-inline-start: 8px; + background: var(--code-runner-gradient); + color: white; + border: none; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); + flex-shrink: 0; + box-shadow: 0 1px 3px var(--code-runner-primary-shadow); + position: relative; + overflow: hidden; +} + +.code-runner-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s ease; +} + +.code-runner-btn:hover::before { + left: 100%; +} + +.code-runner-btn:hover { + background: var(--code-runner-gradient-hover); + box-shadow: 0 3px 8px var(--code-runner-primary-shadow-strong); +} + +.code-runner-btn:active { + transform: translateY(0); + box-shadow: 0 1px 2px var(--code-runner-primary-shadow); +} + +.code-runner-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +.code-runner-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.code-runner-btn i { + font-size: 15px; +} + +/* Slide-out panel overlay */ +.code-runner-overlay { + position: fixed; + inset: 0; + /* Opaque fallback for browsers without backdrop-filter support */ + background: rgba(0, 0, 0, 0.6); + z-index: var(--code-runner-z-overlay); + opacity: 0; + visibility: hidden; + /* visibility uses a step delay: hide after fade-out completes */ + transition: opacity 250ms var(--code-runner-ease), + visibility 0s linear 250ms; +} + +@supports (backdrop-filter: blur(4px)) or (-webkit-backdrop-filter: blur(4px)) { + .code-runner-overlay { + background: rgba(0, 0, 0, 0.4); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + } +} + +.code-runner-overlay.active { + opacity: 1; + visibility: visible; + /* visibility flips immediately on show (0s delay) */ + transition: opacity 250ms var(--code-runner-ease), + visibility 0s linear 0s; +} + +/* Slide-out panel */ +.code-runner-panel { + position: fixed; + top: 0; + inset-inline-end: 0; + bottom: 0; + width: var(--code-runner-panel-width); + max-width: 100vw; + background: var(--code-runner-surface); + box-shadow: + -8px 0 30px rgba(0, 0, 0, 0.12), + -2px 0 8px rgba(0, 0, 0, 0.08); + z-index: var(--code-runner-z-panel); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 350ms var(--code-runner-ease); + border-radius: 16px 0 0 16px; + overflow: hidden; + will-change: transform; + contain: layout style; +} + +.code-runner-overlay.active .code-runner-panel { + transform: translateX(0); +} + +/* Panel header — light gradient, matches chat */ +.code-runner-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-block: 20px; + padding-inline: 24px; + background: linear-gradient(135deg, var(--code-runner-surface-header) 0%, var(--code-runner-surface-header-end) 100%); + border-bottom: 1px solid var(--code-runner-border); + flex-shrink: 0; + position: relative; +} + +.code-runner-header::after { + content: ''; + position: absolute; + bottom: 0; + inset-inline: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, rgba(56, 142, 60, 0.25) 50%, transparent 100%); +} + +.code-runner-title { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--code-runner-text); + letter-spacing: 0.15px; +} + +.code-runner-title i { + font-size: 22px; + color: var(--code-runner-primary); +} + +.code-runner-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: var(--code-runner-hover-bg); + border: none; + border-radius: 50%; + color: var(--code-runner-text-tertiary); + cursor: pointer; + transition: all 200ms var(--code-runner-ease); +} + +.code-runner-close-btn:hover { + background: var(--code-runner-error-close-hover-bg); + color: var(--code-runner-error-color); + transform: rotate(90deg); +} + +.code-runner-close-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +.code-runner-close-btn i { + font-size: 20px; +} + +/* Editor container — top 60% of panel */ +.code-runner-editor-container { + /* 6 : 4 ratio with output container (60% / 40%) */ + flex: 6; + min-height: 0; + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--code-runner-border); + position: relative; +} + +.code-runner-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-block: 10px; + padding-inline: 20px; + background: linear-gradient(135deg, var(--code-runner-surface-alt) 0%, var(--code-runner-surface-alt2) 100%); + border-bottom: 1px solid var(--code-runner-border-light); +} + +.code-runner-editor-header h4 { + margin: 0; + font-size: 13px; + font-weight: 500; + color: var(--code-runner-text-secondary); + letter-spacing: 0.4px; + text-transform: uppercase; +} + +.code-runner-buttons { + display: flex; + gap: 8px; +} + +.code-runner-run-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 20px; + background: var(--code-runner-gradient); + color: white; + border: none; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); + box-shadow: 0 2px 6px var(--code-runner-primary-shadow); + position: relative; + overflow: hidden; +} + +.code-runner-run-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s ease; +} + +.code-runner-run-btn:hover::before { + left: 100%; +} + +.code-runner-run-btn:hover { + background: var(--code-runner-gradient-hover); + box-shadow: 0 4px 12px var(--code-runner-primary-shadow-strong); + transform: translateY(-1px); +} + +.code-runner-run-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px var(--code-runner-primary-shadow); +} + +.code-runner-run-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +.code-runner-run-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.code-runner-clear-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 7px 14px; + background: var(--code-runner-hover-bg); + color: var(--code-runner-text-secondary); + border: none; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); +} + +.code-runner-clear-btn:hover { + background: var(--code-runner-hover-bg-strong); + color: var(--code-runner-text); +} + +.code-runner-clear-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +/* Monaco editor iframe */ +.code-runner-editor { + flex: 1; + width: 100%; + min-height: 0; + border: none; + display: block; +} + +/* Loading state */ +.code-runner-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--code-runner-text-secondary); + background: var(--code-runner-surface); + z-index: 10; +} + +.code-runner-spinner { + display: inline-block; + width: 36px; + height: 36px; + border: 3px solid var(--code-runner-border); + border-radius: 50%; + border-top-color: var(--code-runner-primary); + animation: code-runner-spin 0.8s ease-in-out infinite; +} + +@keyframes code-runner-spin { + to { + transform: rotate(360deg); + } +} + +.code-runner-loading-text { + margin-top: 16px; + font-size: 14px; + font-weight: 400; + letter-spacing: 0.25px; +} + +/* Error state */ +.code-runner-error { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + background: linear-gradient(135deg, var(--code-runner-error-bg-start) 0%, var(--code-runner-error-bg-end) 100%); + text-align: center; + z-index: 10; +} + +.code-runner-error > i { + font-size: 48px; + margin-bottom: 16px; + color: var(--code-runner-error-dark); + opacity: 0.8; +} + +.code-runner-error p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--code-runner-error-darker); + max-width: 320px; +} + +.code-runner-retry-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 20px; + padding: 8px 22px; + background: var(--code-runner-gradient); + color: white; + border: none; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); + box-shadow: 0 2px 6px var(--code-runner-primary-shadow); +} + +.code-runner-retry-btn:hover { + background: var(--code-runner-gradient-hover); + box-shadow: 0 4px 12px var(--code-runner-primary-shadow-strong); + transform: translateY(-1px); +} + +.code-runner-retry-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px var(--code-runner-primary-shadow); +} + +.code-runner-retry-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +.code-runner-retry-btn i { + font-size: 16px; + color: white; +} + +/* Output console — dark theme, bottom 40% of panel */ +.code-runner-output-container { + /* 4 : 6 ratio with editor container (40% / 60%) */ + flex: 4; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--code-runner-console-bg); +} + +.code-runner-output-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-block: 10px; + padding-inline: 20px; + background: var(--code-runner-console-header-bg); + border-bottom: 1px solid var(--code-runner-console-border); +} + +.code-runner-output-header h4 { + margin: 0; + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +/* Scoped specificity: the dark console header forces white text + without needing !important */ +.code-runner-output-container .code-runner-output-header h4 { + color: var(--code-runner-console-text-secondary); +} + +.code-runner-output-header h4 i { + font-size: 15px; + color: var(--code-runner-console-text-tertiary); +} + +.code-runner-clear-output-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + background: transparent; + color: var(--code-runner-console-text-tertiary); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + font-size: 12px; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); +} + +.code-runner-clear-output-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.8); + border-color: var(--code-runner-focus-ring-light); +} + +.code-runner-clear-output-btn:focus-visible { + outline: 2px solid var(--code-runner-focus-ring-light); + outline-offset: 2px; +} + +.code-runner-output { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-block: 16px; + padding-inline: 20px; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 13px; + line-height: 1.6; + color: var(--code-runner-console-text); + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + /* Firefox scrollbar */ + scrollbar-width: thin; + scrollbar-color: var(--code-runner-console-scroll-thumb) transparent; +} + +.code-runner-output.error { + color: var(--code-runner-error-output); +} + +/* Chromium / Safari scrollbar */ +.code-runner-output::-webkit-scrollbar { + width: 8px; +} + +.code-runner-output::-webkit-scrollbar-track { + background: transparent; +} + +.code-runner-output::-webkit-scrollbar-thumb { + background: var(--code-runner-console-scroll-thumb); + border-radius: 4px; +} + +.code-runner-output::-webkit-scrollbar-thumb:hover { + background: var(--code-runner-console-scroll-thumb-hover); +} + +/* Responsive */ +@media (max-width: 768px) { + .code-runner-panel { + width: 100%; + border-radius: 0; + } + + .code-runner-header { + padding: 16px; + } + + .code-runner-title { + font-size: 15px; + } + + .code-runner-editor-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding-block: 10px; + padding-inline: 16px; + } + + .code-runner-buttons { + width: 100%; + justify-content: flex-end; + } + + .code-runner-output-header { + padding-block: 10px; + padding-inline: 16px; + } + + .code-runner-output { + padding-block: 12px; + padding-inline: 16px; + } +} + +/* High contrast mode */ +@media (prefers-contrast: high) { + .code-runner-panel { + border-inline-start: 2px solid #000; + } + + .code-runner-header { + border-bottom: 2px solid #000; + } + + .code-runner-run-btn { + border: 2px solid white; + } + + .code-runner-btn { + border: 2px solid white; + } + + .code-runner-clear-output-btn { + border-color: var(--code-runner-console-text-tertiary); + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .code-runner-overlay, + .code-runner-panel, + .code-runner-btn, + .code-runner-run-btn, + .code-runner-clear-btn, + .code-runner-close-btn, + .code-runner-clear-output-btn, + .code-runner-retry-btn { + transition: none; + } + + .code-runner-btn::before, + .code-runner-run-btn::before { + display: none; + } + + .code-runner-spinner { + animation: none; + opacity: 0.6; + } +} + +/* Print — hide everything */ +@media print { + .code-runner-overlay, + .code-runner-btn { + display: none; + } +} diff --git a/EssentialCSharp.Web/wwwroot/js/site.js b/EssentialCSharp.Web/wwwroot/js/site.js index 2f94169c..7eefaa29 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -10,6 +10,7 @@ import { import { createVuetify } from "vuetify"; import { useWindowSize } from "vue-window-size"; import { useChatWidget } from "./chat-module.js"; +import { useTryDotNet } from "./trydotnet-module.js"; /** * @typedef {Object} TocItem @@ -333,6 +334,9 @@ const app = createApp({ // Initialize chat functionality const chatWidget = useChatWidget(); + // Initialize TryDotNet code runner functionality + const tryDotNet = useTryDotNet(); + return { previousPageUrl, nextPageUrl, @@ -366,7 +370,8 @@ const app = createApp({ enableTocFilter, isContentPage, - ...chatWidget + ...chatWidget, + ...tryDotNet }; }, }); diff --git a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js new file mode 100644 index 00000000..bc23e247 --- /dev/null +++ b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js @@ -0,0 +1,620 @@ +// TryDotNet Module - Vue.js composable for interactive code execution +import { ref, nextTick, onMounted, onUnmounted } from 'vue'; + +// Timeout durations (ms) +const HEALTH_CHECK_TIMEOUT = 5000; +const SESSION_CREATION_TIMEOUT = 20000; +const RUN_TIMEOUT = 30000; + +// User-friendly error messages +const ERROR_MESSAGES = { + serviceUnavailable: 'The code execution service is currently unavailable. Please try again later.', + serviceNotConfigured: 'Interactive code execution is not available at this time.', + sessionTimeout: 'The code editor took too long to load. The service may be temporarily unavailable.', + runTimeout: 'Code execution timed out. The service may be temporarily unavailable.', + editorNotFound: 'Could not initialize the code editor. Please try again.', + sessionNotInitialized: 'The code editor session is not ready. Please try reopening the code runner.', + fetchFailed: 'Could not load the listing source code. Please try again.', +}; + +/** + * Races a promise against a timeout. Rejects with the given message if the + * timeout fires first. + * @param {Promise} promise - The promise to race + * @param {number} ms - Timeout in milliseconds + * @param {string} timeoutMsg - Message for the timeout error + * @returns {Promise} + */ +function withTimeout(promise, ms, timeoutMsg) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMsg)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * Checks whether the TryDotNet origin is configured and non-empty. + * @returns {boolean} + */ +function isTryDotNetConfigured() { + const origin = window.TRYDOTNET_ORIGIN; + return typeof origin === 'string' && origin.trim().length > 0; +} + +/** + * Creates scaffolding for user code to run in the TryDotNet environment. + * @param {string} userCode - The user's C# code to wrap + * @returns {string} Scaffolded code with proper using statements and Main method + */ +function createScaffolding(userCode) { + return `using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Program +{ + class Program + { + static void Main(string[] args) + { + #region controller +${userCode} + #endregion + } + } +}`; +} + +/** + * Dynamically loads a script and returns a promise that resolves when loaded. + * @param {string} url - URL of the script to load + * @param {string} globalName - Name of the global variable the script creates + * @param {number} timeLimit - Maximum time to wait for script load + * @returns {Promise} Promise resolving to the global object + */ +function loadLibrary(url, globalName, timeLimit = 15000) { + return new Promise((resolve, reject) => { + // Check if already loaded + if (globalName && window[globalName]) { + resolve(window[globalName]); + return; + } + + const timeout = setTimeout(() => { + reject(new Error(`${url} load timeout`)); + }, timeLimit); + + const script = document.createElement('script'); + script.src = url; + script.async = true; + script.defer = true; + script.crossOrigin = 'anonymous'; + + script.onload = () => { + clearTimeout(timeout); + if (globalName && !window[globalName]) { + reject(new Error(`${url} loaded but ${globalName} is undefined`)); + } else { + resolve(window[globalName]); + } + }; + + script.onerror = () => { + clearTimeout(timeout); + reject(new Error(`Failed to load ${url}`)); + }; + + document.head.appendChild(script); + }); +} + +/** + * Vue composable for TryDotNet code execution functionality. + * @returns {Object} Composable state and methods + */ +export function useTryDotNet() { + // State + const isCodeRunnerOpen = ref(false); + const codeRunnerLoading = ref(false); + const codeRunnerError = ref(null); + const codeRunnerOutput = ref(''); + const codeRunnerOutputError = ref(false); + const currentListingInfo = ref(null); + const isRunning = ref(false); + const isLibraryLoaded = ref(false); + + // Internal state (not exposed) + let trydotnet = null; + let session = null; + let editorElement = null; + let currentLoadedListing = null; // Track which listing is currently loaded + + /** + * Gets the TryDotNet origin URL from config. + * @returns {string} The TryDotNet service origin URL + */ + function getTryDotNetOrigin() { + return window.TRYDOTNET_ORIGIN; + } + + /** + * Performs a lightweight reachability check against the TryDotNet origin. + * Rejects with a user-friendly message when the service is unreachable. + * @returns {Promise} + */ + async function checkServiceHealth() { + const origin = getTryDotNetOrigin(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT); + + try { + // Check the actual script endpoint rather than the bare origin, + // which may not have a handler and would return 404. + const res = await fetch(`${origin}/api/trydotnet.min.js`, { + method: 'HEAD', + mode: 'no-cors', + signal: controller.signal, + }); + // mode: 'no-cors' gives an opaque response (status 0), which is fine + // — we only care that the network request didn't fail. + } catch { + throw new Error(ERROR_MESSAGES.serviceUnavailable); + } finally { + clearTimeout(timer); + } + } + + /** + * Loads the TryDotNet library from the service. + * Performs a health check first to fail fast. + * @returns {Promise} + */ + async function loadTryDotNetLibrary() { + if (isLibraryLoaded.value && trydotnet) { + return; + } + + if (!isTryDotNetConfigured()) { + throw new Error(ERROR_MESSAGES.serviceNotConfigured); + } + + // Fail fast if the service is unreachable + await checkServiceHealth(); + + const origin = getTryDotNetOrigin(); + const trydotnetUrl = `${origin}/api/trydotnet.min.js`; + + try { + trydotnet = await loadLibrary(trydotnetUrl, 'trydotnet', 15000); + if (!trydotnet) { + throw new Error(ERROR_MESSAGES.serviceUnavailable); + } + isLibraryLoaded.value = true; + } catch (error) { + console.error('Failed to load TryDotNet library:', error); + throw new Error(ERROR_MESSAGES.serviceUnavailable); + } + } + + /** + * Creates a TryDotNet session with the editor iframe and initial code. + * @param {HTMLElement} editorEl - The iframe element for the Monaco editor + * @param {string} userCode - The C# code to display in the editor + * @returns {Promise} + */ + async function createSession(editorEl, userCode) { + if (!trydotnet) { + throw new Error('TryDotNet library not loaded'); + } + + editorElement = editorEl; + + const hostOrigin = window.location.origin; + window.postMessage({ type: 'HostEditorReady', editorId: '0' }, hostOrigin); + + const fileName = 'Program.cs'; + const isComplete = isCompleteProgram(userCode); + const fileContent = isComplete ? userCode : createScaffolding(userCode); + const files = [{ name: fileName, content: fileContent }]; + const project = { package: 'console', files: files }; + const document = isComplete + ? { fileName: fileName } + : { fileName: fileName, region: 'controller' }; + + const configuration = { + hostOrigin: hostOrigin, + trydotnetOrigin: getTryDotNetOrigin(), + enableLogging: false + }; + + session = await withTimeout( + trydotnet.createSessionWithProjectAndOpenDocument( + configuration, + [editorElement], + window, + project, + document + ), + SESSION_CREATION_TIMEOUT, + ERROR_MESSAGES.sessionTimeout + ); + + // Subscribe to output events + session.subscribeToOutputEvents((event) => { + handleOutput(event); + }); + } + + /** + * Sets code in the Monaco editor. + * @param {string} userCode - The C# code to display in the editor + * @returns {Promise} + */ + async function setCode(userCode) { + if (!session || !trydotnet) { + throw new Error('Session not initialized'); + } + + const isComplete = isCompleteProgram(userCode); + const fileContent = isComplete ? userCode : createScaffolding(userCode); + const fileName = 'Program.cs'; + const files = [{ name: fileName, content: fileContent }]; + const project = await trydotnet.createProject({ + packageName: 'console', + files: files + }); + + await session.openProject(project); + + const defaultEditor = session.getTextEditor(); + const documentOptions = { + fileName: fileName, + editorId: defaultEditor.id() + }; + + // Only add region for scaffolded code + if (!isComplete) { + documentOptions.region = 'controller'; + } + + await session.openDocument(documentOptions); + } + + /** + * Runs the code currently in the editor. + * @returns {Promise} + */ + async function runCode() { + if (!session) { + codeRunnerOutput.value = ERROR_MESSAGES.sessionNotInitialized; + codeRunnerOutputError.value = true; + return; + } + + codeRunnerOutput.value = 'Running...'; + codeRunnerOutputError.value = false; + isRunning.value = true; + + try { + await withTimeout(session.run(), RUN_TIMEOUT, ERROR_MESSAGES.runTimeout); + } catch (error) { + codeRunnerOutput.value = error.message; + codeRunnerOutputError.value = true; + } finally { + isRunning.value = false; + } + } + + /** + * Clears the editor content. + */ + function clearEditor() { + if (!session) return; + + const textEditor = session.getTextEditor(); + if (textEditor) { + textEditor.setContent(''); + codeRunnerOutput.value = 'Editor cleared.'; + codeRunnerOutputError.value = false; + } + } + + /** + * Handles output events from the TryDotNet session. + * @param {Object} event - Output event from TryDotNet + */ + function handleOutput(event) { + if (event.exception) { + codeRunnerOutput.value = event.exception.join('\n'); + codeRunnerOutputError.value = true; + } else if (event.diagnostics && event.diagnostics.length > 0) { + // Handle compilation errors/warnings + const diagnosticMessages = event.diagnostics.map(d => { + const severity = d.severity || 'Error'; + const location = d.location ? `(${d.location})` : ''; + const id = d.id ? `${d.id}: ` : ''; + return `${severity} ${location}: ${id}${d.message}`; + }); + codeRunnerOutput.value = diagnosticMessages.join('\n'); + codeRunnerOutputError.value = true; + } else if (event.stderr && event.stderr.length > 0) { + // Handle standard error output + codeRunnerOutput.value = event.stderr.join('\n'); + codeRunnerOutputError.value = true; + } else if (event.stdout) { + codeRunnerOutput.value = event.stdout.join('\n'); + codeRunnerOutputError.value = false; + } else { + codeRunnerOutput.value = 'No output'; + codeRunnerOutputError.value = false; + } + isRunning.value = false; + } + + /** + * Checks if code is a complete C# program that doesn't need scaffolding. + * Complete programs must have a namespace declaration with class and Main, + * or be a class named Program with Main. + * @param {string} code - Source code to check + * @returns {boolean} True if code is complete, false if it needs scaffolding + */ + function isCompleteProgram(code) { + // Check for explicit namespace declaration (most reliable indicator) + const hasNamespace = /namespace\s+\w+/i.test(code); + + // Check if it's a class specifically named "Program" with Main method + const isProgramClass = /class\s+Program\s*[\r\n{]/.test(code) && + /static\s+(void|async\s+Task)\s+Main\s*\(/.test(code); + + // Only consider it complete if it has namespace or is the Program class + return hasNamespace || isProgramClass; + } + + /** + * Extracts executable code snippet from source code. + * If code contains #region INCLUDE, extracts only that portion. + * Otherwise returns the full code. + * @param {string} code - Source code to process + * @returns {string} Extracted code snippet + */ + function extractCodeSnippet(code) { + // Extract code from #region INCLUDE if present + const regionMatch = code.match(/#region\s+INCLUDE\s*\n([\s\S]*?)\n\s*#endregion\s+INCLUDE/); + if (regionMatch) { + return regionMatch[1].trim(); + } + return code; + } + + /** + * Fetches listing source code from the API. + * @param {string|number} chapter - Chapter number + * @param {string|number} listing - Listing number + * @returns {Promise} The listing source code (extracted snippet) + */ + async function fetchListingCode(chapter, listing) { + const response = await fetch(`/api/ListingSourceCode/${chapter}/${listing}`); + if (!response.ok) { + throw new Error(ERROR_MESSAGES.fetchFailed); + } + const data = await response.json(); + const code = data.content || ''; + // Extract the snippet portion if it has INCLUDE regions + return extractCodeSnippet(code); + } + + /** + * Opens the code runner panel with a specific listing. + * @param {string|number} chapter - Chapter number + * @param {string|number} listing - Listing number + * @param {string} title - Title to display + */ + async function openCodeRunner(chapter, listing, title) { + currentListingInfo.value = { chapter, listing, title }; + isCodeRunnerOpen.value = true; + codeRunnerLoading.value = true; + codeRunnerError.value = null; + codeRunnerOutput.value = 'Click "Run" to execute the code.'; + codeRunnerOutputError.value = false; + + const listingKey = `${chapter}.${listing}`; + + try { + // Load the library if not already loaded + if (!isLibraryLoaded.value) { + await loadTryDotNetLibrary(); + } + + // Wait for the panel to render and get the editor element + await nextTick(); + + const editorEl = document.querySelector('.code-runner-editor'); + if (!editorEl) { + throw new Error(ERROR_MESSAGES.editorNotFound); + } + + // Check if this listing is already loaded in the session + if (session && currentLoadedListing === listingKey) { + // Listing already loaded, just show the panel + codeRunnerLoading.value = false; + return; + } + + // Fetch the listing code + const code = await fetchListingCode(chapter, listing); + + // Create session if needed with the fetched code + if (!session) { + await createSession(editorEl, code); + currentLoadedListing = listingKey; + } else { + // Session exists, update the code + await setCode(code); + currentLoadedListing = listingKey; + } + + codeRunnerLoading.value = false; + } catch (error) { + console.error('Failed to open code runner:', error); + codeRunnerError.value = error.message || ERROR_MESSAGES.serviceUnavailable; + codeRunnerLoading.value = false; + } + } + + /** + * Retries opening the code runner after a failure. + * Resets the session so a fresh connection is attempted. + */ + function retryCodeRunner() { + // Reset session state so a fresh connection is attempted + session = null; + currentLoadedListing = null; + isLibraryLoaded.value = false; + trydotnet = null; + + if (currentListingInfo.value) { + const { chapter, listing, title } = currentListingInfo.value; + openCodeRunner(chapter, listing, title); + } + } + + /** + * Closes the code runner panel. + */ + function closeCodeRunner() { + isCodeRunnerOpen.value = false; + currentListingInfo.value = null; + // Note: We keep the session and currentLoadedListing to avoid recreating when reopened + } + + /** + * Clears the output console. + */ + function clearOutput() { + codeRunnerOutput.value = ''; + codeRunnerOutputError.value = false; + } + + /** + * Injects Run buttons into code block sections. + * Skipped entirely when TryDotNet origin is not configured. + */ + function injectRunButtons() { + if (!isTryDotNetConfigured()) { + return; // Don't show Run buttons when the service is not configured + } + + const codeBlocks = document.querySelectorAll('.code-block-section'); + + codeBlocks.forEach((block) => { + const heading = block.querySelector('.code-block-heading'); + if (!heading) return; + + // Skip if button already injected + if (heading.querySelector('.code-runner-btn')) return; + + // Parse chapter and listing numbers from the heading + // Format 1: Listing 1.22 + // Format 2: Listing 1.1: Title + let chapter = null; + let listing = null; + + // First, try to extract from the full heading text + // Pattern: "Listing 1.22" or "Listing 1.1:" + const headingText = heading.textContent; + const listingMatch = headingText.match(/Listing\s+(\d+)\.(\d+)/i); + + if (listingMatch) { + chapter = listingMatch[1]; + listing = listingMatch[2]; + } else { + // Fallback to old method for other formats + const spans = heading.querySelectorAll('span'); + + spans.forEach((span) => { + if (span.classList.contains('TBLNUM')) { + // Extract chapter number (format: "1." -> "1") + const match = span.textContent.match(/(\d+)\./); + if (match) { + chapter = match[1]; + } + } + if (span.classList.contains('CDTNUM') && chapter !== null && listing === null) { + // The CDTNUM after TBLNUM contains the listing number + const num = span.textContent.trim(); + if (/^\d+$/.test(num)) { + listing = num; + } + } + }); + } + + // Only add button for listing 1.1 + if (chapter === '1' && listing === '1') { + // Wrap existing content in a span to keep it together + const contentWrapper = document.createElement('span'); + while (heading.firstChild) { + contentWrapper.appendChild(heading.firstChild); + } + + // Make heading a flex container + heading.style.display = 'flex'; + heading.style.justifyContent = 'space-between'; + heading.style.alignItems = 'center'; + + // Add wrapped content back + heading.appendChild(contentWrapper); + + // Create run button + const runButton = document.createElement('button'); + runButton.className = 'code-runner-btn'; + runButton.type = 'button'; + runButton.title = `Run Listing ${chapter}.${listing}`; + runButton.innerHTML = ' Run'; + runButton.setAttribute('aria-label', `Run Listing ${chapter}.${listing}`); + + runButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openCodeRunner(chapter, listing, `Listing ${chapter}.${listing}`); + }); + + heading.appendChild(runButton); + } + }); + } + + // Lifecycle hooks + onMounted(() => { + // Inject run buttons after component mounts + nextTick(() => { + injectRunButtons(); + }); + }); + + // Return composable interface + return { + // State + isCodeRunnerOpen, + codeRunnerLoading, + codeRunnerError, + codeRunnerOutput, + codeRunnerOutputError, + currentListingInfo, + isRunning, + isLibraryLoaded, + + // Methods + openCodeRunner, + closeCodeRunner, + retryCodeRunner, + runCode, + clearEditor, + clearOutput, + injectRunButtons + }; +} From bd8d5d4b4048bc39ceb9f75d038e9b44914eba78 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Wed, 11 Feb 2026 12:47:37 -0800 Subject: [PATCH 03/16] fix: ensure ListingSourceCode files are copied to output directory for testing --- EssentialCSharp.Web/EssentialCSharp.Web.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 6987cc79..d3bf04c0 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -6,6 +6,8 @@ + + From 17e3b388ee2e7a397a76cbf38edb6f6f9df8403f Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Wed, 11 Feb 2026 13:00:00 -0800 Subject: [PATCH 04/16] fix: enhance WebApplicationFactory to use TestData for IListingSourceCodeService --- .../WebApplicationFactory.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 8c84b992..2acd0427 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -1,9 +1,12 @@ using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; namespace EssentialCSharp.Web.Tests; @@ -39,6 +42,23 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) EssentialCSharpWebContext db = scopedServices.GetRequiredService(); db.Database.EnsureCreated(); + + // Replace IListingSourceCodeService with one backed by TestData + ServiceDescriptor? listingDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(IListingSourceCodeService)); + if (listingDescriptor != null) + { + services.Remove(listingDescriptor); + } + + string testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); + var fileProvider = new PhysicalFileProvider(testDataPath); + services.AddSingleton(sp => + { + var mockEnv = new TestWebHostEnvironment(testDataPath, fileProvider); + var logger = sp.GetRequiredService>(); + return new ListingSourceCodeService(mockEnv, logger); + }); }); } @@ -76,3 +96,22 @@ protected override void Dispose(bool disposing) } } } + +/// +/// Minimal IWebHostEnvironment implementation that redirects ContentRoot to the TestData directory. +/// +internal sealed class TestWebHostEnvironment : IWebHostEnvironment +{ + public TestWebHostEnvironment(string contentRootPath, IFileProvider contentRootFileProvider) + { + ContentRootPath = contentRootPath; + ContentRootFileProvider = contentRootFileProvider; + } + + public string ContentRootPath { get; set; } + public IFileProvider ContentRootFileProvider { get; set; } + public string WebRootPath { get; set; } = string.Empty; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string EnvironmentName { get; set; } = "Testing"; + public string ApplicationName { get; set; } = "EssentialCSharp.Web"; +} From 122cc0a903220d568dbdb6a6db3fb978a691d166 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 10:28:07 -0800 Subject: [PATCH 05/16] fix: update API routes in ListingSourceCodeController and tests for consistency --- .../ListingSourceCodeControllerTests.cs | 10 +++++----- .../Controllers/ListingSourceCodeController.cs | 14 +++++++------- EssentialCSharp.Web/wwwroot/js/trydotnet-module.js | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs index 3fd6f4c1..1944d309 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -14,7 +14,7 @@ public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() HttpClient client = factory.CreateClient(); // Act - using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1/1"); + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/1"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -36,7 +36,7 @@ public async Task GetListing_WithInvalidChapter_Returns404() HttpClient client = factory.CreateClient(); // Act - using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/999/1"); + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999/listing/1"); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -50,7 +50,7 @@ public async Task GetListing_WithInvalidListing_Returns404() HttpClient client = factory.CreateClient(); // Act - using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1/999"); + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/999"); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -64,7 +64,7 @@ public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings( HttpClient client = factory.CreateClient(); // Act - using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1"); + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -95,7 +95,7 @@ public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() HttpClient client = factory.CreateClient(); // Act - using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/999"); + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs b/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs index b8d57a41..d7d6c644 100644 --- a/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs +++ b/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs @@ -18,25 +18,25 @@ public ListingSourceCodeController( _Logger = logger; } - [HttpGet("{chapterNumber}/{listingNumber}")] + [HttpGet("chapter/{chapter}/listing/{listing}")] [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] - public async Task GetListing(int chapterNumber, int listingNumber) + public async Task GetListing(int chapter, int listing) { - var result = await _ListingSourceCodeService.GetListingAsync(chapterNumber, listingNumber); + var result = await _ListingSourceCodeService.GetListingAsync(chapter, listing); if (result == null) { - return NotFound(new { error = $"Listing {chapterNumber}.{listingNumber} not found." }); + return NotFound(new { error = $"Listing {chapter}.{listing} not found." }); } return Ok(result); } - [HttpGet("{chapterNumber}")] + [HttpGet("chapter/{chapter}")] [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] - public async Task GetListingsByChapter(int chapterNumber) + public async Task GetListingsByChapter(int chapter) { - var results = await _ListingSourceCodeService.GetListingsByChapterAsync(chapterNumber); + var results = await _ListingSourceCodeService.GetListingsByChapterAsync(chapter); return Ok(results); } } diff --git a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js index bc23e247..f7cd3689 100644 --- a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js +++ b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js @@ -397,7 +397,7 @@ export function useTryDotNet() { * @returns {Promise} The listing source code (extracted snippet) */ async function fetchListingCode(chapter, listing) { - const response = await fetch(`/api/ListingSourceCode/${chapter}/${listing}`); + const response = await fetch(`/api/ListingSourceCode/chapter/${chapter}/listing/${listing}`); if (!response.ok) { throw new Error(ERROR_MESSAGES.fetchFailed); } From c746e71a4c8cba87884d7af8cec27ec38dd1ef17 Mon Sep 17 00:00:00 2001 From: Joshua Lester <127695056+Joshua-Lester3@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:38:28 -0800 Subject: [PATCH 06/16] Update EssentialCSharp.Web.Tests/WebApplicationFactory.cs Co-authored-by: Kevin B --- EssentialCSharp.Web.Tests/WebApplicationFactory.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 2acd0427..28508d7f 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -44,12 +44,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) db.Database.EnsureCreated(); // Replace IListingSourceCodeService with one backed by TestData - ServiceDescriptor? listingDescriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(IListingSourceCodeService)); - if (listingDescriptor != null) - { - services.Remove(listingDescriptor); - } + builder.Services.RemoveAll(); string testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); var fileProvider = new PhysicalFileProvider(testDataPath); From 6e2d455f3156d6186346fc2c6f14ba65918ad731 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 10:42:29 -0800 Subject: [PATCH 07/16] refactor: convert ListingSourceCodeResponse to a record class for improved immutability and simplicity --- .../Models/ListingSourceCodeResponse.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs b/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs index 95b2c742..ceda8076 100644 --- a/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs +++ b/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs @@ -1,9 +1,7 @@ namespace EssentialCSharp.Web.Models; -public class ListingSourceCodeResponse -{ - public int ChapterNumber { get; set; } - public int ListingNumber { get; set; } - public string FileExtension { get; set; } = string.Empty; - public string Content { get; set; } = string.Empty; -} +public record class ListingSourceCodeResponse( + int ChapterNumber, + int ListingNumber, + string FileExtension = string.Empty, + string Content = string.Empty); \ No newline at end of file From 30a3fd518e286bfd4c41a7831dea63bff089dbb9 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 11:07:51 -0800 Subject: [PATCH 08/16] bug fix: Source code response syntax and instantiation --- .../Models/ListingSourceCodeResponse.cs | 4 +-- .../Services/ListingSourceCodeService.cs | 26 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs b/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs index ceda8076..ac4c3a7e 100644 --- a/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs +++ b/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs @@ -3,5 +3,5 @@ namespace EssentialCSharp.Web.Models; public record class ListingSourceCodeResponse( int ChapterNumber, int ListingNumber, - string FileExtension = string.Empty, - string Content = string.Empty); \ No newline at end of file + string FileExtension = "", + string Content = ""); \ No newline at end of file diff --git a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs index b858af71..e81c14e9 100644 --- a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs +++ b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs @@ -42,13 +42,12 @@ public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger< string content = await ReadFileContentAsync(matchingFile); string extension = Path.GetExtension(matchingFile.Name).TrimStart('.'); - return new ListingSourceCodeResponse - { - ChapterNumber = chapterNumber, - ListingNumber = listingNumber, - FileExtension = extension, - Content = content - }; + return new ListingSourceCodeResponse( + chapterNumber, + listingNumber, + extension, + content + ); } public async Task> GetListingsByChapterAsync(int chapterNumber) @@ -85,13 +84,12 @@ public async Task> GetListingsByChapter { string content = await ReadFileContentAsync(item.File); - results.Add(new ListingSourceCodeResponse - { - ChapterNumber = item.ChapterNumber, - ListingNumber = item.ListingNumber, - FileExtension = item.Extension, - Content = content - }); + results.Add(new ListingSourceCodeResponse( + item.ChapterNumber, + item.ListingNumber, + item.Extension, + content + )); } return results.OrderBy(r => r.ListingNumber).ToList(); From e7ebdffc9e7fbcf149a3b4c401a036cd4ac48634 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 11:10:56 -0800 Subject: [PATCH 09/16] fix: correct service removal in WebApplicationFactory for test data integration --- EssentialCSharp.Web.Tests/WebApplicationFactory.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 28508d7f..6f92ef29 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -5,6 +5,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; @@ -44,7 +45,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) db.Database.EnsureCreated(); // Replace IListingSourceCodeService with one backed by TestData - builder.Services.RemoveAll(); + services.RemoveAll(); string testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); var fileProvider = new PhysicalFileProvider(testDataPath); From d1e638e137e0e2d77d9b4f2abbebe2f88d281d30 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 11:19:14 -0800 Subject: [PATCH 10/16] refactor: update CreateService and GetTestDataPath methods to use DirectoryInfo for improved type safety --- .../ListingSourceCodeServiceTests.cs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs index 7f42210d..b18a59ab 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -120,28 +120,29 @@ public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList( private static ListingSourceCodeService CreateService() { - string testDataRoot = GetTestDataPath(); + DirectoryInfo testDataRoot = GetTestDataPath(); var mockWebHostEnvironment = new Mock(); - mockWebHostEnvironment.Setup(m => m.ContentRootPath).Returns(testDataRoot); - mockWebHostEnvironment.Setup(m => m.ContentRootFileProvider).Returns(new PhysicalFileProvider(testDataRoot)); + mockWebHostEnvironment.Setup(m => m.ContentRootPath).Returns(testDataRoot.FullName); + mockWebHostEnvironment.Setup(m => m.ContentRootFileProvider).Returns(new PhysicalFileProvider(testDataRoot.FullName)); var mockLogger = new Mock>(); return new ListingSourceCodeService(mockWebHostEnvironment.Object, mockLogger.Object); } - private static string GetTestDataPath() + private static DirectoryInfo GetTestDataPath() { - // Get the test project directory and navigate to TestData folder - string currentDirectory = Directory.GetCurrentDirectory(); - string testDataPath = Path.Combine(currentDirectory, "TestData"); + string baseDirectory = AppContext.BaseDirectory; + string testDataPath = Path.Combine(baseDirectory, "TestData"); - if (!Directory.Exists(testDataPath)) + DirectoryInfo testDataDirectory = new(testDataPath); + + if (!testDataDirectory.Exists) { - throw new InvalidOperationException($"TestData directory not found at: {testDataPath}"); + throw new InvalidOperationException($"TestData directory not found at: {testDataDirectory.FullName}"); } - return testDataPath; + return testDataDirectory; } } From 4ddcc59bd8c0db29cff3d6fe7a20d3c77efab702 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 11:28:08 -0800 Subject: [PATCH 11/16] refactor: temporarily (possibly use AutoMoq after I research it) replace TestWebHostEnvironment with Moq for IWebHostEnvironment in WebApplicationFactory --- .../WebApplicationFactory.cs | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 6f92ef29..4d95173f 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; +using Moq; namespace EssentialCSharp.Web.Tests; @@ -51,9 +52,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var fileProvider = new PhysicalFileProvider(testDataPath); services.AddSingleton(sp => { - var mockEnv = new TestWebHostEnvironment(testDataPath, fileProvider); + var mockEnv = new Mock(); + mockEnv.Setup(m => m.ContentRootPath).Returns(testDataPath); + mockEnv.Setup(m => m.ContentRootFileProvider).Returns(fileProvider); var logger = sp.GetRequiredService>(); - return new ListingSourceCodeService(mockEnv, logger); + return new ListingSourceCodeService(mockEnv.Object, logger); }); }); } @@ -92,22 +95,3 @@ protected override void Dispose(bool disposing) } } } - -/// -/// Minimal IWebHostEnvironment implementation that redirects ContentRoot to the TestData directory. -/// -internal sealed class TestWebHostEnvironment : IWebHostEnvironment -{ - public TestWebHostEnvironment(string contentRootPath, IFileProvider contentRootFileProvider) - { - ContentRootPath = contentRootPath; - ContentRootFileProvider = contentRootFileProvider; - } - - public string ContentRootPath { get; set; } - public IFileProvider ContentRootFileProvider { get; set; } - public string WebRootPath { get; set; } = string.Empty; - public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); - public string EnvironmentName { get; set; } = "Testing"; - public string ApplicationName { get; set; } = "EssentialCSharp.Web"; -} From 76c08f4af4dd5e13d04bda1e261dcb26ab1fb348 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 12:54:10 -0800 Subject: [PATCH 12/16] refactor: update test project to use Moq.AutoMock for improved mocking capabilities --- Directory.Packages.props | 1 + .../EssentialCSharp.Web.Tests.csproj | 2 +- .../ListingSourceCodeServiceTests.cs | 13 ++++++------- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 65a63e13..961af558 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,6 +45,7 @@ + diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj index 8ca8e026..6b6690d5 100644 --- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj +++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj @@ -15,7 +15,7 @@ - + diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs index b18a59ab..f4a065ea 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -2,8 +2,8 @@ using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Logging; using Moq; +using Moq.AutoMock; namespace EssentialCSharp.Web.Tests; @@ -121,14 +121,13 @@ public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList( private static ListingSourceCodeService CreateService() { DirectoryInfo testDataRoot = GetTestDataPath(); - - var mockWebHostEnvironment = new Mock(); + + AutoMocker mocker = new(); + Mock mockWebHostEnvironment = mocker.GetMock(); mockWebHostEnvironment.Setup(m => m.ContentRootPath).Returns(testDataRoot.FullName); mockWebHostEnvironment.Setup(m => m.ContentRootFileProvider).Returns(new PhysicalFileProvider(testDataRoot.FullName)); - - var mockLogger = new Mock>(); - - return new ListingSourceCodeService(mockWebHostEnvironment.Object, mockLogger.Object); + + return mocker.CreateInstance(); } private static DirectoryInfo GetTestDataPath() From 2b1afe12a52638004e901d352644b718b80c603d Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 13:10:31 -0800 Subject: [PATCH 13/16] refactor: replace Mock with AutoMocker for IWebHostEnvironment in WebApplicationFactory --- EssentialCSharp.Web.Tests/WebApplicationFactory.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 4d95173f..2a378a67 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -7,8 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Logging; -using Moq; +using Moq.AutoMock; namespace EssentialCSharp.Web.Tests; @@ -52,11 +51,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var fileProvider = new PhysicalFileProvider(testDataPath); services.AddSingleton(sp => { - var mockEnv = new Mock(); - mockEnv.Setup(m => m.ContentRootPath).Returns(testDataPath); - mockEnv.Setup(m => m.ContentRootFileProvider).Returns(fileProvider); - var logger = sp.GetRequiredService>(); - return new ListingSourceCodeService(mockEnv.Object, logger); + var mocker = new AutoMocker(); + mocker.Setup(m => m.ContentRootPath).Returns(testDataPath); + mocker.Setup(m => m.ContentRootFileProvider).Returns(fileProvider); + return mocker.CreateInstance(); }); }); } From 168c8d5b28f8d1131f12e0e4b50e6f0ff3f5383f Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 12 Feb 2026 13:15:32 -0800 Subject: [PATCH 14/16] fix: accidentally removed system.data.common, added back here --- EssentialCSharp.Web.Tests/WebApplicationFactory.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 2a378a67..80d65c92 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -1,4 +1,5 @@ -using EssentialCSharp.Web.Data; +using System.Data.Common; +using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; From f4c065b3aacf039e528afed35b91487d610962ea Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Mon, 23 Feb 2026 13:10:35 -0800 Subject: [PATCH 15/16] Enhance TryDotNet integration with runnable listings and code scaffolding improvements - Load runnable listings from chapter-listings.json and build a Set of valid listings. - Implement functions to check if a listing is runnable and to strip region directives from code. - Update createScaffolding to prepend common using directives and handle user code more effectively. - Modify isCompleteProgram to recognize any class with a static Main method as complete. - Inject Run buttons only for listings present in the curated JSON, improving user experience. --- .../wwwroot/js/chapter-listings.json | 2524 +++++++++++++++++ .../wwwroot/js/trydotnet-module.js | 149 +- 2 files changed, 2639 insertions(+), 34 deletions(-) create mode 100644 EssentialCSharp.Web/wwwroot/js/chapter-listings.json diff --git a/EssentialCSharp.Web/wwwroot/js/chapter-listings.json b/EssentialCSharp.Web/wwwroot/js/chapter-listings.json new file mode 100644 index 00000000..fcc93526 --- /dev/null +++ b/EssentialCSharp.Web/wwwroot/js/chapter-listings.json @@ -0,0 +1,2524 @@ +{ + "description": "Essential C# Listing Source Code Files", + "generated_by": "generate_listings_json.py", + "chapters": { + "Chapter01": [ + { + "filename": "01.01.cs", + "can_run": true + }, + { + "filename": "01.03.cs", + "can_run": true + }, + { + "filename": "01.04.cs", + "can_run": true + }, + { + "filename": "01.05.cs", + "can_run": true + }, + { + "filename": "01.06.cs", + "can_run": true + }, + { + "filename": "01.07.cs", + "can_run": true + }, + { + "filename": "01.08.cs", + "can_run": true + }, + { + "filename": "01.09.cs", + "can_run": true + }, + { + "filename": "01.10.cs", + "can_run": true + }, + { + "filename": "01.11.cs", + "can_run": true + }, + { + "filename": "01.12.cs", + "can_run": true + }, + { + "filename": "01.13.cs", + "can_run": true + }, + { + "filename": "01.14.cs", + "can_run": true + }, + { + "filename": "01.15.cs", + "can_run": true + }, + { + "filename": "01.16.cs", + "can_run": true + }, + { + "filename": "01.17.cs", + "can_run": true + }, + { + "filename": "01.18.cs", + "can_run": true + }, + { + "filename": "01.19.cs", + "can_run": true + }, + { + "filename": "01.20.cs", + "can_run": true + }, + { + "filename": "01.21.cs", + "can_run": true + }, + { + "filename": "01.22.cs", + "can_run": true + } + ], + "Chapter02": [ + { + "filename": "02.01.cs", + "can_run": true + }, + { + "filename": "02.02.cs", + "can_run": true + }, + { + "filename": "02.03.cs", + "can_run": true + }, + { + "filename": "02.04.cs", + "can_run": true + }, + { + "filename": "02.05.cs", + "can_run": true + }, + { + "filename": "02.06.cs", + "can_run": true + }, + { + "filename": "02.07.cs", + "can_run": true + }, + { + "filename": "02.08.cs", + "can_run": true + }, + { + "filename": "02.09.cs", + "can_run": true + }, + { + "filename": "02.10.cs", + "can_run": true + }, + { + "filename": "02.11.cs", + "can_run": true + }, + { + "filename": "02.12.cs", + "can_run": true + }, + { + "filename": "02.13.cs", + "can_run": true + }, + { + "filename": "02.14.cs", + "can_run": true + }, + { + "filename": "02.15.cs", + "can_run": true + }, + { + "filename": "02.16.cs", + "can_run": true + }, + { + "filename": "02.17.cs", + "can_run": true + }, + { + "filename": "02.18.cs", + "can_run": true + }, + { + "filename": "02.19.cs", + "can_run": true + }, + { + "filename": "02.20.cs", + "can_run": true + }, + { + "filename": "02.21.cs", + "can_run": true + }, + { + "filename": "02.22.cs", + "can_run": true + }, + { + "filename": "02.23.cs", + "can_run": true + }, + { + "filename": "02.24.cs", + "can_run": true + }, + { + "filename": "02.25.cs", + "can_run": true + }, + { + "filename": "02.26.cs", + "can_run": true + }, + { + "filename": "02.27.cs", + "can_run": true + }, + { + "filename": "02.28.cs", + "can_run": true + }, + { + "filename": "02.29.cs", + "can_run": true + }, + { + "filename": "02.30.cs", + "can_run": true + }, + { + "filename": "02.31.cs", + "can_run": true + }, + { + "filename": "02.32.cs", + "can_run": true + }, + { + "filename": "02.33.cs", + "can_run": true + }, + { + "filename": "02.34.cs", + "can_run": true + }, + { + "filename": "02.35.cs", + "can_run": true + }, + { + "filename": "02.36.cs", + "can_run": true + } + ], + "Chapter03": [ + { + "filename": "03.01.cs", + "can_run": true + }, + { + "filename": "03.02.cs", + "can_run": true + }, + { + "filename": "03.03.cs", + "can_run": true + }, + { + "filename": "03.04.cs", + "can_run": true + }, + { + "filename": "03.05.cs", + "can_run": true + }, + { + "filename": "03.06.cs", + "can_run": true + }, + { + "filename": "03.07.cs", + "can_run": true + }, + { + "filename": "03.08.cs", + "can_run": true + }, + { + "filename": "03.09.cs", + "can_run": true + }, + { + "filename": "03.10.cs", + "can_run": true + }, + { + "filename": "03.11.cs", + "can_run": true + }, + { + "filename": "03.12.cs", + "can_run": true + }, + { + "filename": "03.13.cs", + "can_run": true + }, + { + "filename": "03.14.cs", + "can_run": true + }, + { + "filename": "03.15.cs", + "can_run": true + }, + { + "filename": "03.16.cs", + "can_run": true + }, + { + "filename": "03.17.cs", + "can_run": true + }, + { + "filename": "03.18.cs", + "can_run": true + }, + { + "filename": "03.19.cs", + "can_run": true + }, + { + "filename": "03.20.cs", + "can_run": true + }, + { + "filename": "03.21.cs", + "can_run": true + }, + { + "filename": "03.22.cs", + "can_run": true + }, + { + "filename": "03.23.cs", + "can_run": true + }, + { + "filename": "03.24.cs", + "can_run": true + }, + { + "filename": "03.25.cs", + "can_run": true + }, + { + "filename": "03.26.cs", + "can_run": true + }, + { + "filename": "03.27.cs", + "can_run": true + }, + { + "filename": "03.28.cs", + "can_run": true + }, + { + "filename": "03.29.cs", + "can_run": true + }, + { + "filename": "03.30.cs", + "can_run": true + }, + { + "filename": "03.31.cs", + "can_run": true + } + ], + "Chapter04": [ + { + "filename": "04.01.cs", + "can_run": true + }, + { + "filename": "04.02.cs", + "can_run": true + }, + { + "filename": "04.03.cs", + "can_run": true + }, + { + "filename": "04.04.cs", + "can_run": true + }, + { + "filename": "04.05.cs", + "can_run": true + }, + { + "filename": "04.06.cs", + "can_run": true + }, + { + "filename": "04.07.cs", + "can_run": true + }, + { + "filename": "04.08.cs", + "can_run": true + }, + { + "filename": "04.09.cs", + "can_run": true + }, + { + "filename": "04.10.cs", + "can_run": true + }, + { + "filename": "04.11.cs", + "can_run": true + }, + { + "filename": "04.12.cs", + "can_run": true + }, + { + "filename": "04.13.cs", + "can_run": true + }, + { + "filename": "04.14.cs", + "can_run": true + }, + { + "filename": "04.15.cs", + "can_run": true + }, + { + "filename": "04.16.cs", + "can_run": true + }, + { + "filename": "04.17.cs", + "can_run": true + }, + { + "filename": "04.18.cs", + "can_run": true + }, + { + "filename": "04.19.cs", + "can_run": true + }, + { + "filename": "04.20.cs", + "can_run": true + }, + { + "filename": "04.21.cs", + "can_run": true + }, + { + "filename": "04.22.cs", + "can_run": true + }, + { + "filename": "04.23.cs", + "can_run": true + }, + { + "filename": "04.24.cs", + "can_run": true + }, + { + "filename": "04.25.cs", + "can_run": true + }, + { + "filename": "04.26.cs", + "can_run": true + }, + { + "filename": "04.27.cs", + "can_run": true + }, + { + "filename": "04.28.cs", + "can_run": true + }, + { + "filename": "04.29.cs", + "can_run": true + }, + { + "filename": "04.30.cs", + "can_run": true + }, + { + "filename": "04.31.cs", + "can_run": true + }, + { + "filename": "04.32.cs", + "can_run": true + }, + { + "filename": "04.33.cs", + "can_run": true + }, + { + "filename": "04.34.cs", + "can_run": true + }, + { + "filename": "04.35.cs", + "can_run": true + }, + { + "filename": "04.36.cs", + "can_run": true + }, + { + "filename": "04.37.cs", + "can_run": true + }, + { + "filename": "04.38.cs", + "can_run": true + }, + { + "filename": "04.39.cs", + "can_run": true + }, + { + "filename": "04.40.cs", + "can_run": true + }, + { + "filename": "04.41.cs", + "can_run": true + }, + { + "filename": "04.42.cs", + "can_run": true + }, + { + "filename": "04.43.cs", + "can_run": true + }, + { + "filename": "04.44.cs", + "can_run": true + }, + { + "filename": "04.45.cs", + "can_run": true + }, + { + "filename": "04.46.cs", + "can_run": true + }, + { + "filename": "04.47.cs", + "can_run": true + }, + { + "filename": "04.48.cs", + "can_run": true + }, + { + "filename": "04.49.cs", + "can_run": true + }, + { + "filename": "04.50.cs", + "can_run": true + }, + { + "filename": "04.51.cs", + "can_run": true + }, + { + "filename": "04.52.cs", + "can_run": true + }, + { + "filename": "04.53.cs", + "can_run": true + }, + { + "filename": "04.54.cs", + "can_run": true + }, + { + "filename": "04.55.cs", + "can_run": true + }, + { + "filename": "04.56.cs", + "can_run": true + }, + { + "filename": "04.57.cs", + "can_run": true + }, + { + "filename": "04.58.cs", + "can_run": true + }, + { + "filename": "04.59.cs", + "can_run": true + }, + { + "filename": "04.60.cs", + "can_run": true + }, + { + "filename": "04.61.cs", + "can_run": true + }, + { + "filename": "04.62.cs", + "can_run": true + }, + { + "filename": "04.63.cs", + "can_run": true + } + ], + "Chapter05": [ + { + "filename": "05.01.cs", + "can_run": true + }, + { + "filename": "05.02.cs", + "can_run": true + }, + { + "filename": "05.03.cs", + "can_run": true + }, + { + "filename": "05.04.cs", + "can_run": true + }, + { + "filename": "05.05.cs", + "can_run": true + }, + { + "filename": "05.06.cs", + "can_run": true + }, + { + "filename": "05.07.cs", + "can_run": true + }, + { + "filename": "05.08.cs", + "can_run": true + }, + { + "filename": "05.09.cs", + "can_run": true + }, + { + "filename": "05.10.cs", + "can_run": true + }, + { + "filename": "05.12.cs", + "can_run": true + }, + { + "filename": "05.13.cs", + "can_run": true + }, + { + "filename": "05.14.cs", + "can_run": true + }, + { + "filename": "05.15.cs", + "can_run": true + }, + { + "filename": "05.16.cs", + "can_run": true + }, + { + "filename": "05.17.cs", + "can_run": true + }, + { + "filename": "05.18.cs", + "can_run": true + }, + { + "filename": "05.19.cs", + "can_run": true + }, + { + "filename": "05.20.cs", + "can_run": true + }, + { + "filename": "05.21.cs", + "can_run": true + }, + { + "filename": "05.22.cs", + "can_run": true + }, + { + "filename": "05.23.cs", + "can_run": true + }, + { + "filename": "05.24.cs", + "can_run": true + }, + { + "filename": "05.25.cs", + "can_run": true + }, + { + "filename": "05.26.cs", + "can_run": true + }, + { + "filename": "05.27.cs", + "can_run": true + }, + { + "filename": "05.28.cs", + "can_run": true + }, + { + "filename": "05.29.cs", + "can_run": true + }, + { + "filename": "05.30.cs", + "can_run": true + }, + { + "filename": "05.31.cs", + "can_run": true + }, + { + "filename": "05.32.cs", + "can_run": true + }, + { + "filename": "05.33.cs", + "can_run": true + }, + { + "filename": "05.34.cs", + "can_run": true + } + ], + "Chapter06": [ + { + "filename": "06.01.cs", + "can_run": true + }, + { + "filename": "06.02.cs", + "can_run": true + }, + { + "filename": "06.03.cs", + "can_run": true + }, + { + "filename": "06.04.cs", + "can_run": true + }, + { + "filename": "06.05.cs", + "can_run": true + }, + { + "filename": "06.06.cs", + "can_run": true + }, + { + "filename": "06.07.cs", + "can_run": true + }, + { + "filename": "06.08.cs", + "can_run": true + }, + { + "filename": "06.09.cs", + "can_run": true + }, + { + "filename": "06.10.cs", + "can_run": true + }, + { + "filename": "06.11.cs", + "can_run": true + }, + { + "filename": "06.12.cs", + "can_run": true + }, + { + "filename": "06.13.cs", + "can_run": true + }, + { + "filename": "06.14.cs", + "can_run": true + }, + { + "filename": "06.15.cs", + "can_run": true + }, + { + "filename": "06.16.cs", + "can_run": true + }, + { + "filename": "06.17.cs", + "can_run": true + }, + { + "filename": "06.18.cs", + "can_run": true + }, + { + "filename": "06.19.cs", + "can_run": true + }, + { + "filename": "06.20.cs", + "can_run": true + }, + { + "filename": "06.21.cs", + "can_run": true + }, + { + "filename": "06.22.cs", + "can_run": true + }, + { + "filename": "06.23.cs", + "can_run": true + }, + { + "filename": "06.24.cs", + "can_run": true + }, + { + "filename": "06.25.cs", + "can_run": true + }, + { + "filename": "06.26.cs", + "can_run": true + }, + { + "filename": "06.27.cs", + "can_run": true + }, + { + "filename": "06.28.cs", + "can_run": true + }, + { + "filename": "06.29.cs", + "can_run": true + }, + { + "filename": "06.30.cs", + "can_run": true + }, + { + "filename": "06.31.cs", + "can_run": true + }, + { + "filename": "06.32.cs", + "can_run": true + }, + { + "filename": "06.33.cs", + "can_run": true + }, + { + "filename": "06.34.cs", + "can_run": true + }, + { + "filename": "06.35.cs", + "can_run": true + }, + { + "filename": "06.36.cs", + "can_run": true + }, + { + "filename": "06.37.cs", + "can_run": true + }, + { + "filename": "06.38.cs", + "can_run": true + }, + { + "filename": "06.39.cs", + "can_run": true + }, + { + "filename": "06.40.cs", + "can_run": true + }, + { + "filename": "06.41.cs", + "can_run": true + }, + { + "filename": "06.42.cs", + "can_run": true + }, + { + "filename": "06.43.cs", + "can_run": true + }, + { + "filename": "06.44.cs", + "can_run": true + }, + { + "filename": "06.45.cs", + "can_run": true + }, + { + "filename": "06.46.cs", + "can_run": true + }, + { + "filename": "06.47.cs", + "can_run": true + }, + { + "filename": "06.48.cs", + "can_run": true + }, + { + "filename": "06.49.cs", + "can_run": true + }, + { + "filename": "06.50.cs", + "can_run": true + }, + { + "filename": "06.51.cs", + "can_run": true + }, + { + "filename": "06.52.cs", + "can_run": true + }, + { + "filename": "06.53.cs", + "can_run": true + }, + { + "filename": "06.54.cs", + "can_run": true + }, + { + "filename": "06.55.cs", + "can_run": true + }, + { + "filename": "06.56.cs", + "can_run": true + }, + { + "filename": "06.57.cs", + "can_run": true + }, + { + "filename": "06.58.cs", + "can_run": true + }, + { + "filename": "06.59.cs", + "can_run": true + } + ], + "Chapter07": [ + { + "filename": "07.01.cs", + "can_run": true + }, + { + "filename": "07.02.cs", + "can_run": true + }, + { + "filename": "07.03.cs", + "can_run": true + }, + { + "filename": "07.04.cs", + "can_run": true + }, + { + "filename": "07.05.cs", + "can_run": true + }, + { + "filename": "07.06.cs", + "can_run": true + }, + { + "filename": "07.07.cs", + "can_run": true + }, + { + "filename": "07.08.cs", + "can_run": true + }, + { + "filename": "07.09.cs", + "can_run": true + }, + { + "filename": "07.10.cs", + "can_run": true + }, + { + "filename": "07.11.cs", + "can_run": true + }, + { + "filename": "07.12.cs", + "can_run": true + }, + { + "filename": "07.13.cs", + "can_run": true + }, + { + "filename": "07.14.cs", + "can_run": true + }, + { + "filename": "07.15.cs", + "can_run": true + }, + { + "filename": "07.16.cs", + "can_run": true + }, + { + "filename": "07.17.cs", + "can_run": true + }, + { + "filename": "07.18.cs", + "can_run": true + }, + { + "filename": "07.19.cs", + "can_run": true + }, + { + "filename": "07.20.cs", + "can_run": true + }, + { + "filename": "07.21.cs", + "can_run": true + }, + { + "filename": "07.22.cs", + "can_run": true + }, + { + "filename": "07.23.cs", + "can_run": true + }, + { + "filename": "07.24.cs", + "can_run": true + }, + { + "filename": "07.25.cs", + "can_run": true + }, + { + "filename": "07.26.cs", + "can_run": true + }, + { + "filename": "07.27.cs", + "can_run": true + }, + { + "filename": "07.28.cs", + "can_run": true + }, + { + "filename": "07.29.cs", + "can_run": true + }, + { + "filename": "07.30.cs", + "can_run": true + }, + { + "filename": "07.31.cs", + "can_run": true + }, + { + "filename": "07.32.cs", + "can_run": true + }, + { + "filename": "07.33.cs", + "can_run": true + }, + { + "filename": "07.34.cs", + "can_run": true + } + ], + "Chapter08": [ + { + "filename": "08.01.cs", + "can_run": true + }, + { + "filename": "08.02.cs", + "can_run": true + }, + { + "filename": "08.03.cs", + "can_run": true + }, + { + "filename": "08.04.cs", + "can_run": true + }, + { + "filename": "08.05.cs", + "can_run": true + }, + { + "filename": "08.06.cs", + "can_run": true + }, + { + "filename": "08.07.cs", + "can_run": true + }, + { + "filename": "08.08.cs", + "can_run": true + }, + { + "filename": "08.09.cs", + "can_run": true + }, + { + "filename": "08.10.cs", + "can_run": true + }, + { + "filename": "08.11.cs", + "can_run": true + }, + { + "filename": "08.12.cs", + "can_run": true + }, + { + "filename": "08.13.cs", + "can_run": true + }, + { + "filename": "08.14.cs", + "can_run": true + } + ], + "Chapter09": [ + { + "filename": "09.01.cs", + "can_run": true + }, + { + "filename": "09.02.cs", + "can_run": true + }, + { + "filename": "09.03.cs", + "can_run": true + }, + { + "filename": "09.04.cs", + "can_run": true + }, + { + "filename": "09.05.cs", + "can_run": true + }, + { + "filename": "09.06.cs", + "can_run": true + }, + { + "filename": "09.07.cs", + "can_run": true + }, + { + "filename": "09.08.cs", + "can_run": true + }, + { + "filename": "09.09.cs", + "can_run": true + }, + { + "filename": "09.10.cs", + "can_run": true + }, + { + "filename": "09.11.cs", + "can_run": true + }, + { + "filename": "09.12.cs", + "can_run": true + }, + { + "filename": "09.13.cs", + "can_run": true + }, + { + "filename": "09.14.cs", + "can_run": true + }, + { + "filename": "09.15.cs", + "can_run": true + }, + { + "filename": "09.16.cs", + "can_run": true + }, + { + "filename": "09.17.cs", + "can_run": true + }, + { + "filename": "09.18.cs", + "can_run": true + }, + { + "filename": "09.19.cs", + "can_run": true + }, + { + "filename": "09.20.cs", + "can_run": true + }, + { + "filename": "09.21.cs", + "can_run": true + }, + { + "filename": "09.22.cs", + "can_run": true + }, + { + "filename": "09.23.cs", + "can_run": true + }, + { + "filename": "09.24.cs", + "can_run": true + }, + { + "filename": "09.25.cs", + "can_run": true + }, + { + "filename": "09.26.cs", + "can_run": true + }, + { + "filename": "09.27.cs", + "can_run": true + }, + { + "filename": "09.28.cs", + "can_run": true + }, + { + "filename": "09.29.cs", + "can_run": true + }, + { + "filename": "09.30.cs", + "can_run": true + }, + { + "filename": "09.31.cs", + "can_run": true + }, + { + "filename": "09.32.cs", + "can_run": true + }, + { + "filename": "09.33.cs", + "can_run": true + }, + { + "filename": "09.34.cs", + "can_run": true + }, + { + "filename": "09.35.cs", + "can_run": true + }, + { + "filename": "09.36.cs", + "can_run": true + }, + { + "filename": "09.37.cs", + "can_run": true + }, + { + "filename": "09.38.cs", + "can_run": true + } + ], + "Chapter10": [ + { + "filename": "10.01.cs", + "can_run": true + }, + { + "filename": "10.02.cs", + "can_run": true + }, + { + "filename": "10.03.cs", + "can_run": true + }, + { + "filename": "10.04.cs", + "can_run": true + }, + { + "filename": "10.05.cs", + "can_run": true + }, + { + "filename": "10.06.cs", + "can_run": true + }, + { + "filename": "10.07.cs", + "can_run": true + }, + { + "filename": "10.08.cs", + "can_run": true + }, + { + "filename": "10.09.cs", + "can_run": true + }, + { + "filename": "10.10.cs", + "can_run": true + }, + { + "filename": "10.11.cs", + "can_run": true + }, + { + "filename": "10.12.cs", + "can_run": true + }, + { + "filename": "10.14.cs", + "can_run": true + }, + { + "filename": "10.15.cs", + "can_run": true + }, + { + "filename": "10.16.cs", + "can_run": true + }, + { + "filename": "10.17.cs", + "can_run": true + }, + { + "filename": "10.18.cs", + "can_run": true + }, + { + "filename": "10.19.cs", + "can_run": true + }, + { + "filename": "10.20.cs", + "can_run": true + } + ], + "Chapter11": [ + { + "filename": "11.01.cs", + "can_run": true + }, + { + "filename": "11.02.cs", + "can_run": true + }, + { + "filename": "11.03.cs", + "can_run": true + }, + { + "filename": "11.04.cs", + "can_run": true + }, + { + "filename": "11.05.cs", + "can_run": true + }, + { + "filename": "11.06.cs", + "can_run": true + }, + { + "filename": "11.07.cs", + "can_run": true + }, + { + "filename": "11.08.cs", + "can_run": true + } + ], + "Chapter12": [ + { + "filename": "12.01.cs", + "can_run": true + }, + { + "filename": "12.02.cs", + "can_run": true + }, + { + "filename": "12.03.cs", + "can_run": true + }, + { + "filename": "12.04.cs", + "can_run": true + }, + { + "filename": "12.05.cs", + "can_run": true + }, + { + "filename": "12.06.cs", + "can_run": true + }, + { + "filename": "12.07.cs", + "can_run": true + }, + { + "filename": "12.08.cs", + "can_run": true + }, + { + "filename": "12.09.cs", + "can_run": true + }, + { + "filename": "12.10.cs", + "can_run": true + }, + { + "filename": "12.11.cs", + "can_run": true + }, + { + "filename": "12.12.cs", + "can_run": true + }, + { + "filename": "12.13.cs", + "can_run": true + }, + { + "filename": "12.14.cs", + "can_run": true + }, + { + "filename": "12.15.cs", + "can_run": true + }, + { + "filename": "12.16.cs", + "can_run": true + }, + { + "filename": "12.17.cs", + "can_run": true + }, + { + "filename": "12.18.cs", + "can_run": true + }, + { + "filename": "12.19.cs", + "can_run": true + }, + { + "filename": "12.20.cs", + "can_run": true + }, + { + "filename": "12.21.cs", + "can_run": true + }, + { + "filename": "12.22.cs", + "can_run": true + }, + { + "filename": "12.23.cs", + "can_run": true + }, + { + "filename": "12.24.cs", + "can_run": true + }, + { + "filename": "12.25.cs", + "can_run": true + }, + { + "filename": "12.26.cs", + "can_run": true + }, + { + "filename": "12.27.cs", + "can_run": true + }, + { + "filename": "12.28.cs", + "can_run": true + }, + { + "filename": "12.29.cs", + "can_run": true + }, + { + "filename": "12.30.cs", + "can_run": true + }, + { + "filename": "12.31.cs", + "can_run": true + }, + { + "filename": "12.32.cs", + "can_run": true + }, + { + "filename": "12.33.cs", + "can_run": true + }, + { + "filename": "12.34.cs", + "can_run": true + }, + { + "filename": "12.35.cs", + "can_run": true + }, + { + "filename": "12.36.cs", + "can_run": true + }, + { + "filename": "12.37.cs", + "can_run": true + }, + { + "filename": "12.38.cs", + "can_run": true + }, + { + "filename": "12.39.cs", + "can_run": true + }, + { + "filename": "12.40.cs", + "can_run": true + }, + { + "filename": "12.41.cs", + "can_run": true + }, + { + "filename": "12.42.cs", + "can_run": true + }, + { + "filename": "12.43.cs", + "can_run": true + }, + { + "filename": "12.44.cs", + "can_run": true + }, + { + "filename": "12.45.cs", + "can_run": true + }, + { + "filename": "12.46.cs", + "can_run": true + }, + { + "filename": "12.47.cs", + "can_run": true + }, + { + "filename": "12.48.cs", + "can_run": true + }, + { + "filename": "12.49.cs", + "can_run": true + }, + { + "filename": "12.50.cs", + "can_run": true + } + ], + "Chapter13": [ + { + "filename": "13.01.cs", + "can_run": true + }, + { + "filename": "13.02.cs", + "can_run": true + }, + { + "filename": "13.03.cs", + "can_run": true + }, + { + "filename": "13.04.cs", + "can_run": true + }, + { + "filename": "13.05.cs", + "can_run": true + }, + { + "filename": "13.06.cs", + "can_run": true + }, + { + "filename": "13.07.cs", + "can_run": true + }, + { + "filename": "13.08.cs", + "can_run": true + }, + { + "filename": "13.09.cs", + "can_run": true + }, + { + "filename": "13.10.cs", + "can_run": true + }, + { + "filename": "13.11.cs", + "can_run": true + }, + { + "filename": "13.12.cs", + "can_run": true + }, + { + "filename": "13.13.cs", + "can_run": true + }, + { + "filename": "13.14.cs", + "can_run": true + }, + { + "filename": "13.15.cs", + "can_run": true + }, + { + "filename": "13.16.cs", + "can_run": true + }, + { + "filename": "13.17.cs", + "can_run": true + }, + { + "filename": "13.18.cs", + "can_run": true + }, + { + "filename": "13.19.cs", + "can_run": true + }, + { + "filename": "13.20.cs", + "can_run": true + }, + { + "filename": "13.21.cs", + "can_run": true + }, + { + "filename": "13.22.cs", + "can_run": true + }, + { + "filename": "13.23.cs", + "can_run": true + }, + { + "filename": "13.24.cs", + "can_run": true + }, + { + "filename": "13.25.cs", + "can_run": true + }, + { + "filename": "13.26.cs", + "can_run": true + } + ], + "Chapter14": [ + { + "filename": "14.01.cs", + "can_run": true + }, + { + "filename": "14.02.cs", + "can_run": true + }, + { + "filename": "14.03.cs", + "can_run": true + }, + { + "filename": "14.04.cs", + "can_run": true + }, + { + "filename": "14.05.cs", + "can_run": true + }, + { + "filename": "14.06.cs", + "can_run": true + }, + { + "filename": "14.07.cs", + "can_run": true + }, + { + "filename": "14.08.cs", + "can_run": true + }, + { + "filename": "14.09.cs", + "can_run": true + }, + { + "filename": "14.10.cs", + "can_run": true + }, + { + "filename": "14.11.cs", + "can_run": true + }, + { + "filename": "14.12.cs", + "can_run": true + }, + { + "filename": "14.13.cs", + "can_run": true + }, + { + "filename": "14.14.cs", + "can_run": true + }, + { + "filename": "14.15.cs", + "can_run": true + }, + { + "filename": "14.16.cs", + "can_run": true + }, + { + "filename": "14.17.cs", + "can_run": true + } + ], + "Chapter15": [ + { + "filename": "15.01.cs", + "can_run": true + }, + { + "filename": "15.02.cs", + "can_run": true + }, + { + "filename": "15.03.cs", + "can_run": true + }, + { + "filename": "15.04.cs", + "can_run": true + }, + { + "filename": "15.05.cs", + "can_run": true + }, + { + "filename": "15.06.cs", + "can_run": true + }, + { + "filename": "15.07.cs", + "can_run": true + }, + { + "filename": "15.08.cs", + "can_run": true + }, + { + "filename": "15.09.cs", + "can_run": true + }, + { + "filename": "15.10.cs", + "can_run": true + }, + { + "filename": "15.11.cs", + "can_run": true + }, + { + "filename": "15.12.cs", + "can_run": true + }, + { + "filename": "15.13.cs", + "can_run": true + }, + { + "filename": "15.14.cs", + "can_run": true + }, + { + "filename": "15.15.cs", + "can_run": true + }, + { + "filename": "15.16.cs", + "can_run": true + }, + { + "filename": "15.17.cs", + "can_run": true + }, + { + "filename": "15.18.cs", + "can_run": true + }, + { + "filename": "15.19.cs", + "can_run": true + }, + { + "filename": "15.20.cs", + "can_run": true + }, + { + "filename": "15.21.cs", + "can_run": true + }, + { + "filename": "15.22.cs", + "can_run": true + }, + { + "filename": "15.23.cs", + "can_run": true + }, + { + "filename": "15.24.cs", + "can_run": true + }, + { + "filename": "15.25.cs", + "can_run": true + }, + { + "filename": "15.26.cs", + "can_run": true + }, + { + "filename": "15.27.cs", + "can_run": true + }, + { + "filename": "15.28.cs", + "can_run": true + }, + { + "filename": "15.29.cs", + "can_run": true + } + ], + "Chapter16": [ + { + "filename": "16.01.cs", + "can_run": true + }, + { + "filename": "16.02.cs", + "can_run": true + }, + { + "filename": "16.03.cs", + "can_run": true + }, + { + "filename": "16.04.cs", + "can_run": true + }, + { + "filename": "16.05.cs", + "can_run": true + }, + { + "filename": "16.06.cs", + "can_run": true + }, + { + "filename": "16.07.cs", + "can_run": true + }, + { + "filename": "16.08.cs", + "can_run": true + }, + { + "filename": "16.09.cs", + "can_run": true + }, + { + "filename": "16.10.cs", + "can_run": true + }, + { + "filename": "16.11.cs", + "can_run": true + }, + { + "filename": "16.12.cs", + "can_run": true + }, + { + "filename": "16.13.cs", + "can_run": true + }, + { + "filename": "16.14.cs", + "can_run": true + }, + { + "filename": "16.15.cs", + "can_run": true + }, + { + "filename": "16.16.cs", + "can_run": true + }, + { + "filename": "16.17.cs", + "can_run": true + } + ], + "Chapter17": [ + { + "filename": "17.01.cs", + "can_run": true + }, + { + "filename": "17.02.cs", + "can_run": true + }, + { + "filename": "17.03.cs", + "can_run": true + }, + { + "filename": "17.04.cs", + "can_run": true + }, + { + "filename": "17.05.cs", + "can_run": true + }, + { + "filename": "17.06.cs", + "can_run": true + }, + { + "filename": "17.07.cs", + "can_run": true + }, + { + "filename": "17.08.cs", + "can_run": true + }, + { + "filename": "17.09.cs", + "can_run": true + }, + { + "filename": "17.10.cs", + "can_run": true + }, + { + "filename": "17.11.cs", + "can_run": true + }, + { + "filename": "17.12.cs", + "can_run": true + }, + { + "filename": "17.13.cs", + "can_run": true + }, + { + "filename": "17.14.cs", + "can_run": true + }, + { + "filename": "17.15.cs", + "can_run": true + }, + { + "filename": "17.16.cs", + "can_run": true + }, + { + "filename": "17.17.cs", + "can_run": true + }, + { + "filename": "17.18.cs", + "can_run": true + }, + { + "filename": "17.19.cs", + "can_run": true + }, + { + "filename": "17.20.cs", + "can_run": true + }, + { + "filename": "17.21.cs", + "can_run": true + } + ], + "Chapter18": [ + { + "filename": "18.01.cs", + "can_run": true + }, + { + "filename": "18.02.cs", + "can_run": true + }, + { + "filename": "18.03.cs", + "can_run": true + }, + { + "filename": "18.04.cs", + "can_run": true + }, + { + "filename": "18.05.cs", + "can_run": true + }, + { + "filename": "18.06.cs", + "can_run": true + }, + { + "filename": "18.07.cs", + "can_run": true + }, + { + "filename": "18.08.cs", + "can_run": true + }, + { + "filename": "18.09.cs", + "can_run": true + }, + { + "filename": "18.10.cs", + "can_run": true + }, + { + "filename": "18.11.cs", + "can_run": true + }, + { + "filename": "18.12.cs", + "can_run": true + }, + { + "filename": "18.13.cs", + "can_run": true + }, + { + "filename": "18.14.cs", + "can_run": true + }, + { + "filename": "18.15.cs", + "can_run": true + }, + { + "filename": "18.16.cs", + "can_run": true + }, + { + "filename": "18.17.cs", + "can_run": true + }, + { + "filename": "18.18.cs", + "can_run": true + }, + { + "filename": "18.19.cs", + "can_run": true + }, + { + "filename": "18.20.cs", + "can_run": true + }, + { + "filename": "18.21.cs", + "can_run": true + }, + { + "filename": "18.22.cs", + "can_run": true + }, + { + "filename": "18.23.cs", + "can_run": true + }, + { + "filename": "18.24.cs", + "can_run": true + }, + { + "filename": "18.25.cs", + "can_run": true + }, + { + "filename": "18.26.cs", + "can_run": true + }, + { + "filename": "18.27.cs", + "can_run": true + }, + { + "filename": "18.28.cs", + "can_run": true + }, + { + "filename": "18.29.cs", + "can_run": true + }, + { + "filename": "18.30.cs", + "can_run": true + }, + { + "filename": "18.31.cs", + "can_run": true + }, + { + "filename": "18.32.cs", + "can_run": true + }, + { + "filename": "18.33.cs", + "can_run": true + } + ], + "Chapter19": [ + { + "filename": "19.01.cs", + "can_run": true + }, + { + "filename": "19.02.cs", + "can_run": true + }, + { + "filename": "19.03.cs", + "can_run": true + }, + { + "filename": "19.04.cs", + "can_run": true + }, + { + "filename": "19.05.cs", + "can_run": true + }, + { + "filename": "19.06.cs", + "can_run": true + }, + { + "filename": "19.07.cs", + "can_run": true + }, + { + "filename": "19.08.cs", + "can_run": true + }, + { + "filename": "19.09.cs", + "can_run": true + }, + { + "filename": "19.10.cs", + "can_run": true + } + ], + "Chapter20": [ + { + "filename": "20.01.cs", + "can_run": true + }, + { + "filename": "20.02.cs", + "can_run": true + }, + { + "filename": "20.03.cs", + "can_run": true + }, + { + "filename": "20.04.cs", + "can_run": true + }, + { + "filename": "20.05.cs", + "can_run": true + }, + { + "filename": "20.06.cs", + "can_run": true + }, + { + "filename": "20.07.cs", + "can_run": true + }, + { + "filename": "20.08.cs", + "can_run": true + }, + { + "filename": "20.09.cs", + "can_run": true + }, + { + "filename": "20.10.cs", + "can_run": true + }, + { + "filename": "20.11.cs", + "can_run": true + }, + { + "filename": "20.12.cs", + "can_run": true + }, + { + "filename": "20.13.cs", + "can_run": true + }, + { + "filename": "20.14.cs", + "can_run": true + }, + { + "filename": "20.15.cs", + "can_run": true + }, + { + "filename": "20.16.cs", + "can_run": true + } + ], + "Chapter21": [ + { + "filename": "21.01.cs", + "can_run": true + }, + { + "filename": "21.02.cs", + "can_run": true + }, + { + "filename": "21.03.cs", + "can_run": true + }, + { + "filename": "21.04.cs", + "can_run": true + }, + { + "filename": "21.05.cs", + "can_run": true + }, + { + "filename": "21.06.cs", + "can_run": true + }, + { + "filename": "21.07.cs", + "can_run": true + }, + { + "filename": "21.08.cs", + "can_run": true + }, + { + "filename": "21.09.cs", + "can_run": true + }, + { + "filename": "21.10.cs", + "can_run": true + } + ], + "Chapter22": [ + { + "filename": "22.01.cs", + "can_run": true + }, + { + "filename": "22.02.cs", + "can_run": true + }, + { + "filename": "22.03.cs", + "can_run": true + }, + { + "filename": "22.04.cs", + "can_run": true + }, + { + "filename": "22.05.cs", + "can_run": true + }, + { + "filename": "22.06.cs", + "can_run": true + }, + { + "filename": "22.07.cs", + "can_run": true + }, + { + "filename": "22.08.cs", + "can_run": true + }, + { + "filename": "22.09.cs", + "can_run": true + }, + { + "filename": "22.10.cs", + "can_run": true + }, + { + "filename": "22.11.cs", + "can_run": true + }, + { + "filename": "22.12.cs", + "can_run": true + }, + { + "filename": "22.13.cs", + "can_run": true + } + ], + "Chapter23": [ + { + "filename": "23.01.cs", + "can_run": true + }, + { + "filename": "23.02.cs", + "can_run": true + }, + { + "filename": "23.03.cs", + "can_run": true + }, + { + "filename": "23.04.cs", + "can_run": true + }, + { + "filename": "23.05.cs", + "can_run": true + }, + { + "filename": "23.06.cs", + "can_run": true + }, + { + "filename": "23.07.cs", + "can_run": true + }, + { + "filename": "23.08.cs", + "can_run": true + }, + { + "filename": "23.09.cs", + "can_run": true + }, + { + "filename": "23.10.cs", + "can_run": true + }, + { + "filename": "23.11.cs", + "can_run": true + }, + { + "filename": "23.13.cs", + "can_run": true + }, + { + "filename": "23.14.cs", + "can_run": true + }, + { + "filename": "23.15.cs", + "can_run": true + }, + { + "filename": "23.16.cs", + "can_run": true + }, + { + "filename": "23.17.cs", + "can_run": true + }, + { + "filename": "23.18.cs", + "can_run": true + }, + { + "filename": "23.19.cs", + "can_run": true + }, + { + "filename": "23.20.cs", + "can_run": true + } + ] + }, + "statistics": { + "total_chapters": 23, + "total_listings": 617 + } +} \ No newline at end of file diff --git a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js index f7cd3689..e50d4189 100644 --- a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js +++ b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js @@ -42,19 +42,99 @@ function isTryDotNetConfigured() { return typeof origin === 'string' && origin.trim().length > 0; } +// ── Runnable-listings data (loaded once from chapter-listings.json) ────────── + +/** @type {Promise>|null} */ +let _runnableListingsPromise = null; + /** - * Creates scaffolding for user code to run in the TryDotNet environment. - * @param {string} userCode - The user's C# code to wrap - * @returns {string} Scaffolded code with proper using statements and Main method + * Loads chapter-listings.json (once) and builds a Set of normalised + * "chapter.listing" keys, e.g. "1.3", "12.50". + * Only includes listings where can_run is true. + * @returns {Promise>} */ -function createScaffolding(userCode) { - return `using System; +function loadRunnableListings() { + if (_runnableListingsPromise) return _runnableListingsPromise; + + _runnableListingsPromise = fetch('/js/chapter-listings.json') + .then(res => { + if (!res.ok) throw new Error(`Failed to load chapter-listings.json: ${res.status}`); + return res.json(); + }) + .then(data => { + const set = new Set(); + const chapters = data.chapters || {}; + for (const [, files] of Object.entries(chapters)) { + for (const fileObj of files) { + // fileObj is now { filename: "01.03.cs", can_run: true } + if (!fileObj.can_run) continue; // Skip listings that can't be run + + const filename = fileObj.filename; + // filename looks like "01.03.cs" → chapter 1, listing 3 + const m = filename.match(/^(\d+)\.(\d+)\./); + if (m) { + set.add(`${parseInt(m[1], 10)}.${parseInt(m[2], 10)}`); + } + } + } + return set; + }) + .catch(err => { + console.warn('Could not load runnable listings:', err); + return new Set(); // graceful degradation — no Run buttons + }); + + return _runnableListingsPromise; +} + +/** + * Checks whether a listing is present in the curated runnable set. + * @param {string|number} chapter + * @param {string|number} listing + * @returns {Promise} + */ +async function isRunnableListing(chapter, listing) { + const set = await loadRunnableListings(); + return set.has(`${parseInt(chapter, 10)}.${parseInt(listing, 10)}`); +} + +/** + * Strips #region / #endregion directive lines (INCLUDE, EXCLUDE, etc.) + * from source code while keeping the code between them intact. + * @param {string} code - Raw source code + * @returns {string} Code with region directive lines removed + */ +function stripRegionDirectives(code) { + return code.replace(/^\s*#(?:region|endregion)\s+(?:INCLUDE|EXCLUDE).*$/gm, '').trim(); +} + +// Common using directives that mirror the SDK's implicit global usings. +// These are needed because TryDotNet's 'console' package does not inject them. +const COMMON_USINGS = `using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Globalization; -using System.Text.RegularExpressions; +using System.Text.RegularExpressions;`; + +/** + * Prepends COMMON_USINGS to code that has no using directives of its own. + * @param {string} code - Source code + * @returns {string} + */ +function prependUsings(code) { + if (/^\s*using\s+/m.test(code)) return code; // already has usings + return `${COMMON_USINGS}\n\n${code}`; +} +/** + * Creates scaffolding for user code to run in the TryDotNet environment. + * @param {string} userCode - The user's C# code to wrap + * @returns {string} Scaffolded code with proper using statements and Main method + */ +function createScaffolding(userCode) { + // return `${COMMON_USINGS} + return ` namespace Program { class Program @@ -222,7 +302,7 @@ export function useTryDotNet() { const files = [{ name: fileName, content: fileContent }]; const project = { package: 'console', files: files }; const document = isComplete - ? { fileName: fileName } + ? fileName : { fileName: fileName, region: 'controller' }; const configuration = { @@ -357,8 +437,10 @@ export function useTryDotNet() { /** * Checks if code is a complete C# program that doesn't need scaffolding. - * Complete programs must have a namespace declaration with class and Main, - * or be a class named Program with Main. + * A program is "complete" when it contains a namespace declaration, OR + * when it defines any class with a static Main method. + * Top-level statement files (no class, no namespace) return false and + * will be wrapped by createScaffolding(). * @param {string} code - Source code to check * @returns {boolean} True if code is complete, false if it needs scaffolding */ @@ -366,28 +448,11 @@ export function useTryDotNet() { // Check for explicit namespace declaration (most reliable indicator) const hasNamespace = /namespace\s+\w+/i.test(code); - // Check if it's a class specifically named "Program" with Main method - const isProgramClass = /class\s+Program\s*[\r\n{]/.test(code) && + // Check if any class has a static Main method + const hasClassWithMain = /class\s+\w+/.test(code) && /static\s+(void|async\s+Task)\s+Main\s*\(/.test(code); - // Only consider it complete if it has namespace or is the Program class - return hasNamespace || isProgramClass; - } - - /** - * Extracts executable code snippet from source code. - * If code contains #region INCLUDE, extracts only that portion. - * Otherwise returns the full code. - * @param {string} code - Source code to process - * @returns {string} Extracted code snippet - */ - function extractCodeSnippet(code) { - // Extract code from #region INCLUDE if present - const regionMatch = code.match(/#region\s+INCLUDE\s*\n([\s\S]*?)\n\s*#endregion\s+INCLUDE/); - if (regionMatch) { - return regionMatch[1].trim(); - } - return code; + return hasNamespace || hasClassWithMain; } /** @@ -403,8 +468,17 @@ export function useTryDotNet() { } const data = await response.json(); const code = data.content || ''; - // Extract the snippet portion if it has INCLUDE regions - return extractCodeSnippet(code); + + // Complete programs (namespace or class+Main) are sent as-is, but + // with common usings prepended when the file has none — TryDotNet's + // 'console' package does not provide SDK implicit global usings. + // Top-level statement files get region directives stripped so the + // scaffolding wrapper doesn't contain raw #region lines. + if (isCompleteProgram(code)) { + // return prependUsings(code); + return code; + } + return stripRegionDirectives(code); } /** @@ -502,12 +576,19 @@ export function useTryDotNet() { /** * Injects Run buttons into code block sections. * Skipped entirely when TryDotNet origin is not configured. + * Only adds buttons for listings present in chapter-listings.json. */ - function injectRunButtons() { + async function injectRunButtons() { if (!isTryDotNetConfigured()) { return; // Don't show Run buttons when the service is not configured } + // Pre-load the runnable listings set so we can check membership below + const runnableSet = await loadRunnableListings(); + if (runnableSet.size === 0) { + return; // JSON failed to load or is empty — no buttons + } + const codeBlocks = document.querySelectorAll('.code-block-section'); codeBlocks.forEach((block) => { @@ -553,8 +634,8 @@ export function useTryDotNet() { }); } - // Only add button for listing 1.1 - if (chapter === '1' && listing === '1') { + // Only add button for listings present in the curated JSON + if (chapter && listing && runnableSet.has(`${parseInt(chapter, 10)}.${parseInt(listing, 10)}`)) { // Wrap existing content in a span to keep it together const contentWrapper = document.createElement('span'); while (heading.firstChild) { From 274e78cbce681612f9e6f17a396b881b1e971f14 Mon Sep 17 00:00:00 2001 From: Joshua Lester Date: Thu, 5 Mar 2026 11:22:21 -0800 Subject: [PATCH 16/16] Undo bad merge conflict resolution --- .../ListingSourceCodeControllerTests.cs | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs index 1944d309..05a29baa 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -4,104 +4,104 @@ namespace EssentialCSharp.Web.Tests; -public class ListingSourceCodeControllerTests +[ClassDataSource(Shared = SharedType.PerClass)] +public class ListingSourceCodeControllerTests(WebApplicationFactory factory) { - [Fact] + [Test] public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/1"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + ListingSourceCodeResponse? result = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(result); - Assert.Equal(1, result.ChapterNumber); - Assert.Equal(1, result.ListingNumber); - Assert.NotEmpty(result.FileExtension); - Assert.NotEmpty(result.Content); + await Assert.That(result).IsNotNull(); + using (Assert.Multiple()) + { + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.ListingNumber).IsEqualTo(1); + await Assert.That(result.FileExtension).IsNotEmpty(); + await Assert.That(result.Content).IsNotEmpty(); + } } - [Fact] + [Test] public async Task GetListing_WithInvalidChapter_Returns404() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999/listing/1"); // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } - [Fact] + [Test] public async Task GetListing_WithInvalidListing_Returns404() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/999"); // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } - [Fact] + [Test] public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + List? results = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(results); - Assert.NotEmpty(results); - - // Verify all results are from chapter 1 - Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); - + await Assert.That(results).IsNotNull(); + await Assert.That(results).IsNotEmpty(); + // Verify results are ordered by listing number - Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); - - // Verify each listing has required properties - Assert.All(results, r => + await Assert.That(results).IsOrderedBy(r => r.ListingNumber); + + // Verify all results are from chapter 1 and have required properties + foreach (var r in results) { - Assert.NotEmpty(r.FileExtension); - Assert.NotEmpty(r.Content); - }); + using (Assert.Multiple()) + { + await Assert.That(r.ChapterNumber).IsEqualTo(1); + await Assert.That(r.FileExtension).IsNotEmpty(); + await Assert.That(r.Content).IsNotEmpty(); + } + } } - [Fact] + [Test] public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + List? results = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(results); - Assert.Empty(results); + await Assert.That(results).IsNotNull(); + await Assert.That(results).IsEmpty(); } }