From b2a029d50bf6b9125e0f68529ce8123b45d6d2b7 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Feb 2026 19:32:33 +0100 Subject: [PATCH] Added configurators --- .gitea/workflows/ci.yml | 46 ++++ HopFrame.slnx | 12 +- debug/TestApplication/Components/App.razor | 21 ++ .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Components/Layout/ReconnectModal.razor | 31 +++ .../Layout/ReconnectModal.razor.css | 157 +++++++++++ .../Components/Layout/ReconnectModal.razor.js | 63 +++++ .../Components/Pages/Error.razor | 35 +++ .../Components/Pages/Home.razor | 7 + .../Components/Pages/NotFound.razor | 5 + debug/TestApplication/Components/Routes.razor | 6 + .../TestApplication/Components/_Imports.razor | 11 + debug/TestApplication/Program.cs | 27 ++ .../Properties/launchSettings.json | 23 ++ debug/TestApplication/TestApplication.csproj | 14 + .../appsettings.Development.json | 8 + debug/TestApplication/appsettings.json | 9 + debug/TestApplication/wwwroot/app.css | 38 +++ .../Configuration/HopFrameConfig.cs | 11 + .../Configuration/PropertyConfig.cs | 41 +++ .../Configuration/TableConfig.cs | 32 +++ .../Configurators/HopFrameConfigurator.cs | 51 ++++ .../Configurators/PropertyConfigurator.cs | 59 +++++ .../Configurators/TableConfigurator.cs | 59 +++++ .../Helpers/ConfigurationHelper.cs | 75 ++++++ src/HopFrame.Core/Helpers/ExpressionHelper.cs | 27 ++ src/HopFrame.Core/HopFrame.Core.csproj | 24 ++ .../Repositories/HopFrameRepository.cs | 43 +++ .../Repositories/IHopFrameRepository.cs | 42 +++ .../ServiceCollectionExtensions.cs | 22 ++ src/HopFrame.Core/Services/IConfigAccessor.cs | 33 +++ .../Services/Implementation/ConfigAccessor.cs | 25 ++ .../HopFrameConfiguratorTests.cs | 165 ++++++++++++ .../Helpers/ConfigurationHelperTests.cs | 249 ++++++++++++++++++ .../Helpers/ExpressionHelperTests.cs | 44 ++++ .../HopFrame.Tests.Core.csproj | 26 ++ .../Repositories/HopFrameRepositoryTests.cs | 98 +++++++ .../Implementation/ConfigAccessorTests.cs | 160 +++++++++++ tests/HopFrame.Tests.Core/TestModel.cs | 10 + 40 files changed, 1837 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 debug/TestApplication/Components/App.razor create mode 100644 debug/TestApplication/Components/Layout/MainLayout.razor create mode 100644 debug/TestApplication/Components/Layout/MainLayout.razor.css create mode 100644 debug/TestApplication/Components/Layout/ReconnectModal.razor create mode 100644 debug/TestApplication/Components/Layout/ReconnectModal.razor.css create mode 100644 debug/TestApplication/Components/Layout/ReconnectModal.razor.js create mode 100644 debug/TestApplication/Components/Pages/Error.razor create mode 100644 debug/TestApplication/Components/Pages/Home.razor create mode 100644 debug/TestApplication/Components/Pages/NotFound.razor create mode 100644 debug/TestApplication/Components/Routes.razor create mode 100644 debug/TestApplication/Components/_Imports.razor create mode 100644 debug/TestApplication/Program.cs create mode 100644 debug/TestApplication/Properties/launchSettings.json create mode 100644 debug/TestApplication/TestApplication.csproj create mode 100644 debug/TestApplication/appsettings.Development.json create mode 100644 debug/TestApplication/appsettings.json create mode 100644 debug/TestApplication/wwwroot/app.css create mode 100644 src/HopFrame.Core/Configuration/HopFrameConfig.cs create mode 100644 src/HopFrame.Core/Configuration/PropertyConfig.cs create mode 100644 src/HopFrame.Core/Configuration/TableConfig.cs create mode 100644 src/HopFrame.Core/Configurators/HopFrameConfigurator.cs create mode 100644 src/HopFrame.Core/Configurators/PropertyConfigurator.cs create mode 100644 src/HopFrame.Core/Configurators/TableConfigurator.cs create mode 100644 src/HopFrame.Core/Helpers/ConfigurationHelper.cs create mode 100644 src/HopFrame.Core/Helpers/ExpressionHelper.cs create mode 100644 src/HopFrame.Core/HopFrame.Core.csproj create mode 100644 src/HopFrame.Core/Repositories/HopFrameRepository.cs create mode 100644 src/HopFrame.Core/Repositories/IHopFrameRepository.cs create mode 100644 src/HopFrame.Core/ServiceCollectionExtensions.cs create mode 100644 src/HopFrame.Core/Services/IConfigAccessor.cs create mode 100644 src/HopFrame.Core/Services/Implementation/ConfigAccessor.cs create mode 100644 tests/HopFrame.Tests.Core/Configurators/HopFrameConfiguratorTests.cs create mode 100644 tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs create mode 100644 tests/HopFrame.Tests.Core/Helpers/ExpressionHelperTests.cs create mode 100644 tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj create mode 100644 tests/HopFrame.Tests.Core/Repositories/HopFrameRepositoryTests.cs create mode 100644 tests/HopFrame.Tests.Core/Services/Implementation/ConfigAccessorTests.cs create mode 100644 tests/HopFrame.Tests.Core/TestModel.cs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..362c887 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,46 @@ +name: HopFrame CI + +on: + push: + branches: + - "**" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.x" + + - name: Restore dependencies + run: dotnet restore + + - name: Build solution + run: dotnet build --configuration Release --no-restore + + + test: + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.x" + + - name: Restore dependencies + run: dotnet restore + + - name: Build solution + run: dotnet build --configuration Release --no-restore + + - name: Run tests + run: dotnet test --configuration Release --no-build --verbosity normal diff --git a/HopFrame.slnx b/HopFrame.slnx index 4e2253d..c3fee8a 100644 --- a/HopFrame.slnx +++ b/HopFrame.slnx @@ -1 +1,11 @@ - + + + + + + + + + + + diff --git a/debug/TestApplication/Components/App.razor b/debug/TestApplication/Components/App.razor new file mode 100644 index 0000000..4729eac --- /dev/null +++ b/debug/TestApplication/Components/App.razor @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/debug/TestApplication/Components/Layout/MainLayout.razor b/debug/TestApplication/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..624f437 --- /dev/null +++ b/debug/TestApplication/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
\ No newline at end of file diff --git a/debug/TestApplication/Components/Layout/MainLayout.razor.css b/debug/TestApplication/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..60cec92 --- /dev/null +++ b/debug/TestApplication/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/debug/TestApplication/Components/Layout/ReconnectModal.razor b/debug/TestApplication/Components/Layout/ReconnectModal.razor new file mode 100644 index 0000000..424f032 --- /dev/null +++ b/debug/TestApplication/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+ +

+ Failed to resume the session.
Please reload the page. +

+
+
\ No newline at end of file diff --git a/debug/TestApplication/Components/Layout/ReconnectModal.razor.css b/debug/TestApplication/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..3ad3773 --- /dev/null +++ b/debug/TestApplication/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/debug/TestApplication/Components/Layout/ReconnectModal.razor.js b/debug/TestApplication/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..e52a190 --- /dev/null +++ b/debug/TestApplication/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + location.reload(); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/debug/TestApplication/Components/Pages/Error.razor b/debug/TestApplication/Components/Pages/Error.razor new file mode 100644 index 0000000..06de831 --- /dev/null +++ b/debug/TestApplication/Components/Pages/Error.razor @@ -0,0 +1,35 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) { +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + +} \ No newline at end of file diff --git a/debug/TestApplication/Components/Pages/Home.razor b/debug/TestApplication/Components/Pages/Home.razor new file mode 100644 index 0000000..dfcdf75 --- /dev/null +++ b/debug/TestApplication/Components/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new app. \ No newline at end of file diff --git a/debug/TestApplication/Components/Pages/NotFound.razor b/debug/TestApplication/Components/Pages/NotFound.razor new file mode 100644 index 0000000..917ada1 --- /dev/null +++ b/debug/TestApplication/Components/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/debug/TestApplication/Components/Routes.razor b/debug/TestApplication/Components/Routes.razor new file mode 100644 index 0000000..9661f49 --- /dev/null +++ b/debug/TestApplication/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/debug/TestApplication/Components/_Imports.razor b/debug/TestApplication/Components/_Imports.razor new file mode 100644 index 0000000..2949d52 --- /dev/null +++ b/debug/TestApplication/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using TestApplication +@using TestApplication.Components +@using TestApplication.Components.Layout \ No newline at end of file diff --git a/debug/TestApplication/Program.cs b/debug/TestApplication/Program.cs new file mode 100644 index 0000000..5f6d302 --- /dev/null +++ b/debug/TestApplication/Program.cs @@ -0,0 +1,27 @@ +using TestApplication.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) { + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); \ No newline at end of file diff --git a/debug/TestApplication/Properties/launchSettings.json b/debug/TestApplication/Properties/launchSettings.json new file mode 100644 index 0000000..81ef9cb --- /dev/null +++ b/debug/TestApplication/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7126;http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/debug/TestApplication/TestApplication.csproj b/debug/TestApplication/TestApplication.csproj new file mode 100644 index 0000000..8d53cbd --- /dev/null +++ b/debug/TestApplication/TestApplication.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + true + + + + + + + diff --git a/debug/TestApplication/appsettings.Development.json b/debug/TestApplication/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/debug/TestApplication/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/debug/TestApplication/appsettings.json b/debug/TestApplication/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/debug/TestApplication/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/debug/TestApplication/wwwroot/app.css b/debug/TestApplication/wwwroot/app.css new file mode 100644 index 0000000..5388357 --- /dev/null +++ b/debug/TestApplication/wwwroot/app.css @@ -0,0 +1,38 @@ +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/src/HopFrame.Core/Configuration/HopFrameConfig.cs b/src/HopFrame.Core/Configuration/HopFrameConfig.cs new file mode 100644 index 0000000..767d91d --- /dev/null +++ b/src/HopFrame.Core/Configuration/HopFrameConfig.cs @@ -0,0 +1,11 @@ +namespace HopFrame.Core.Configuration; + +/** + * The configuration for the library + */ +public sealed class HopFrameConfig { + /** The configurations for the table repositories */ + public IList Tables { get; set; } = new List(); + + internal HopFrameConfig() {} +} \ No newline at end of file diff --git a/src/HopFrame.Core/Configuration/PropertyConfig.cs b/src/HopFrame.Core/Configuration/PropertyConfig.cs new file mode 100644 index 0000000..0bbe217 --- /dev/null +++ b/src/HopFrame.Core/Configuration/PropertyConfig.cs @@ -0,0 +1,41 @@ +namespace HopFrame.Core.Configuration; + +/** + * The configuration for a single property + */ +public class PropertyConfig { + /** The unique identifier for the property (usually the real property name in the model) */ + public required string Identifier { get; init; } + + /** The displayed name of the Property */ + public required string DisplayName { get; set; } + + /** The type of the property */ + public required Type Type { get; set; } + + /** Determines if the property will appear in the table */ + public bool Listable { get; set; } = true; + + /** Determines if the table can be sorted by the property */ + public bool Sortable { get; set; } = true; + + /** Determines if the table can be searched by the property */ + public bool Searchable { get; set; } = true; + + /** + * Determines if the value of the property can be edited + * (if true the value can still be set during creation) + */ + public bool Editable { get; set; } = true; + + /** Determines if the property is visible in the creation or edit dialog */ + public bool Creatable { get; set; } = true; + + /** Determines if the actual value should be displayed (useful for passwords) */ + public bool DisplayValue { get; set; } = true; + + /** The place (from left to right) that the property will appear in the table and editor */ + public int OrderIndex { get; set; } + + internal PropertyConfig() {} +} \ No newline at end of file diff --git a/src/HopFrame.Core/Configuration/TableConfig.cs b/src/HopFrame.Core/Configuration/TableConfig.cs new file mode 100644 index 0000000..dc3ea4c --- /dev/null +++ b/src/HopFrame.Core/Configuration/TableConfig.cs @@ -0,0 +1,32 @@ +namespace HopFrame.Core.Configuration; + +/** + * The configuration for a table + */ +public class TableConfig { + /** The unique identifier for the table (usually the name of the model) */ + public required string Identifier { get; init; } + + /** The configurations for the properties of the model */ + public IList Properties { get; set; } = new List(); + + /** The type of the model */ + public required Type TableType { get; set; } + + /** The type identifier for the repository */ + public required Type RepositoryType { get; set; } + + /** the url of the table page */ + public required string Route { get; set; } + + /** The displayed name of the table */ + public required string DisplayName { get; set; } + + /** A short description for the table */ + public string? Description { get; set; } + + /** The place (from top to bottom) that the table will appear in on the sidebar */ + public int OrderIndex { get; set; } + + internal TableConfig() {} +} \ No newline at end of file diff --git a/src/HopFrame.Core/Configurators/HopFrameConfigurator.cs b/src/HopFrame.Core/Configurators/HopFrameConfigurator.cs new file mode 100644 index 0000000..62b890e --- /dev/null +++ b/src/HopFrame.Core/Configurators/HopFrameConfigurator.cs @@ -0,0 +1,51 @@ +using HopFrame.Core.Configuration; +using HopFrame.Core.Helpers; +using HopFrame.Core.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace HopFrame.Core.Configurators; + +/** + * The configurator for the + */ +public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection services) { + /** The internal config that is modified */ + public HopFrameConfig Config { get; } = config; + + /// + /// Adds a new table to the configuration based on the provided repository + /// + /// The repository that handles the table + /// The type of the model + /// The configurator for the table + public HopFrameConfigurator AddRepository(Action>? configurator = null) where TRepository : IHopFrameRepository where TModel : notnull { + var table = ConfigurationHelper.InitializeTable(Config, typeof(TRepository), typeof(TModel)); + Config.Tables.Add(table); + services.TryAddScoped(typeof(TRepository)); + configurator?.Invoke(new TableConfigurator(table)); + return this; + } + + /// + /// Adds a new table to the configuration + /// + /// The configuration for the table + /// The configurator for the table + /// The model of the table + /// Is thrown when configuration validation fails + public HopFrameConfigurator AddTable(TableConfig config, Action>? configurator = null) where TModel : notnull { + if (typeof(TModel) != config.TableType) + throw new ArgumentException($"Table type for table '{config.Identifier}' does not mach requested type '{typeof(TModel).Name}'!"); + + var errors = ConfigurationHelper.ValidateTable(Config, config).ToArray(); + + if (errors.Length != 0) + throw new ArgumentException($"Table '{config.Identifier}' has some validation errors:\n\t{string.Join("\n\t", errors)}"); + + Config.Tables.Add(config); + services.TryAddScoped(config.RepositoryType); + configurator?.Invoke(new TableConfigurator(config)); + return this; + } +} \ No newline at end of file diff --git a/src/HopFrame.Core/Configurators/PropertyConfigurator.cs b/src/HopFrame.Core/Configurators/PropertyConfigurator.cs new file mode 100644 index 0000000..f5bee78 --- /dev/null +++ b/src/HopFrame.Core/Configurators/PropertyConfigurator.cs @@ -0,0 +1,59 @@ +using HopFrame.Core.Configuration; + +namespace HopFrame.Core.Configurators; + +/** + * The configurator for the + */ +public class PropertyConfigurator(PropertyConfig config) { + /** The internal config that is modified */ + public PropertyConfig Config { get; } = config; + + /** */ + public PropertyConfigurator SetDisplayName(string displayName) { + Config.DisplayName = displayName; + return this; + } + + /** */ + public PropertyConfigurator Listable(bool listable) { + Config.Listable = listable; + return this; + } + + /** */ + public PropertyConfigurator Sortable(bool sortable) { + Config.Sortable = sortable; + return this; + } + + /** */ + public PropertyConfigurator Searchable(bool searchable) { + Config.Searchable = searchable; + return this; + } + + /** */ + public PropertyConfigurator Editable(bool editable) { + Config.Editable = editable; + return this; + } + + /** */ + public PropertyConfigurator Creatable(bool creatable) { + Config.Creatable = creatable; + return this; + } + + /** */ + public PropertyConfigurator DisplayValue(bool displayValue) { + Config.DisplayValue = displayValue; + return this; + } + + /** */ + public PropertyConfigurator SetOrderIndex(int index) { + Config.OrderIndex = index; + return this; + } +} \ No newline at end of file diff --git a/src/HopFrame.Core/Configurators/TableConfigurator.cs b/src/HopFrame.Core/Configurators/TableConfigurator.cs new file mode 100644 index 0000000..f492dd9 --- /dev/null +++ b/src/HopFrame.Core/Configurators/TableConfigurator.cs @@ -0,0 +1,59 @@ +using System.Linq.Expressions; +using HopFrame.Core.Configuration; +using HopFrame.Core.Helpers; + +namespace HopFrame.Core.Configurators; + +/** + * The configurator for the + */ +public class TableConfigurator(TableConfig config) where TModel : notnull { + /** The internal config that is modified */ + public TableConfig Config { get; } = config; + + /** */ + public TableConfigurator SetRoute(string route) { + Config.Route = route; + return this; + } + + /** */ + public TableConfigurator SetDisplayName(string displayName) { + Config.DisplayName = displayName; + return this; + } + + /** */ + public TableConfigurator SetDescription(string description) { + Config.Description = description; + return this; + } + + /** */ + public TableConfigurator SetOrderIndex(int index) { + Config.OrderIndex = index; + return this; + } + + /** Returns the configurator for a property */ + public PropertyConfigurator Property(string identifier) { + var prop = Config.Properties + .FirstOrDefault(p => p.Identifier == identifier); + + if (prop is null) + throw new ArgumentException($"No attribute '{identifier}' found in '{Config.Identifier}'!"); + + return new PropertyConfigurator(prop); + } + + /** */ + public PropertyConfigurator Property(Expression> propertyExpression) { + var propertyName = ExpressionHelper.GetPropertyInfo(propertyExpression).Name; + var prop = Config.Properties.FirstOrDefault(p => p.Identifier == propertyName); + + if (prop is null) + throw new ArgumentException($"No attribute '{propertyName}' found in '{Config.Identifier}'!"); + + return new PropertyConfigurator(prop); + } +} \ No newline at end of file diff --git a/src/HopFrame.Core/Helpers/ConfigurationHelper.cs b/src/HopFrame.Core/Helpers/ConfigurationHelper.cs new file mode 100644 index 0000000..e6b5cc9 --- /dev/null +++ b/src/HopFrame.Core/Helpers/ConfigurationHelper.cs @@ -0,0 +1,75 @@ +using System.Reflection; +using HopFrame.Core.Configuration; +// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + +namespace HopFrame.Core.Helpers; + +internal static class ConfigurationHelper { + + public static TableConfig InitializeTable(HopFrameConfig global, Type repositoryType, Type modelType) { + var identifier = modelType.Name; + + if (global.Tables.Any(t => t.Identifier == identifier)) + identifier = Guid.NewGuid().ToString(); + + var config = new TableConfig { + RepositoryType = repositoryType, + TableType = modelType, + Identifier = identifier, + Route = modelType.Name.ToLower(), + DisplayName = modelType.Name, + OrderIndex = global.Tables.Count + }; + + foreach (var property in modelType.GetProperties()) { + config.Properties.Add(InitializeProperty(config, property)); + } + + return config; + } + + private static PropertyConfig InitializeProperty(TableConfig table, PropertyInfo property) { + var identifier = property.Name; + + if (table.Properties.Any(p => p.Identifier == identifier)) + identifier = Guid.NewGuid().ToString(); + + var config = new PropertyConfig { + Identifier = identifier, + Type = property.PropertyType, + DisplayName = property.Name, + OrderIndex = table.Properties.Count + }; + + return config; + } + + public static IEnumerable ValidateTable(HopFrameConfig global, TableConfig config) { + if (global.Tables.Any(t => t.Identifier == config.Identifier)) + yield return $"Table identifier '{config.Identifier}' is not unique"; + + if (config.TableType is null) + yield return "TableType cannot be null"; + + if (config.RepositoryType is null) + yield return "RepositoryType cannot be null"; + + if (config.Route is null) + yield return "Route cannot be null"; + + if (config.DisplayName is null) + yield return "DisplayName cannot be null"; + + foreach (var property in config.Properties) { + if (config.Properties.Count(p => p.Identifier == property.Identifier) > 1) + yield return $"Property identifier '{property.Identifier}' is not unique"; + + if (property.DisplayName is null) + yield return $"Property '{property.Identifier}': DisplayName cannot be null"; + + if (property.Type is null) + yield return $"Property '{property.Identifier}': Type cannot be null"; + } + } + +} \ No newline at end of file diff --git a/src/HopFrame.Core/Helpers/ExpressionHelper.cs b/src/HopFrame.Core/Helpers/ExpressionHelper.cs new file mode 100644 index 0000000..438aa38 --- /dev/null +++ b/src/HopFrame.Core/Helpers/ExpressionHelper.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace HopFrame.Core.Helpers; + +internal static class ExpressionHelper { + public static PropertyInfo GetPropertyInfo(Expression> propertyLambda) { + if (propertyLambda.Body is not MemberExpression member) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property."); + } + + if (member.Member is not PropertyInfo propInfo) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property."); + } + + var type = typeof(TSource); + if (propInfo.ReflectedType != null && type != propInfo.ReflectedType && + !type.IsSubclassOf(propInfo.ReflectedType)) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}."); + } + + if (propInfo.Name is null) + throw new ArgumentException($"Expression '{propertyLambda}' refers a not existing property."); + + return propInfo; + } +} \ No newline at end of file diff --git a/src/HopFrame.Core/HopFrame.Core.csproj b/src/HopFrame.Core/HopFrame.Core.csproj new file mode 100644 index 0000000..d1881a3 --- /dev/null +++ b/src/HopFrame.Core/HopFrame.Core.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + + true + MIT + HopFrame.Core + true + + + + + + + + + <_Parameter1>HopFrame.Tests.Core + + + + diff --git a/src/HopFrame.Core/Repositories/HopFrameRepository.cs b/src/HopFrame.Core/Repositories/HopFrameRepository.cs new file mode 100644 index 0000000..96b8489 --- /dev/null +++ b/src/HopFrame.Core/Repositories/HopFrameRepository.cs @@ -0,0 +1,43 @@ +using System.Collections; + +namespace HopFrame.Core.Repositories; + +/** The base repository that provides access to the model dataset */ +public abstract class HopFrameRepository : IHopFrameRepository where TModel : notnull { + + /** */ + public abstract Task> LoadPageAsync(int page, int perPage, CancellationToken ct = default); + + /** */ + public abstract Task CountAsync(CancellationToken ct = default); + + /** */ + public abstract Task> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default); + + /** */ + public abstract Task CreateAsync(TModel entry, CancellationToken ct); + + /** */ + public abstract Task DeleteAsync(TModel entry, CancellationToken ct); + + /** */ + public async Task LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) { + return await LoadPageAsync(page, perPage, ct); + } + + /** */ + public async Task SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) { + return await SearchAsync(searchTerm, page, perPage, ct); + } + + /** */ + public Task CreateGenericAsync(object entry, CancellationToken ct) { + return CreateAsync((TModel)entry, ct); + } + + /** */ + public Task DeleteGenericAsync(object entry, CancellationToken ct) { + return DeleteAsync((TModel)entry, ct); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Core/Repositories/IHopFrameRepository.cs b/src/HopFrame.Core/Repositories/IHopFrameRepository.cs new file mode 100644 index 0000000..7ffdd16 --- /dev/null +++ b/src/HopFrame.Core/Repositories/IHopFrameRepository.cs @@ -0,0 +1,42 @@ +using System.Collections; + +#pragma warning disable CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do) +namespace HopFrame.Core.Repositories; + +/** The generic repository that provides access to the model dataset */ +public interface IHopFrameRepository { + + /// + /// Loads a whole page of entries + /// + /// The index of the current page (starts at 0) + /// The amount of entries that should be loaded + public Task LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default); + + /// + /// Returns the total amount of entries in the dataset + /// + public Task CountAsync(CancellationToken ct = default); + + /// + /// Searches through the whole dataset and returns a page of matching entries + /// + /// The search text provided by the user + /// The index of the current page (starts at 0) + /// The amount of entries that should be loaded + public Task SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default); + + + /// + /// Saves the newly created entry to the dataset + /// + /// The entry that needs to be saved + public Task CreateGenericAsync(object entry, CancellationToken ct); + + /// + /// Deletes the provided entry from the dataset + /// + /// The entry that needs to be deleted + public Task DeleteGenericAsync(object entry, CancellationToken ct); + +} \ No newline at end of file diff --git a/src/HopFrame.Core/ServiceCollectionExtensions.cs b/src/HopFrame.Core/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..033ffaf --- /dev/null +++ b/src/HopFrame.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using HopFrame.Core.Configuration; +using HopFrame.Core.Configurators; +using HopFrame.Core.Services; +using HopFrame.Core.Services.Implementation; +using Microsoft.Extensions.DependencyInjection; + +namespace HopFrame.Core; + +/** An extension class to provide access to the setup of the library */ +public static class ServiceCollectionExtensions { + + /** Configures the library using the provided configurator */ + public static void AddHopFrame(this IServiceCollection services, Action configurator) { + var config = new HopFrameConfig(); + services.AddSingleton(config); + + services.AddTransient(); + + configurator.Invoke(new HopFrameConfigurator(config, services)); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/IConfigAccessor.cs b/src/HopFrame.Core/Services/IConfigAccessor.cs new file mode 100644 index 0000000..42b027d --- /dev/null +++ b/src/HopFrame.Core/Services/IConfigAccessor.cs @@ -0,0 +1,33 @@ +using HopFrame.Core.Configuration; +using HopFrame.Core.Repositories; + +namespace HopFrame.Core.Services; + +/** A service used to access configs and repositories provided by the */ +public interface IConfigAccessor { + + /// + /// Searches through the config and returns the table with the specified identifier if it exists + /// + /// The identifier of the table + public TableConfig? GetTableByIdentifier(string identifier); + + /// + /// Searches through the config and returns the table with the specified route if it exists + /// + /// The route of the table + public TableConfig? GetTableByRoute(string route); + + /// + /// Searches through the config and returns the table with the specified type if it exists + /// + /// The model type for the table + public TableConfig? GetTableByType(Type type); + + /// + /// Loads the repository for the specified table + /// + /// The table to load the repository for + public IHopFrameRepository LoadRepository(TableConfig table); + +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/Implementation/ConfigAccessor.cs b/src/HopFrame.Core/Services/Implementation/ConfigAccessor.cs new file mode 100644 index 0000000..b650096 --- /dev/null +++ b/src/HopFrame.Core/Services/Implementation/ConfigAccessor.cs @@ -0,0 +1,25 @@ +using HopFrame.Core.Configuration; +using HopFrame.Core.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace HopFrame.Core.Services.Implementation; + +internal sealed class ConfigAccessor(HopFrameConfig config, IServiceProvider services) : IConfigAccessor { + + public TableConfig? GetTableByIdentifier(string identifier) { + return config.Tables.FirstOrDefault(t => t.Identifier == identifier); + } + + public TableConfig? GetTableByRoute(string route) { + return config.Tables.FirstOrDefault(t => t.Route == route); + } + + public TableConfig? GetTableByType(Type type) { + return config.Tables.FirstOrDefault(t => t.TableType == type); + } + + public IHopFrameRepository LoadRepository(TableConfig table) { + return (IHopFrameRepository)services.GetRequiredService(table.RepositoryType); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Core/Configurators/HopFrameConfiguratorTests.cs b/tests/HopFrame.Tests.Core/Configurators/HopFrameConfiguratorTests.cs new file mode 100644 index 0000000..ab4d3af --- /dev/null +++ b/tests/HopFrame.Tests.Core/Configurators/HopFrameConfiguratorTests.cs @@ -0,0 +1,165 @@ +using System.Collections; +using HopFrame.Core.Configuration; +using HopFrame.Core.Configurators; +using HopFrame.Core.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace HopFrame.Tests.Core.Configurators; + +public class HopFrameConfiguratorTests { + private class TestRepository : IHopFrameRepository { + public Task LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) { + throw new NotImplementedException(); + } + public Task CountAsync(CancellationToken ct = default) { + throw new NotImplementedException(); + } + public Task SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) { + throw new NotImplementedException(); + } + public Task CreateGenericAsync(object entry, CancellationToken ct) { + throw new NotImplementedException(); + } + public Task DeleteGenericAsync(object entry, CancellationToken ct) { + throw new NotImplementedException(); + } + } + + private HopFrameConfig CreateConfig() + => new HopFrameConfig { Tables = new List() }; + + private TableConfig CreateValidTable() + => new TableConfig { + Identifier = typeof(TModel).Name, + TableType = typeof(TModel), + RepositoryType = typeof(TestRepository), + Route = typeof(TModel).Name.ToLower(), + DisplayName = typeof(TModel).Name, + OrderIndex = 0, + Properties = new List { + new PropertyConfig { + Identifier = "Id", + DisplayName = "Id", + Type = typeof(int), + OrderIndex = 0 + } + } + }; + + // ------------------------------------------------------------- + // AddRepository + // ------------------------------------------------------------- + + [Fact] + public void AddRepository_AddsTableToConfig() { + var config = CreateConfig(); + var services = new ServiceCollection(); + var configurator = new HopFrameConfigurator(config, services); + + configurator.AddRepository(); + + Assert.Single(config.Tables); + Assert.Equal(typeof(TestModel), config.Tables[0].TableType); + } + + [Fact] + public void AddRepository_RegistersRepositoryInServices() { + var config = CreateConfig(); + var services = new ServiceCollection(); + var configurator = new HopFrameConfigurator(config, services); + + configurator.AddRepository(); + + Assert.Contains(services, d => d.ServiceType == typeof(TestRepository)); + } + + [Fact] + public void AddRepository_InvokesConfiguratorAction() { + var config = CreateConfig(); + var services = new ServiceCollection(); + var configurator = new HopFrameConfigurator(config, services); + + bool invoked = false; + + configurator.AddRepository(_ => { invoked = true; }); + + Assert.True(invoked); + } + + // ------------------------------------------------------------- + // AddTable + // ------------------------------------------------------------- + + [Fact] + public void AddTable_AddsValidTableToConfig() { + var config = CreateConfig(); + var services = new ServiceCollection(); + var configurator = new HopFrameConfigurator(config, services); + + var table = CreateValidTable(); + + configurator.AddTable(table); + + Assert.Single(config.Tables); + Assert.Equal(table, config.Tables[0]); + } + + [Fact] + public void AddTable_RegistersRepositoryType() { + var config = CreateConfig(); + var services = new ServiceCollection(); + var configurator = new HopFrameConfigurator(config, services); + + var table = CreateValidTable(); + + configurator.AddTable(table); + + Assert.Contains(services, d => d.ServiceType == typeof(TestRepository)); + } + + [Fact] + public void AddTable_Throws_WhenTableTypeDoesNotMatch() { + var config = CreateConfig(); + var services = new ServiceCollection(); + var configurator = new HopFrameConfigurator(config, services); + + var table = CreateValidTable(); + table.TableType = typeof(string); // falscher Typ + + var ex = Assert.Throws(() => + configurator.AddTable(table)); + + Assert.Contains("does not mach requested type", ex.Message); + } + + [Fact] + public void AddTable_Throws_WhenValidationFails() { + var config = CreateConfig(); + var services = new ServiceCollection(); + var configurator = new HopFrameConfigurator(config, services); + + var table = CreateValidTable(); + table.DisplayName = null!; // invalid + + var ex = Assert.Throws(() => + configurator.AddTable(table)); + + Assert.Contains("validation errors", ex.Message); + Assert.Contains("DisplayName cannot be null", ex.Message); + } + + [Fact] + public void AddTable_InvokesConfiguratorAction() { + var config = CreateConfig(); + var services = new ServiceCollection(); + var configurator = new HopFrameConfigurator(config, services); + + var table = CreateValidTable(); + + bool invoked = false; + + configurator.AddTable(table, _ => { invoked = true; }); + + Assert.True(invoked); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs b/tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs new file mode 100644 index 0000000..90813cd --- /dev/null +++ b/tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs @@ -0,0 +1,249 @@ +using System.Reflection; +using HopFrame.Core.Configuration; +using HopFrame.Core.Helpers; + +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + +namespace HopFrame.Tests.Core.Helpers; + +public class ConfigurationHelperTests { + private HopFrameConfig CreateGlobal(params TableConfig[] tables) + => new HopFrameConfig { Tables = tables.ToList() }; + + private TableConfig CreateValidTable() + => new TableConfig { + Identifier = "Test", + TableType = typeof(string), + RepositoryType = typeof(string), + Route = "/test", + DisplayName = "Test Table", + Properties = new List { + new PropertyConfig { + Identifier = "Prop1", + DisplayName = "Property 1", + Type = typeof(int) + } + } + }; + + private TableConfig CreateDummyTable(string identifier) + => new TableConfig { + Identifier = identifier, + TableType = typeof(object), + RepositoryType = typeof(object), + Route = identifier.ToLower(), + DisplayName = identifier, + OrderIndex = 0 + }; + + [Fact] + public void InitializeTable_UsesModelNameAsIdentifier_WhenUnique() { + var global = CreateGlobal(); + + var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel)); + + Assert.Equal("TestModel", config.Identifier); + } + + [Fact] + public void InitializeTable_GeneratesGuid_WhenIdentifierAlreadyExists() { + var existing = CreateDummyTable("TestModel"); + var global = CreateGlobal(existing); + + var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel)); + + Assert.NotEqual("TestModel", config.Identifier); + Assert.True(Guid.TryParse(config.Identifier, out _)); + } + + [Fact] + public void InitializeTable_SetsBasicFieldsCorrectly() { + var global = CreateGlobal(); + + var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel)); + + Assert.Equal(typeof(string), config.RepositoryType); + Assert.Equal(typeof(TestModel), config.TableType); + Assert.Equal("testmodel", config.Route); + Assert.Equal("TestModel", config.DisplayName); + Assert.Equal(0, config.OrderIndex); + } + + [Fact] + public void InitializeTable_SetsOrderIndex_ToCurrentTableCount() { + var global = CreateGlobal( + CreateDummyTable("A"), + CreateDummyTable("B") + ); + + var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel)); + + Assert.Equal(2, config.OrderIndex); + } + + [Fact] + public void InitializeTable_CreatesPropertyConfigs_ForAllModelProperties() { + var global = CreateGlobal(); + + var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel)); + + Assert.Equal(2, config.Properties.Count); + Assert.Contains(config.Properties, p => p.Identifier == "Id"); + Assert.Contains(config.Properties, p => p.Identifier == "Name"); + } + + [Fact] + public void InitializeProperty_UsesPropertyNameAsIdentifier_WhenUnique() { + var table = CreateDummyTable("T"); + var property = typeof(TestModel).GetProperty(nameof(TestModel.Id))!; + + var config = InvokeInitializeProperty(table, property); + + Assert.Equal("Id", config.Identifier); + } + + [Fact] + public void InitializeProperty_GeneratesGuid_WhenIdentifierAlreadyExists() { + var table = CreateDummyTable("T"); + table.Properties.Add(new PropertyConfig + { Identifier = "Id", Type = typeof(int), DisplayName = "Id", OrderIndex = 0 }); + + var property = typeof(TestModel).GetProperty(nameof(TestModel.Id))!; + + var config = InvokeInitializeProperty(table, property); + + Assert.NotEqual("Id", config.Identifier); + Assert.True(Guid.TryParse(config.Identifier, out _)); + } + + [Fact] + public void InitializeProperty_SetsBasicFieldsCorrectly() { + var table = CreateDummyTable("T"); + var property = typeof(TestModel).GetProperty(nameof(TestModel.Name))!; + + var config = InvokeInitializeProperty(table, property); + + Assert.Equal("Name", config.DisplayName); + Assert.Equal(typeof(string), config.Type); + Assert.Equal(0, config.OrderIndex); + } + + [Fact] + public void InitializeProperty_SetsOrderIndex_ToCurrentPropertyCount() { + var table = CreateDummyTable("T"); + table.Properties.Add(new PropertyConfig + { Identifier = "X", Type = typeof(int), DisplayName = "X", OrderIndex = 0 }); + + var property = typeof(TestModel).GetProperty(nameof(TestModel.Name))!; + + var config = InvokeInitializeProperty(table, property); + + Assert.Equal(1, config.OrderIndex); + } + + private PropertyConfig InvokeInitializeProperty(TableConfig table, PropertyInfo property) { + var method = typeof(ConfigurationHelper) + .GetMethod("InitializeProperty", BindingFlags.NonPublic | BindingFlags.Static)!; + + return (PropertyConfig)method.Invoke(null, [table, property])!; + } + + [Fact] + public void ValidateTable_ReturnsError_WhenIdentifierNotUnique() { + var config = CreateValidTable(); + var global = CreateGlobal(new TableConfig { + Identifier = "Test", + DisplayName = null, + TableType = null, + RepositoryType = null, + Route = null + }); + + var result = ConfigurationHelper.ValidateTable(global, config).ToList(); + + Assert.Contains("Table identifier 'Test' is not unique", result); + } + + [Fact] + public void ValidateTable_ReturnsError_WhenTableTypeIsNull() { + var config = CreateValidTable(); + config.TableType = null; + + var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList(); + + Assert.Contains("TableType cannot be null", result); + } + + [Fact] + public void ValidateTable_ReturnsError_WhenRepositoryTypeIsNull() { + var config = CreateValidTable(); + config.RepositoryType = null; + + var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList(); + + Assert.Contains("RepositoryType cannot be null", result); + } + + [Fact] + public void ValidateTable_ReturnsError_WhenRouteIsNull() { + var config = CreateValidTable(); + config.Route = null; + + var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList(); + + Assert.Contains("Route cannot be null", result); + } + + [Fact] + public void ValidateTable_ReturnsError_WhenDisplayNameIsNull() { + var config = CreateValidTable(); + config.DisplayName = null; + + var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList(); + + Assert.Contains("DisplayName cannot be null", result); + } + + [Fact] + public void ValidateTable_ReturnsError_WhenPropertyIdentifierNotUnique() { + var config = CreateValidTable(); + config.Properties.Add(new PropertyConfig { + Identifier = "Prop1", + DisplayName = "Duplicate", + Type = typeof(int) + }); + + var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList(); + + Assert.Contains("Property identifier 'Prop1' is not unique", result); + } + + [Fact] + public void ValidateTable_ReturnsError_WhenPropertyDisplayNameIsNull() { + var config = CreateValidTable(); + config.Properties[0].DisplayName = null; + + var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList(); + + Assert.Contains("Property 'Prop1': DisplayName cannot be null", result); + } + + [Fact] + public void ValidateTable_ReturnsError_WhenPropertyTypeIsNull() { + var config = CreateValidTable(); + config.Properties[0].Type = null; + + var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList(); + + Assert.Contains("Property 'Prop1': Type cannot be null", result); + } + + [Fact] + public void ValidateTable_ReturnsNoErrors_WhenConfigIsValid() { + var config = CreateValidTable(); + + var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList(); + + Assert.Empty(result); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Core/Helpers/ExpressionHelperTests.cs b/tests/HopFrame.Tests.Core/Helpers/ExpressionHelperTests.cs new file mode 100644 index 0000000..37fe5f4 --- /dev/null +++ b/tests/HopFrame.Tests.Core/Helpers/ExpressionHelperTests.cs @@ -0,0 +1,44 @@ +using System.Linq.Expressions; +using HopFrame.Core.Helpers; + +namespace HopFrame.Tests.Core.Helpers; + +public class ExpressionHelperTests { + + [Fact] + public void GetPropertyInfo_ReturnsPropertyInfo_WhenExpressionIsValid() { + Expression> expr = x => x.Id; + + var prop = ExpressionHelper.GetPropertyInfo(expr); + + Assert.Equal(nameof(TestModel.Id), prop.Name); + Assert.Equal(typeof(int), prop.PropertyType); + } + + [Fact] + public void GetPropertyInfo_Throws_WhenExpressionRefersToMethod() { + Expression> expr = x => x.Method(); + + var ex = Assert.Throws(() => ExpressionHelper.GetPropertyInfo(expr)); + + Assert.Contains("refers to a method", ex.Message); + } + + [Fact] + public void GetPropertyInfo_Throws_WhenExpressionRefersToField() { + Expression> expr = x => x.FieldBacking; + + var ex = Assert.Throws(() => ExpressionHelper.GetPropertyInfo(expr)); + + Assert.Contains("refers to a field", ex.Message); + } + + [Fact] + public void GetPropertyInfo_Throws_WhenExpressionBodyIsNotMemberExpression() { + Expression> expr = x => 5; + + var ex = Assert.Throws(() => ExpressionHelper.GetPropertyInfo(expr)); + + Assert.Contains("refers to a method, not a property", ex.Message); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj b/tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj new file mode 100644 index 0000000..68cc8e9 --- /dev/null +++ b/tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/HopFrame.Tests.Core/Repositories/HopFrameRepositoryTests.cs b/tests/HopFrame.Tests.Core/Repositories/HopFrameRepositoryTests.cs new file mode 100644 index 0000000..a666aad --- /dev/null +++ b/tests/HopFrame.Tests.Core/Repositories/HopFrameRepositoryTests.cs @@ -0,0 +1,98 @@ +using HopFrame.Core.Repositories; +using Moq; + +namespace HopFrame.Tests.Core.Repositories; + +public class HopFrameRepositoryTests { + + private Mock> CreateMock() + => new(MockBehavior.Strict); + + // ------------------------------------------------------------- + // LoadPageGenericAsync + // ------------------------------------------------------------- + + [Fact] + public async Task LoadPageGenericAsync_DelegatesToTypedMethod() { + var mock = CreateMock(); + + var expected = new List { new TestModel { Id = 1 } }; + + mock.Setup(r => r.LoadPageAsync(2, 10, It.IsAny())) + .ReturnsAsync(expected); + + var result = await mock.Object.LoadPageGenericAsync(2, 10); + + Assert.Equal(expected, result); + } + + // ------------------------------------------------------------- + // SearchGenericAsync + // ------------------------------------------------------------- + + [Fact] + public async Task SearchGenericAsync_DelegatesToTypedMethod() { + var mock = CreateMock(); + + var expected = new List { new TestModel { Id = 5 } }; + + mock.Setup(r => r.SearchAsync("abc", 1, 20, It.IsAny())) + .ReturnsAsync(expected); + + var result = await mock.Object.SearchGenericAsync("abc", 1, 20); + + Assert.Equal(expected, result); + } + + // ------------------------------------------------------------- + // CreateGenericAsync + // ------------------------------------------------------------- + + [Fact] + public async Task CreateGenericAsync_CastsAndDelegates() { + var mock = CreateMock(); + + var model = new TestModel { Id = 99 }; + + mock.Setup(r => r.CreateAsync(model, It.IsAny())) + .Returns(Task.CompletedTask); + + await mock.Object.CreateGenericAsync(model, CancellationToken.None); + + mock.Verify(r => r.CreateAsync(model, It.IsAny()), Times.Once); + } + + // ------------------------------------------------------------- + // DeleteGenericAsync + // ------------------------------------------------------------- + + [Fact] + public async Task DeleteGenericAsync_CastsAndDelegates() { + var mock = CreateMock(); + + var model = new TestModel { Id = 42 }; + + mock.Setup(r => r.DeleteAsync(model, It.IsAny())) + .Returns(Task.CompletedTask); + + await mock.Object.DeleteGenericAsync(model, CancellationToken.None); + + mock.Verify(r => r.DeleteAsync(model, It.IsAny()), Times.Once); + } + + // ------------------------------------------------------------- + // CountAsync (direct abstract method) + // ------------------------------------------------------------- + + [Fact] + public async Task CountAsync_CanBeMockedAndReturnsValue() { + var mock = CreateMock(); + + mock.Setup(r => r.CountAsync(It.IsAny())) + .ReturnsAsync(123); + + var result = await mock.Object.CountAsync(); + + Assert.Equal(123, result); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Core/Services/Implementation/ConfigAccessorTests.cs b/tests/HopFrame.Tests.Core/Services/Implementation/ConfigAccessorTests.cs new file mode 100644 index 0000000..b2d3f9e --- /dev/null +++ b/tests/HopFrame.Tests.Core/Services/Implementation/ConfigAccessorTests.cs @@ -0,0 +1,160 @@ +using System.Collections; +using HopFrame.Core.Configuration; +using HopFrame.Core.Repositories; +using HopFrame.Core.Services.Implementation; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace HopFrame.Tests.Core.Services.Implementation; + +public class ConfigAccessorTests { + private class TestRepository : IHopFrameRepository { + public Task LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) { + throw new NotImplementedException(); + } + public Task CountAsync(CancellationToken ct = default) { + throw new NotImplementedException(); + } + public Task SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) { + throw new NotImplementedException(); + } + public Task CreateGenericAsync(object entry, CancellationToken ct) { + throw new NotImplementedException(); + } + public Task DeleteGenericAsync(object entry, CancellationToken ct) { + throw new NotImplementedException(); + } + } + + private TableConfig CreateTable(string id, string route, Type type) + => new TableConfig { + Identifier = id, + Route = route, + TableType = type, + RepositoryType = typeof(TestRepository), + DisplayName = id, + OrderIndex = 0, + Properties = new List { + new PropertyConfig { + Identifier = "Id", + DisplayName = "Id", + Type = typeof(int), + OrderIndex = 0 + } + } + }; + + private HopFrameConfig CreateConfig(params TableConfig[] tables) + => new HopFrameConfig { Tables = new List(tables) }; + + // ------------------------------------------------------------- + // GetTableByIdentifier + // ------------------------------------------------------------- + + [Fact] + public void GetTableByIdentifier_ReturnsCorrectTable() { + var table = CreateTable("A", "a", typeof(TestModel)); + var config = CreateConfig(table); + + var accessor = new ConfigAccessor(config, Mock.Of()); + + var result = accessor.GetTableByIdentifier("A"); + + Assert.Equal(table, result); + } + + [Fact] + public void GetTableByIdentifier_ReturnsNull_WhenNotFound() { + var config = CreateConfig(); + var accessor = new ConfigAccessor(config, Mock.Of()); + + var result = accessor.GetTableByIdentifier("missing"); + + Assert.Null(result); + } + + // ------------------------------------------------------------- + // GetTableByRoute + // ------------------------------------------------------------- + + [Fact] + public void GetTableByRoute_ReturnsCorrectTable() { + var table = CreateTable("A", "routeA", typeof(TestModel)); + var config = CreateConfig(table); + + var accessor = new ConfigAccessor(config, Mock.Of()); + + var result = accessor.GetTableByRoute("routeA"); + + Assert.Equal(table, result); + } + + [Fact] + public void GetTableByRoute_ReturnsNull_WhenNotFound() { + var config = CreateConfig(); + var accessor = new ConfigAccessor(config, Mock.Of()); + + var result = accessor.GetTableByRoute("missing"); + + Assert.Null(result); + } + + // ------------------------------------------------------------- + // GetTableByType + // ------------------------------------------------------------- + + [Fact] + public void GetTableByType_ReturnsCorrectTable() { + var table = CreateTable("A", "a", typeof(TestModel)); + var config = CreateConfig(table); + + var accessor = new ConfigAccessor(config, Mock.Of()); + + var result = accessor.GetTableByType(typeof(TestModel)); + + Assert.Equal(table, result); + } + + [Fact] + public void GetTableByType_ReturnsNull_WhenNotFound() { + var config = CreateConfig(); + var accessor = new ConfigAccessor(config, Mock.Of()); + + var result = accessor.GetTableByType(typeof(TestModel)); + + Assert.Null(result); + } + + // ------------------------------------------------------------- + // LoadRepository + // ------------------------------------------------------------- + + [Fact] + public void LoadRepository_ResolvesRepositoryFromServiceProvider() { + var table = CreateTable("A", "a", typeof(TestModel)); + + var repo = new TestRepository(); + + var providerMock = new Mock(); + providerMock + .Setup(p => p.GetService(typeof(TestRepository))) + .Returns(repo); + + var accessor = new ConfigAccessor(CreateConfig(table), providerMock.Object); + + var result = accessor.LoadRepository(table); + + Assert.Equal(repo, result); + } + + [Fact] + public void LoadRepository_Throws_WhenServiceNotRegistered() { + var table = CreateTable("A", "a", typeof(TestModel)); + + var provider = new ServiceCollection().BuildServiceProvider(); + + var accessor = new ConfigAccessor(CreateConfig(table), provider); + + Assert.Throws(() => accessor.LoadRepository(table)); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Core/TestModel.cs b/tests/HopFrame.Tests.Core/TestModel.cs new file mode 100644 index 0000000..c7239f6 --- /dev/null +++ b/tests/HopFrame.Tests.Core/TestModel.cs @@ -0,0 +1,10 @@ +namespace HopFrame.Tests.Core; + +public class TestModel { + public int Id { get; set; } + public string Name { get; set; } + + public int Method() => 42; + + public int FieldBacking; +} \ No newline at end of file -- 2.49.1