Merge pull request 'Added configurators' (#81) from feature/config into dev
All checks were successful
HopFrame CI / build (push) Successful in 39s
HopFrame CI / test (push) Successful in 47s

Reviewed-on: #81
This commit was merged in pull request #81.
This commit is contained in:
2026-02-22 19:40:08 +01:00
40 changed files with 1837 additions and 1 deletions

46
.gitea/workflows/ci.yml Normal file
View File

@@ -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

View File

@@ -1 +1,11 @@
<Solution /> <Solution>
<Folder Name="/debug/">
<Project Path="debug/TestApplication/TestApplication.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/HopFrame.Core/HopFrame.Core.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<ResourcePreloader/>
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["TestApplication.styles.css"]"/>
<ImportMap/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<ReconnectModal/>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
@inherits LayoutComponentBase
@Body
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -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;
}

View File

@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br/>Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<button id="components-resume-button" class="components-pause-visible">
Resume
</button>
<p class="components-resume-failed-visible">
Failed to resume the session.<br/>Please reload the page.
</p>
</div>
</dialog>

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,35 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId) {
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@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;
}

View File

@@ -0,0 +1,7 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.

View File

@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>

View File

@@ -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

View File

@@ -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<App>()
.AddInteractiveServerRenderMode();
app.Run();

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HopFrame.Core\HopFrame.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -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;
}

View File

@@ -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<TableConfig> Tables { get; set; } = new List<TableConfig>();
internal HopFrameConfig() {}
}

View File

@@ -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() {}
}

View File

@@ -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<PropertyConfig> Properties { get; set; } = new List<PropertyConfig>();
/** 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() {}
}

View File

@@ -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 <see cref="HopFrameConfig"/>
*/
public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection services) {
/** The internal config that is modified */
public HopFrameConfig Config { get; } = config;
/// <summary>
/// Adds a new table to the configuration based on the provided repository
/// </summary>
/// <typeparam name="TRepository">The repository that handles the table</typeparam>
/// <typeparam name="TModel">The type of the model</typeparam>
/// <param name="configurator">The configurator for the table</param>
public HopFrameConfigurator AddRepository<TRepository, TModel>(Action<TableConfigurator<TModel>>? 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<TModel>(table));
return this;
}
/// <summary>
/// Adds a new table to the configuration
/// </summary>
/// <param name="config">The configuration for the table</param>
/// <param name="configurator">The configurator for the table</param>
/// <typeparam name="TModel">The model of the table</typeparam>
/// <exception cref="ArgumentException">Is thrown when configuration validation fails</exception>
public HopFrameConfigurator AddTable<TModel>(TableConfig config, Action<TableConfigurator<TModel>>? 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<TModel>(config));
return this;
}
}

View File

@@ -0,0 +1,59 @@
using HopFrame.Core.Configuration;
namespace HopFrame.Core.Configurators;
/**
* The configurator for the <see cref="PropertyConfig"/>
*/
public class PropertyConfigurator(PropertyConfig config) {
/** The internal config that is modified */
public PropertyConfig Config { get; } = config;
/** <inheritdoc cref="PropertyConfig.DisplayName" /> */
public PropertyConfigurator SetDisplayName(string displayName) {
Config.DisplayName = displayName;
return this;
}
/** <inheritdoc cref="PropertyConfig.Listable" /> */
public PropertyConfigurator Listable(bool listable) {
Config.Listable = listable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Sortable" /> */
public PropertyConfigurator Sortable(bool sortable) {
Config.Sortable = sortable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Searchable" /> */
public PropertyConfigurator Searchable(bool searchable) {
Config.Searchable = searchable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Editable" /> */
public PropertyConfigurator Editable(bool editable) {
Config.Editable = editable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Creatable" /> */
public PropertyConfigurator Creatable(bool creatable) {
Config.Creatable = creatable;
return this;
}
/** <inheritdoc cref="PropertyConfig.DisplayValue" /> */
public PropertyConfigurator DisplayValue(bool displayValue) {
Config.DisplayValue = displayValue;
return this;
}
/** <inheritdoc cref="PropertyConfig.OrderIndex" /> */
public PropertyConfigurator SetOrderIndex(int index) {
Config.OrderIndex = index;
return this;
}
}

View File

@@ -0,0 +1,59 @@
using System.Linq.Expressions;
using HopFrame.Core.Configuration;
using HopFrame.Core.Helpers;
namespace HopFrame.Core.Configurators;
/**
* The configurator for the <see cref="TableConfig"/>
*/
public class TableConfigurator<TModel>(TableConfig config) where TModel : notnull {
/** The internal config that is modified */
public TableConfig Config { get; } = config;
/** <inheritdoc cref="TableConfig.Route"/> */
public TableConfigurator<TModel> SetRoute(string route) {
Config.Route = route;
return this;
}
/** <inheritdoc cref="TableConfig.DisplayName"/> */
public TableConfigurator<TModel> SetDisplayName(string displayName) {
Config.DisplayName = displayName;
return this;
}
/** <inheritdoc cref="TableConfig.Description"/> */
public TableConfigurator<TModel> SetDescription(string description) {
Config.Description = description;
return this;
}
/** <inheritdoc cref="TableConfig.OrderIndex"/> */
public TableConfigurator<TModel> 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);
}
/** <inheritdoc cref="Property"/> */
public PropertyConfigurator Property<TProp>(Expression<Func<TModel, TProp>> 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);
}
}

View File

@@ -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<string> 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";
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Linq.Expressions;
using System.Reflection;
namespace HopFrame.Core.Helpers;
internal static class ExpressionHelper {
public static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> 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;
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>true</IsPackable>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageId>HopFrame.Core</PackageId>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>HopFrame.Tests.Core</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -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<TModel> : IHopFrameRepository where TModel : notnull {
/** <inheritdoc cref="LoadPageGenericAsync"/> */
public abstract Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default);
/** <inheritdoc/> */
public abstract Task<int> CountAsync(CancellationToken ct = default);
/** <inheritdoc cref="SearchGenericAsync"/> */
public abstract Task<IEnumerable<TModel>> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default);
/** <inheritdoc cref="CreateGenericAsync"/> */
public abstract Task CreateAsync(TModel entry, CancellationToken ct);
/** <inheritdoc cref="DeleteGenericAsync"/> */
public abstract Task DeleteAsync(TModel entry, CancellationToken ct);
/** <inheritdoc/> */
public async Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) {
return await LoadPageAsync(page, perPage, ct);
}
/** <inheritdoc/> */
public async Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
return await SearchAsync(searchTerm, page, perPage, ct);
}
/** <inheritdoc/> */
public Task CreateGenericAsync(object entry, CancellationToken ct) {
return CreateAsync((TModel)entry, ct);
}
/** <inheritdoc/> */
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
return DeleteAsync((TModel)entry, ct);
}
}

View File

@@ -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 {
/// <summary>
/// Loads a whole page of entries
/// </summary>
/// <param name="page">The index of the current page (starts at 0)</param>
/// <param name="perPage">The amount of entries that should be loaded</param>
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default);
/// <summary>
/// Returns the total amount of entries in the dataset
/// </summary>
public Task<int> CountAsync(CancellationToken ct = default);
/// <summary>
/// Searches through the whole dataset and returns a page of matching entries
/// </summary>
/// <param name="searchTerm">The search text provided by the user</param>
/// <param name="page">The index of the current page (starts at 0)</param>
/// <param name="perPage">The amount of entries that should be loaded</param>
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default);
/// <summary>
/// Saves the newly created entry to the dataset
/// </summary>
/// <param name="entry">The entry that needs to be saved</param>
public Task CreateGenericAsync(object entry, CancellationToken ct);
/// <summary>
/// Deletes the provided entry from the dataset
/// </summary>
/// <param name="entry">The entry that needs to be deleted</param>
public Task DeleteGenericAsync(object entry, CancellationToken ct);
}

View File

@@ -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<HopFrameConfigurator> configurator) {
var config = new HopFrameConfig();
services.AddSingleton(config);
services.AddTransient<IConfigAccessor, ConfigAccessor>();
configurator.Invoke(new HopFrameConfigurator(config, services));
}
}

View File

@@ -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 <see cref="HopFrameConfig"/> */
public interface IConfigAccessor {
/// <summary>
/// Searches through the config and returns the table with the specified identifier if it exists
/// </summary>
/// <param name="identifier">The identifier of the table</param>
public TableConfig? GetTableByIdentifier(string identifier);
/// <summary>
/// Searches through the config and returns the table with the specified route if it exists
/// </summary>
/// <param name="route">The route of the table</param>
public TableConfig? GetTableByRoute(string route);
/// <summary>
/// Searches through the config and returns the table with the specified type if it exists
/// </summary>
/// <param name="type">The model type for the table</param>
public TableConfig? GetTableByType(Type type);
/// <summary>
/// Loads the repository for the specified table
/// </summary>
/// <param name="table">The table to load the repository for</param>
public IHopFrameRepository LoadRepository(TableConfig table);
}

View File

@@ -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);
}
}

View File

@@ -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<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) {
throw new NotImplementedException();
}
public Task<int> CountAsync(CancellationToken ct = default) {
throw new NotImplementedException();
}
public Task<IEnumerable> 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<TableConfig>() };
private TableConfig CreateValidTable<TModel>()
=> 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<PropertyConfig> {
new PropertyConfig {
Identifier = "Id",
DisplayName = "Id",
Type = typeof(int),
OrderIndex = 0
}
}
};
// -------------------------------------------------------------
// AddRepository<TRepository, TModel>
// -------------------------------------------------------------
[Fact]
public void AddRepository_AddsTableToConfig() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
configurator.AddRepository<TestRepository, TestModel>();
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<TestRepository, TestModel>();
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<TestRepository, TestModel>(_ => { invoked = true; });
Assert.True(invoked);
}
// -------------------------------------------------------------
// AddTable<TModel>
// -------------------------------------------------------------
[Fact]
public void AddTable_AddsValidTableToConfig() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
var table = CreateValidTable<TestModel>();
configurator.AddTable<TestModel>(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<TestModel>();
configurator.AddTable<TestModel>(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<TestModel>();
table.TableType = typeof(string); // falscher Typ
var ex = Assert.Throws<ArgumentException>(() =>
configurator.AddTable<TestModel>(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<TestModel>();
table.DisplayName = null!; // invalid
var ex = Assert.Throws<ArgumentException>(() =>
configurator.AddTable<TestModel>(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<TestModel>();
bool invoked = false;
configurator.AddTable<TestModel>(table, _ => { invoked = true; });
Assert.True(invoked);
}
}

View File

@@ -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<PropertyConfig> {
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);
}
}

View File

@@ -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<Func<TestModel, int>> 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<Func<TestModel, int>> expr = x => x.Method();
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
Assert.Contains("refers to a method", ex.Message);
}
[Fact]
public void GetPropertyInfo_Throws_WhenExpressionRefersToField() {
Expression<Func<TestModel, int>> expr = x => x.FieldBacking;
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
Assert.Contains("refers to a field", ex.Message);
}
[Fact]
public void GetPropertyInfo_Throws_WhenExpressionBodyIsNotMemberExpression() {
Expression<Func<TestModel, int>> expr = x => 5;
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
Assert.Contains("refers to a method, not a property", ex.Message);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HopFrame.Core\HopFrame.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,98 @@
using HopFrame.Core.Repositories;
using Moq;
namespace HopFrame.Tests.Core.Repositories;
public class HopFrameRepositoryTests {
private Mock<HopFrameRepository<TestModel>> CreateMock()
=> new(MockBehavior.Strict);
// -------------------------------------------------------------
// LoadPageGenericAsync
// -------------------------------------------------------------
[Fact]
public async Task LoadPageGenericAsync_DelegatesToTypedMethod() {
var mock = CreateMock();
var expected = new List<TestModel> { new TestModel { Id = 1 } };
mock.Setup(r => r.LoadPageAsync(2, 10, It.IsAny<CancellationToken>()))
.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<TestModel> { new TestModel { Id = 5 } };
mock.Setup(r => r.SearchAsync("abc", 1, 20, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.Returns(Task.CompletedTask);
await mock.Object.CreateGenericAsync(model, CancellationToken.None);
mock.Verify(r => r.CreateAsync(model, It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.Returns(Task.CompletedTask);
await mock.Object.DeleteGenericAsync(model, CancellationToken.None);
mock.Verify(r => r.DeleteAsync(model, It.IsAny<CancellationToken>()), Times.Once);
}
// -------------------------------------------------------------
// CountAsync (direct abstract method)
// -------------------------------------------------------------
[Fact]
public async Task CountAsync_CanBeMockedAndReturnsValue() {
var mock = CreateMock();
mock.Setup(r => r.CountAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(123);
var result = await mock.Object.CountAsync();
Assert.Equal(123, result);
}
}

View File

@@ -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<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) {
throw new NotImplementedException();
}
public Task<int> CountAsync(CancellationToken ct = default) {
throw new NotImplementedException();
}
public Task<IEnumerable> 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<PropertyConfig> {
new PropertyConfig {
Identifier = "Id",
DisplayName = "Id",
Type = typeof(int),
OrderIndex = 0
}
}
};
private HopFrameConfig CreateConfig(params TableConfig[] tables)
=> new HopFrameConfig { Tables = new List<TableConfig>(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<IServiceProvider>());
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<IServiceProvider>());
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<IServiceProvider>());
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<IServiceProvider>());
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<IServiceProvider>());
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<IServiceProvider>());
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<IServiceProvider>();
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<InvalidOperationException>(() => accessor.LoadRepository(table));
}
}

View File

@@ -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;
}