+ 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.
+
+
+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