Compare commits
2 Commits
79ed400185
...
e8ac7eb88a
| Author | SHA1 | Date | |
|---|---|---|---|
| e8ac7eb88a | |||
| b2a029d50b |
46
.gitea/workflows/ci.yml
Normal file
46
.gitea/workflows/ci.yml
Normal 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
|
||||
@@ -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>
|
||||
|
||||
21
debug/TestApplication/Components/App.razor
Normal file
21
debug/TestApplication/Components/App.razor
Normal 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>
|
||||
9
debug/TestApplication/Components/Layout/MainLayout.razor
Normal file
9
debug/TestApplication/Components/Layout/MainLayout.razor
Normal 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>
|
||||
20
debug/TestApplication/Components/Layout/MainLayout.razor.css
Normal file
20
debug/TestApplication/Components/Layout/MainLayout.razor.css
Normal 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;
|
||||
}
|
||||
31
debug/TestApplication/Components/Layout/ReconnectModal.razor
Normal file
31
debug/TestApplication/Components/Layout/ReconnectModal.razor
Normal 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>
|
||||
157
debug/TestApplication/Components/Layout/ReconnectModal.razor.css
Normal file
157
debug/TestApplication/Components/Layout/ReconnectModal.razor.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
35
debug/TestApplication/Components/Pages/Error.razor
Normal file
35
debug/TestApplication/Components/Pages/Error.razor
Normal 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;
|
||||
|
||||
}
|
||||
7
debug/TestApplication/Components/Pages/Home.razor
Normal file
7
debug/TestApplication/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,7 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
5
debug/TestApplication/Components/Pages/NotFound.razor
Normal file
5
debug/TestApplication/Components/Pages/NotFound.razor
Normal 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>
|
||||
6
debug/TestApplication/Components/Routes.razor
Normal file
6
debug/TestApplication/Components/Routes.razor
Normal 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>
|
||||
11
debug/TestApplication/Components/_Imports.razor
Normal file
11
debug/TestApplication/Components/_Imports.razor
Normal 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
|
||||
27
debug/TestApplication/Program.cs
Normal file
27
debug/TestApplication/Program.cs
Normal 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();
|
||||
23
debug/TestApplication/Properties/launchSettings.json
Normal file
23
debug/TestApplication/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
debug/TestApplication/TestApplication.csproj
Normal file
14
debug/TestApplication/TestApplication.csproj
Normal 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>
|
||||
8
debug/TestApplication/appsettings.Development.json
Normal file
8
debug/TestApplication/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
debug/TestApplication/appsettings.json
Normal file
9
debug/TestApplication/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
38
debug/TestApplication/wwwroot/app.css
Normal file
38
debug/TestApplication/wwwroot/app.css
Normal 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;
|
||||
}
|
||||
11
src/HopFrame.Core/Configuration/HopFrameConfig.cs
Normal file
11
src/HopFrame.Core/Configuration/HopFrameConfig.cs
Normal 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() {}
|
||||
}
|
||||
41
src/HopFrame.Core/Configuration/PropertyConfig.cs
Normal file
41
src/HopFrame.Core/Configuration/PropertyConfig.cs
Normal 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() {}
|
||||
}
|
||||
32
src/HopFrame.Core/Configuration/TableConfig.cs
Normal file
32
src/HopFrame.Core/Configuration/TableConfig.cs
Normal 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() {}
|
||||
}
|
||||
51
src/HopFrame.Core/Configurators/HopFrameConfigurator.cs
Normal file
51
src/HopFrame.Core/Configurators/HopFrameConfigurator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/HopFrame.Core/Configurators/PropertyConfigurator.cs
Normal file
59
src/HopFrame.Core/Configurators/PropertyConfigurator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/HopFrame.Core/Configurators/TableConfigurator.cs
Normal file
59
src/HopFrame.Core/Configurators/TableConfigurator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
75
src/HopFrame.Core/Helpers/ConfigurationHelper.cs
Normal file
75
src/HopFrame.Core/Helpers/ConfigurationHelper.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
27
src/HopFrame.Core/Helpers/ExpressionHelper.cs
Normal file
27
src/HopFrame.Core/Helpers/ExpressionHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
24
src/HopFrame.Core/HopFrame.Core.csproj
Normal file
24
src/HopFrame.Core/HopFrame.Core.csproj
Normal 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>
|
||||
43
src/HopFrame.Core/Repositories/HopFrameRepository.cs
Normal file
43
src/HopFrame.Core/Repositories/HopFrameRepository.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
42
src/HopFrame.Core/Repositories/IHopFrameRepository.cs
Normal file
42
src/HopFrame.Core/Repositories/IHopFrameRepository.cs
Normal 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);
|
||||
|
||||
}
|
||||
22
src/HopFrame.Core/ServiceCollectionExtensions.cs
Normal file
22
src/HopFrame.Core/ServiceCollectionExtensions.cs
Normal 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));
|
||||
}
|
||||
|
||||
}
|
||||
33
src/HopFrame.Core/Services/IConfigAccessor.cs
Normal file
33
src/HopFrame.Core/Services/IConfigAccessor.cs
Normal 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);
|
||||
|
||||
}
|
||||
25
src/HopFrame.Core/Services/Implementation/ConfigAccessor.cs
Normal file
25
src/HopFrame.Core/Services/Implementation/ConfigAccessor.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
249
tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs
Normal file
249
tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
44
tests/HopFrame.Tests.Core/Helpers/ExpressionHelperTests.cs
Normal file
44
tests/HopFrame.Tests.Core/Helpers/ExpressionHelperTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj
Normal file
26
tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
10
tests/HopFrame.Tests.Core/TestModel.cs
Normal file
10
tests/HopFrame.Tests.Core/TestModel.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user