init: scaffold Junction multi-protocol machine monitor (M1 walking skeleton)
Greenfield C# app reading industrial machine data over pluggable protocols. M1 walking skeleton: MTConnect plugin polls a machine, Avalonia dashboard shows live last datum. Build 0 warn/0 err on net48 + net8.0 (Linux), 115 unit tests + 2 docker-gated integration tests. Projects: - Junction.Domain (netstandard2.0): Result<T>, models, IProtocolDriver, IMachineRepository, plugin manifest. Zero package deps. - Junction.Core (netstandard2.0): PollingEngine, PluginLoader (Assembly.LoadFrom), MachineMonitor, DI extensions. - Junction.Persistence (netstandard2.0): SqliteMachineRepository (Dapper), schema, connection factory. Provider-swap seam to SQL Server. - Junction.Protocols.MTConnect (netstandard2.0): HTTP driver + namespace- version-agnostic parser (MTConnect 1.7 + 2.0). Runtime plugin. - Junction.App (net48;net8.0): Avalonia MVVM, live dashboard, NLog, DI root. - Junction.Tests (net8.0): xUnit + Moq, 117 tests. - mock/: docker-compose MTConnect agent (ladder99/agent). Target net48 for Windows 7/8 fleet compatibility. Avalonia pinned 11.3.x. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
507753f82e
78 changed files with 6364 additions and 0 deletions
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
## .NET build output
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Oo]ut/
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
## IDE / editor
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
*.userprefs
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
## Rider
|
||||||
|
.idea/
|
||||||
|
*.sln.iml
|
||||||
|
|
||||||
|
## Test / coverage results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
## NuGet
|
||||||
|
*.nupkg
|
||||||
|
*.snupkg
|
||||||
|
.nuget/
|
||||||
|
packages/
|
||||||
|
!packages/build/
|
||||||
|
|
||||||
|
## MSBuild / Roslyn caches
|
||||||
|
*.binlog
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
|
||||||
|
## OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
14
Directory.Build.props
Normal file
14
Directory.Build.props
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Enables building net48 target-framework libraries on Linux/macOS via reference assemblies. -->
|
||||||
|
<ItemGroup Condition="'$(TargetFramework)'=='net48'">
|
||||||
|
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
64
Junction.sln
Normal file
64
Junction.sln
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6DDDE464-201D-4B4F-A23E-566B5FC111BC}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Domain", "src\Junction.Domain\Junction.Domain.csproj", "{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Core", "src\Junction.Core\Junction.Core.csproj", "{56814732-D6CE-4BA9-8686-7540270FBCE3}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Persistence", "src\Junction.Persistence\Junction.Persistence.csproj", "{AF355443-8E2A-43C5-9FB2-AE263A14F08B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Protocols.MTConnect", "src\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj", "{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.App", "src\Junction.App\Junction.App.csproj", "{965918A3-4C29-4F6A-B53D-0A72BDF4376C}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{362A84DF-21D2-4DA7-B8B5-8C5E3C6E8500}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Tests", "tests\Junction.Tests\Junction.Tests.csproj", "{618E6634-A16C-4806-836A-185ECDEE7312}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{56814732-D6CE-4BA9-8686-7540270FBCE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{56814732-D6CE-4BA9-8686-7540270FBCE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{56814732-D6CE-4BA9-8686-7540270FBCE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{56814732-D6CE-4BA9-8686-7540270FBCE3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{AF355443-8E2A-43C5-9FB2-AE263A14F08B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{AF355443-8E2A-43C5-9FB2-AE263A14F08B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{AF355443-8E2A-43C5-9FB2-AE263A14F08B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{AF355443-8E2A-43C5-9FB2-AE263A14F08B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{965918A3-4C29-4F6A-B53D-0A72BDF4376C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{965918A3-4C29-4F6A-B53D-0A72BDF4376C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{965918A3-4C29-4F6A-B53D-0A72BDF4376C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{965918A3-4C29-4F6A-B53D-0A72BDF4376C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{618E6634-A16C-4806-836A-185ECDEE7312}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{618E6634-A16C-4806-836A-185ECDEE7312}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
|
{56814732-D6CE-4BA9-8686-7540270FBCE3} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
|
{AF355443-8E2A-43C5-9FB2-AE263A14F08B} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
|
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
|
{965918A3-4C29-4F6A-B53D-0A72BDF4376C} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
|
{618E6634-A16C-4806-836A-185ECDEE7312} = {362A84DF-21D2-4DA7-B8B5-8C5E3C6E8500}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
6
global.json
Normal file
6
global.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "8.0.129",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
mock/README.md
Normal file
33
mock/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# MTConnect Mock Agent
|
||||||
|
|
||||||
|
DEV MOCK only. Not prod. Local MTConnect agent + built-in VMC-3Axis simulator producing LIVE CHANGING data. For testing the MTConnect protocol driver.
|
||||||
|
|
||||||
|
Image: `ladder99/agent:latest` (C++ cppagent + simulator adapter on :7878).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # start agent (detached)
|
||||||
|
docker compose down # stop + remove
|
||||||
|
docker compose logs -f agent # follow logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent serves on http://localhost:5000
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5000/probe # device model (MTConnectDevices XML)
|
||||||
|
curl http://localhost:5000/current # latest value per data item (MTConnectStreams XML)
|
||||||
|
curl http://localhost:5000/sample # historical observations from buffer (MTConnectStreams XML)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `/probe` — static device model. Devices, Components, DataItems. Device `VMC-3Axis`.
|
||||||
|
- `/current` — snapshot: newest value + timestamp per DataItem. Poll repeatedly -> values change (Xact/Yact positions, SpindleSpeed, Execution, Line, Block).
|
||||||
|
- `/sample` — stream of observations from ring buffer. Params: `?from=<seq>&count=<n>&path=<xpath>`.
|
||||||
|
|
||||||
|
## Proof live
|
||||||
|
|
||||||
|
Two `/current` calls ~3s apart: `Position Xact` 0.8996 -> -0.2139, `Yact` -1.1855 -> -1.4569, `Line` 263 -> 271. Simulator runs an NC program on loop.
|
||||||
13
mock/docker-compose.yml
Normal file
13
mock/docker-compose.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
services:
|
||||||
|
agent:
|
||||||
|
image: ladder99/agent:latest
|
||||||
|
container_name: mtconnect-mock-agent
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:5000/probe > /dev/null || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
11
src/Junction.App/App.axaml
Normal file
11
src/Junction.App/App.axaml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="clr-namespace:Junction.App"
|
||||||
|
x:Class="Junction.App.App">
|
||||||
|
<Application.DataTemplates>
|
||||||
|
<local:ViewLocator />
|
||||||
|
</Application.DataTemplates>
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
148
src/Junction.App/App.axaml.cs
Normal file
148
src/Junction.App/App.axaml.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Junction.App.ViewModels;
|
||||||
|
using Junction.Core.Monitoring;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Persistence;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Junction.App
|
||||||
|
{
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
private IServiceProvider? _services;
|
||||||
|
private readonly CancellationTokenSource _appCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
_services = Bootstrapper.Build();
|
||||||
|
var logger = _services.GetRequiredService<ILogger<App>>();
|
||||||
|
logger.LogInformation("Junction starting. Db={Db} Plugins={Plugins}",
|
||||||
|
Bootstrapper.DatabasePath, Bootstrapper.PluginsDirectory);
|
||||||
|
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var mainVm = _services.GetRequiredService<MainWindowViewModel>();
|
||||||
|
desktop.MainWindow = new MainWindow { DataContext = mainVm };
|
||||||
|
|
||||||
|
desktop.ShutdownRequested += (_, __) => Shutdown(logger);
|
||||||
|
|
||||||
|
// Seed → start monitor → load dashboard, off the UI thread.
|
||||||
|
_ = InitializeBackendAsync(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeBackendAsync(ILogger logger)
|
||||||
|
{
|
||||||
|
var provider = _services!;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SeedIfEmptyAsync(provider, logger).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var monitor = provider.GetRequiredService<IMachineMonitor>();
|
||||||
|
|
||||||
|
// Non-blocking start; a failed start is logged, not thrown.
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await monitor.StartAsync(Bootstrapper.PluginsDirectory, _appCts.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
var detail = result.Errors.Count > 0 ? result.Errors[0].Message : "unknown error";
|
||||||
|
logger.LogError("Machine monitor start failed: {Detail}", detail);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogInformation("Machine monitor started.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Machine monitor start threw.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the dashboard rows on the UI thread; it subscribes to live updates.
|
||||||
|
var dashboard = provider.GetRequiredService<DashboardViewModel>();
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(async () => await dashboard.LoadAsync().ConfigureAwait(true));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Backend initialization failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedIfEmptyAsync(IServiceProvider provider, ILogger logger)
|
||||||
|
{
|
||||||
|
var repo = provider.GetRequiredService<IMachineRepository>();
|
||||||
|
var existing = await repo.GetAllAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (!existing.IsSuccess)
|
||||||
|
{
|
||||||
|
var detail = existing.Errors.Count > 0 ? existing.Errors[0].Message : "unknown error";
|
||||||
|
logger.LogError("Seed check (GetAll) failed: {Detail}", detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.Value.Count > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Seed skipped; {Count} machine(s) already configured.", existing.Value.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var machine = new Machine(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"VMC Sim (mock)",
|
||||||
|
"mtconnect",
|
||||||
|
new Dictionary<string, string> { ["AgentUrl"] = "http://localhost:5000" },
|
||||||
|
TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
var upsert = await repo.UpsertAsync(machine, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (!upsert.IsSuccess)
|
||||||
|
{
|
||||||
|
var detail = upsert.Errors.Count > 0 ? upsert.Errors[0].Message : "unknown error";
|
||||||
|
logger.LogError("Seed upsert failed: {Detail}", detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Seeded machine {Name} ({Id}) → mtconnect http://localhost:5000",
|
||||||
|
machine.Name, machine.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown(ILogger logger)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_appCts.Cancel();
|
||||||
|
|
||||||
|
if (_services != null)
|
||||||
|
{
|
||||||
|
var monitor = _services.GetRequiredService<IMachineMonitor>();
|
||||||
|
monitor.StopAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var dashboard = _services.GetRequiredService<DashboardViewModel>();
|
||||||
|
dashboard.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Junction stopped.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Shutdown error.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/Junction.App/Bootstrapper.cs
Normal file
48
src/Junction.App/Bootstrapper.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Junction.App.ViewModels;
|
||||||
|
using Junction.Core;
|
||||||
|
using Junction.Persistence;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NLog.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Junction.App
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Composition root. Wires logging (NLog), persistence (SQLite), the Core monitoring stack,
|
||||||
|
/// and the app view-models into a single <see cref="IServiceProvider"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class Bootstrapper
|
||||||
|
{
|
||||||
|
/// <summary>Absolute path of the SQLite db, next to the running app.</summary>
|
||||||
|
public static string DatabasePath =>
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "junction.db");
|
||||||
|
|
||||||
|
/// <summary>Absolute path of the plugins directory the monitor scans.</summary>
|
||||||
|
public static string PluginsDirectory =>
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||||
|
|
||||||
|
/// <summary>Builds the app-wide service provider. Call once at startup.</summary>
|
||||||
|
public static IServiceProvider Build()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
|
services.AddLogging(b =>
|
||||||
|
{
|
||||||
|
b.ClearProviders();
|
||||||
|
b.SetMinimumLevel(LogLevel.Debug);
|
||||||
|
b.AddNLog();
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddJunctionPersistence("Data Source=" + DatabasePath);
|
||||||
|
services.AddJunctionCore();
|
||||||
|
|
||||||
|
// View-models. Dashboard is a singleton (holds live monitor subscription).
|
||||||
|
services.AddSingleton<DashboardViewModel>();
|
||||||
|
services.AddSingleton<MainWindowViewModel>();
|
||||||
|
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Junction.App/Junction.App.csproj
Normal file
55
src/Junction.App/Junction.App.csproj
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- net48 = shipped fleet (Win7/8); net8.0 = Linux dev iteration (dotnet run -f net8.0). -->
|
||||||
|
<TargetFrameworks>net48;net8.0</TargetFrameworks>
|
||||||
|
<!-- WinExe: no console window on Windows; on Linux net8.0 still runs. -->
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<RootNamespace>Junction.App</RootNamespace>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Avalonia 11.3.x supports net48; Avalonia 12 DROPS net48. PIN 11.3.10 exact everywhere. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10" />
|
||||||
|
<!-- Dev tooling only: not shipped to net48 fleet. -->
|
||||||
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10"
|
||||||
|
Condition="'$(Configuration)'=='Debug' and '$(TargetFramework)'=='net8.0'" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- NLog config copied next to the app so runtime target paths (logs/junction.log) resolve. -->
|
||||||
|
<None Include="nlog.config" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<!-- Concrete logging provider (Core depends on Logging.Abstractions). -->
|
||||||
|
<PackageReference Include="NLog" Version="5.3.4" />
|
||||||
|
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.15" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Composition root: needs these types at compile time. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Junction.Domain\Junction.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Junction.Core\Junction.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Junction.Persistence\Junction.Persistence.csproj" />
|
||||||
|
<!-- Plugin = RUNTIME artifact, NOT a compile reference. Build-order-only so we can copy its output. -->
|
||||||
|
<ProjectReference Include="..\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj"
|
||||||
|
ReferenceOutputAssembly="false" Private="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Copy MTConnect plugin (dll + manifest) into OutDir/plugins/mtconnect/ after build. -->
|
||||||
|
<Target Name="CopyMtconnectPlugin" AfterTargets="Build">
|
||||||
|
<ItemGroup>
|
||||||
|
<MtconnectPluginFiles Include="$(MSBuildThisFileDirectory)..\Junction.Protocols.MTConnect\bin\$(Configuration)\netstandard2.0\Junction.Protocols.MTConnect.dll" />
|
||||||
|
<MtconnectPluginFiles Include="$(MSBuildThisFileDirectory)..\Junction.Protocols.MTConnect\bin\$(Configuration)\netstandard2.0\plugin.manifest.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(MtconnectPluginFiles)" DestinationFolder="$(OutDir)plugins\mtconnect\" SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
11
src/Junction.App/MainWindow.axaml
Normal file
11
src/Junction.App/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:Junction.App.ViewModels"
|
||||||
|
x:Class="Junction.App.MainWindow"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
x:CompileBindings="True"
|
||||||
|
Title="Junction"
|
||||||
|
Width="800" Height="600">
|
||||||
|
<!-- Shell: hosts the current page; ViewLocator maps the VM to its View. -->
|
||||||
|
<ContentControl Content="{Binding CurrentPage}" />
|
||||||
|
</Window>
|
||||||
13
src/Junction.App/MainWindow.axaml.cs
Normal file
13
src/Junction.App/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace Junction.App
|
||||||
|
{
|
||||||
|
// T27 minimal bootstrap placeholder. T20 adds real dashboard.
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow() => InitializeComponent();
|
||||||
|
|
||||||
|
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Junction.App/Program.cs
Normal file
21
src/Junction.App/Program.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace Junction.App
|
||||||
|
{
|
||||||
|
// T27 minimal bootstrap: prove Avalonia launches on net48 + net8.0.
|
||||||
|
// T19 replaces with DI + navigation; T20 adds dashboard.
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args)
|
||||||
|
=> BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
// Avalonia XAML tooling / previewer entry point.
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Junction.App/ViewLocator.cs
Normal file
35
src/Junction.App/ViewLocator.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Templates;
|
||||||
|
using Junction.App.ViewModels;
|
||||||
|
|
||||||
|
namespace Junction.App
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Convention view locator: ViewModels.XxxViewModel → Views.XxxView.
|
||||||
|
/// Registered in App.axaml DataTemplates so a bound view-model renders its matching view.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ViewLocator : IDataTemplate
|
||||||
|
{
|
||||||
|
public Control Build(object? data)
|
||||||
|
{
|
||||||
|
if (data is null)
|
||||||
|
{
|
||||||
|
return new TextBlock { Text = "null" };
|
||||||
|
}
|
||||||
|
|
||||||
|
var vmName = data.GetType().FullName!;
|
||||||
|
var viewName = vmName.Replace(".ViewModels.", ".Views.").Replace("ViewModel", "View");
|
||||||
|
var type = Type.GetType(viewName);
|
||||||
|
|
||||||
|
if (type != null)
|
||||||
|
{
|
||||||
|
return (Control)Activator.CreateInstance(type)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextBlock { Text = "View not found: " + viewName };
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Match(object? data) => data is ViewModelBase;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/Junction.App/ViewModels/DashboardViewModel.cs
Normal file
114
src/Junction.App/ViewModels/DashboardViewModel.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Junction.Core.Monitoring;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Persistence;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Junction.App.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Live machine dashboard. Loads configured machines from the repository, seeds each row from
|
||||||
|
/// the monitor's latest-snapshot cache, and refreshes rows as <see cref="IMachineMonitor.SnapshotUpdated"/>
|
||||||
|
/// fires. Snapshot events arrive on poll-loop threads and are marshalled onto the UI thread.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class DashboardViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IMachineRepository _repository;
|
||||||
|
private readonly IMachineMonitor _monitor;
|
||||||
|
private readonly ILogger<DashboardViewModel> _logger;
|
||||||
|
private readonly Dictionary<Guid, MachineRowViewModel> _rowsById = new Dictionary<Guid, MachineRowViewModel>();
|
||||||
|
private bool _subscribed;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string Title => "Junction — Machines";
|
||||||
|
|
||||||
|
public ObservableCollection<MachineRowViewModel> Machines { get; } = new ObservableCollection<MachineRowViewModel>();
|
||||||
|
|
||||||
|
public DashboardViewModel(IMachineRepository repository, IMachineMonitor monitor, ILogger<DashboardViewModel> logger)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_monitor = monitor;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads machines, seeds latest snapshots, and subscribes to live updates.</summary>
|
||||||
|
public async Task LoadAsync()
|
||||||
|
{
|
||||||
|
// Subscribe first so no update slips through between load and subscribe;
|
||||||
|
// rows are looked up by id, unknown ids are ignored.
|
||||||
|
if (!_subscribed)
|
||||||
|
{
|
||||||
|
_monitor.SnapshotUpdated += OnSnapshotUpdated;
|
||||||
|
_subscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _repository.GetAllAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
var detail = result.Errors.Count > 0 ? result.Errors[0].Message : "unknown error";
|
||||||
|
_logger.LogError("Dashboard load failed: {Detail}", detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Machines.Clear();
|
||||||
|
_rowsById.Clear();
|
||||||
|
|
||||||
|
var latest = _monitor.LatestSnapshots;
|
||||||
|
foreach (var machine in result.Value)
|
||||||
|
{
|
||||||
|
var row = new MachineRowViewModel(machine);
|
||||||
|
if (latest.TryGetValue(machine.Id, out var snapshot))
|
||||||
|
{
|
||||||
|
row.Apply(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
_rowsById[machine.Id] = row;
|
||||||
|
Machines.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Dashboard loaded {Count} machine(s).", Machines.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSnapshotUpdated(object? sender, MachineSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event fires on a poll-loop thread → marshal all observable mutations to the UI thread.
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_rowsById.TryGetValue(snapshot.MachineId, out var row))
|
||||||
|
{
|
||||||
|
row.Apply(snapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
if (_subscribed)
|
||||||
|
{
|
||||||
|
_monitor.SnapshotUpdated -= OnSnapshotUpdated;
|
||||||
|
_subscribed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/Junction.App/ViewModels/MachineRowViewModel.cs
Normal file
83
src/Junction.App/ViewModels/MachineRowViewModel.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
using System;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
|
||||||
|
namespace Junction.App.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One dashboard row = one configured machine + its latest snapshot projection.
|
||||||
|
/// Mutated only on the UI thread (see <see cref="DashboardViewModel"/> marshalling).
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class MachineRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public Guid MachineId { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string _name;
|
||||||
|
[ObservableProperty] private string _protocolId;
|
||||||
|
[ObservableProperty] private ConnectionState _connectionState;
|
||||||
|
[ObservableProperty] private string _lastDatum;
|
||||||
|
[ObservableProperty] private string _lastUpdated;
|
||||||
|
|
||||||
|
public MachineRowViewModel(Machine machine)
|
||||||
|
{
|
||||||
|
MachineId = machine.Id;
|
||||||
|
_name = machine.Name;
|
||||||
|
_protocolId = machine.ProtocolId;
|
||||||
|
_connectionState = ConnectionState.Unknown;
|
||||||
|
_lastDatum = "—";
|
||||||
|
_lastUpdated = "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a snapshot onto this row. Call on the UI thread.</summary>
|
||||||
|
public void Apply(MachineSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectionState = snapshot.ConnectionState;
|
||||||
|
LastUpdated = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss");
|
||||||
|
LastDatum = Representative(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks a human-meaningful datum to show: prefer an availability/execution item,
|
||||||
|
/// else the first item's value, else an em-dash placeholder.
|
||||||
|
/// </summary>
|
||||||
|
private static string Representative(MachineSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
DataItem? preferred = null;
|
||||||
|
for (int i = 0; i < snapshot.Items.Count; i++)
|
||||||
|
{
|
||||||
|
var item = snapshot.Items[i];
|
||||||
|
if (Matches(item.Id) || Matches(item.Name))
|
||||||
|
{
|
||||||
|
preferred = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chosen = preferred ?? snapshot.Items[0];
|
||||||
|
var value = string.IsNullOrWhiteSpace(chosen.Value) ? "—" : chosen.Value;
|
||||||
|
var label = string.IsNullOrWhiteSpace(chosen.Name) ? chosen.Id : chosen.Name;
|
||||||
|
return string.IsNullOrWhiteSpace(label) ? value : label + " = " + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Matches(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.IndexOf("avail", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| s.IndexOf("execution", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Junction.App/ViewModels/MainWindowViewModel.cs
Normal file
22
src/Junction.App/ViewModels/MainWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Junction.App.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shell view-model. Hosts the currently shown page. Only the dashboard is used this slice;
|
||||||
|
/// <see cref="Navigate"/> is the seam for detail (T21) / config (T22) navigation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class MainWindowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
private object? _currentPage;
|
||||||
|
|
||||||
|
public MainWindowViewModel(DashboardViewModel dashboard)
|
||||||
|
{
|
||||||
|
_currentPage = dashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Navigation seam. Swaps the hosted page. (Only Dashboard wired now.)</summary>
|
||||||
|
public void Navigate(object viewModel) => CurrentPage = viewModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/Junction.App/ViewModels/ViewModelBase.cs
Normal file
9
src/Junction.App/ViewModels/ViewModelBase.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Junction.App.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>Base for all view-models. INotifyPropertyChanged via CommunityToolkit.Mvvm.</summary>
|
||||||
|
public abstract class ViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Junction.App/Views/DashboardView.axaml
Normal file
47
src/Junction.App/Views/DashboardView.axaml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="clr-namespace:Junction.App.ViewModels"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="Junction.App.Views.DashboardView"
|
||||||
|
x:DataType="vm:DashboardViewModel"
|
||||||
|
x:CompileBindings="True">
|
||||||
|
|
||||||
|
<DockPanel Margin="16">
|
||||||
|
<TextBlock DockPanel.Dock="Top"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
FontSize="22" FontWeight="SemiBold"
|
||||||
|
Margin="0,0,0,12" />
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Top"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
|
Padding="0,0,0,6" Margin="0,0,0,4">
|
||||||
|
<Grid ColumnDefinitions="2*,1.2*,3*,1.2*">
|
||||||
|
<TextBlock Grid.Column="0" Text="Name" FontWeight="Bold" />
|
||||||
|
<TextBlock Grid.Column="1" Text="Connection" FontWeight="Bold" />
|
||||||
|
<TextBlock Grid.Column="2" Text="Last Datum" FontWeight="Bold" />
|
||||||
|
<TextBlock Grid.Column="3" Text="Updated" FontWeight="Bold" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsControl ItemsSource="{Binding Machines}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:MachineRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="2*,1.2*,3*,1.2*" Margin="0,6">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding ProtocolId}" FontSize="11" Opacity="0.6" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding ConnectionState}" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding LastDatum}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding LastUpdated}" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</UserControl>
|
||||||
12
src/Junction.App/Views/DashboardView.axaml.cs
Normal file
12
src/Junction.App/Views/DashboardView.axaml.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace Junction.App.Views
|
||||||
|
{
|
||||||
|
public partial class DashboardView : UserControl
|
||||||
|
{
|
||||||
|
public DashboardView() => InitializeComponent();
|
||||||
|
|
||||||
|
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Junction.App/nlog.config
Normal file
28
src/Junction.App/nlog.config
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
autoReload="true"
|
||||||
|
internalLogLevel="Off">
|
||||||
|
|
||||||
|
<targets>
|
||||||
|
<!-- Rolling file next to the app; captures full poll/monitor trace. -->
|
||||||
|
<target xsi:type="File"
|
||||||
|
name="file"
|
||||||
|
fileName="${basedir}/logs/junction.log"
|
||||||
|
layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=ToString}"
|
||||||
|
keepFileOpen="false"
|
||||||
|
concurrentWrites="true" />
|
||||||
|
|
||||||
|
<!-- Console for dev iteration (dotnet run). -->
|
||||||
|
<target xsi:type="Console"
|
||||||
|
name="console"
|
||||||
|
layout="${time}|${level:uppercase=true}|${logger:shortName=true}|${message} ${exception:format=Message}" />
|
||||||
|
</targets>
|
||||||
|
|
||||||
|
<rules>
|
||||||
|
<!-- Junction.* verbose (Debug) so we can see poll cycles. -->
|
||||||
|
<logger name="Junction.*" minlevel="Debug" writeTo="file,console" />
|
||||||
|
<!-- Everything else at Info. -->
|
||||||
|
<logger name="*" minlevel="Info" writeTo="file,console" />
|
||||||
|
</rules>
|
||||||
|
</nlog>
|
||||||
Binary file not shown.
7
src/Junction.App/plugins/mtconnect/plugin.manifest.json
Normal file
7
src/Junction.App/plugins/mtconnect/plugin.manifest.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"protocolId": "mtconnect",
|
||||||
|
"displayName": "MTConnect",
|
||||||
|
"assemblyFile": "Junction.Protocols.MTConnect.dll",
|
||||||
|
"entryTypeName": "Junction.Protocols.MTConnect.MtconnectDriverFactory",
|
||||||
|
"apiVersion": "1.0"
|
||||||
|
}
|
||||||
18
src/Junction.Core/Junction.Core.csproj
Normal file
18
src/Junction.Core/Junction.Core.csproj
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<RootNamespace>Junction.Core</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Junction.Domain\Junction.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
|
||||||
|
<PackageReference Include="System.Text.Json" Version="8.0.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
56
src/Junction.Core/Monitoring/IMachineMonitor.cs
Normal file
56
src/Junction.Core/Monitoring/IMachineMonitor.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
|
||||||
|
namespace Junction.Core.Monitoring
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level orchestration hub: loads protocol plugins, resolves a driver per configured
|
||||||
|
/// machine, and runs one <see cref="Junction.Core.Polling.IPollingEngine"/> poll loop per
|
||||||
|
/// machine. Persists every produced snapshot, keeps an in-memory latest-snapshot cache for
|
||||||
|
/// the dashboard, and raises <see cref="SnapshotUpdated"/> as fresh data arrives.
|
||||||
|
/// <para>
|
||||||
|
/// Partial-start policy: a machine whose protocol has no loaded plugin, or whose driver
|
||||||
|
/// fails to build, is logged and skipped; the remaining machines still start. Only hard
|
||||||
|
/// infrastructure failures (plugin directory missing, repository read failing) abort the
|
||||||
|
/// start and surface as a failed <see cref="Result"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IMachineMonitor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raised (on the poll-loop thread) whenever a machine produces a fresh snapshot,
|
||||||
|
/// after the in-memory cache has been updated. Subscribers must be thread-safe and
|
||||||
|
/// must not block; snapshots arrive concurrently from multiple poll loops.
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<MachineSnapshot> SnapshotUpdated;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live, thread-safe view of the most recent snapshot per machine (keyed by
|
||||||
|
/// <see cref="Machine.Id"/>). Reads are safe from any thread; the dashboard uses this
|
||||||
|
/// to render each machine's last datum.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyDictionary<Guid, MachineSnapshot> LatestSnapshots { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads plugins from <paramref name="pluginsDirectory"/>, loads configured machines,
|
||||||
|
/// and starts a poll loop for each machine with a matching protocol driver.
|
||||||
|
/// <para>
|
||||||
|
/// Returns <see cref="Result.Ok"/> even when some machines were skipped (partial start).
|
||||||
|
/// Returns <see cref="Result.Fail"/> only on hard infrastructure failures (plugin
|
||||||
|
/// directory missing/invalid, repository read failure). Returns
|
||||||
|
/// <see cref="Result.Cancelled"/> if cancellation trips before start completes.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> StartAsync(string pluginsDirectory, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels every running poll loop and awaits their clean shutdown. Safe to call when
|
||||||
|
/// not started. After it returns, no further snapshots are produced.
|
||||||
|
/// </summary>
|
||||||
|
Task StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
313
src/Junction.Core/Monitoring/MachineMonitor.cs
Normal file
313
src/Junction.Core/Monitoring/MachineMonitor.cs
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Junction.Core.Plugins;
|
||||||
|
using Junction.Core.Polling;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Persistence;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Junction.Core.Monitoring
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IMachineMonitor"/>. See the interface for the orchestration contract
|
||||||
|
/// and partial-start policy.
|
||||||
|
/// <para>
|
||||||
|
/// PollingEngine-per-machine: each machine gets its own <see cref="IPollingEngine"/> built
|
||||||
|
/// from an injected <see cref="Func{IPollingEngine}"/> factory. The factory keeps the
|
||||||
|
/// monitor decoupled from engine construction (and from <see cref="ILogger{PollingEngine}"/>
|
||||||
|
/// wiring), which makes the monitor trivially unit-testable with a stub engine. DI registers
|
||||||
|
/// the default factory as <c>() => new PollingEngine(loggerFactory.CreateLogger<PollingEngine>())</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Thread-safety: snapshots arrive concurrently from multiple poll loops. The latest-snapshot
|
||||||
|
/// cache is a <see cref="ConcurrentDictionary{Guid, MachineSnapshot}"/> (lock-free reads for
|
||||||
|
/// the dashboard), and the <see cref="SnapshotUpdated"/> event is raised through a captured
|
||||||
|
/// local delegate. Start/Stop mutate the running-loop set under a private lock.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MachineMonitor : IMachineMonitor
|
||||||
|
{
|
||||||
|
private const string Source = "MachineMonitor";
|
||||||
|
|
||||||
|
private readonly IMachineRepository _repository;
|
||||||
|
private readonly IPluginLoader _pluginLoader;
|
||||||
|
private readonly Func<IPollingEngine> _engineFactory;
|
||||||
|
private readonly ILogger<MachineMonitor> _logger;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<Guid, MachineSnapshot> _snapshots =
|
||||||
|
new ConcurrentDictionary<Guid, MachineSnapshot>();
|
||||||
|
|
||||||
|
private readonly object _lifecycleLock = new object();
|
||||||
|
private readonly List<RunningLoop> _running = new List<RunningLoop>();
|
||||||
|
|
||||||
|
public MachineMonitor(
|
||||||
|
IMachineRepository repository,
|
||||||
|
IPluginLoader pluginLoader,
|
||||||
|
Func<IPollingEngine> engineFactory,
|
||||||
|
ILogger<MachineMonitor> logger)
|
||||||
|
{
|
||||||
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||||
|
_pluginLoader = pluginLoader ?? throw new ArgumentNullException(nameof(pluginLoader));
|
||||||
|
_engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<MachineSnapshot>? SnapshotUpdated;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyDictionary<Guid, MachineSnapshot> LatestSnapshots => _snapshots;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result> StartAsync(string pluginsDirectory, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Load plugins. A failure here (missing/invalid directory) is a hard infra failure.
|
||||||
|
Result<IReadOnlyList<LoadedPlugin>> loadResult = _pluginLoader.LoadFrom(pluginsDirectory);
|
||||||
|
if (!loadResult.IsSuccess)
|
||||||
|
{
|
||||||
|
_logger.LogError("Plugin load failed; monitor cannot start: {Errors}", DescribeErrors(loadResult.Errors));
|
||||||
|
return Result.Fail(loadResult.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var factories = BuildFactoryMap(loadResult.Value);
|
||||||
|
|
||||||
|
// 2. Load configured machines. A repository failure is a hard infra failure.
|
||||||
|
Result<IReadOnlyList<Machine>> machinesResult;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
machinesResult = await _repository.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (machinesResult.WasCancelled)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!machinesResult.IsSuccess)
|
||||||
|
{
|
||||||
|
_logger.LogError("Machine load failed; monitor cannot start: {Errors}", DescribeErrors(machinesResult.Errors));
|
||||||
|
return Result.Fail(machinesResult.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Start one poll loop per machine that resolves a driver. Skips are non-fatal.
|
||||||
|
int started = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
lock (_lifecycleLock)
|
||||||
|
{
|
||||||
|
foreach (Machine machine in machinesResult.Value)
|
||||||
|
{
|
||||||
|
if (!factories.TryGetValue(machine.ProtocolId, out IProtocolDriverFactory factory))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No plugin loaded for protocol '{ProtocolId}'; skipping machine {MachineId} ({MachineName}).",
|
||||||
|
machine.ProtocolId, machine.Id, machine.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<IProtocolDriver> driverResult = factory.Create(machine);
|
||||||
|
if (!driverResult.IsSuccess)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Driver creation failed for machine {MachineId} ({MachineName}) via protocol '{ProtocolId}'; skipping: {Errors}",
|
||||||
|
machine.Id, machine.Name, machine.ProtocolId, DescribeErrors(driverResult.Errors));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
IProtocolDriver driver = driverResult.Value;
|
||||||
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
IPollingEngine engine = _engineFactory();
|
||||||
|
|
||||||
|
CancellationToken loopToken = cts.Token;
|
||||||
|
Task loop = engine.RunAsync(
|
||||||
|
machine,
|
||||||
|
driver,
|
||||||
|
snapshot => OnSnapshot(snapshot, loopToken),
|
||||||
|
loopToken);
|
||||||
|
|
||||||
|
_running.Add(new RunningLoop(machine.Id, cts, loop));
|
||||||
|
started++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"MachineMonitor started: {Started} machine(s) polling, {Skipped} skipped.",
|
||||||
|
started, skipped);
|
||||||
|
|
||||||
|
// Partial start is a success.
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
List<RunningLoop> loops;
|
||||||
|
lock (_lifecycleLock)
|
||||||
|
{
|
||||||
|
loops = new List<RunningLoop>(_running);
|
||||||
|
_running.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loops.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (RunningLoop loop in loops)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loop.Cts.Cancel();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Already disposed; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks = new Task[loops.Count];
|
||||||
|
for (int i = 0; i < loops.Count; i++)
|
||||||
|
{
|
||||||
|
tasks[i] = loops[i].Loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Poll loops never fault (they swallow driver/cancellation errors), but guard anyway.
|
||||||
|
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "A poll loop faulted during shutdown; continuing cleanup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (RunningLoop loop in loops)
|
||||||
|
{
|
||||||
|
loop.Cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("MachineMonitor stopped: {Count} poll loop(s) shut down.", loops.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot callback invoked from a poll loop: updates the latest cache, raises the
|
||||||
|
/// event, then fires the persistence write off (fire-and-forget with error logging) so
|
||||||
|
/// a slow/failed save never stalls or breaks the poll loop.
|
||||||
|
/// </summary>
|
||||||
|
private void OnSnapshot(MachineSnapshot snapshot, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_snapshots[snapshot.MachineId] = snapshot;
|
||||||
|
|
||||||
|
EventHandler<MachineSnapshot>? handler = SnapshotUpdated;
|
||||||
|
if (handler != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
handler(this, snapshot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "SnapshotUpdated subscriber threw for machine {MachineId}; continuing.", snapshot.MachineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = PersistAsync(snapshot, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistAsync(MachineSnapshot snapshot, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Result result = await _repository.SaveSnapshotAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess && !result.WasCancelled)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Failed to persist snapshot for machine {MachineId}: {Errors}",
|
||||||
|
snapshot.MachineId, DescribeErrors(result.Errors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Shutdown in progress; nothing to do.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Persisting snapshot threw for machine {MachineId}; continuing.", snapshot.MachineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps ProtocolId (case-insensitive) to factory. On duplicate protocol ids the first
|
||||||
|
/// loaded plugin wins; the collision is logged.
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, IProtocolDriverFactory> BuildFactoryMap(IReadOnlyList<LoadedPlugin> plugins)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, IProtocolDriverFactory>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (LoadedPlugin plugin in plugins)
|
||||||
|
{
|
||||||
|
string protocolId = plugin.Factory.ProtocolId ?? "";
|
||||||
|
if (map.ContainsKey(protocolId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Duplicate plugin for protocol '{ProtocolId}'; keeping the first loaded.", protocolId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
map[protocolId] = plugin.Factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeErrors(IReadOnlyList<OperationError> errors)
|
||||||
|
{
|
||||||
|
if (errors is null || errors.Count == 0)
|
||||||
|
{
|
||||||
|
return "(no detail)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count == 1)
|
||||||
|
{
|
||||||
|
return errors[0].ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = new string[errors.Count];
|
||||||
|
for (int i = 0; i < errors.Count; i++)
|
||||||
|
{
|
||||||
|
parts[i] = errors[i].ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("; ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A running per-machine poll loop with its cancellation source.</summary>
|
||||||
|
private sealed class RunningLoop
|
||||||
|
{
|
||||||
|
public Guid MachineId { get; }
|
||||||
|
public CancellationTokenSource Cts { get; }
|
||||||
|
public Task Loop { get; }
|
||||||
|
|
||||||
|
public RunningLoop(Guid machineId, CancellationTokenSource cts, Task loop)
|
||||||
|
{
|
||||||
|
MachineId = machineId;
|
||||||
|
Cts = cts;
|
||||||
|
Loop = loop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Junction.Core/Plugins/IPluginLoader.cs
Normal file
28
src/Junction.Core/Plugins/IPluginLoader.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Junction.Domain;
|
||||||
|
|
||||||
|
namespace Junction.Core.Plugins
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers and loads protocol plugins from a directory at host startup.
|
||||||
|
/// Implementations use <see cref="System.Reflection.Assembly.LoadFrom(string)"/>
|
||||||
|
/// (net48-compatible; no AssemblyLoadContext, no runtime unload). Reflection stays
|
||||||
|
/// inside the loader so the rest of the app stays reflection-free.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPluginLoader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scan <paramref name="pluginsDirectory"/> (and its immediate subdirectories) for
|
||||||
|
/// <c>plugin.manifest.json</c> sidecars and load each declared plugin.
|
||||||
|
/// <para>
|
||||||
|
/// Partial-success policy: a single plugin failing to load (missing/bad manifest,
|
||||||
|
/// dll not found, entry type not found or not an <see cref="IProtocolDriverFactory"/>,
|
||||||
|
/// ctor throwing) is logged and skipped; the remaining plugins still load. The call
|
||||||
|
/// returns <see cref="Result{T}.Ok"/> with the successfully-loaded subset even when
|
||||||
|
/// some plugins failed. It returns <see cref="Result{T}.Fail"/> only when the
|
||||||
|
/// plugins directory itself is missing. It never throws to the caller.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
Result<IReadOnlyList<LoadedPlugin>> LoadFrom(string pluginsDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Junction.Core/Plugins/LoadedPlugin.cs
Normal file
25
src/Junction.Core/Plugins/LoadedPlugin.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
|
||||||
|
namespace Junction.Core.Plugins
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A successfully loaded protocol plugin: the Domain <see cref="PluginDescriptor"/>
|
||||||
|
/// (pure data: manifest + resolved assembly path) paired with the Core-level resolved
|
||||||
|
/// <see cref="IProtocolDriverFactory"/> instance created via reflection.
|
||||||
|
/// The reflection concern lives here in Core so the Domain descriptor stays Type-free.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LoadedPlugin
|
||||||
|
{
|
||||||
|
/// <summary>The plugin's manifest + resolved assembly path.</summary>
|
||||||
|
public PluginDescriptor Descriptor { get; }
|
||||||
|
|
||||||
|
/// <summary>The instantiated factory entrypoint for this plugin.</summary>
|
||||||
|
public IProtocolDriverFactory Factory { get; }
|
||||||
|
|
||||||
|
public LoadedPlugin(PluginDescriptor descriptor, IProtocolDriverFactory factory)
|
||||||
|
{
|
||||||
|
Descriptor = descriptor;
|
||||||
|
Factory = factory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
src/Junction.Core/Plugins/PluginLoader.cs
Normal file
229
src/Junction.Core/Plugins/PluginLoader.cs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Junction.Core.Plugins
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IPluginLoader"/>. Discovers <c>plugin.manifest.json</c> sidecars
|
||||||
|
/// under a plugins directory, loads each declared assembly via
|
||||||
|
/// <see cref="Assembly.LoadFrom(string)"/> (net48-compatible; no AssemblyLoadContext,
|
||||||
|
/// no runtime unload), instantiates its entry <see cref="IProtocolDriverFactory"/> via
|
||||||
|
/// reflection, and returns the successfully-loaded set. Per-plugin failures are logged
|
||||||
|
/// and skipped (see <see cref="IPluginLoader.LoadFrom"/> for the partial-success policy).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginLoader : IPluginLoader
|
||||||
|
{
|
||||||
|
/// <summary>Well-known sidecar file name discovered beside each plugin dll.</summary>
|
||||||
|
public const string ManifestFileName = "plugin.manifest.json";
|
||||||
|
|
||||||
|
private const string Source = "PluginLoader";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly ILogger<PluginLoader> _logger;
|
||||||
|
|
||||||
|
public PluginLoader(ILogger<PluginLoader> logger)
|
||||||
|
{
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Result<IReadOnlyList<LoadedPlugin>> LoadFrom(string pluginsDirectory)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pluginsDirectory))
|
||||||
|
{
|
||||||
|
var err = new OperationError("PLUGIN_DIR_MISSING", Source, "Plugins directory path is null or empty.");
|
||||||
|
_logger.LogError("Plugins directory path is null or empty.");
|
||||||
|
return Result<IReadOnlyList<LoadedPlugin>>.Fail(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(pluginsDirectory))
|
||||||
|
{
|
||||||
|
var err = new OperationError(
|
||||||
|
"PLUGIN_DIR_MISSING",
|
||||||
|
Source,
|
||||||
|
$"Plugins directory not found: '{pluginsDirectory}'.");
|
||||||
|
_logger.LogError("Plugins directory not found: {Directory}", pluginsDirectory);
|
||||||
|
return Result<IReadOnlyList<LoadedPlugin>>.Fail(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
var loaded = new List<LoadedPlugin>();
|
||||||
|
|
||||||
|
foreach (var manifestPath in DiscoverManifests(pluginsDirectory))
|
||||||
|
{
|
||||||
|
var result = TryLoadPlugin(manifestPath);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
loaded.Add(result.Value);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Loaded plugin '{ProtocolId}' from {ManifestPath}.",
|
||||||
|
result.Value.Descriptor.Manifest.ProtocolId,
|
||||||
|
manifestPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var e in result.Errors)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Skipping plugin at {ManifestPath}: [{Code}] {Message}",
|
||||||
|
manifestPath,
|
||||||
|
e.Code,
|
||||||
|
e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Plugin load complete: {Count} plugin(s) loaded from {Directory}.", loaded.Count, pluginsDirectory);
|
||||||
|
return Result<IReadOnlyList<LoadedPlugin>>.Ok(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Candidate manifest paths: the root directory itself, plus each immediate
|
||||||
|
/// subdirectory (the conventional one-directory-per-plugin layout).
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<string> DiscoverManifests(string pluginsDirectory)
|
||||||
|
{
|
||||||
|
var rootManifest = Path.Combine(pluginsDirectory, ManifestFileName);
|
||||||
|
if (File.Exists(rootManifest))
|
||||||
|
yield return rootManifest;
|
||||||
|
|
||||||
|
foreach (var subDir in Directory.EnumerateDirectories(pluginsDirectory))
|
||||||
|
{
|
||||||
|
var manifest = Path.Combine(subDir, ManifestFileName);
|
||||||
|
if (File.Exists(manifest))
|
||||||
|
yield return manifest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<LoadedPlugin> TryLoadPlugin(string manifestPath)
|
||||||
|
{
|
||||||
|
PluginManifest manifest;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(manifestPath);
|
||||||
|
var dto = JsonSerializer.Deserialize<ManifestDto>(json, JsonOptions);
|
||||||
|
if (dto is null)
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_MANIFEST_INVALID", $"Manifest deserialized to null: '{manifestPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest = new PluginManifest(
|
||||||
|
dto.ProtocolId ?? "",
|
||||||
|
dto.DisplayName ?? "",
|
||||||
|
dto.AssemblyFile ?? "",
|
||||||
|
dto.EntryTypeName ?? "",
|
||||||
|
dto.ApiVersion ?? "");
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_MANIFEST_INVALID", $"Failed to parse manifest '{manifestPath}': {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_MANIFEST_IO", $"Failed to read manifest '{manifestPath}': {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_MANIFEST_IO", $"Failed to read manifest '{manifestPath}': {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(manifest.AssemblyFile))
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_ASSEMBLY_UNSPECIFIED", $"Manifest '{manifestPath}' has no AssemblyFile.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(manifest.EntryTypeName))
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_ENTRYTYPE_UNSPECIFIED", $"Manifest '{manifestPath}' has no EntryTypeName.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestDir = Path.GetDirectoryName(manifestPath) ?? "";
|
||||||
|
var dllPath = Path.GetFullPath(Path.Combine(manifestDir, manifest.AssemblyFile));
|
||||||
|
|
||||||
|
if (!File.Exists(dllPath))
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_ASSEMBLY_NOT_FOUND", $"Plugin assembly not found: '{dllPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assembly assembly;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
assembly = Assembly.LoadFrom(dllPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex) // BadImageFormatException, FileLoadException, etc.
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_ASSEMBLY_LOAD_FAILED", $"Failed to load assembly '{dllPath}': {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Type? entryType;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entryType = assembly.GetType(manifest.EntryTypeName, throwOnError: false, ignoreCase: false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_ENTRYTYPE_LOAD_FAILED", $"Failed to resolve type '{manifest.EntryTypeName}': {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryType is null)
|
||||||
|
{
|
||||||
|
return Fail("PLUGIN_ENTRYTYPE_NOT_FOUND", $"Entry type '{manifest.EntryTypeName}' not found in '{dllPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!typeof(IProtocolDriverFactory).IsAssignableFrom(entryType))
|
||||||
|
{
|
||||||
|
return Fail(
|
||||||
|
"PLUGIN_ENTRYTYPE_WRONG_CONTRACT",
|
||||||
|
$"Entry type '{manifest.EntryTypeName}' does not implement {nameof(IProtocolDriverFactory)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
IProtocolDriverFactory factory;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var instance = Activator.CreateInstance(entryType);
|
||||||
|
if (instance is not IProtocolDriverFactory f)
|
||||||
|
{
|
||||||
|
return Fail(
|
||||||
|
"PLUGIN_ENTRYTYPE_WRONG_CONTRACT",
|
||||||
|
$"Instance of '{manifest.EntryTypeName}' is not an {nameof(IProtocolDriverFactory)}.");
|
||||||
|
}
|
||||||
|
factory = f;
|
||||||
|
}
|
||||||
|
catch (Exception ex) // ctor throw, MissingMethodException (no public parameterless ctor), TargetInvocationException, etc.
|
||||||
|
{
|
||||||
|
var reason = ex is TargetInvocationException tie && tie.InnerException is not null
|
||||||
|
? tie.InnerException.Message
|
||||||
|
: ex.Message;
|
||||||
|
return Fail("PLUGIN_ENTRYTYPE_ACTIVATION_FAILED", $"Failed to instantiate '{manifest.EntryTypeName}': {reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptor = new PluginDescriptor(manifest, dllPath);
|
||||||
|
return Result<LoadedPlugin>.Ok(new LoadedPlugin(descriptor, factory));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<LoadedPlugin> Fail(string code, string message) =>
|
||||||
|
Result<LoadedPlugin>.Fail(new OperationError(code, Source, message));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal DTO used only to deserialize the attribute-free
|
||||||
|
/// <see cref="PluginManifest"/> shape with case-insensitive property matching.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ManifestDto
|
||||||
|
{
|
||||||
|
public string? ProtocolId { get; set; }
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public string? AssemblyFile { get; set; }
|
||||||
|
public string? EntryTypeName { get; set; }
|
||||||
|
public string? ApiVersion { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Junction.Core/Polling/IPollingEngine.cs
Normal file
43
src/Junction.Core/Polling/IPollingEngine.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
|
||||||
|
namespace Junction.Core.Polling
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the poll loop for a SINGLE machine: repeatedly reads the machine's current
|
||||||
|
/// values through its <see cref="IProtocolDriver"/> and forwards each successful
|
||||||
|
/// <see cref="MachineSnapshot"/> to a caller-supplied callback.
|
||||||
|
/// <para>
|
||||||
|
/// One instance polls one machine. A higher-level component (MachineMonitor) owns
|
||||||
|
/// one engine per machine.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Fault tolerance: a driver read that fails is logged and skipped; the loop keeps
|
||||||
|
/// running. Only cancellation (via the token or a cancelled <see cref="Domain.Result{T}"/>)
|
||||||
|
/// stops the loop, and it stops cleanly without leaking an exception.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IPollingEngine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the poll loop until <paramref name="cancellationToken"/> is cancelled.
|
||||||
|
/// The returned <see cref="Task"/> represents the running loop; awaiting it
|
||||||
|
/// completes (never faults on driver errors or cancellation) once the loop stops.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="machine">Machine to poll. Supplies the poll interval.</param>
|
||||||
|
/// <param name="driver">Driver bound to <paramref name="machine"/>.</param>
|
||||||
|
/// <param name="onSnapshot">
|
||||||
|
/// Invoked once per successful read with the produced snapshot. Never invoked for
|
||||||
|
/// failed or cancelled reads.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Stops the loop when cancelled.</param>
|
||||||
|
Task RunAsync(
|
||||||
|
Machine machine,
|
||||||
|
IProtocolDriver driver,
|
||||||
|
Action<MachineSnapshot> onSnapshot,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/Junction.Core/Polling/PollingEngine.cs
Normal file
139
src/Junction.Core/Polling/PollingEngine.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Junction.Core.Polling
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IPollingEngine"/>. Single-machine poll loop.
|
||||||
|
/// <para>
|
||||||
|
/// API shape: a <c>RunAsync(machine, driver, onSnapshot, ct)</c> method taking an
|
||||||
|
/// <see cref="Action{MachineSnapshot}"/> callback. Chosen over an event because a
|
||||||
|
/// callback is trivially testable (a captured local counter/list), keeps the engine
|
||||||
|
/// free of subscriber lifecycle concerns, and makes the "emit only on success"
|
||||||
|
/// contract explicit at the call site.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The loop never throws for control flow: driver failures are logged and skipped,
|
||||||
|
/// cancellation exits cleanly.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PollingEngine : IPollingEngine
|
||||||
|
{
|
||||||
|
private readonly ILogger<PollingEngine> _logger;
|
||||||
|
|
||||||
|
public PollingEngine(ILogger<PollingEngine> logger)
|
||||||
|
{
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RunAsync(
|
||||||
|
Machine machine,
|
||||||
|
IProtocolDriver driver,
|
||||||
|
Action<MachineSnapshot> onSnapshot,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (machine is null) throw new ArgumentNullException(nameof(machine));
|
||||||
|
if (driver is null) throw new ArgumentNullException(nameof(driver));
|
||||||
|
if (onSnapshot is null) throw new ArgumentNullException(nameof(onSnapshot));
|
||||||
|
|
||||||
|
TimeSpan interval = machine.PollInterval;
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Result<MachineSnapshot> result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await driver.ReadCurrentAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Cooperative cancellation surfaced as an exception by the driver: clean stop.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Driver misbehaved (threw instead of returning Result.Fail). Survive it.
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Polling read threw for machine {MachineId} ({MachineName}); continuing.",
|
||||||
|
machine.Id,
|
||||||
|
machine.Name);
|
||||||
|
result = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result is object)
|
||||||
|
{
|
||||||
|
if (result.WasCancelled)
|
||||||
|
{
|
||||||
|
// Driver reports cancellation: stop cleanly.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
MachineSnapshot snapshot = result.Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
onSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A faulty subscriber must not kill the loop.
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Snapshot callback threw for machine {MachineId} ({MachineName}); continuing.",
|
||||||
|
machine.Id,
|
||||||
|
machine.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Read failed: log and KEEP LOOPING (fault tolerance).
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Polling read failed for machine {MachineId} ({MachineName}): {Errors}",
|
||||||
|
machine.Id,
|
||||||
|
machine.Name,
|
||||||
|
DescribeErrors(result.Errors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Cancelled during the inter-poll wait: clean stop.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeErrors(System.Collections.Generic.IReadOnlyList<OperationError> errors)
|
||||||
|
{
|
||||||
|
if (errors is null || errors.Count == 0)
|
||||||
|
{
|
||||||
|
return "(no detail)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count == 1)
|
||||||
|
{
|
||||||
|
return errors[0].ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = new string[errors.Count];
|
||||||
|
for (int i = 0; i < errors.Count; i++)
|
||||||
|
{
|
||||||
|
parts[i] = errors[i].ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("; ", parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Junction.Core/ServiceCollectionExtensions.cs
Normal file
47
src/Junction.Core/ServiceCollectionExtensions.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using System;
|
||||||
|
using Junction.Core.Monitoring;
|
||||||
|
using Junction.Core.Plugins;
|
||||||
|
using Junction.Core.Polling;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Junction.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DI registration for the Junction.Core orchestration stack.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the Core services:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="IPluginLoader"/> → <see cref="PluginLoader"/> (singleton; scanned once at startup).</item>
|
||||||
|
/// <item><see cref="IPollingEngine"/> → <see cref="PollingEngine"/> (transient; one loop per machine),
|
||||||
|
/// plus a <see cref="Func{IPollingEngine}"/> factory the monitor uses to build engines on demand.</item>
|
||||||
|
/// <item><see cref="IMachineMonitor"/> → <see cref="MachineMonitor"/> (singleton; the app-wide hub).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Does NOT register <c>IMachineRepository</c> (that is <c>AddJunctionPersistence</c>) nor any concrete
|
||||||
|
/// logging provider (the app host wires NLog). Logging is consumed via the
|
||||||
|
/// <c>Microsoft.Extensions.Logging</c> abstractions only.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to add to.</param>
|
||||||
|
/// <returns>The same collection, for chaining.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="services"/> is null.</exception>
|
||||||
|
public static IServiceCollection AddJunctionCore(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
if (services is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(services));
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddSingleton<IPluginLoader, PluginLoader>();
|
||||||
|
|
||||||
|
// One engine per machine: transient, plus a factory the monitor calls per machine.
|
||||||
|
services.AddTransient<IPollingEngine, PollingEngine>();
|
||||||
|
services.AddSingleton<Func<IPollingEngine>>(sp => () => sp.GetRequiredService<IPollingEngine>());
|
||||||
|
|
||||||
|
services.AddSingleton<IMachineMonitor, MachineMonitor>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/Junction.Domain/Junction.Domain.csproj
Normal file
8
src/Junction.Domain/Junction.Domain.csproj
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<RootNamespace>Junction.Domain</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
15
src/Junction.Domain/Models/ConnectionState.cs
Normal file
15
src/Junction.Domain/Models/ConnectionState.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace Junction.Domain.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol-agnostic connection status of a machine.
|
||||||
|
/// MTConnect availability (AVAILABLE/UNAVAILABLE) maps onto Connected/Disconnected.
|
||||||
|
/// </summary>
|
||||||
|
public enum ConnectionState
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Connecting = 1,
|
||||||
|
Connected = 2,
|
||||||
|
Disconnected = 3,
|
||||||
|
Error = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/Junction.Domain/Models/DataItem.cs
Normal file
41
src/Junction.Domain/Models/DataItem.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Junction.Domain.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One current telemetry/sample value read from a machine (only-latest, no history).
|
||||||
|
/// Immutable, protocol-agnostic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DataItem
|
||||||
|
{
|
||||||
|
/// <summary>Stable identifier of the datum (e.g. MTConnect dataItemId).</summary>
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable name of the datum.</summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Value kept as string to stay type-agnostic across protocols;
|
||||||
|
/// callers parse to the concrete type they need.
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category of the datum. Kept as a string (not an enum) to stay generic:
|
||||||
|
/// MTConnect uses Sample/Event/Condition, but other protocols may define others.
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; }
|
||||||
|
|
||||||
|
/// <summary>Instant the value was reported/observed.</summary>
|
||||||
|
public DateTimeOffset Timestamp { get; }
|
||||||
|
|
||||||
|
public DataItem(string id, string name, string value, string category, DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
Id = id ?? "";
|
||||||
|
Name = name ?? "";
|
||||||
|
Value = value ?? "";
|
||||||
|
Category = category ?? "";
|
||||||
|
Timestamp = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Junction.Domain/Models/Machine.cs
Normal file
60
src/Junction.Domain/Models/Machine.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Junction.Domain.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A configured machine. Immutable value-style.
|
||||||
|
/// The connection configuration is a generic string bag; each protocol driver
|
||||||
|
/// validates the keys it needs. Domain stays protocol-agnostic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Machine
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> EmptyConfig =
|
||||||
|
new Dictionary<string, string>(0);
|
||||||
|
|
||||||
|
/// <summary>Stable unique identifier.</summary>
|
||||||
|
public Guid Id { get; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable name.</summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>Protocol identifier matching the driver/plugin (e.g. "mtconnect").</summary>
|
||||||
|
public string ProtocolId { get; }
|
||||||
|
|
||||||
|
/// <summary>Generic connection config. Never null; the protocol validates its own keys.</summary>
|
||||||
|
public IReadOnlyDictionary<string, string> ConnectionConfig { get; }
|
||||||
|
|
||||||
|
/// <summary>How often to poll the machine.</summary>
|
||||||
|
public TimeSpan PollInterval { get; }
|
||||||
|
|
||||||
|
public Machine(
|
||||||
|
Guid id,
|
||||||
|
string name,
|
||||||
|
string protocolId,
|
||||||
|
IReadOnlyDictionary<string, string>? connectionConfig,
|
||||||
|
TimeSpan pollInterval)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name ?? "";
|
||||||
|
ProtocolId = protocolId ?? "";
|
||||||
|
ConnectionConfig = connectionConfig ?? EmptyConfig;
|
||||||
|
PollInterval = pollInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a copy with the given fields overridden; null args keep the current value.</summary>
|
||||||
|
public Machine With(
|
||||||
|
string? name = null,
|
||||||
|
string? protocolId = null,
|
||||||
|
IReadOnlyDictionary<string, string>? connectionConfig = null,
|
||||||
|
TimeSpan? pollInterval = null)
|
||||||
|
{
|
||||||
|
return new Machine(
|
||||||
|
Id,
|
||||||
|
name ?? Name,
|
||||||
|
protocolId ?? ProtocolId,
|
||||||
|
connectionConfig ?? ConnectionConfig,
|
||||||
|
pollInterval ?? PollInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/Junction.Domain/Models/MachineSnapshot.cs
Normal file
63
src/Junction.Domain/Models/MachineSnapshot.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Junction.Domain.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Full current read of a single machine (only-latest datums, no history).
|
||||||
|
/// Immutable. <see cref="Items"/> is never null.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MachineSnapshot
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<DataItem> EmptyItems = new DataItem[0];
|
||||||
|
|
||||||
|
/// <summary>Identifier of the machine this snapshot belongs to.</summary>
|
||||||
|
public Guid MachineId { get; }
|
||||||
|
|
||||||
|
/// <summary>Instant the snapshot was captured.</summary>
|
||||||
|
public DateTimeOffset CapturedAt { get; }
|
||||||
|
|
||||||
|
/// <summary>Connection status at capture time.</summary>
|
||||||
|
public ConnectionState ConnectionState { get; }
|
||||||
|
|
||||||
|
/// <summary>Current datums. Never null; empty is allowed.</summary>
|
||||||
|
public IReadOnlyList<DataItem> Items { get; }
|
||||||
|
|
||||||
|
public MachineSnapshot(
|
||||||
|
Guid machineId,
|
||||||
|
DateTimeOffset capturedAt,
|
||||||
|
ConnectionState connectionState,
|
||||||
|
IReadOnlyList<DataItem>? items)
|
||||||
|
{
|
||||||
|
MachineId = machineId;
|
||||||
|
CapturedAt = capturedAt;
|
||||||
|
ConnectionState = connectionState;
|
||||||
|
Items = items ?? EmptyItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the snapshot carries no datums.</summary>
|
||||||
|
public bool IsEmpty => Items.Count == 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the datum with the given <see cref="DataItem.Id"/>, or null if absent.
|
||||||
|
/// Used by the dashboard to display a specific "last datum".
|
||||||
|
/// </summary>
|
||||||
|
public DataItem? TryGetItem(string id)
|
||||||
|
{
|
||||||
|
if (id == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < Items.Count; i++)
|
||||||
|
{
|
||||||
|
if (Items[i].Id == id)
|
||||||
|
{
|
||||||
|
return Items[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Junction.Domain/OperationError.cs
Normal file
28
src/Junction.Domain/OperationError.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Junction.Domain
|
||||||
|
{
|
||||||
|
/// <summary>Immutable error describing a single failure of an operation.</summary>
|
||||||
|
public sealed class OperationError
|
||||||
|
{
|
||||||
|
public string Code { get; }
|
||||||
|
public string Source { get; }
|
||||||
|
public string Message { get; }
|
||||||
|
public DateTime At { get; }
|
||||||
|
|
||||||
|
public OperationError(string code, string source, string message)
|
||||||
|
{
|
||||||
|
Code = code ?? "";
|
||||||
|
Source = source ?? "";
|
||||||
|
Message = message ?? "";
|
||||||
|
At = DateTime.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convenience factory for an error without a specific code.</summary>
|
||||||
|
public static OperationError Of(string source, string message) =>
|
||||||
|
new OperationError("", source, message);
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
"[" + Code + "] " + Source + ": " + Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Junction.Domain/Persistence/IMachineRepository.cs
Normal file
46
src/Junction.Domain/Persistence/IMachineRepository.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
|
||||||
|
namespace Junction.Domain.Persistence
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Persistence-agnostic port for storing/retrieving machines and their latest snapshot.
|
||||||
|
/// This is the provider-swap seam: the default adapter is Dapper + SQLite, but the same
|
||||||
|
/// contract must hold for SQL Server or any other store.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Implementations MUST NOT leak persistence-technology types across this boundary.
|
||||||
|
/// No SQLite, SQL Server, Dapper, ADO.NET (e.g. DbConnection, SqlException, SqliteException,
|
||||||
|
/// DataTable) or any provider-specific type may appear in these signatures or in the values
|
||||||
|
/// returned. Only <see cref="Junction.Domain"/> types and BCL primitives cross this port.
|
||||||
|
/// Expected failures (not found, store unavailable, constraint violation) are reported via
|
||||||
|
/// the <see cref="Result"/> / <see cref="Result{T}"/> return types, not by throwing.
|
||||||
|
/// Cancellation is surfaced through <see cref="Result.WasCancelled"/> / <see cref="Result{T}.WasCancelled"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IMachineRepository
|
||||||
|
{
|
||||||
|
/// <summary>Returns all configured machines. Empty list when none; never a null value on success.</summary>
|
||||||
|
Task<Result<IReadOnlyList<Machine>>> GetAllAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Returns the machine with the given id, or a failed result when absent.</summary>
|
||||||
|
Task<Result<Machine>> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Inserts or updates the machine (keyed by <see cref="Machine.Id"/>).</summary>
|
||||||
|
Task<Result> UpsertAsync(Machine machine, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Deletes the machine with the given id.</summary>
|
||||||
|
Task<Result> DeleteAsync(Guid id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores the snapshot as the latest for its machine, overwriting any previous one.
|
||||||
|
/// Only-latest policy: no history is retained.
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> SaveSnapshotAsync(MachineSnapshot snapshot, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Returns the latest stored snapshot for the machine, or a failed result when none exists.</summary>
|
||||||
|
Task<Result<MachineSnapshot>> GetLatestSnapshotAsync(Guid machineId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/Junction.Domain/Protocols/IProtocolDriver.cs
Normal file
24
src/Junction.Domain/Protocols/IProtocolDriver.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
|
||||||
|
namespace Junction.Domain.Protocols
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A per-machine protocol driver. Bound to a single <see cref="Machine"/> at creation
|
||||||
|
/// time (via <see cref="IProtocolDriverFactory"/>) and used to read that machine's
|
||||||
|
/// current values. One instance per machine.
|
||||||
|
/// </summary>
|
||||||
|
public interface IProtocolDriver
|
||||||
|
{
|
||||||
|
/// <summary>Protocol identifier this driver serves (e.g. "mtconnect").</summary>
|
||||||
|
string ProtocolId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the current values of the machine this driver was created for.
|
||||||
|
/// Returns a failed <see cref="Result{T}"/> on error, cancelled when the token trips.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token. Mandatory.</param>
|
||||||
|
Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Junction.Domain/Protocols/IProtocolDriverFactory.cs
Normal file
23
src/Junction.Domain/Protocols/IProtocolDriverFactory.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
|
||||||
|
namespace Junction.Domain.Protocols
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin entrypoint contract. The plugin loader resolves this type from a loaded
|
||||||
|
/// assembly and uses it to build per-machine <see cref="IProtocolDriver"/> instances.
|
||||||
|
/// The factory owns protocol-specific config validation, keeping the Domain
|
||||||
|
/// protocol-agnostic (config is a generic string bag on <see cref="Machine"/>).
|
||||||
|
/// </summary>
|
||||||
|
public interface IProtocolDriverFactory
|
||||||
|
{
|
||||||
|
/// <summary>Protocol identifier this factory produces drivers for (e.g. "mtconnect").</summary>
|
||||||
|
string ProtocolId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a driver bound to the given machine's <see cref="Machine.ConnectionConfig"/>.
|
||||||
|
/// Validates protocol-specific config here; returns <see cref="Result{T}"/> failure
|
||||||
|
/// on bad or missing keys.
|
||||||
|
/// </summary>
|
||||||
|
Result<IProtocolDriver> Create(Machine machine);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Junction.Domain/Protocols/PluginDescriptor.cs
Normal file
23
src/Junction.Domain/Protocols/PluginDescriptor.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace Junction.Domain.Protocols
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable POCO pairing a validated <see cref="PluginManifest"/> with the resolved
|
||||||
|
/// absolute path of its assembly on disk. Produced by the Core loader after locating
|
||||||
|
/// the plugin. Deliberately holds no loaded <see cref="System.Type"/> or factory
|
||||||
|
/// instance (that is a Core loader concern) so the Domain stays reflection-free.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginDescriptor
|
||||||
|
{
|
||||||
|
/// <summary>The plugin's declared manifest.</summary>
|
||||||
|
public PluginManifest Manifest { get; }
|
||||||
|
|
||||||
|
/// <summary>Absolute path to the plugin assembly (dll) on disk.</summary>
|
||||||
|
public string AssemblyPath { get; }
|
||||||
|
|
||||||
|
public PluginDescriptor(PluginManifest manifest, string assemblyPath)
|
||||||
|
{
|
||||||
|
Manifest = manifest;
|
||||||
|
AssemblyPath = assemblyPath ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Junction.Domain/Protocols/PluginManifest.cs
Normal file
44
src/Junction.Domain/Protocols/PluginManifest.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
namespace Junction.Domain.Protocols
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable POCO describing a protocol plugin, as declared in its
|
||||||
|
/// <c>plugin.manifest.json</c> sidecar file. Pure data only: the host loader
|
||||||
|
/// (Core) parses the JSON into this shape, then uses it to locate and load the
|
||||||
|
/// plugin assembly. Deliberately free of serialization attributes and of any
|
||||||
|
/// <see cref="System.Reflection"/> members so the Domain stays package-free.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginManifest
|
||||||
|
{
|
||||||
|
/// <summary>Protocol identifier this plugin provides (e.g. "mtconnect").</summary>
|
||||||
|
public string ProtocolId { get; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable plugin name for UI/logging.</summary>
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
/// <summary>File name of the plugin assembly (dll), relative to the manifest.</summary>
|
||||||
|
public string AssemblyFile { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fully-qualified type name of the <see cref="IProtocolDriverFactory"/>
|
||||||
|
/// implementation to instantiate as the plugin entrypoint.
|
||||||
|
/// </summary>
|
||||||
|
public string EntryTypeName { get; }
|
||||||
|
|
||||||
|
/// <summary>Plugin contract (API) version this plugin was built against (e.g. "1.0").</summary>
|
||||||
|
public string ApiVersion { get; }
|
||||||
|
|
||||||
|
public PluginManifest(
|
||||||
|
string protocolId,
|
||||||
|
string displayName,
|
||||||
|
string assemblyFile,
|
||||||
|
string entryTypeName,
|
||||||
|
string apiVersion)
|
||||||
|
{
|
||||||
|
ProtocolId = protocolId ?? "";
|
||||||
|
DisplayName = displayName ?? "";
|
||||||
|
AssemblyFile = assemblyFile ?? "";
|
||||||
|
EntryTypeName = entryTypeName ?? "";
|
||||||
|
ApiVersion = apiVersion ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/Junction.Domain/Result.cs
Normal file
80
src/Junction.Domain/Result.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Junction.Domain
|
||||||
|
{
|
||||||
|
/// <summary>Result wrapper for operations that produce a value.</summary>
|
||||||
|
public sealed class Result<T>
|
||||||
|
{
|
||||||
|
private readonly T _value;
|
||||||
|
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public bool WasCancelled { get; }
|
||||||
|
public IReadOnlyList<OperationError> Errors { get; }
|
||||||
|
|
||||||
|
/// <summary>The produced value. Meaningful only when <see cref="IsSuccess"/> is true; otherwise default.</summary>
|
||||||
|
public T Value => _value;
|
||||||
|
|
||||||
|
private Result(bool isSuccess, T value, IReadOnlyList<OperationError>? errors, bool cancelled)
|
||||||
|
{
|
||||||
|
IsSuccess = isSuccess;
|
||||||
|
_value = value;
|
||||||
|
Errors = errors ?? (IReadOnlyList<OperationError>)Array.Empty<OperationError>();
|
||||||
|
WasCancelled = cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> Ok(T value) =>
|
||||||
|
new Result<T>(true, value, null, false);
|
||||||
|
|
||||||
|
public static Result<T> Fail(OperationError error) =>
|
||||||
|
new Result<T>(false, default!, new[] { error }, false);
|
||||||
|
|
||||||
|
public static Result<T> Fail(IEnumerable<OperationError> errors) =>
|
||||||
|
new Result<T>(false, default!, errors.ToArray(), false);
|
||||||
|
|
||||||
|
public static Result<T> Cancelled() =>
|
||||||
|
new Result<T>(false, default!, null, true);
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
if (WasCancelled) return $"Result<{typeof(T).Name}>: Cancelled";
|
||||||
|
if (!IsSuccess) return $"Result<{typeof(T).Name}>: Fail ({Errors.Count} errors)";
|
||||||
|
return $"Result<{typeof(T).Name}>: Ok";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result wrapper for void-style operations (no produced value).</summary>
|
||||||
|
public sealed class Result
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public bool WasCancelled { get; }
|
||||||
|
public IReadOnlyList<OperationError> Errors { get; }
|
||||||
|
|
||||||
|
private Result(bool isSuccess, IReadOnlyList<OperationError>? errors, bool cancelled)
|
||||||
|
{
|
||||||
|
IsSuccess = isSuccess;
|
||||||
|
Errors = errors ?? (IReadOnlyList<OperationError>)Array.Empty<OperationError>();
|
||||||
|
WasCancelled = cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result Ok() =>
|
||||||
|
new Result(true, null, false);
|
||||||
|
|
||||||
|
public static Result Fail(OperationError error) =>
|
||||||
|
new Result(false, new[] { error }, false);
|
||||||
|
|
||||||
|
public static Result Fail(IEnumerable<OperationError> errors) =>
|
||||||
|
new Result(false, errors.ToArray(), false);
|
||||||
|
|
||||||
|
public static Result Cancelled() =>
|
||||||
|
new Result(false, null, true);
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
if (WasCancelled) return "Result: Cancelled";
|
||||||
|
if (!IsSuccess) return $"Result: Fail ({Errors.Count} errors)";
|
||||||
|
return "Result: Ok";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/Junction.Persistence/ConnectionFactory.cs
Normal file
50
src/Junction.Persistence/ConnectionFactory.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using System;
|
||||||
|
using System.Data;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Junction.Persistence
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates database connections for the persistence layer.
|
||||||
|
/// Provider-swap seam: only implementations of this interface (and the repository)
|
||||||
|
/// know the concrete database technology. Callers see plain <see cref="IDbConnection"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IConnectionFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and opens a new connection. Caller owns the returned connection and
|
||||||
|
/// must dispose it.
|
||||||
|
/// </summary>
|
||||||
|
IDbConnection CreateOpenConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite-backed <see cref="IConnectionFactory"/>. Central place the connection string
|
||||||
|
/// lives; the rest of the app never sees a <see cref="SqliteConnection"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqliteConnectionFactory : IConnectionFactory
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
/// <param name="connectionString">
|
||||||
|
/// A Microsoft.Data.Sqlite connection string (e.g. "Data Source=junction.db").
|
||||||
|
/// </param>
|
||||||
|
public SqliteConnectionFactory(string connectionString)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Connection string must not be empty.", nameof(connectionString));
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDbConnection CreateOpenConnection()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Junction.Persistence/Junction.Persistence.csproj
Normal file
25
src/Junction.Persistence/Junction.Persistence.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<RootNamespace>Junction.Persistence</RootNamespace>
|
||||||
|
<!-- RIDs so a net48 consumer publish copies runtimes/<rid>/native/e_sqlite3.dll (efcore#19396). -->
|
||||||
|
<RuntimeIdentifiers>win-x64;win-x86</RuntimeIdentifiers>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Junction.Domain\Junction.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
||||||
|
<!-- Explicit native SQLite bundle (also pulled transitively by Microsoft.Data.Sqlite). -->
|
||||||
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
|
||||||
|
<!-- JSON serializer for ConnectionConfigJson column (T18 repository). Pinned exact for deterministic builds on old Windows fleet. -->
|
||||||
|
<PackageReference Include="System.Text.Json" Version="8.0.6" />
|
||||||
|
<!-- DI seam for AddJunctionPersistence (T18). Abstractions only; net48-safe; pinned. -->
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
53
src/Junction.Persistence/ServiceCollectionExtensions.cs
Normal file
53
src/Junction.Persistence/ServiceCollectionExtensions.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
using System;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Persistence;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Junction.Persistence
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DI registration for the SQLite-backed persistence adapter.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the SQLite persistence stack:
|
||||||
|
/// <see cref="IConnectionFactory"/> (<see cref="SqliteConnectionFactory"/>) and
|
||||||
|
/// <see cref="IMachineRepository"/> (<see cref="SqliteMachineRepository"/>), and ensures the
|
||||||
|
/// schema exists (idempotent) at registration time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to add to.</param>
|
||||||
|
/// <param name="connectionString">A Microsoft.Data.Sqlite connection string (e.g. "Data Source=junction.db").</param>
|
||||||
|
/// <returns>The same collection, for chaining.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="services"/> is null.</exception>
|
||||||
|
/// <exception cref="ArgumentException"><paramref name="connectionString"/> is null/empty.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">Schema initialization failed.</exception>
|
||||||
|
public static IServiceCollection AddJunctionPersistence(this IServiceCollection services, string connectionString)
|
||||||
|
{
|
||||||
|
if (services == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(services));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Connection string must not be empty.", nameof(connectionString));
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectionFactory = new SqliteConnectionFactory(connectionString);
|
||||||
|
|
||||||
|
// Fail fast at composition time if the store can't be initialized; EnsureCreated is idempotent.
|
||||||
|
Result schema = SqliteSchema.EnsureCreated(connectionFactory);
|
||||||
|
if (!schema.IsSuccess)
|
||||||
|
{
|
||||||
|
var detail = schema.Errors.Count > 0 ? schema.Errors[0].Message : "unknown error";
|
||||||
|
throw new InvalidOperationException("Junction persistence schema init failed: " + detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddSingleton<IConnectionFactory>(connectionFactory);
|
||||||
|
services.AddSingleton<IMachineRepository, SqliteMachineRepository>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
446
src/Junction.Persistence/SqliteMachineRepository.cs
Normal file
446
src/Junction.Persistence/SqliteMachineRepository.cs
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Dapper;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Persistence;
|
||||||
|
|
||||||
|
namespace Junction.Persistence
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Dapper + SQLite adapter for <see cref="IMachineRepository"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Provider-swap seam: all SQLite/Dapper/ADO.NET types stay inside this class. Only
|
||||||
|
/// <see cref="Junction.Domain"/> types and BCL primitives cross the boundary.
|
||||||
|
///
|
||||||
|
/// Column mapping (see <see cref="SqliteSchema"/> DDL):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><see cref="Guid"/> <-> TEXT via <c>ToString("D")</c> (canonical 8-4-4-4-12).</description></item>
|
||||||
|
/// <item><description><see cref="Machine.PollInterval"/> <-> INTEGER ticks (<see cref="TimeSpan.Ticks"/>).</description></item>
|
||||||
|
/// <item><description><see cref="Machine.ConnectionConfig"/> <-> TEXT JSON of a Dictionary<string,string> (System.Text.Json). Null/blank JSON rehydrates to an empty dictionary.</description></item>
|
||||||
|
/// <item><description><see cref="DateTimeOffset"/> <-> TEXT ISO 8601 round-trip ("O").</description></item>
|
||||||
|
/// <item><description><see cref="ConnectionState"/> <-> INTEGER (enum cast).</description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Not-found policy: <see cref="GetByIdAsync"/> and <see cref="GetLatestSnapshotAsync"/> return
|
||||||
|
/// <c>Result.Fail</c> carrying an <see cref="OperationError"/> with code <c>"not_found"</c> — never throw,
|
||||||
|
/// never a success with a null value.
|
||||||
|
///
|
||||||
|
/// Errors: expected failures are reported via <see cref="Result"/>/<see cref="Result{T}"/>. Store
|
||||||
|
/// exceptions are caught and mapped to <c>Result.Fail</c>; cancellation is mapped to <c>Result.Cancelled</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SqliteMachineRepository : IMachineRepository
|
||||||
|
{
|
||||||
|
private const string Source = "SqliteMachineRepository";
|
||||||
|
private const string NotFoundCode = "not_found";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions();
|
||||||
|
|
||||||
|
private readonly IConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public SqliteMachineRepository(IConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<IReadOnlyList<Machine>>> GetAllAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<Machine>>.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
{
|
||||||
|
var command = new CommandDefinition(
|
||||||
|
"SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson FROM machines;",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<MachineRow>(command).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var machines = new List<Machine>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
machines.Add(MapMachine(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<IReadOnlyList<Machine>>.Ok(machines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<Machine>>.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<Machine>>.Fail(
|
||||||
|
OperationError.Of(Source, "GetAll failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Machine>> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<Machine>.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
{
|
||||||
|
var command = new CommandDefinition(
|
||||||
|
"SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson " +
|
||||||
|
"FROM machines WHERE Id = @Id;",
|
||||||
|
new { Id = GuidText(id) },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<MachineRow>(command).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (row == null)
|
||||||
|
{
|
||||||
|
return Result<Machine>.Fail(new OperationError(
|
||||||
|
NotFoundCode, Source, "Machine not found: " + GuidText(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<Machine>.Ok(MapMachine(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result<Machine>.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<Machine>.Fail(
|
||||||
|
OperationError.Of(Source, "GetById failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> UpsertAsync(Machine machine, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (machine == null)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of(Source, "Machine is null."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
{
|
||||||
|
var command = new CommandDefinition(
|
||||||
|
"INSERT INTO machines (Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson) " +
|
||||||
|
"VALUES (@Id, @Name, @ProtocolId, @PollIntervalTicks, @ConnectionConfigJson) " +
|
||||||
|
"ON CONFLICT(Id) DO UPDATE SET " +
|
||||||
|
"Name = excluded.Name, " +
|
||||||
|
"ProtocolId = excluded.ProtocolId, " +
|
||||||
|
"PollIntervalTicks = excluded.PollIntervalTicks, " +
|
||||||
|
"ConnectionConfigJson = excluded.ConnectionConfigJson;",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = GuidText(machine.Id),
|
||||||
|
machine.Name,
|
||||||
|
machine.ProtocolId,
|
||||||
|
PollIntervalTicks = machine.PollInterval.Ticks,
|
||||||
|
ConnectionConfigJson = SerializeConfig(machine.ConnectionConfig)
|
||||||
|
},
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(command).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of(Source, "Upsert failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
using (var transaction = connection.BeginTransaction())
|
||||||
|
{
|
||||||
|
var idParam = new { Id = GuidText(id) };
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"DELETE FROM snapshot_items WHERE MachineId = @Id;",
|
||||||
|
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"DELETE FROM latest_snapshots WHERE MachineId = @Id;",
|
||||||
|
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"DELETE FROM machines WHERE Id = @Id;",
|
||||||
|
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of(Source, "Delete failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> SaveSnapshotAsync(MachineSnapshot snapshot, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (snapshot == null)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of(Source, "Snapshot is null."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
using (var transaction = connection.BeginTransaction())
|
||||||
|
{
|
||||||
|
var machineIdText = GuidText(snapshot.MachineId);
|
||||||
|
|
||||||
|
// Upsert the single latest-snapshot header row.
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"INSERT INTO latest_snapshots (MachineId, CapturedAt, ConnectionState) " +
|
||||||
|
"VALUES (@MachineId, @CapturedAt, @ConnectionState) " +
|
||||||
|
"ON CONFLICT(MachineId) DO UPDATE SET " +
|
||||||
|
"CapturedAt = excluded.CapturedAt, " +
|
||||||
|
"ConnectionState = excluded.ConnectionState;",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
MachineId = machineIdText,
|
||||||
|
CapturedAt = IsoText(snapshot.CapturedAt),
|
||||||
|
ConnectionState = (int)snapshot.ConnectionState
|
||||||
|
},
|
||||||
|
transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Only-latest policy: drop previous items, insert current set.
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"DELETE FROM snapshot_items WHERE MachineId = @MachineId;",
|
||||||
|
new { MachineId = machineIdText },
|
||||||
|
transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (snapshot.Items.Count > 0)
|
||||||
|
{
|
||||||
|
var itemParams = new List<object>(snapshot.Items.Count);
|
||||||
|
foreach (var item in snapshot.Items)
|
||||||
|
{
|
||||||
|
itemParams.Add(new
|
||||||
|
{
|
||||||
|
MachineId = machineIdText,
|
||||||
|
ItemId = item.Id,
|
||||||
|
item.Name,
|
||||||
|
item.Value,
|
||||||
|
item.Category,
|
||||||
|
Timestamp = IsoText(item.Timestamp)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"INSERT INTO snapshot_items (MachineId, ItemId, Name, Value, Category, Timestamp) " +
|
||||||
|
"VALUES (@MachineId, @ItemId, @Name, @Value, @Category, @Timestamp);",
|
||||||
|
itemParams,
|
||||||
|
transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of(Source, "SaveSnapshot failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<MachineSnapshot>> GetLatestSnapshotAsync(Guid machineId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
{
|
||||||
|
var machineIdText = GuidText(machineId);
|
||||||
|
|
||||||
|
var header = await connection.QuerySingleOrDefaultAsync<SnapshotRow>(new CommandDefinition(
|
||||||
|
"SELECT MachineId, CapturedAt, ConnectionState FROM latest_snapshots WHERE MachineId = @MachineId;",
|
||||||
|
new { MachineId = machineIdText },
|
||||||
|
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (header == null)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Fail(new OperationError(
|
||||||
|
NotFoundCode, Source, "Snapshot not found for machine: " + machineIdText));
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemRows = await connection.QueryAsync<SnapshotItemRow>(new CommandDefinition(
|
||||||
|
"SELECT ItemId, Name, Value, Category, Timestamp FROM snapshot_items WHERE MachineId = @MachineId;",
|
||||||
|
new { MachineId = machineIdText },
|
||||||
|
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var items = new List<DataItem>();
|
||||||
|
foreach (var itemRow in itemRows)
|
||||||
|
{
|
||||||
|
items.Add(new DataItem(
|
||||||
|
itemRow.ItemId,
|
||||||
|
itemRow.Name,
|
||||||
|
itemRow.Value,
|
||||||
|
itemRow.Category,
|
||||||
|
ParseIso(itemRow.Timestamp)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = new MachineSnapshot(
|
||||||
|
machineId,
|
||||||
|
ParseIso(header.CapturedAt),
|
||||||
|
(ConnectionState)header.ConnectionState,
|
||||||
|
items);
|
||||||
|
|
||||||
|
return Result<MachineSnapshot>.Ok(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Fail(
|
||||||
|
OperationError.Of(Source, "GetLatestSnapshot failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- helpers -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private DbConnection OpenConnection()
|
||||||
|
{
|
||||||
|
// SqliteConnectionFactory yields a SqliteConnection (a DbConnection); Dapper's *Async
|
||||||
|
// needs the DbConnection surface. The concrete Sqlite type never escapes this class.
|
||||||
|
var connection = _connectionFactory.CreateOpenConnection();
|
||||||
|
if (connection is DbConnection dbConnection)
|
||||||
|
{
|
||||||
|
return dbConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
connection?.Dispose();
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"IConnectionFactory must return a DbConnection for async Dapper operations.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Machine MapMachine(MachineRow row)
|
||||||
|
{
|
||||||
|
return new Machine(
|
||||||
|
Guid.Parse(row.Id),
|
||||||
|
row.Name,
|
||||||
|
row.ProtocolId,
|
||||||
|
DeserializeConfig(row.ConnectionConfigJson),
|
||||||
|
TimeSpan.FromTicks(row.PollIntervalTicks));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GuidText(Guid id) => id.ToString("D");
|
||||||
|
|
||||||
|
private static string IsoText(DateTimeOffset value) => value.ToString("O");
|
||||||
|
|
||||||
|
private static DateTimeOffset ParseIso(string value) =>
|
||||||
|
DateTimeOffset.Parse(value, null, System.Globalization.DateTimeStyles.RoundtripKind);
|
||||||
|
|
||||||
|
private static string SerializeConfig(IReadOnlyDictionary<string, string> config)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> dict;
|
||||||
|
if (config is Dictionary<string, string> concrete)
|
||||||
|
{
|
||||||
|
dict = concrete;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dict = new Dictionary<string, string>(config.Count);
|
||||||
|
foreach (var pair in config)
|
||||||
|
{
|
||||||
|
dict[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(dict, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> DeserializeConfig(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string>(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json!, JsonOptions);
|
||||||
|
return dict ?? new Dictionary<string, string>(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- row DTOs (private; never cross the boundary) -----------------------------------
|
||||||
|
|
||||||
|
private sealed class MachineRow
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string ProtocolId { get; set; } = "";
|
||||||
|
public long PollIntervalTicks { get; set; }
|
||||||
|
public string? ConnectionConfigJson { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SnapshotRow
|
||||||
|
{
|
||||||
|
public string MachineId { get; set; } = "";
|
||||||
|
public string CapturedAt { get; set; } = "";
|
||||||
|
public int ConnectionState { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SnapshotItemRow
|
||||||
|
{
|
||||||
|
public string ItemId { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string Timestamp { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/Junction.Persistence/SqliteSchema.cs
Normal file
104
src/Junction.Persistence/SqliteSchema.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using System;
|
||||||
|
using System.Data;
|
||||||
|
using Junction.Domain;
|
||||||
|
|
||||||
|
namespace Junction.Persistence
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the SQLite schema (DDL). Idempotent: safe to run on every startup.
|
||||||
|
/// Only-latest policy — one snapshot row and one item-set per machine — but the
|
||||||
|
/// shape leaves room to add history tables later without breaking these tables.
|
||||||
|
/// </summary>
|
||||||
|
public static class SqliteSchema
|
||||||
|
{
|
||||||
|
// machines: one row per configured machine. ConnectionConfig bag stored as JSON text.
|
||||||
|
private const string CreateMachines =
|
||||||
|
@"CREATE TABLE IF NOT EXISTS machines (
|
||||||
|
Id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
Name TEXT NOT NULL,
|
||||||
|
ProtocolId TEXT NOT NULL,
|
||||||
|
PollIntervalTicks INTEGER NOT NULL,
|
||||||
|
ConnectionConfigJson TEXT NOT NULL
|
||||||
|
);";
|
||||||
|
|
||||||
|
// latest_snapshots: exactly one row per machine (PK = MachineId). Upsert overwrites.
|
||||||
|
private const string CreateLatestSnapshots =
|
||||||
|
@"CREATE TABLE IF NOT EXISTS latest_snapshots (
|
||||||
|
MachineId TEXT NOT NULL PRIMARY KEY,
|
||||||
|
CapturedAt TEXT NOT NULL,
|
||||||
|
ConnectionState INTEGER NOT NULL
|
||||||
|
);";
|
||||||
|
|
||||||
|
// snapshot_items: datums of the latest snapshot. Replaced on each SaveSnapshot.
|
||||||
|
private const string CreateSnapshotItems =
|
||||||
|
@"CREATE TABLE IF NOT EXISTS snapshot_items (
|
||||||
|
MachineId TEXT NOT NULL,
|
||||||
|
ItemId TEXT NOT NULL,
|
||||||
|
Name TEXT NOT NULL,
|
||||||
|
Value TEXT NOT NULL,
|
||||||
|
Category TEXT NOT NULL,
|
||||||
|
Timestamp TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (MachineId, ItemId)
|
||||||
|
);";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates all tables if they do not already exist. Idempotent.
|
||||||
|
/// Returns a failed <see cref="Result"/> instead of throwing on store errors.
|
||||||
|
/// </summary>
|
||||||
|
public static Result EnsureCreated(IDbConnection connection)
|
||||||
|
{
|
||||||
|
if (connection == null)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of("SqliteSchema", "Connection is null."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (connection.State != ConnectionState.Open)
|
||||||
|
{
|
||||||
|
connection.Open();
|
||||||
|
}
|
||||||
|
|
||||||
|
Execute(connection, CreateMachines);
|
||||||
|
Execute(connection, CreateLatestSnapshots);
|
||||||
|
Execute(connection, CreateSnapshotItems);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of("SqliteSchema", "Schema init failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convenience overload: opens a connection from the factory and runs <see cref="EnsureCreated(IDbConnection)"/>.</summary>
|
||||||
|
public static Result EnsureCreated(IConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
if (connectionFactory == null)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of("SqliteSchema", "Connection factory is null."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = connectionFactory.CreateOpenConnection())
|
||||||
|
{
|
||||||
|
return EnsureCreated(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of("SqliteSchema", "Schema init failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Execute(IDbConnection connection, string sql)
|
||||||
|
{
|
||||||
|
using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = sql;
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<RootNamespace>Junction.Protocols.MTConnect</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Junction.Domain\Junction.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Plugin manifest: ships beside built assembly. File created in T17; Condition keeps build green until then. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="plugin.manifest.json" Condition="Exists('plugin.manifest.json')" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
125
src/Junction.Protocols.MTConnect/MtconnectDriver.cs
Normal file
125
src/Junction.Protocols.MTConnect/MtconnectDriver.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Junction.Protocols.MTConnect.Parsing;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.MTConnect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-machine MTConnect protocol driver. Reads the agent's <c>/current</c> probe stream
|
||||||
|
/// over HTTP and parses it into a <see cref="MachineSnapshot"/> via
|
||||||
|
/// <see cref="MtconnectCurrentParser"/>. Network-only concern: parsing lives in T15 parsers.
|
||||||
|
/// <para>
|
||||||
|
/// The <see cref="HttpClient"/> is injected so the transport can be stubbed in tests
|
||||||
|
/// (no live network). The factory supplies a real client with a sane timeout.
|
||||||
|
/// </para>
|
||||||
|
/// Never throws for expected transport/parse failures; maps them to
|
||||||
|
/// <see cref="Result{T}.Fail(OperationError)"/> and honours cancellation via
|
||||||
|
/// <see cref="Result{T}.Cancelled"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MtconnectDriver : IProtocolDriver
|
||||||
|
{
|
||||||
|
private const string Source = "MtconnectDriver";
|
||||||
|
private const string CurrentPath = "current";
|
||||||
|
|
||||||
|
private readonly Guid _machineId;
|
||||||
|
private readonly Uri _currentUri;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
|
/// <summary>Protocol identifier this driver serves.</summary>
|
||||||
|
public string ProtocolId => "mtconnect";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a driver bound to a single machine.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="machineId">Machine this driver reads for; stamped onto the snapshot.</param>
|
||||||
|
/// <param name="agentUrl">Base MTConnect agent URL (e.g. "http://host:5000"). Non-null, absolute.</param>
|
||||||
|
/// <param name="httpClient">HTTP transport. Injected for testability; owned by the caller/factory.</param>
|
||||||
|
public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient)
|
||||||
|
{
|
||||||
|
if (agentUrl is null) throw new ArgumentNullException(nameof(agentUrl));
|
||||||
|
_http = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
|
_machineId = machineId;
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(agentUrl, UriKind.Absolute, out var baseUri))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Agent URL must be an absolute URI: '" + agentUrl + "'.", nameof(agentUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine base + "current" preserving any base path segment.
|
||||||
|
var basePath = baseUri.AbsoluteUri;
|
||||||
|
if (!basePath.EndsWith("/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
basePath += "/";
|
||||||
|
}
|
||||||
|
_currentUri = new Uri(new Uri(basePath, UriKind.Absolute), CurrentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await _http.GetAsync(_currentUri, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Caller-requested cancellation.
|
||||||
|
return Result<MachineSnapshot>.Cancelled();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex)
|
||||||
|
{
|
||||||
|
// TaskCanceledException not tied to the caller token => client timeout.
|
||||||
|
return Fail("CURRENT_TIMEOUT", "HTTP request to '" + _currentUri + "' timed out: " + ex.Message);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Fail("CURRENT_HTTP_ERROR", "HTTP request to '" + _currentUri + "' failed: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return Fail(
|
||||||
|
"CURRENT_HTTP_STATUS",
|
||||||
|
"Agent returned non-success status " + (int)response.StatusCode + " (" + response.StatusCode + ") for '" + _currentUri + "'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#if NET5_0_OR_GREATER
|
||||||
|
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
#else
|
||||||
|
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Cancelled();
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Fail("CURRENT_HTTP_ERROR", "Reading response body from '" + _currentUri + "' failed: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate parsing (T15). Parser never throws; returns Fail on malformed input.
|
||||||
|
return MtconnectCurrentParser.Parse(body, _machineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<MachineSnapshot> Fail(string code, string message) =>
|
||||||
|
Result<MachineSnapshot>.Fail(new OperationError(code, Source, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs
Normal file
105
src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.MTConnect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin entrypoint for the MTConnect protocol. The Core plugin loader (T12) resolves
|
||||||
|
/// this type by name from <c>plugin.manifest.json</c> and instantiates it via
|
||||||
|
/// <see cref="Activator.CreateInstance(Type)"/>, so it MUST have a public parameterless
|
||||||
|
/// constructor.
|
||||||
|
/// <para>
|
||||||
|
/// Owns MTConnect-specific config validation and builds a real <see cref="HttpClient"/>
|
||||||
|
/// for the produced <see cref="MtconnectDriver"/>, keeping the Domain protocol-agnostic.
|
||||||
|
/// </para>
|
||||||
|
/// <para>Expected <see cref="Machine.ConnectionConfig"/> keys (case-insensitive):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>AgentUrl</c> (required) — absolute base URL of the MTConnect agent, e.g. "http://host:5000".</description></item>
|
||||||
|
/// <item><description><c>TimeoutSeconds</c> (optional) — HTTP timeout in whole seconds; defaults to 10.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MtconnectDriverFactory : IProtocolDriverFactory
|
||||||
|
{
|
||||||
|
private const string Source = "MtconnectDriverFactory";
|
||||||
|
private const string AgentUrlKey = "AgentUrl";
|
||||||
|
private const string TimeoutKey = "TimeoutSeconds";
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>Protocol identifier this factory produces drivers for.</summary>
|
||||||
|
public string ProtocolId => "mtconnect";
|
||||||
|
|
||||||
|
/// <summary>Required by the plugin loader (Activator.CreateInstance).</summary>
|
||||||
|
public MtconnectDriverFactory()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Result<IProtocolDriver> Create(Machine machine)
|
||||||
|
{
|
||||||
|
if (machine is null)
|
||||||
|
{
|
||||||
|
return Fail("MACHINE_NULL", "Machine was null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = machine.ConnectionConfig;
|
||||||
|
|
||||||
|
var agentUrl = GetValue(config, AgentUrlKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(agentUrl))
|
||||||
|
{
|
||||||
|
return Fail(
|
||||||
|
"CONFIG_AGENTURL_MISSING",
|
||||||
|
"Required connection config key '" + AgentUrlKey + "' is missing or empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
agentUrl = agentUrl!.Trim();
|
||||||
|
if (!Uri.TryCreate(agentUrl, UriKind.Absolute, out _))
|
||||||
|
{
|
||||||
|
return Fail(
|
||||||
|
"CONFIG_AGENTURL_INVALID",
|
||||||
|
"Connection config key '" + AgentUrlKey + "' is not an absolute URI: '" + agentUrl + "'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeout = DefaultTimeout;
|
||||||
|
var timeoutRaw = GetValue(config, TimeoutKey);
|
||||||
|
if (!string.IsNullOrWhiteSpace(timeoutRaw))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(timeoutRaw, out var seconds) || seconds <= 0)
|
||||||
|
{
|
||||||
|
return Fail(
|
||||||
|
"CONFIG_TIMEOUT_INVALID",
|
||||||
|
"Connection config key '" + TimeoutKey + "' must be a positive integer (seconds): '" + timeoutRaw + "'.");
|
||||||
|
}
|
||||||
|
timeout = TimeSpan.FromSeconds(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var http = new HttpClient { Timeout = timeout };
|
||||||
|
var driver = new MtconnectDriver(machine.Id, agentUrl, http);
|
||||||
|
return Result<IProtocolDriver>.Ok(driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetValue(System.Collections.Generic.IReadOnlyDictionary<string, string> config, string key)
|
||||||
|
{
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive lookup over a possibly case-sensitive dictionary.
|
||||||
|
foreach (var pair in config)
|
||||||
|
{
|
||||||
|
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<IProtocolDriver> Fail(string code, string message) =>
|
||||||
|
Result<IProtocolDriver>.Fail(new OperationError(code, Source, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.MTConnect.Parsing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure parser for an MTConnect current (MTConnectStreams) document.
|
||||||
|
/// Network-free: operates on an XML string. Version-agnostic: matches by local name only.
|
||||||
|
/// </summary>
|
||||||
|
public static class MtconnectCurrentParser
|
||||||
|
{
|
||||||
|
private const string Source = "MtconnectCurrentParser";
|
||||||
|
|
||||||
|
// Container local names that hold observations.
|
||||||
|
private const string Samples = "Samples";
|
||||||
|
private const string Events = "Events";
|
||||||
|
private const string Condition = "Condition";
|
||||||
|
private const string AvailabilityType = "Availability";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a current XML document into a <see cref="MachineSnapshot"/> stamped with
|
||||||
|
/// <paramref name="machineId"/>. Never throws: malformed/unexpected input yields
|
||||||
|
/// <see cref="Result{T}.Fail(OperationError)"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static Result<MachineSnapshot> Parse(string? xml, Guid machineId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(xml))
|
||||||
|
{
|
||||||
|
return Fail("CURRENT_EMPTY", "Current XML was null or empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
XDocument doc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
doc = XDocument.Parse(xml);
|
||||||
|
}
|
||||||
|
catch (XmlException ex)
|
||||||
|
{
|
||||||
|
return Fail("CURRENT_MALFORMED", "Current XML is not well-formed: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = doc.Root;
|
||||||
|
if (root is null || !root.Is("MTConnectStreams"))
|
||||||
|
{
|
||||||
|
return Fail("CURRENT_UNEXPECTED", "Root element is not MTConnectStreams (local name).");
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = new List<DataItem>();
|
||||||
|
DateTimeOffset? latest = null;
|
||||||
|
var connectionState = ConnectionState.Unknown;
|
||||||
|
|
||||||
|
foreach (var componentStream in root.DescendantsLocal("ComponentStream"))
|
||||||
|
{
|
||||||
|
foreach (var container in componentStream.Elements())
|
||||||
|
{
|
||||||
|
var containerName = container.Name.LocalName;
|
||||||
|
var isSamples = containerName == Samples;
|
||||||
|
var isEvents = containerName == Events;
|
||||||
|
var isCondition = containerName == Condition;
|
||||||
|
|
||||||
|
if (!isSamples && !isEvents && !isCondition)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var obs in container.Elements())
|
||||||
|
{
|
||||||
|
var typeLocalName = obs.Name.LocalName;
|
||||||
|
var dataItemId = obs.Attr("dataItemId");
|
||||||
|
var timestamp = ParseTimestamp(obs.Attr("timestamp"));
|
||||||
|
|
||||||
|
// For Samples/Events value is element text; for Condition it is the state (element local name).
|
||||||
|
var value = isCondition ? typeLocalName : obs.Value.Trim();
|
||||||
|
|
||||||
|
items.Add(new DataItem(
|
||||||
|
id: dataItemId,
|
||||||
|
name: typeLocalName,
|
||||||
|
value: value,
|
||||||
|
category: containerName,
|
||||||
|
timestamp: timestamp ?? default));
|
||||||
|
|
||||||
|
if (timestamp.HasValue && (latest is null || timestamp.Value > latest.Value))
|
||||||
|
{
|
||||||
|
latest = timestamp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Availability drives the connection state.
|
||||||
|
if (typeLocalName == AvailabilityType)
|
||||||
|
{
|
||||||
|
connectionState = MapAvailability(obs.Value.Trim(), connectionState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var capturedAt = latest ?? DateTimeOffset.UtcNow;
|
||||||
|
var snapshot = new MachineSnapshot(machineId, capturedAt, connectionState, items);
|
||||||
|
return Result<MachineSnapshot>.Ok(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConnectionState MapAvailability(string value, ConnectionState current)
|
||||||
|
{
|
||||||
|
if (value.Equals("AVAILABLE", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ConnectionState.Connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Equals("UNAVAILABLE", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ConnectionState.Disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset? ParseTimestamp(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(raw))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTimeOffset.TryParse(
|
||||||
|
raw,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.RoundtripKind | DateTimeStyles.AssumeUniversal,
|
||||||
|
out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<MachineSnapshot> Fail(string code, string message) =>
|
||||||
|
Result<MachineSnapshot>.Fail(new OperationError(code, Source, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Junction.Domain;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.MTConnect.Parsing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure parser for an MTConnect probe (MTConnectDevices) document.
|
||||||
|
/// Network-free: operates on an XML string. Version-agnostic: matches by local name only.
|
||||||
|
/// </summary>
|
||||||
|
public static class MtconnectProbeParser
|
||||||
|
{
|
||||||
|
private const string Source = "MtconnectProbeParser";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a probe XML document into a flat list of <see cref="ProbeDataItemDescriptor"/>
|
||||||
|
/// (every DataItem across every Device, at any nesting depth).
|
||||||
|
/// Never throws: malformed/unexpected input yields <see cref="Result{T}.Fail(OperationError)"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static Result<IReadOnlyList<ProbeDataItemDescriptor>> Parse(string? xml)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(xml))
|
||||||
|
{
|
||||||
|
return Fail("PROBE_EMPTY", "Probe XML was null or empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
XDocument doc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
doc = XDocument.Parse(xml);
|
||||||
|
}
|
||||||
|
catch (XmlException ex)
|
||||||
|
{
|
||||||
|
return Fail("PROBE_MALFORMED", "Probe XML is not well-formed: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = doc.Root;
|
||||||
|
if (root is null || !root.Is("MTConnectDevices"))
|
||||||
|
{
|
||||||
|
return Fail("PROBE_UNEXPECTED", "Root element is not MTConnectDevices (local name).");
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptors = new List<ProbeDataItemDescriptor>();
|
||||||
|
|
||||||
|
foreach (var device in root.DescendantsLocal("Device"))
|
||||||
|
{
|
||||||
|
var deviceId = device.Attr("id");
|
||||||
|
var deviceName = device.Attr("name");
|
||||||
|
var deviceUuid = device.Attr("uuid");
|
||||||
|
|
||||||
|
foreach (var di in device.DescendantsLocal("DataItem"))
|
||||||
|
{
|
||||||
|
descriptors.Add(new ProbeDataItemDescriptor(
|
||||||
|
deviceId,
|
||||||
|
deviceName,
|
||||||
|
deviceUuid,
|
||||||
|
di.Attr("id"),
|
||||||
|
di.Attr("name"),
|
||||||
|
di.Attr("type"),
|
||||||
|
di.Attr("category"),
|
||||||
|
di.Attr("units"),
|
||||||
|
di.Attr("subType")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<IReadOnlyList<ProbeDataItemDescriptor>>.Ok(descriptors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<IReadOnlyList<ProbeDataItemDescriptor>> Fail(string code, string message) =>
|
||||||
|
Result<IReadOnlyList<ProbeDataItemDescriptor>>.Fail(new OperationError(code, Source, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
namespace Junction.Protocols.MTConnect.Parsing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Flat descriptor of a single MTConnect probe DataItem plus its owning device.
|
||||||
|
/// Public so the driver assembly (T17) and tests can consume it across assembly boundaries.
|
||||||
|
/// Namespace-version-agnostic: produced by matching on XML local names only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProbeDataItemDescriptor
|
||||||
|
{
|
||||||
|
/// <summary>id of the owning Device element.</summary>
|
||||||
|
public string DeviceId { get; }
|
||||||
|
|
||||||
|
/// <summary>name of the owning Device element.</summary>
|
||||||
|
public string DeviceName { get; }
|
||||||
|
|
||||||
|
/// <summary>uuid of the owning Device element.</summary>
|
||||||
|
public string DeviceUuid { get; }
|
||||||
|
|
||||||
|
/// <summary>DataItem id (stable identifier used to correlate current observations).</summary>
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
/// <summary>DataItem name attribute (may be empty).</summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>DataItem type (e.g. POSITION, EXECUTION, AVAILABILITY).</summary>
|
||||||
|
public string Type { get; }
|
||||||
|
|
||||||
|
/// <summary>DataItem category (SAMPLE, EVENT, CONDITION).</summary>
|
||||||
|
public string Category { get; }
|
||||||
|
|
||||||
|
/// <summary>DataItem units (may be empty).</summary>
|
||||||
|
public string Units { get; }
|
||||||
|
|
||||||
|
/// <summary>DataItem subType (e.g. ACTUAL, COMMANDED; may be empty).</summary>
|
||||||
|
public string SubType { get; }
|
||||||
|
|
||||||
|
public ProbeDataItemDescriptor(
|
||||||
|
string deviceId,
|
||||||
|
string deviceName,
|
||||||
|
string deviceUuid,
|
||||||
|
string id,
|
||||||
|
string name,
|
||||||
|
string type,
|
||||||
|
string category,
|
||||||
|
string units,
|
||||||
|
string subType)
|
||||||
|
{
|
||||||
|
DeviceId = deviceId ?? "";
|
||||||
|
DeviceName = deviceName ?? "";
|
||||||
|
DeviceUuid = deviceUuid ?? "";
|
||||||
|
Id = id ?? "";
|
||||||
|
Name = name ?? "";
|
||||||
|
Type = type ?? "";
|
||||||
|
Category = category ?? "";
|
||||||
|
Units = units ?? "";
|
||||||
|
SubType = subType ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Junction.Protocols.MTConnect/Parsing/XmlLocalName.cs
Normal file
44
src/Junction.Protocols.MTConnect/Parsing/XmlLocalName.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.MTConnect.Parsing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Local-name-only XML helpers. The whole MTConnect parser matches elements and attributes
|
||||||
|
/// by <see cref="XName.LocalName"/> so it stays agnostic to the namespace VERSION
|
||||||
|
/// (urn:mtconnect.org:...:1.7 vs :2.0). No namespace URI is ever hardcoded.
|
||||||
|
/// </summary>
|
||||||
|
internal static class XmlLocalName
|
||||||
|
{
|
||||||
|
/// <summary>True when the element's local name matches (ordinal, case-sensitive).</summary>
|
||||||
|
public static bool Is(this XElement element, string localName) =>
|
||||||
|
element.Name.LocalName == localName;
|
||||||
|
|
||||||
|
/// <summary>Direct children whose local name equals <paramref name="localName"/>.</summary>
|
||||||
|
public static IEnumerable<XElement> ElementsLocal(this XElement element, string localName) =>
|
||||||
|
element.Elements().Where(e => e.Name.LocalName == localName);
|
||||||
|
|
||||||
|
/// <summary>All descendants (any depth) whose local name equals <paramref name="localName"/>.</summary>
|
||||||
|
public static IEnumerable<XElement> DescendantsLocal(this XElement element, string localName) =>
|
||||||
|
element.Descendants().Where(e => e.Name.LocalName == localName);
|
||||||
|
|
||||||
|
/// <summary>First direct or nested descendant with the given local name, or null.</summary>
|
||||||
|
public static XElement? FirstDescendantLocal(this XElement element, string localName) =>
|
||||||
|
element.Descendants().FirstOrDefault(e => e.Name.LocalName == localName);
|
||||||
|
|
||||||
|
/// <summary>Attribute value matched by local name (attributes are namespace-less here), or "".</summary>
|
||||||
|
public static string Attr(this XElement element, string localName)
|
||||||
|
{
|
||||||
|
foreach (var a in element.Attributes())
|
||||||
|
{
|
||||||
|
if (a.Name.LocalName == localName)
|
||||||
|
{
|
||||||
|
return a.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Junction.Protocols.MTConnect/plugin.manifest.json
Normal file
7
src/Junction.Protocols.MTConnect/plugin.manifest.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"protocolId": "mtconnect",
|
||||||
|
"displayName": "MTConnect",
|
||||||
|
"assemblyFile": "Junction.Protocols.MTConnect.dll",
|
||||||
|
"entryTypeName": "Junction.Protocols.MTConnect.MtconnectDriverFactory",
|
||||||
|
"apiVersion": "1.0"
|
||||||
|
}
|
||||||
69
tests/Junction.Tests/Fixtures/mtconnect/current.xml
Normal file
69
tests/Junction.Tests/Fixtures/mtconnect/current.xml
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.7"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:m="urn:mtconnect.org:MTConnectStreams:1.7"
|
||||||
|
xsi:schemaLocation="urn:mtconnect.org:MTConnectStreams:1.7 http://schemas.mtconnect.org/schemas/MTConnectStreams_1.7.xsd">
|
||||||
|
<Header creationTime="2026-07-21T09:15:30Z" sender="junction-agent" instanceId="1721545200"
|
||||||
|
version="1.7.0" bufferSize="131072" nextSequence="10240" firstSequence="1" lastSequence="10239"/>
|
||||||
|
<Streams>
|
||||||
|
<DeviceStream name="VMC-01" uuid="junction-vmc-01">
|
||||||
|
<ComponentStream component="Device" name="VMC-01" componentId="dev1">
|
||||||
|
<Events>
|
||||||
|
<Availability dataItemId="dev1_avail" timestamp="2026-07-21T09:15:30.100Z" sequence="10230">AVAILABLE</Availability>
|
||||||
|
</Events>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Linear" name="X" componentId="x1">
|
||||||
|
<Samples>
|
||||||
|
<Position dataItemId="x1_pos" subType="ACTUAL" timestamp="2026-07-21T09:15:30.210Z" sequence="10231">125.4300</Position>
|
||||||
|
<Position dataItemId="x1_pos_cmd" subType="COMMANDED" timestamp="2026-07-21T09:15:30.210Z" sequence="10232">125.5000</Position>
|
||||||
|
<Load dataItemId="x1_load" timestamp="2026-07-21T09:15:30.210Z" sequence="10233">42.1</Load>
|
||||||
|
</Samples>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Linear" name="Y" componentId="y1">
|
||||||
|
<Samples>
|
||||||
|
<Position dataItemId="y1_pos" subType="ACTUAL" timestamp="2026-07-21T09:15:30.220Z" sequence="10234">-88.7600</Position>
|
||||||
|
</Samples>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Linear" name="Z" componentId="z1">
|
||||||
|
<Samples>
|
||||||
|
<Position dataItemId="z1_pos" subType="ACTUAL" timestamp="2026-07-21T09:15:30.230Z" sequence="10235">15.0020</Position>
|
||||||
|
</Samples>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Rotary" name="C" componentId="c1">
|
||||||
|
<Samples>
|
||||||
|
<RotaryVelocity dataItemId="c1_spindle_speed" subType="ACTUAL" timestamp="2026-07-21T09:15:30.240Z" sequence="10236">3200.0</RotaryVelocity>
|
||||||
|
<RotaryVelocity dataItemId="c1_spindle_speed_cmd" subType="COMMANDED" timestamp="2026-07-21T09:15:30.240Z" sequence="10237">3200.0</RotaryVelocity>
|
||||||
|
<Load dataItemId="c1_load" timestamp="2026-07-21T09:15:30.240Z" sequence="10238">61.5</Load>
|
||||||
|
</Samples>
|
||||||
|
<Events>
|
||||||
|
<RotaryMode dataItemId="c1_rot_mode" timestamp="2026-07-21T09:15:30.240Z" sequence="10239">SPINDLE</RotaryMode>
|
||||||
|
</Events>
|
||||||
|
<Condition>
|
||||||
|
<Normal dataItemId="c1_temp_cond" type="TEMPERATURE" timestamp="2026-07-21T09:15:30.240Z" sequence="10240"/>
|
||||||
|
</Condition>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Controller" name="controller" componentId="cn1">
|
||||||
|
<Events>
|
||||||
|
<ControllerMode dataItemId="cn1_mode" timestamp="2026-07-21T09:15:30.150Z" sequence="10225">AUTOMATIC</ControllerMode>
|
||||||
|
<EmergencyStop dataItemId="cn1_estop" timestamp="2026-07-21T09:15:30.150Z" sequence="10226">ARMED</EmergencyStop>
|
||||||
|
</Events>
|
||||||
|
<Condition>
|
||||||
|
<Normal dataItemId="cn1_system" type="SYSTEM" timestamp="2026-07-21T09:15:30.150Z" sequence="10227"/>
|
||||||
|
</Condition>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Path" name="path" componentId="path1">
|
||||||
|
<Events>
|
||||||
|
<Execution dataItemId="path1_exec" timestamp="2026-07-21T09:15:30.160Z" sequence="10228">ACTIVE</Execution>
|
||||||
|
<Program dataItemId="path1_program" timestamp="2026-07-21T09:15:30.160Z" sequence="10229">O1234.NC</Program>
|
||||||
|
<Line dataItemId="path1_line" timestamp="2026-07-21T09:15:30.160Z" sequence="10221">142</Line>
|
||||||
|
</Events>
|
||||||
|
<Samples>
|
||||||
|
<PathFeedrate dataItemId="path1_feed" subType="ACTUAL" timestamp="2026-07-21T09:15:30.160Z" sequence="10222">85.0</PathFeedrate>
|
||||||
|
</Samples>
|
||||||
|
<Condition>
|
||||||
|
<Normal dataItemId="path1_logic" type="LOGIC_PROGRAM" timestamp="2026-07-21T09:15:30.160Z" sequence="10223"/>
|
||||||
|
</Condition>
|
||||||
|
</ComponentStream>
|
||||||
|
</DeviceStream>
|
||||||
|
</Streams>
|
||||||
|
</MTConnectStreams>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.7">
|
||||||
|
<Header creationTime="2026-07-21T09:25:00Z" sender="junction-agent" instanceId="1721545200"
|
||||||
|
version="1.7.0" bufferSize="131072" nextSequence="10400" firstSequence="1" lastSequence="10399"/>
|
||||||
|
<Streams>
|
||||||
|
<DeviceStream name="VMC-01" uuid="junction-vmc-01">
|
||||||
|
<ComponentStream component="Linear" name="X" componentId="x1">
|
||||||
|
<Samples>
|
||||||
|
<Position dataItemId="x1_pos" subType="ACTUAL" timestamp="2026-07-21T09:25:00.000Z" sequence="10391">125.4300</Position>
|
||||||
|
<Load dataItemId="x1_load" timestamp="2026-07-21T09:25:00.000Z" sequence="10392">42.1</Load>
|
||||||
|
</Samples>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Rotary" name="C" componentId="c1">
|
||||||
|
<Samples>
|
||||||
|
<RotaryVelocity dataItemId="c1_spindle_speed" subType="ACTUAL" timestamp="2026-07-21T09:25:00.000Z" sequence="10393">3200.0
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.7"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:m="urn:mtconnect.org:MTConnectStreams:1.7"
|
||||||
|
xsi:schemaLocation="urn:mtconnect.org:MTConnectStreams:1.7 http://schemas.mtconnect.org/schemas/MTConnectStreams_1.7.xsd">
|
||||||
|
<Header creationTime="2026-07-21T09:20:00Z" sender="junction-agent" instanceId="1721545200"
|
||||||
|
version="1.7.0" bufferSize="131072" nextSequence="10300" firstSequence="1" lastSequence="10299"/>
|
||||||
|
<Streams>
|
||||||
|
<DeviceStream name="VMC-01" uuid="junction-vmc-01">
|
||||||
|
<ComponentStream component="Device" name="VMC-01" componentId="dev1">
|
||||||
|
<Events>
|
||||||
|
<Availability dataItemId="dev1_avail" timestamp="2026-07-21T09:20:00.000Z" sequence="10290">UNAVAILABLE</Availability>
|
||||||
|
</Events>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Linear" name="X" componentId="x1">
|
||||||
|
<Samples>
|
||||||
|
<Position dataItemId="x1_pos" subType="ACTUAL" timestamp="2026-07-21T09:20:00.000Z" sequence="10291">UNAVAILABLE</Position>
|
||||||
|
<Position dataItemId="x1_pos_cmd" subType="COMMANDED" timestamp="2026-07-21T09:20:00.000Z" sequence="10292">UNAVAILABLE</Position>
|
||||||
|
<Load dataItemId="x1_load" timestamp="2026-07-21T09:20:00.000Z" sequence="10293">UNAVAILABLE</Load>
|
||||||
|
</Samples>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Linear" name="Y" componentId="y1">
|
||||||
|
<Samples>
|
||||||
|
<Position dataItemId="y1_pos" subType="ACTUAL" timestamp="2026-07-21T09:20:00.000Z" sequence="10294">UNAVAILABLE</Position>
|
||||||
|
</Samples>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Linear" name="Z" componentId="z1">
|
||||||
|
<Samples>
|
||||||
|
<Position dataItemId="z1_pos" subType="ACTUAL" timestamp="2026-07-21T09:20:00.000Z" sequence="10295">UNAVAILABLE</Position>
|
||||||
|
</Samples>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Rotary" name="C" componentId="c1">
|
||||||
|
<Samples>
|
||||||
|
<RotaryVelocity dataItemId="c1_spindle_speed" subType="ACTUAL" timestamp="2026-07-21T09:20:00.000Z" sequence="10296">UNAVAILABLE</RotaryVelocity>
|
||||||
|
<RotaryVelocity dataItemId="c1_spindle_speed_cmd" subType="COMMANDED" timestamp="2026-07-21T09:20:00.000Z" sequence="10297">UNAVAILABLE</RotaryVelocity>
|
||||||
|
<Load dataItemId="c1_load" timestamp="2026-07-21T09:20:00.000Z" sequence="10298">UNAVAILABLE</Load>
|
||||||
|
</Samples>
|
||||||
|
<Events>
|
||||||
|
<RotaryMode dataItemId="c1_rot_mode" timestamp="2026-07-21T09:20:00.000Z" sequence="10299">UNAVAILABLE</RotaryMode>
|
||||||
|
</Events>
|
||||||
|
<Condition>
|
||||||
|
<Unavailable dataItemId="c1_temp_cond" type="TEMPERATURE" timestamp="2026-07-21T09:20:00.000Z" sequence="10300"/>
|
||||||
|
</Condition>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Controller" name="controller" componentId="cn1">
|
||||||
|
<Events>
|
||||||
|
<ControllerMode dataItemId="cn1_mode" timestamp="2026-07-21T09:20:00.000Z" sequence="10285">UNAVAILABLE</ControllerMode>
|
||||||
|
<EmergencyStop dataItemId="cn1_estop" timestamp="2026-07-21T09:20:00.000Z" sequence="10286">UNAVAILABLE</EmergencyStop>
|
||||||
|
</Events>
|
||||||
|
<Condition>
|
||||||
|
<Unavailable dataItemId="cn1_system" type="SYSTEM" timestamp="2026-07-21T09:20:00.000Z" sequence="10287"/>
|
||||||
|
</Condition>
|
||||||
|
</ComponentStream>
|
||||||
|
<ComponentStream component="Path" name="path" componentId="path1">
|
||||||
|
<Events>
|
||||||
|
<Execution dataItemId="path1_exec" timestamp="2026-07-21T09:20:00.000Z" sequence="10288">UNAVAILABLE</Execution>
|
||||||
|
<Program dataItemId="path1_program" timestamp="2026-07-21T09:20:00.000Z" sequence="10289">UNAVAILABLE</Program>
|
||||||
|
<Line dataItemId="path1_line" timestamp="2026-07-21T09:20:00.000Z" sequence="10281">UNAVAILABLE</Line>
|
||||||
|
</Events>
|
||||||
|
<Samples>
|
||||||
|
<PathFeedrate dataItemId="path1_feed" subType="ACTUAL" timestamp="2026-07-21T09:20:00.000Z" sequence="10282">UNAVAILABLE</PathFeedrate>
|
||||||
|
</Samples>
|
||||||
|
<Condition>
|
||||||
|
<Unavailable dataItemId="path1_logic" type="LOGIC_PROGRAM" timestamp="2026-07-21T09:20:00.000Z" sequence="10283"/>
|
||||||
|
</Condition>
|
||||||
|
</ComponentStream>
|
||||||
|
</DeviceStream>
|
||||||
|
</Streams>
|
||||||
|
</MTConnectStreams>
|
||||||
68
tests/Junction.Tests/Fixtures/mtconnect/probe.xml
Normal file
68
tests/Junction.Tests/Fixtures/mtconnect/probe.xml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:1.7"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:m="urn:mtconnect.org:MTConnectDevices:1.7"
|
||||||
|
xsi:schemaLocation="urn:mtconnect.org:MTConnectDevices:1.7 http://schemas.mtconnect.org/schemas/MTConnectDevices_1.7.xsd">
|
||||||
|
<Header creationTime="2026-07-21T09:00:00Z" sender="junction-agent" instanceId="1721545200"
|
||||||
|
version="1.7.0" assetBufferSize="1024" assetCount="0" bufferSize="131072"/>
|
||||||
|
<Devices>
|
||||||
|
<Device id="dev1" name="VMC-01" uuid="junction-vmc-01">
|
||||||
|
<Description manufacturer="Junction" model="VMC-500" serialNumber="SN-0001"/>
|
||||||
|
<DataItems>
|
||||||
|
<DataItem id="dev1_avail" category="EVENT" type="AVAILABILITY"/>
|
||||||
|
<DataItem id="dev1_asset_chg" category="EVENT" type="ASSET_CHANGED"/>
|
||||||
|
<DataItem id="dev1_asset_rem" category="EVENT" type="ASSET_REMOVED"/>
|
||||||
|
</DataItems>
|
||||||
|
<Components>
|
||||||
|
<Axes id="axes1" name="axes">
|
||||||
|
<Components>
|
||||||
|
<Linear id="x1" name="X" nativeUnits="MILLIMETER">
|
||||||
|
<DataItems>
|
||||||
|
<DataItem id="x1_pos" category="SAMPLE" type="POSITION" subType="ACTUAL" units="MILLIMETER" nativeUnits="MILLIMETER"/>
|
||||||
|
<DataItem id="x1_pos_cmd" category="SAMPLE" type="POSITION" subType="COMMANDED" units="MILLIMETER"/>
|
||||||
|
<DataItem id="x1_load" category="SAMPLE" type="LOAD" units="PERCENT"/>
|
||||||
|
</DataItems>
|
||||||
|
</Linear>
|
||||||
|
<Linear id="y1" name="Y" nativeUnits="MILLIMETER">
|
||||||
|
<DataItems>
|
||||||
|
<DataItem id="y1_pos" category="SAMPLE" type="POSITION" subType="ACTUAL" units="MILLIMETER"/>
|
||||||
|
</DataItems>
|
||||||
|
</Linear>
|
||||||
|
<Linear id="z1" name="Z" nativeUnits="MILLIMETER">
|
||||||
|
<DataItems>
|
||||||
|
<DataItem id="z1_pos" category="SAMPLE" type="POSITION" subType="ACTUAL" units="MILLIMETER"/>
|
||||||
|
</DataItems>
|
||||||
|
</Linear>
|
||||||
|
<Rotary id="c1" name="C">
|
||||||
|
<DataItems>
|
||||||
|
<DataItem id="c1_spindle_speed" category="SAMPLE" type="ROTARY_VELOCITY" subType="ACTUAL" units="REVOLUTION/MINUTE"/>
|
||||||
|
<DataItem id="c1_spindle_speed_cmd" category="SAMPLE" type="ROTARY_VELOCITY" subType="COMMANDED" units="REVOLUTION/MINUTE"/>
|
||||||
|
<DataItem id="c1_load" category="SAMPLE" type="LOAD" units="PERCENT"/>
|
||||||
|
<DataItem id="c1_rot_mode" category="EVENT" type="ROTARY_MODE"/>
|
||||||
|
<DataItem id="c1_temp_cond" category="CONDITION" type="TEMPERATURE"/>
|
||||||
|
</DataItems>
|
||||||
|
</Rotary>
|
||||||
|
</Components>
|
||||||
|
</Axes>
|
||||||
|
<Controller id="cn1" name="controller">
|
||||||
|
<DataItems>
|
||||||
|
<DataItem id="cn1_mode" category="EVENT" type="CONTROLLER_MODE"/>
|
||||||
|
<DataItem id="cn1_estop" category="EVENT" type="EMERGENCY_STOP"/>
|
||||||
|
<DataItem id="cn1_system" category="CONDITION" type="SYSTEM"/>
|
||||||
|
</DataItems>
|
||||||
|
<Components>
|
||||||
|
<Path id="path1" name="path">
|
||||||
|
<DataItems>
|
||||||
|
<DataItem id="path1_exec" category="EVENT" type="EXECUTION"/>
|
||||||
|
<DataItem id="path1_program" category="EVENT" type="PROGRAM"/>
|
||||||
|
<DataItem id="path1_line" category="EVENT" type="LINE"/>
|
||||||
|
<DataItem id="path1_feed" category="SAMPLE" type="PATH_FEEDRATE" subType="ACTUAL" units="MILLIMETER/SECOND"/>
|
||||||
|
<DataItem id="path1_logic" category="CONDITION" type="LOGIC_PROGRAM"/>
|
||||||
|
</DataItems>
|
||||||
|
</Path>
|
||||||
|
</Components>
|
||||||
|
</Controller>
|
||||||
|
</Components>
|
||||||
|
</Device>
|
||||||
|
</Devices>
|
||||||
|
</MTConnectDevices>
|
||||||
348
tests/Junction.Tests/Integration/MtconnectEndToEndTests.cs
Normal file
348
tests/Junction.Tests/Integration/MtconnectEndToEndTests.cs
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Junction.Persistence;
|
||||||
|
using Junction.Protocols.MTConnect;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Integration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Docker-gated end-to-end integration tests: real <see cref="MtconnectDriver"/> ->
|
||||||
|
/// live ladder99 MTConnect agent (docker mock at http://localhost:5000) -> snapshot
|
||||||
|
/// round-trip through the real SQLite repository.
|
||||||
|
/// <para>
|
||||||
|
/// These tests assume the mock is ALREADY running (they do NOT auto-start docker):
|
||||||
|
/// <c>docker compose -f mock/docker-compose.yml up -d</c>. When the agent is not
|
||||||
|
/// reachable (no docker in CI, mock down) each test SKIPS (never fails), so a CI
|
||||||
|
/// without docker stays green.
|
||||||
|
/// </para>
|
||||||
|
/// xUnit v2 2.9.3 has no <c>Assert.Skip</c> (v3-only), so a self-contained skippable-fact
|
||||||
|
/// discoverer (<see cref="DockerFactAttribute"/> + <see cref="SkipTestException"/>) provides a
|
||||||
|
/// genuine runtime SKIP result — no extra NuGet package, no csproj change.
|
||||||
|
/// <para>Filter with <c>dotnet test --filter Category=Docker</c>.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Docker")]
|
||||||
|
public sealed class MtconnectEndToEndTests
|
||||||
|
{
|
||||||
|
private const string AgentUrl = "http://localhost:5000";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes the agent's /probe endpoint with a short timeout. Returns true only when the
|
||||||
|
/// agent answers with a success status. Never throws — any failure means "not reachable".
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsAgentReachable()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||||
|
using var response = http.GetAsync(AgentUrl + "/probe").GetAwaiter().GetResult();
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Skips the current test (does not fail) when the docker MTConnect mock is down.</summary>
|
||||||
|
private static void SkipIfAgentUnavailable()
|
||||||
|
{
|
||||||
|
if (!IsAgentReachable())
|
||||||
|
{
|
||||||
|
throw new SkipTestException(
|
||||||
|
"MTConnect docker mock not reachable at " + AgentUrl +
|
||||||
|
"/probe. Start it with: docker compose -f mock/docker-compose.yml up -d");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Machine NewMtconnectMachine() =>
|
||||||
|
new Machine(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"IT Mock Mill",
|
||||||
|
"mtconnect",
|
||||||
|
new Dictionary<string, string> { ["AgentUrl"] = AgentUrl },
|
||||||
|
TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
[DockerFact]
|
||||||
|
public async Task Driver_ReadsCurrent_FromLiveAgent_VersionAgnostic()
|
||||||
|
{
|
||||||
|
SkipIfAgentUnavailable();
|
||||||
|
|
||||||
|
var machine = NewMtconnectMachine();
|
||||||
|
|
||||||
|
var factory = new MtconnectDriverFactory();
|
||||||
|
Result<IProtocolDriver> created = factory.Create(machine);
|
||||||
|
Assert.True(created.IsSuccess, Describe(created));
|
||||||
|
|
||||||
|
IProtocolDriver driver = created.Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
Result<MachineSnapshot> read = await driver.ReadCurrentAsync(cts.Token);
|
||||||
|
|
||||||
|
Assert.True(read.IsSuccess, Describe(read));
|
||||||
|
|
||||||
|
var snapshot = read.Value;
|
||||||
|
Assert.Equal(machine.Id, snapshot.MachineId);
|
||||||
|
Assert.True(snapshot.Items.Count > 0, "expected at least one datum from the live agent");
|
||||||
|
|
||||||
|
// Agent reports AVAILABLE -> Connected. Real MTConnect 2.0 output must parse.
|
||||||
|
Assert.Equal(ConnectionState.Connected, snapshot.ConnectionState);
|
||||||
|
|
||||||
|
// A meaningful datum with a non-empty value must be present. The parser is
|
||||||
|
// version-agnostic, so match on the datum Name (parsed from the agent's real feed).
|
||||||
|
var meaningful = snapshot.Items.FirstOrDefault(i =>
|
||||||
|
!string.IsNullOrWhiteSpace(i.Value) &&
|
||||||
|
(Contains(i.Name, "Position") ||
|
||||||
|
Contains(i.Name, "Execution") ||
|
||||||
|
Contains(i.Name, "Availability")));
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
meaningful != null,
|
||||||
|
"expected a Position/Execution/Availability datum with a non-empty value; got: " +
|
||||||
|
string.Join(", ", snapshot.Items.Take(20).Select(i => i.Name + "=" + i.Value)));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
(driver as IDisposable)?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DockerFact]
|
||||||
|
public async Task Driver_To_Repository_RoundTrip_PersistsSnapshot()
|
||||||
|
{
|
||||||
|
SkipIfAgentUnavailable();
|
||||||
|
|
||||||
|
var machine = NewMtconnectMachine();
|
||||||
|
|
||||||
|
var dbPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"junction_it_" + Guid.NewGuid().ToString("N") + ".db");
|
||||||
|
var connectionFactory = new SqliteConnectionFactory("Data Source=" + dbPath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Result schema = SqliteSchema.EnsureCreated(connectionFactory);
|
||||||
|
Assert.True(schema.IsSuccess, Describe(schema));
|
||||||
|
|
||||||
|
var repo = new SqliteMachineRepository(connectionFactory);
|
||||||
|
|
||||||
|
Result upsert = await repo.UpsertAsync(machine, CancellationToken.None);
|
||||||
|
Assert.True(upsert.IsSuccess, Describe(upsert));
|
||||||
|
|
||||||
|
// Take a live snapshot via the real driver.
|
||||||
|
var driverFactory = new MtconnectDriverFactory();
|
||||||
|
Result<IProtocolDriver> created = driverFactory.Create(machine);
|
||||||
|
Assert.True(created.IsSuccess, Describe(created));
|
||||||
|
|
||||||
|
IProtocolDriver driver = created.Value;
|
||||||
|
MachineSnapshot snapshot;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
Result<MachineSnapshot> read = await driver.ReadCurrentAsync(cts.Token);
|
||||||
|
Assert.True(read.IsSuccess, Describe(read));
|
||||||
|
snapshot = read.Value;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
(driver as IDisposable)?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(snapshot.Items.Count > 0, "live snapshot must carry datums to persist");
|
||||||
|
|
||||||
|
// Persist and read back the latest snapshot.
|
||||||
|
Result save = await repo.SaveSnapshotAsync(snapshot, CancellationToken.None);
|
||||||
|
Assert.True(save.IsSuccess, Describe(save));
|
||||||
|
|
||||||
|
Result<MachineSnapshot> loadedResult =
|
||||||
|
await repo.GetLatestSnapshotAsync(machine.Id, CancellationToken.None);
|
||||||
|
Assert.True(loadedResult.IsSuccess, Describe(loadedResult));
|
||||||
|
|
||||||
|
var loaded = loadedResult.Value;
|
||||||
|
Assert.Equal(machine.Id, loaded.MachineId);
|
||||||
|
Assert.Equal(snapshot.ConnectionState, loaded.ConnectionState);
|
||||||
|
Assert.Equal(snapshot.Items.Count, loaded.Items.Count);
|
||||||
|
|
||||||
|
// Every persisted datum is retrievable with its value intact (last datum).
|
||||||
|
var expected = snapshot.Items[0];
|
||||||
|
var actual = loaded.TryGetItem(expected.Id);
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.Equal(expected.Value, actual!.Value);
|
||||||
|
Assert.Equal(expected.Name, actual.Name);
|
||||||
|
Assert.Equal(expected.Category, actual.Category);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDelete(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Contains(string haystack, string needle) =>
|
||||||
|
haystack != null && haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
|
private static void TryDelete(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe<T>(Result<T> result) =>
|
||||||
|
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
||||||
|
|
||||||
|
private static string Describe(Result result) =>
|
||||||
|
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Self-contained skippable-fact support for xUnit v2 (no Assert.Skip, no extra package).
|
||||||
|
// A test throws SkipTestException at runtime; the custom test case rewrites the resulting
|
||||||
|
// TestFailed message into a TestSkipped message, so the runner reports a genuine SKIP.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Thrown to skip a test at runtime (dynamic skip for xUnit v2).</summary>
|
||||||
|
public sealed class SkipTestException : Exception
|
||||||
|
{
|
||||||
|
public SkipTestException(string reason) : base(reason) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A <see cref="FactAttribute"/> whose tests may skip at runtime via <see cref="SkipTestException"/>.</summary>
|
||||||
|
[XunitTestCaseDiscoverer("Junction.Tests.Integration.DockerFactDiscoverer", "Junction.Tests")]
|
||||||
|
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||||
|
public sealed class DockerFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Discovers <see cref="DockerFactAttribute"/>-decorated methods as skippable test cases.</summary>
|
||||||
|
public sealed class DockerFactDiscoverer : IXunitTestCaseDiscoverer
|
||||||
|
{
|
||||||
|
private static readonly string[] SkippingExceptionNames = { typeof(SkipTestException).FullName! };
|
||||||
|
|
||||||
|
private readonly IMessageSink _diagnosticMessageSink;
|
||||||
|
|
||||||
|
public DockerFactDiscoverer(IMessageSink diagnosticMessageSink)
|
||||||
|
{
|
||||||
|
_diagnosticMessageSink = diagnosticMessageSink;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<IXunitTestCase> Discover(
|
||||||
|
ITestFrameworkDiscoveryOptions discoveryOptions,
|
||||||
|
ITestMethod testMethod,
|
||||||
|
IAttributeInfo factAttribute)
|
||||||
|
{
|
||||||
|
yield return new SkippableFactTestCase(
|
||||||
|
SkippingExceptionNames,
|
||||||
|
_diagnosticMessageSink,
|
||||||
|
discoveryOptions.MethodDisplayOrDefault(),
|
||||||
|
discoveryOptions.MethodDisplayOptionsOrDefault(),
|
||||||
|
testMethod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test case that converts a designated exception into a skip result.</summary>
|
||||||
|
public sealed class SkippableFactTestCase : XunitTestCase
|
||||||
|
{
|
||||||
|
private string[] _skippingExceptionNames = Array.Empty<string>();
|
||||||
|
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
[Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")]
|
||||||
|
public SkippableFactTestCase()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SkippableFactTestCase(
|
||||||
|
string[] skippingExceptionNames,
|
||||||
|
IMessageSink diagnosticMessageSink,
|
||||||
|
TestMethodDisplay defaultMethodDisplay,
|
||||||
|
TestMethodDisplayOptions defaultMethodDisplayOptions,
|
||||||
|
ITestMethod testMethod,
|
||||||
|
object[]? testMethodArguments = null)
|
||||||
|
: base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments)
|
||||||
|
{
|
||||||
|
_skippingExceptionNames = skippingExceptionNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Serialize(IXunitSerializationInfo data)
|
||||||
|
{
|
||||||
|
base.Serialize(data);
|
||||||
|
data.AddValue(nameof(_skippingExceptionNames), _skippingExceptionNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Deserialize(IXunitSerializationInfo data)
|
||||||
|
{
|
||||||
|
base.Deserialize(data);
|
||||||
|
_skippingExceptionNames = data.GetValue<string[]>(nameof(_skippingExceptionNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<RunSummary> RunAsync(
|
||||||
|
IMessageSink diagnosticMessageSink,
|
||||||
|
IMessageBus messageBus,
|
||||||
|
object[] constructorArguments,
|
||||||
|
ExceptionAggregator aggregator,
|
||||||
|
CancellationTokenSource cancellationTokenSource)
|
||||||
|
{
|
||||||
|
var interceptor = new SkippableTestMessageBus(messageBus, _skippingExceptionNames);
|
||||||
|
var result = await base.RunAsync(
|
||||||
|
diagnosticMessageSink, interceptor, constructorArguments, aggregator, cancellationTokenSource);
|
||||||
|
|
||||||
|
result.Failed -= interceptor.SkippedCount;
|
||||||
|
result.Skipped += interceptor.SkippedCount;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rewrites <see cref="ITestFailed"/> carrying a skipping exception into <see cref="ITestSkipped"/>.</summary>
|
||||||
|
public sealed class SkippableTestMessageBus : IMessageBus
|
||||||
|
{
|
||||||
|
private readonly IMessageBus _inner;
|
||||||
|
private readonly string[] _skippingExceptionNames;
|
||||||
|
|
||||||
|
public SkippableTestMessageBus(IMessageBus inner, string[] skippingExceptionNames)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_skippingExceptionNames = skippingExceptionNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SkippedCount { get; private set; }
|
||||||
|
|
||||||
|
public bool QueueMessage(IMessageSinkMessage message)
|
||||||
|
{
|
||||||
|
if (message is ITestFailed failed)
|
||||||
|
{
|
||||||
|
var exceptionType = failed.ExceptionTypes.FirstOrDefault();
|
||||||
|
if (exceptionType != null && _skippingExceptionNames.Contains(exceptionType))
|
||||||
|
{
|
||||||
|
SkippedCount++;
|
||||||
|
var reason = failed.Messages != null && failed.Messages.Length > 0
|
||||||
|
? failed.Messages[0]
|
||||||
|
: "skipped";
|
||||||
|
return _inner.QueueMessage(new TestSkipped(failed.Test, reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _inner.QueueMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _inner.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
tests/Junction.Tests/Junction.Tests.csproj
Normal file
35
tests/Junction.Tests/Junction.Tests.csproj
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>Junction.Tests</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Junction.Domain\Junction.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Junction.Core\Junction.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Junction.Persistence\Junction.Persistence.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
40
tests/Junction.Tests/Unit/DataItemTests.cs
Normal file
40
tests/Junction.Tests/Unit/DataItemTests.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
using System;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
public class DataItemTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_HoldsAllValues()
|
||||||
|
{
|
||||||
|
var ts = DateTimeOffset.UtcNow;
|
||||||
|
var item = new DataItem("id1", "Temperature", "42.5", "Sample", ts);
|
||||||
|
|
||||||
|
Assert.Equal("id1", item.Id);
|
||||||
|
Assert.Equal("Temperature", item.Name);
|
||||||
|
Assert.Equal("42.5", item.Value);
|
||||||
|
Assert.Equal("Sample", item.Category);
|
||||||
|
Assert.Equal(ts, item.Timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_NullStrings_DefaultToEmpty()
|
||||||
|
{
|
||||||
|
var item = new DataItem(null!, null!, null!, null!, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal("", item.Id);
|
||||||
|
Assert.Equal("", item.Name);
|
||||||
|
Assert.Equal("", item.Value);
|
||||||
|
Assert.Equal("", item.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Properties_AreGetOnly()
|
||||||
|
{
|
||||||
|
var props = typeof(DataItem).GetProperties();
|
||||||
|
Assert.All(props, p => Assert.Null(p.SetMethod));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
tests/Junction.Tests/Unit/MachineMonitorTests.cs
Normal file
244
tests/Junction.Tests/Unit/MachineMonitorTests.cs
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Core.Monitoring;
|
||||||
|
using Junction.Core.Plugins;
|
||||||
|
using Junction.Core.Polling;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Persistence;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Behavior tests for <see cref="MachineMonitor"/>. Repository and plugin loader are mocked;
|
||||||
|
/// drivers/factories are hand-rolled fakes. The real <see cref="PollingEngine"/> drives the
|
||||||
|
/// loops. Timings are deliberately generous to avoid CI flake.
|
||||||
|
/// </summary>
|
||||||
|
public class MachineMonitorTests
|
||||||
|
{
|
||||||
|
private const string PluginsDir = "/fake/plugins";
|
||||||
|
|
||||||
|
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(25);
|
||||||
|
|
||||||
|
private static Machine MachineWith(string protocolId) =>
|
||||||
|
new Machine(Guid.NewGuid(), "M-" + protocolId, protocolId, null, FastPoll);
|
||||||
|
|
||||||
|
private static MachineSnapshot Snapshot(Guid machineId) =>
|
||||||
|
new MachineSnapshot(machineId, DateTimeOffset.UtcNow, ConnectionState.Connected, Array.Empty<DataItem>());
|
||||||
|
|
||||||
|
private static Func<IPollingEngine> RealEngineFactory() =>
|
||||||
|
() => new PollingEngine(NullLogger<PollingEngine>.Instance);
|
||||||
|
|
||||||
|
private static MachineMonitor NewMonitor(IMachineRepository repo, IPluginLoader loader) =>
|
||||||
|
new MachineMonitor(repo, loader, RealEngineFactory(), NullLogger<MachineMonitor>.Instance);
|
||||||
|
|
||||||
|
private static Mock<IPluginLoader> LoaderReturning(params IProtocolDriverFactory[] factories)
|
||||||
|
{
|
||||||
|
var loaded = new List<LoadedPlugin>();
|
||||||
|
foreach (var f in factories)
|
||||||
|
{
|
||||||
|
var manifest = new PluginManifest(f.ProtocolId, f.ProtocolId, "x.dll", "X", "1.0");
|
||||||
|
loaded.Add(new LoadedPlugin(new PluginDescriptor(manifest, "/fake/x.dll"), f));
|
||||||
|
}
|
||||||
|
|
||||||
|
var mock = new Mock<IPluginLoader>();
|
||||||
|
mock.Setup(l => l.LoadFrom(It.IsAny<string>()))
|
||||||
|
.Returns(Result<IReadOnlyList<LoadedPlugin>>.Ok(loaded));
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IMachineRepository> RepoReturning(params Machine[] machines)
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Ok(machines));
|
||||||
|
mock.Setup(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok());
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_PollsMachine_RaisesEvent_UpdatesCache_Persists()
|
||||||
|
{
|
||||||
|
var machine = MachineWith("test");
|
||||||
|
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||||
|
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(machine.Id)))));
|
||||||
|
|
||||||
|
var loader = LoaderReturning(factory);
|
||||||
|
var repo = RepoReturning(machine);
|
||||||
|
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
var eventRaised = new ManualResetEventSlim(false);
|
||||||
|
monitor.SnapshotUpdated += (_, snap) =>
|
||||||
|
{
|
||||||
|
if (snap.MachineId == machine.Id) eventRaised.Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
Assert.True(start.IsSuccess);
|
||||||
|
|
||||||
|
Assert.True(eventRaised.Wait(TimeSpan.FromSeconds(2)), "SnapshotUpdated was not raised");
|
||||||
|
|
||||||
|
await monitor.StopAsync();
|
||||||
|
|
||||||
|
Assert.True(monitor.LatestSnapshots.ContainsKey(machine.Id));
|
||||||
|
Assert.Equal(machine.Id, monitor.LatestSnapshots[machine.Id].MachineId);
|
||||||
|
repo.Verify(r => r.SaveSnapshotAsync(
|
||||||
|
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id),
|
||||||
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_UnknownProtocol_SkipsMachine_OthersRun_StillOk()
|
||||||
|
{
|
||||||
|
var known = MachineWith("test");
|
||||||
|
var unknown = MachineWith("nope");
|
||||||
|
|
||||||
|
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||||
|
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(known.Id)))));
|
||||||
|
|
||||||
|
var loader = LoaderReturning(factory);
|
||||||
|
var repo = RepoReturning(unknown, known);
|
||||||
|
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
Assert.True(start.IsSuccess);
|
||||||
|
|
||||||
|
// Give the known machine time to produce.
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
while (!monitor.LatestSnapshots.ContainsKey(known.Id) && sw.Elapsed < TimeSpan.FromSeconds(2))
|
||||||
|
{
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
await monitor.StopAsync();
|
||||||
|
|
||||||
|
Assert.True(monitor.LatestSnapshots.ContainsKey(known.Id), "known machine did not run");
|
||||||
|
Assert.False(monitor.LatestSnapshots.ContainsKey(unknown.Id), "unknown-protocol machine must be skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_DriverCreateFails_SkipsMachine_StillOk()
|
||||||
|
{
|
||||||
|
var machine = MachineWith("test");
|
||||||
|
var factory = new FakeFactory("test", m =>
|
||||||
|
Result<IProtocolDriver>.Fail(OperationError.Of("test", "bad config")));
|
||||||
|
|
||||||
|
var loader = LoaderReturning(factory);
|
||||||
|
var repo = RepoReturning(machine);
|
||||||
|
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
Assert.True(start.IsSuccess);
|
||||||
|
|
||||||
|
await monitor.StopAsync();
|
||||||
|
Assert.False(monitor.LatestSnapshots.ContainsKey(machine.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_LoaderFails_ReturnsFail()
|
||||||
|
{
|
||||||
|
var loader = new Mock<IPluginLoader>();
|
||||||
|
loader.Setup(l => l.LoadFrom(It.IsAny<string>()))
|
||||||
|
.Returns(Result<IReadOnlyList<LoadedPlugin>>.Fail(
|
||||||
|
new OperationError("PLUGIN_DIR_MISSING", "PluginLoader", "no dir")));
|
||||||
|
|
||||||
|
var repo = RepoReturning();
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(start.IsSuccess);
|
||||||
|
Assert.NotEmpty(start.Errors);
|
||||||
|
repo.Verify(r => r.GetAllAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_RepoGetAllFails_ReturnsFail()
|
||||||
|
{
|
||||||
|
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||||
|
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(Guid.NewGuid())))));
|
||||||
|
var loader = LoaderReturning(factory);
|
||||||
|
|
||||||
|
var repo = new Mock<IMachineRepository>();
|
||||||
|
repo.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Fail(
|
||||||
|
new OperationError("STORE_UNAVAILABLE", "repo", "db down")));
|
||||||
|
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(start.IsSuccess);
|
||||||
|
Assert.NotEmpty(start.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StopAsync_CancelsLoops_Cleanly()
|
||||||
|
{
|
||||||
|
var machine = MachineWith("test");
|
||||||
|
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||||
|
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(machine.Id)))));
|
||||||
|
|
||||||
|
var loader = LoaderReturning(factory);
|
||||||
|
var repo = RepoReturning(machine);
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
await Task.Delay(80);
|
||||||
|
|
||||||
|
var stop = monitor.StopAsync();
|
||||||
|
var completed = await Task.WhenAny(stop, Task.Delay(2000)) == stop;
|
||||||
|
Assert.True(completed, "StopAsync did not complete promptly");
|
||||||
|
await stop; // rethrows if faulted
|
||||||
|
|
||||||
|
// Idempotent: second stop is a no-op and must not throw.
|
||||||
|
await monitor.StopAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hand-rolled factory; behavior supplied by a delegate.</summary>
|
||||||
|
private sealed class FakeFactory : IProtocolDriverFactory
|
||||||
|
{
|
||||||
|
private readonly Func<Machine, Result<IProtocolDriver>> _create;
|
||||||
|
|
||||||
|
public FakeFactory(string protocolId, Func<Machine, Result<IProtocolDriver>> create)
|
||||||
|
{
|
||||||
|
ProtocolId = protocolId;
|
||||||
|
_create = create;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProtocolId { get; }
|
||||||
|
|
||||||
|
public Result<IProtocolDriver> Create(Machine machine) => _create(machine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hand-rolled driver; behavior supplied by a delegate.</summary>
|
||||||
|
private sealed class FakeDriver : IProtocolDriver
|
||||||
|
{
|
||||||
|
private readonly Func<Result<MachineSnapshot>> _read;
|
||||||
|
|
||||||
|
public FakeDriver(string protocolId, Func<Result<MachineSnapshot>> read)
|
||||||
|
{
|
||||||
|
ProtocolId = protocolId;
|
||||||
|
_read = read;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProtocolId { get; }
|
||||||
|
|
||||||
|
public Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.FromResult(_read());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
tests/Junction.Tests/Unit/MachineRepositoryContractTests.cs
Normal file
236
tests/Junction.Tests/Unit/MachineRepositoryContractTests.cs
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Persistence;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shape/contract tests for <see cref="IMachineRepository"/> using a Moq mock.
|
||||||
|
/// No real database: these assert the async signatures and Result shapes only.
|
||||||
|
/// </summary>
|
||||||
|
public class MachineRepositoryContractTests
|
||||||
|
{
|
||||||
|
private static readonly OperationError SampleError =
|
||||||
|
new OperationError("repo.error", "repository", "boom");
|
||||||
|
|
||||||
|
private static Machine SampleMachine(Guid id) =>
|
||||||
|
new Machine(id, "Mill", "mtconnect", null, TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
private static MachineSnapshot SampleSnapshot(Guid machineId) =>
|
||||||
|
new MachineSnapshot(machineId, DateTimeOffset.UtcNow, ConnectionState.Connected, null);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_Ok_WrapsReadOnlyListOfMachines()
|
||||||
|
{
|
||||||
|
var machines = new List<Machine> { SampleMachine(Guid.NewGuid()) };
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Ok(machines));
|
||||||
|
|
||||||
|
Result<IReadOnlyList<Machine>> result = await mock.Object.GetAllAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.WasCancelled);
|
||||||
|
Assert.Single(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_Fail_HasErrorsNoValue()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Fail(SampleError));
|
||||||
|
|
||||||
|
var result = await mock.Object.GetAllAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Single(result.Errors);
|
||||||
|
Assert.Equal("repo.error", result.Errors[0].Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_Ok_WrapsMachine()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<Machine>.Ok(SampleMachine(id)));
|
||||||
|
|
||||||
|
var result = await mock.Object.GetByIdAsync(id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(id, result.Value.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_Fail_NotFoundShape()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<Machine>.Fail(SampleError));
|
||||||
|
|
||||||
|
var result = await mock.Object.GetByIdAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_Cancelled_SetsWasCancelled()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<Machine>.Cancelled());
|
||||||
|
|
||||||
|
var result = await mock.Object.GetByIdAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpsertAsync_ReturnsNonGenericResult_Ok()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.UpsertAsync(It.IsAny<Machine>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok());
|
||||||
|
|
||||||
|
Result result = await mock.Object.UpsertAsync(SampleMachine(Guid.NewGuid()), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<Result>(result);
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpsertAsync_Fail_CarriesErrors()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.UpsertAsync(It.IsAny<Machine>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Fail(SampleError));
|
||||||
|
|
||||||
|
var result = await mock.Object.UpsertAsync(SampleMachine(Guid.NewGuid()), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Single(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_ReturnsNonGenericResult_Ok()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.DeleteAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok());
|
||||||
|
|
||||||
|
Result result = await mock.Object.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<Result>(result);
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_Cancelled_SetsWasCancelled()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.DeleteAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Cancelled());
|
||||||
|
|
||||||
|
var result = await mock.Object.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveSnapshotAsync_ReturnsNonGenericResult_Ok()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok());
|
||||||
|
|
||||||
|
Result result = await mock.Object.SaveSnapshotAsync(SampleSnapshot(machineId), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<Result>(result);
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveSnapshotAsync_Fail_CarriesErrors()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Fail(SampleError));
|
||||||
|
|
||||||
|
var result = await mock.Object.SaveSnapshotAsync(SampleSnapshot(Guid.NewGuid()), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLatestSnapshotAsync_Ok_WrapsSnapshot()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.GetLatestSnapshotAsync(machineId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<MachineSnapshot>.Ok(SampleSnapshot(machineId)));
|
||||||
|
|
||||||
|
Result<MachineSnapshot> result = await mock.Object.GetLatestSnapshotAsync(machineId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(machineId, result.Value.MachineId);
|
||||||
|
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLatestSnapshotAsync_Fail_NoneStored()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
mock.Setup(r => r.GetLatestSnapshotAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<MachineSnapshot>.Fail(SampleError));
|
||||||
|
|
||||||
|
var result = await mock.Object.GetLatestSnapshotAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Single(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AllMethods_HonorPassedCancellationToken()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var token = cts.Token;
|
||||||
|
var mock = new Mock<IMachineRepository>();
|
||||||
|
|
||||||
|
mock.Setup(r => r.GetAllAsync(token))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Ok(new List<Machine>()));
|
||||||
|
mock.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), token))
|
||||||
|
.ReturnsAsync(Result<Machine>.Ok(SampleMachine(Guid.NewGuid())));
|
||||||
|
mock.Setup(r => r.UpsertAsync(It.IsAny<Machine>(), token)).ReturnsAsync(Result.Ok());
|
||||||
|
mock.Setup(r => r.DeleteAsync(It.IsAny<Guid>(), token)).ReturnsAsync(Result.Ok());
|
||||||
|
mock.Setup(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), token)).ReturnsAsync(Result.Ok());
|
||||||
|
mock.Setup(r => r.GetLatestSnapshotAsync(It.IsAny<Guid>(), token))
|
||||||
|
.ReturnsAsync(Result<MachineSnapshot>.Ok(SampleSnapshot(Guid.NewGuid())));
|
||||||
|
|
||||||
|
await mock.Object.GetAllAsync(token);
|
||||||
|
await mock.Object.GetByIdAsync(Guid.NewGuid(), token);
|
||||||
|
await mock.Object.UpsertAsync(SampleMachine(Guid.NewGuid()), token);
|
||||||
|
await mock.Object.DeleteAsync(Guid.NewGuid(), token);
|
||||||
|
await mock.Object.SaveSnapshotAsync(SampleSnapshot(Guid.NewGuid()), token);
|
||||||
|
await mock.Object.GetLatestSnapshotAsync(Guid.NewGuid(), token);
|
||||||
|
|
||||||
|
mock.Verify(r => r.GetAllAsync(token), Times.Once);
|
||||||
|
mock.Verify(r => r.GetByIdAsync(It.IsAny<Guid>(), token), Times.Once);
|
||||||
|
mock.Verify(r => r.UpsertAsync(It.IsAny<Machine>(), token), Times.Once);
|
||||||
|
mock.Verify(r => r.DeleteAsync(It.IsAny<Guid>(), token), Times.Once);
|
||||||
|
mock.Verify(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), token), Times.Once);
|
||||||
|
mock.Verify(r => r.GetLatestSnapshotAsync(It.IsAny<Guid>(), token), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/Junction.Tests/Unit/MachineSnapshotTests.cs
Normal file
91
tests/Junction.Tests/Unit/MachineSnapshotTests.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
public class MachineSnapshotTests
|
||||||
|
{
|
||||||
|
private static DataItem Item(string id, string name = "n", string value = "v") =>
|
||||||
|
new DataItem(id, name, value, "Sample", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_HoldsValues()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var at = DateTimeOffset.UtcNow;
|
||||||
|
var items = new[] { Item("a"), Item("b") };
|
||||||
|
|
||||||
|
var snap = new MachineSnapshot(machineId, at, ConnectionState.Connected, items);
|
||||||
|
|
||||||
|
Assert.Equal(machineId, snap.MachineId);
|
||||||
|
Assert.Equal(at, snap.CapturedAt);
|
||||||
|
Assert.Equal(ConnectionState.Connected, snap.ConnectionState);
|
||||||
|
Assert.Equal(2, snap.Items.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_NullItems_DefaultsToEmptyNeverNull()
|
||||||
|
{
|
||||||
|
var snap = new MachineSnapshot(Guid.NewGuid(), DateTimeOffset.UtcNow, ConnectionState.Unknown, null);
|
||||||
|
|
||||||
|
Assert.NotNull(snap.Items);
|
||||||
|
Assert.Empty(snap.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEmpty_TrueWhenZeroItems()
|
||||||
|
{
|
||||||
|
var snap = new MachineSnapshot(Guid.NewGuid(), DateTimeOffset.UtcNow, ConnectionState.Connected, null);
|
||||||
|
Assert.True(snap.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEmpty_FalseWhenHasItems()
|
||||||
|
{
|
||||||
|
var snap = new MachineSnapshot(
|
||||||
|
Guid.NewGuid(), DateTimeOffset.UtcNow, ConnectionState.Connected, new[] { Item("a") });
|
||||||
|
Assert.False(snap.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetItem_ReturnsItem_WhenPresent()
|
||||||
|
{
|
||||||
|
var snap = new MachineSnapshot(
|
||||||
|
Guid.NewGuid(), DateTimeOffset.UtcNow, ConnectionState.Connected,
|
||||||
|
new[] { Item("temp", "Temperature", "42") });
|
||||||
|
|
||||||
|
var found = snap.TryGetItem("temp");
|
||||||
|
|
||||||
|
Assert.NotNull(found);
|
||||||
|
Assert.Equal("Temperature", found!.Name);
|
||||||
|
Assert.Equal("42", found.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetItem_ReturnsNull_WhenMissing()
|
||||||
|
{
|
||||||
|
var snap = new MachineSnapshot(
|
||||||
|
Guid.NewGuid(), DateTimeOffset.UtcNow, ConnectionState.Connected, new[] { Item("a") });
|
||||||
|
|
||||||
|
Assert.Null(snap.TryGetItem("nope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetItem_ReturnsNull_WhenIdNull()
|
||||||
|
{
|
||||||
|
var snap = new MachineSnapshot(
|
||||||
|
Guid.NewGuid(), DateTimeOffset.UtcNow, ConnectionState.Connected, new[] { Item("a") });
|
||||||
|
|
||||||
|
Assert.Null(snap.TryGetItem(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Properties_AreGetOnly()
|
||||||
|
{
|
||||||
|
var props = typeof(MachineSnapshot).GetProperties();
|
||||||
|
Assert.All(props, p => Assert.Null(p.SetMethod));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
tests/Junction.Tests/Unit/MachineTests.cs
Normal file
72
tests/Junction.Tests/Unit/MachineTests.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
public class MachineTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_HoldsGenericConfigBag()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var config = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["url"] = "http://host:5000",
|
||||||
|
["deviceName"] = "M1"
|
||||||
|
};
|
||||||
|
|
||||||
|
var m = new Machine(id, "Mill", "mtconnect", config, TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
Assert.Equal(id, m.Id);
|
||||||
|
Assert.Equal("Mill", m.Name);
|
||||||
|
Assert.Equal("mtconnect", m.ProtocolId);
|
||||||
|
Assert.Equal("http://host:5000", m.ConnectionConfig["url"]);
|
||||||
|
Assert.Equal("M1", m.ConnectionConfig["deviceName"]);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(2), m.PollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_NullConfig_DefaultsToEmptyNeverNull()
|
||||||
|
{
|
||||||
|
var m = new Machine(Guid.NewGuid(), "M", "p", null, TimeSpan.Zero);
|
||||||
|
|
||||||
|
Assert.NotNull(m.ConnectionConfig);
|
||||||
|
Assert.Empty(m.ConnectionConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_NullStrings_DefaultToEmpty()
|
||||||
|
{
|
||||||
|
var m = new Machine(Guid.NewGuid(), null!, null!, null, TimeSpan.Zero);
|
||||||
|
|
||||||
|
Assert.Equal("", m.Name);
|
||||||
|
Assert.Equal("", m.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Properties_AreGetOnly()
|
||||||
|
{
|
||||||
|
var props = typeof(Machine).GetProperties();
|
||||||
|
Assert.All(props, p => Assert.Null(p.SetMethod));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_OverridesGivenFields_KeepsRest()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var m = new Machine(id, "Mill", "mtconnect", null, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
var updated = m.With(name: "Lathe", pollInterval: TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.Equal(id, updated.Id);
|
||||||
|
Assert.Equal("Lathe", updated.Name);
|
||||||
|
Assert.Equal("mtconnect", updated.ProtocolId);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(5), updated.PollInterval);
|
||||||
|
// original unchanged (immutable)
|
||||||
|
Assert.Equal("Mill", m.Name);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(1), m.PollInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
tests/Junction.Tests/Unit/MtconnectDriverTests.cs
Normal file
226
tests/Junction.Tests/Unit/MtconnectDriverTests.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Junction.Protocols.MTConnect;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
public sealed class MtconnectDriverTests
|
||||||
|
{
|
||||||
|
private static readonly Guid MachineId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||||
|
private const string AgentUrl = "http://mtconnect-agent.test:5000";
|
||||||
|
|
||||||
|
private static string LoadCurrentFixture()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "mtconnect", "current.xml");
|
||||||
|
return File.ReadAllText(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stub handler: canned response or thrown exception, per configuration.</summary>
|
||||||
|
private sealed class StubHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _responder;
|
||||||
|
|
||||||
|
public Uri? LastRequestUri { get; private set; }
|
||||||
|
|
||||||
|
public StubHandler(HttpStatusCode status, string body)
|
||||||
|
{
|
||||||
|
_responder = (_, __) => Task.FromResult(new HttpResponseMessage(status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
|
||||||
|
{
|
||||||
|
_responder = responder;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastRequestUri = request.RequestUri;
|
||||||
|
return _responder(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MtconnectDriver DriverWith(HttpMessageHandler handler) =>
|
||||||
|
new MtconnectDriver(MachineId, AgentUrl, new HttpClient(handler));
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: happy path ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_200WithValidXml_ReturnsOkSnapshotStampedWithMachineId()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK, LoadCurrentFixture());
|
||||||
|
var driver = DriverWith(handler);
|
||||||
|
|
||||||
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.WasCancelled);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
Assert.Equal(MachineId, result.Value.MachineId);
|
||||||
|
Assert.True(result.Value.Items.Count > 0);
|
||||||
|
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
|
||||||
|
|
||||||
|
// Driver hits the agent's /current endpoint.
|
||||||
|
Assert.NotNull(handler.LastRequestUri);
|
||||||
|
Assert.EndsWith("/current", handler.LastRequestUri!.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProtocolId_IsMtconnect()
|
||||||
|
{
|
||||||
|
var driver = DriverWith(new StubHandler(HttpStatusCode.OK, "<x/>"));
|
||||||
|
Assert.Equal("mtconnect", driver.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: HTTP failure statuses ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.NotFound)]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError)]
|
||||||
|
public async Task ReadCurrentAsync_NonSuccessStatus_ReturnsFailNoThrow(HttpStatusCode status)
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(status, "irrelevant");
|
||||||
|
var driver = DriverWith(handler);
|
||||||
|
|
||||||
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.False(result.WasCancelled);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: network down ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_HttpRequestException_ReturnsFailNoThrow()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler((_, __) =>
|
||||||
|
throw new HttpRequestException("connection refused"));
|
||||||
|
var driver = DriverWith(handler);
|
||||||
|
|
||||||
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.False(result.WasCancelled);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: cancellation ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_CancelledBeforeCall_ReturnsCancelled()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK, LoadCurrentFixture());
|
||||||
|
var driver = DriverWith(handler);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
var result = await driver.ReadCurrentAsync(cts.Token);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_CancelledDuringSend_ReturnsCancelledNoThrow()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(async (_, ct) =>
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
});
|
||||||
|
var driver = DriverWith(handler);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var task = driver.ReadCurrentAsync(cts.Token);
|
||||||
|
cts.Cancel();
|
||||||
|
var result = await task;
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Factory: config validation ----
|
||||||
|
|
||||||
|
private static Machine MachineWithConfig(IReadOnlyDictionary<string, string>? config) =>
|
||||||
|
new Machine(MachineId, "VMC-01", "mtconnect", config, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_ProtocolId_IsMtconnect()
|
||||||
|
{
|
||||||
|
Assert.Equal("mtconnect", new MtconnectDriverFactory().ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_MissingAgentUrl_ReturnsFail()
|
||||||
|
{
|
||||||
|
var factory = new MtconnectDriverFactory();
|
||||||
|
var machine = MachineWithConfig(new Dictionary<string, string>());
|
||||||
|
|
||||||
|
var result = factory.Create(machine);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_EmptyAgentUrl_ReturnsFail()
|
||||||
|
{
|
||||||
|
var factory = new MtconnectDriverFactory();
|
||||||
|
var machine = MachineWithConfig(new Dictionary<string, string> { ["AgentUrl"] = " " });
|
||||||
|
|
||||||
|
var result = factory.Create(machine);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_ValidAgentUrl_ReturnsOkDriverWithMtconnectProtocolId()
|
||||||
|
{
|
||||||
|
var factory = new MtconnectDriverFactory();
|
||||||
|
var machine = MachineWithConfig(new Dictionary<string, string> { ["AgentUrl"] = AgentUrl });
|
||||||
|
|
||||||
|
var result = factory.Create(machine);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
Assert.Equal("mtconnect", result.Value.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_AgentUrlKeyIsCaseInsensitive()
|
||||||
|
{
|
||||||
|
var factory = new MtconnectDriverFactory();
|
||||||
|
var machine = MachineWithConfig(new Dictionary<string, string> { ["agenturl"] = AgentUrl });
|
||||||
|
|
||||||
|
var result = factory.Create(machine);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_InvalidTimeout_ReturnsFail()
|
||||||
|
{
|
||||||
|
var factory = new MtconnectDriverFactory();
|
||||||
|
var machine = MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["AgentUrl"] = AgentUrl,
|
||||||
|
["TimeoutSeconds"] = "not-a-number",
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = factory.Create(machine);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
tests/Junction.Tests/Unit/MtconnectParserTests.cs
Normal file
164
tests/Junction.Tests/Unit/MtconnectParserTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Protocols.MTConnect.Parsing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
public sealed class MtconnectParserTests
|
||||||
|
{
|
||||||
|
private static readonly Guid MachineId = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
||||||
|
|
||||||
|
private static string LoadFixture(string name)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "mtconnect", name);
|
||||||
|
return File.ReadAllText(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PROBE ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Probe_ParsesDataItemDescriptors()
|
||||||
|
{
|
||||||
|
var result = MtconnectProbeParser.Parse(LoadFixture("probe.xml"));
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var items = result.Value;
|
||||||
|
Assert.True(items.Count > 0);
|
||||||
|
|
||||||
|
// Spot-check a known DataItem from the fixture.
|
||||||
|
var pos = items.SingleOrDefault(d => d.Id == "x1_pos");
|
||||||
|
Assert.NotNull(pos);
|
||||||
|
Assert.Equal("POSITION", pos!.Type);
|
||||||
|
Assert.Equal("SAMPLE", pos.Category);
|
||||||
|
Assert.Equal("ACTUAL", pos.SubType);
|
||||||
|
Assert.Equal("MILLIMETER", pos.Units);
|
||||||
|
Assert.Equal("dev1", pos.DeviceId);
|
||||||
|
Assert.Equal("junction-vmc-01", pos.DeviceUuid);
|
||||||
|
|
||||||
|
// Availability descriptor present.
|
||||||
|
Assert.Contains(items, d => d.Id == "dev1_avail" && d.Type == "AVAILABILITY");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Probe_MalformedRoot_Fails()
|
||||||
|
{
|
||||||
|
var result = MtconnectProbeParser.Parse("<NotAProbe/>");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.False(result.WasCancelled);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CURRENT ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Current_ParsesSnapshot_WithKnownObservations()
|
||||||
|
{
|
||||||
|
var result = MtconnectCurrentParser.Parse(LoadFixture("current.xml"), MachineId);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var snapshot = result.Value;
|
||||||
|
|
||||||
|
Assert.Equal(MachineId, snapshot.MachineId);
|
||||||
|
Assert.False(snapshot.IsEmpty);
|
||||||
|
|
||||||
|
// Known position value.
|
||||||
|
var xPos = snapshot.TryGetItem("x1_pos");
|
||||||
|
Assert.NotNull(xPos);
|
||||||
|
Assert.Equal("125.4300", xPos!.Value);
|
||||||
|
Assert.Equal("Position", xPos.Name);
|
||||||
|
Assert.Equal("Samples", xPos.Category);
|
||||||
|
|
||||||
|
// EXECUTION = ACTIVE.
|
||||||
|
var exec = snapshot.TryGetItem("path1_exec");
|
||||||
|
Assert.NotNull(exec);
|
||||||
|
Assert.Equal("ACTIVE", exec!.Value);
|
||||||
|
|
||||||
|
// Condition state carried as value.
|
||||||
|
var tempCond = snapshot.TryGetItem("c1_temp_cond");
|
||||||
|
Assert.NotNull(tempCond);
|
||||||
|
Assert.Equal("Condition", tempCond!.Category);
|
||||||
|
Assert.Equal("Normal", tempCond.Value);
|
||||||
|
|
||||||
|
// AVAILABILITY -> Connected.
|
||||||
|
Assert.Equal(ConnectionState.Connected, snapshot.ConnectionState);
|
||||||
|
|
||||||
|
// CapturedAt = latest observed timestamp (10240 seq @ .240Z).
|
||||||
|
Assert.Equal(
|
||||||
|
DateTimeOffset.Parse("2026-07-21T09:15:30.240Z"),
|
||||||
|
snapshot.CapturedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Current_Unavailable_YieldsDisconnected()
|
||||||
|
{
|
||||||
|
var result = MtconnectCurrentParser.Parse(LoadFixture("current_unavailable.xml"), MachineId);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(ConnectionState.Disconnected, result.Value.ConnectionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Current_Malformed_FailsWithoutThrowing()
|
||||||
|
{
|
||||||
|
// current_malformed.xml is truncated (unclosed elements) -> XmlException internally.
|
||||||
|
var result = MtconnectCurrentParser.Parse(LoadFixture("current_malformed.xml"), MachineId);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.False(result.WasCancelled);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- VERSION-AGNOSTIC PROOF ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Current_IsNamespaceVersionAgnostic()
|
||||||
|
{
|
||||||
|
var v17 = LoadFixture("current.xml");
|
||||||
|
var v20 = v17.Replace(":1.7", ":2.0");
|
||||||
|
Assert.Contains(":2.0", v20);
|
||||||
|
Assert.DoesNotContain(":1.7", v20);
|
||||||
|
|
||||||
|
var r17 = MtconnectCurrentParser.Parse(v17, MachineId);
|
||||||
|
var r20 = MtconnectCurrentParser.Parse(v20, MachineId);
|
||||||
|
|
||||||
|
Assert.True(r17.IsSuccess);
|
||||||
|
Assert.True(r20.IsSuccess);
|
||||||
|
|
||||||
|
var s17 = r17.Value;
|
||||||
|
var s20 = r20.Value;
|
||||||
|
|
||||||
|
Assert.Equal(s17.Items.Count, s20.Items.Count);
|
||||||
|
Assert.Equal(s17.ConnectionState, s20.ConnectionState);
|
||||||
|
Assert.Equal(s17.CapturedAt, s20.CapturedAt);
|
||||||
|
|
||||||
|
// Item-by-item identical (id, value, category, timestamp).
|
||||||
|
foreach (var a in s17.Items)
|
||||||
|
{
|
||||||
|
var b = s20.TryGetItem(a.Id);
|
||||||
|
Assert.NotNull(b);
|
||||||
|
Assert.Equal(a.Value, b!.Value);
|
||||||
|
Assert.Equal(a.Category, b.Category);
|
||||||
|
Assert.Equal(a.Name, b.Name);
|
||||||
|
Assert.Equal(a.Timestamp, b.Timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Probe_IsNamespaceVersionAgnostic()
|
||||||
|
{
|
||||||
|
var v17 = LoadFixture("probe.xml");
|
||||||
|
var v20 = v17.Replace(":1.7", ":2.0");
|
||||||
|
|
||||||
|
var r17 = MtconnectProbeParser.Parse(v17);
|
||||||
|
var r20 = MtconnectProbeParser.Parse(v20);
|
||||||
|
|
||||||
|
Assert.True(r17.IsSuccess);
|
||||||
|
Assert.True(r20.IsSuccess);
|
||||||
|
Assert.Equal(r17.Value.Count, r20.Value.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
tests/Junction.Tests/Unit/OperationErrorTests.cs
Normal file
61
tests/Junction.Tests/Unit/OperationErrorTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using System;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
public class OperationErrorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_StoresFields()
|
||||||
|
{
|
||||||
|
var err = new OperationError("E01", "Repo", "boom");
|
||||||
|
|
||||||
|
Assert.Equal("E01", err.Code);
|
||||||
|
Assert.Equal("Repo", err.Source);
|
||||||
|
Assert.Equal("boom", err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_SetsTimestamp()
|
||||||
|
{
|
||||||
|
var before = DateTime.Now;
|
||||||
|
var err = new OperationError("E", "S", "M");
|
||||||
|
var after = DateTime.Now;
|
||||||
|
|
||||||
|
Assert.InRange(err.At, before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, null, null)]
|
||||||
|
[InlineData(null, "S", null)]
|
||||||
|
public void Ctor_NullsBecomeEmptyStrings(string? code, string? source, string? message)
|
||||||
|
{
|
||||||
|
var err = new OperationError(code!, source!, message!);
|
||||||
|
|
||||||
|
Assert.NotNull(err.Code);
|
||||||
|
Assert.NotNull(err.Source);
|
||||||
|
Assert.NotNull(err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Of_CreatesErrorWithEmptyCode()
|
||||||
|
{
|
||||||
|
var err = OperationError.Of("Repo", "boom");
|
||||||
|
|
||||||
|
Assert.Equal("", err.Code);
|
||||||
|
Assert.Equal("Repo", err.Source);
|
||||||
|
Assert.Equal("boom", err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToString_ContainsFields()
|
||||||
|
{
|
||||||
|
var err = new OperationError("E01", "Repo", "boom");
|
||||||
|
|
||||||
|
Assert.Contains("E01", err.ToString());
|
||||||
|
Assert.Contains("Repo", err.ToString());
|
||||||
|
Assert.Contains("boom", err.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
289
tests/Junction.Tests/Unit/PluginLoaderTests.cs
Normal file
289
tests/Junction.Tests/Unit/PluginLoaderTests.cs
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Core.Plugins;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="PluginLoader"/>. A real loadable plugin is synthesized at test
|
||||||
|
/// time from THIS test assembly: a temp plugin dir gets a copy of the test dll plus a
|
||||||
|
/// <c>plugin.manifest.json</c> whose EntryTypeName points at <see cref="FakeFactory"/>
|
||||||
|
/// below. Because the copy shares the already-loaded test assembly's identity,
|
||||||
|
/// <c>Assembly.LoadFrom</c> resolves the running assembly and the type is found.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginLoaderTests : IDisposable
|
||||||
|
{
|
||||||
|
// ---- Fake plugin types defined IN the test project ----
|
||||||
|
|
||||||
|
/// <summary>Valid, loadable factory used for the happy path.</summary>
|
||||||
|
public sealed class FakeFactory : IProtocolDriverFactory
|
||||||
|
{
|
||||||
|
public string ProtocolId => "fake";
|
||||||
|
|
||||||
|
public Result<IProtocolDriver> Create(Machine machine) =>
|
||||||
|
Result<IProtocolDriver>.Ok(new FakeDriver());
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FakeDriver : IProtocolDriver
|
||||||
|
{
|
||||||
|
public string ProtocolId => "fake";
|
||||||
|
|
||||||
|
public Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(Result<MachineSnapshot>.Ok(
|
||||||
|
new MachineSnapshot(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
ConnectionState.Connected,
|
||||||
|
Array.Empty<DataItem>())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Type that does NOT implement the factory contract.</summary>
|
||||||
|
public sealed class NotAFactory
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Factory whose ctor throws (activation failure path).</summary>
|
||||||
|
public sealed class ThrowingFactory : IProtocolDriverFactory
|
||||||
|
{
|
||||||
|
public ThrowingFactory() => throw new InvalidOperationException("boom");
|
||||||
|
public string ProtocolId => "throwing";
|
||||||
|
public Result<IProtocolDriver> Create(Machine machine) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string TestAssemblyPath =
|
||||||
|
typeof(PluginLoaderTests).Assembly.Location;
|
||||||
|
|
||||||
|
private static readonly string TestAssemblyFileName =
|
||||||
|
Path.GetFileName(TestAssemblyPath);
|
||||||
|
|
||||||
|
private readonly string _root;
|
||||||
|
|
||||||
|
public PluginLoaderTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), "junction-plugintests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static PluginLoader NewLoader(ILogger<PluginLoader>? logger = null) =>
|
||||||
|
new PluginLoader(logger ?? NullLogger<PluginLoader>.Instance);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a plugin subdir with a copy of the test dll (so AssemblyFile resolves)
|
||||||
|
/// and a manifest pointing at <paramref name="entryTypeName"/>.
|
||||||
|
/// </summary>
|
||||||
|
private string CreatePluginDir(string dirName, string entryTypeName, string protocolId = "fake", bool copyDll = true)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(_root, dirName);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
if (copyDll)
|
||||||
|
File.Copy(TestAssemblyPath, Path.Combine(dir, TestAssemblyFileName), overwrite: true);
|
||||||
|
|
||||||
|
var manifest =
|
||||||
|
"{\n" +
|
||||||
|
$" \"protocolId\": \"{protocolId}\",\n" +
|
||||||
|
" \"displayName\": \"Fake Plugin\",\n" +
|
||||||
|
$" \"assemblyFile\": \"{TestAssemblyFileName}\",\n" +
|
||||||
|
$" \"entryTypeName\": \"{entryTypeName}\",\n" +
|
||||||
|
" \"apiVersion\": \"1.0\"\n" +
|
||||||
|
"}";
|
||||||
|
File.WriteAllText(Path.Combine(dir, PluginLoader.ManifestFileName), manifest);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateBadJsonPluginDir(string dirName)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(_root, dirName);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
File.Copy(TestAssemblyPath, Path.Combine(dir, TestAssemblyFileName), overwrite: true);
|
||||||
|
File.WriteAllText(Path.Combine(dir, PluginLoader.ManifestFileName), "{ this is : not valid json ]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tests ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_ValidPlugin_ReturnsLoadedPluginWithWorkingFactory()
|
||||||
|
{
|
||||||
|
CreatePluginDir("good", typeof(FakeFactory).FullName!);
|
||||||
|
|
||||||
|
var result = NewLoader().LoadFrom(_root);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var plugin = Assert.Single(result.Value);
|
||||||
|
Assert.Equal("fake", plugin.Descriptor.Manifest.ProtocolId);
|
||||||
|
Assert.Equal("fake", plugin.Factory.ProtocolId);
|
||||||
|
Assert.True(File.Exists(plugin.Descriptor.AssemblyPath));
|
||||||
|
|
||||||
|
var machine = new Machine(Guid.NewGuid(), "M1", "fake", null, TimeSpan.FromSeconds(1));
|
||||||
|
var created = plugin.Factory.Create(machine);
|
||||||
|
Assert.True(created.IsSuccess);
|
||||||
|
Assert.Equal("fake", created.Value.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_RootManifest_IsDiscovered()
|
||||||
|
{
|
||||||
|
// manifest + dll directly in root (no subdir)
|
||||||
|
File.Copy(TestAssemblyPath, Path.Combine(_root, TestAssemblyFileName), overwrite: true);
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(_root, PluginLoader.ManifestFileName),
|
||||||
|
"{ \"protocolId\": \"fake\", \"displayName\": \"d\", " +
|
||||||
|
$"\"assemblyFile\": \"{TestAssemblyFileName}\", " +
|
||||||
|
$"\"entryTypeName\": \"{typeof(FakeFactory).FullName}\", \"apiVersion\": \"1.0\" }}");
|
||||||
|
|
||||||
|
var result = NewLoader().LoadFrom(_root);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Single(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_BadJson_SkipsThatPlugin_LoadsOthers()
|
||||||
|
{
|
||||||
|
CreateBadJsonPluginDir("bad");
|
||||||
|
CreatePluginDir("good", typeof(FakeFactory).FullName!);
|
||||||
|
|
||||||
|
var logger = new CapturingLogger<PluginLoader>();
|
||||||
|
var result = NewLoader(logger).LoadFrom(_root);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var plugin = Assert.Single(result.Value); // only the good one
|
||||||
|
Assert.Equal("fake", plugin.Descriptor.Manifest.ProtocolId);
|
||||||
|
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_MissingDll_SkipsPlugin_NoThrow()
|
||||||
|
{
|
||||||
|
// manifest present, but do not copy the dll
|
||||||
|
CreatePluginDir("nodll", typeof(FakeFactory).FullName!, copyDll: false);
|
||||||
|
|
||||||
|
var logger = new CapturingLogger<PluginLoader>();
|
||||||
|
var result = NewLoader(logger).LoadFrom(_root);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Empty(result.Value);
|
||||||
|
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_UnknownEntryType_SkipsPlugin_NoThrow()
|
||||||
|
{
|
||||||
|
CreatePluginDir("badtype", "Junction.Tests.Unit.NoSuchType");
|
||||||
|
|
||||||
|
var logger = new CapturingLogger<PluginLoader>();
|
||||||
|
var result = NewLoader(logger).LoadFrom(_root);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Empty(result.Value);
|
||||||
|
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_EntryTypeNotAFactory_SkipsPlugin()
|
||||||
|
{
|
||||||
|
CreatePluginDir("wrongcontract", typeof(NotAFactory).FullName!);
|
||||||
|
|
||||||
|
var result = NewLoader().LoadFrom(_root);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Empty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_FactoryCtorThrows_SkipsPlugin()
|
||||||
|
{
|
||||||
|
CreatePluginDir("throwctor", typeof(ThrowingFactory).FullName!);
|
||||||
|
|
||||||
|
var result = NewLoader().LoadFrom(_root);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Empty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_MissingDirectory_ReturnsFail()
|
||||||
|
{
|
||||||
|
var missing = Path.Combine(_root, "does-not-exist");
|
||||||
|
|
||||||
|
var result = NewLoader().LoadFrom(missing);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "PLUGIN_DIR_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_EmptyPath_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = NewLoader().LoadFrom("");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "PLUGIN_DIR_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFrom_MultiplePlugins_LoadsAllValid()
|
||||||
|
{
|
||||||
|
CreatePluginDir("p1", typeof(FakeFactory).FullName!);
|
||||||
|
CreatePluginDir("p2", typeof(FakeFactory).FullName!);
|
||||||
|
CreateBadJsonPluginDir("bad");
|
||||||
|
|
||||||
|
var result = NewLoader().LoadFrom(_root);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- capturing logger ----
|
||||||
|
|
||||||
|
private sealed class CapturingLogger<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public readonly List<(LogLevel Level, string Message)> Entries = new();
|
||||||
|
|
||||||
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
Entries.Add((logLevel, formatter(state, exception)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullScope Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
tests/Junction.Tests/Unit/PluginManifestTests.cs
Normal file
75
tests/Junction.Tests/Unit/PluginManifestTests.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the plugin manifest POCO models. Data-only: no serialization here
|
||||||
|
/// (JSON parse lives in Core). Covers construction, immutability, null handling,
|
||||||
|
/// and the manifest+path pairing of <see cref="PluginDescriptor"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginManifestTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Manifest_Ctor_HoldsAllValues()
|
||||||
|
{
|
||||||
|
var m = new PluginManifest(
|
||||||
|
"mtconnect",
|
||||||
|
"MTConnect Driver",
|
||||||
|
"Junction.Protocols.MTConnect.dll",
|
||||||
|
"Junction.Protocols.MTConnect.MTConnectDriverFactory",
|
||||||
|
"1.0");
|
||||||
|
|
||||||
|
Assert.Equal("mtconnect", m.ProtocolId);
|
||||||
|
Assert.Equal("MTConnect Driver", m.DisplayName);
|
||||||
|
Assert.Equal("Junction.Protocols.MTConnect.dll", m.AssemblyFile);
|
||||||
|
Assert.Equal("Junction.Protocols.MTConnect.MTConnectDriverFactory", m.EntryTypeName);
|
||||||
|
Assert.Equal("1.0", m.ApiVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Manifest_NullStrings_DefaultToEmpty()
|
||||||
|
{
|
||||||
|
var m = new PluginManifest(null!, null!, null!, null!, null!);
|
||||||
|
|
||||||
|
Assert.Equal("", m.ProtocolId);
|
||||||
|
Assert.Equal("", m.DisplayName);
|
||||||
|
Assert.Equal("", m.AssemblyFile);
|
||||||
|
Assert.Equal("", m.EntryTypeName);
|
||||||
|
Assert.Equal("", m.ApiVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Manifest_Properties_AreGetOnly()
|
||||||
|
{
|
||||||
|
var props = typeof(PluginManifest).GetProperties();
|
||||||
|
Assert.All(props, p => Assert.Null(p.SetMethod));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Descriptor_Ctor_PairsManifestAndPath()
|
||||||
|
{
|
||||||
|
var m = new PluginManifest("mtconnect", "MTConnect", "p.dll", "T", "1.0");
|
||||||
|
var d = new PluginDescriptor(m, "/opt/junction/plugins/mtconnect/p.dll");
|
||||||
|
|
||||||
|
Assert.Same(m, d.Manifest);
|
||||||
|
Assert.Equal("/opt/junction/plugins/mtconnect/p.dll", d.AssemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Descriptor_NullPath_DefaultsToEmpty()
|
||||||
|
{
|
||||||
|
var m = new PluginManifest("mtconnect", "MTConnect", "p.dll", "T", "1.0");
|
||||||
|
var d = new PluginDescriptor(m, null!);
|
||||||
|
|
||||||
|
Assert.Equal("", d.AssemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Descriptor_Properties_AreGetOnly()
|
||||||
|
{
|
||||||
|
var props = typeof(PluginDescriptor).GetProperties();
|
||||||
|
Assert.All(props, p => Assert.Null(p.SetMethod));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
tests/Junction.Tests/Unit/PollingEngineTests.cs
Normal file
166
tests/Junction.Tests/Unit/PollingEngineTests.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Core.Polling;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Behavior tests for <see cref="PollingEngine"/> using a hand-rolled fake driver.
|
||||||
|
/// Timings are deliberately generous to avoid CI flake.
|
||||||
|
/// </summary>
|
||||||
|
public class PollingEngineTests
|
||||||
|
{
|
||||||
|
private static PollingEngine NewEngine() =>
|
||||||
|
new PollingEngine(NullLogger<PollingEngine>.Instance);
|
||||||
|
|
||||||
|
private static Machine MachineWithInterval(TimeSpan interval) =>
|
||||||
|
new Machine(Guid.NewGuid(), "M1", "fake", null, interval);
|
||||||
|
|
||||||
|
private static MachineSnapshot Snapshot(Guid machineId) =>
|
||||||
|
new MachineSnapshot(
|
||||||
|
machineId,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
ConnectionState.Connected,
|
||||||
|
Array.Empty<DataItem>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_PollsRepeatedly_OverShortInterval()
|
||||||
|
{
|
||||||
|
var machine = MachineWithInterval(TimeSpan.FromMilliseconds(25));
|
||||||
|
var driver = new FakeDriver(m => Result<MachineSnapshot>.Ok(Snapshot(machine.Id)));
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var loop = NewEngine().RunAsync(machine, driver, _ => Interlocked.Increment(ref count), cts.Token);
|
||||||
|
|
||||||
|
await Task.Delay(200);
|
||||||
|
cts.Cancel();
|
||||||
|
await loop;
|
||||||
|
|
||||||
|
Assert.True(count >= 2, $"expected >= 2 polls, got {count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Cancellation_StopsLoop_NoException()
|
||||||
|
{
|
||||||
|
var machine = MachineWithInterval(TimeSpan.FromMilliseconds(20));
|
||||||
|
var driver = new FakeDriver(m => Result<MachineSnapshot>.Ok(Snapshot(machine.Id)));
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var loop = NewEngine().RunAsync(machine, driver, _ => { }, cts.Token);
|
||||||
|
|
||||||
|
await Task.Delay(60);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
// Must complete promptly and without propagating any exception.
|
||||||
|
var completed = await Task.WhenAny(loop, Task.Delay(1000)) == loop;
|
||||||
|
Assert.True(completed, "loop did not stop promptly after cancellation");
|
||||||
|
await loop; // would rethrow if it faulted
|
||||||
|
Assert.True(loop.IsCompletedSuccessfully);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_DriverFail_DoesNotEmit_AndKeepsLooping()
|
||||||
|
{
|
||||||
|
var machine = MachineWithInterval(TimeSpan.FromMilliseconds(25));
|
||||||
|
int calls = 0;
|
||||||
|
var emitted = new List<MachineSnapshot>();
|
||||||
|
|
||||||
|
// Fail on cycle 1, succeed thereafter. Proves the loop survives a fault.
|
||||||
|
var driver = new FakeDriver(m =>
|
||||||
|
{
|
||||||
|
int c = Interlocked.Increment(ref calls);
|
||||||
|
if (c == 1)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Fail(OperationError.Of("fake", "boom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<MachineSnapshot>.Ok(Snapshot(machine.Id));
|
||||||
|
});
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var loop = NewEngine().RunAsync(machine, driver, s =>
|
||||||
|
{
|
||||||
|
lock (emitted) { emitted.Add(s); }
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
await Task.Delay(200);
|
||||||
|
cts.Cancel();
|
||||||
|
await loop;
|
||||||
|
|
||||||
|
Assert.True(calls >= 2, $"expected loop to continue past the failed cycle, calls={calls}");
|
||||||
|
lock (emitted)
|
||||||
|
{
|
||||||
|
// First (failed) cycle emitted nothing; a later Ok cycle did.
|
||||||
|
Assert.NotEmpty(emitted);
|
||||||
|
Assert.True(emitted.Count < calls, "a failed cycle must not have emitted a snapshot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Ok_ForwardsSnapshotIntact()
|
||||||
|
{
|
||||||
|
var machine = MachineWithInterval(TimeSpan.FromMilliseconds(20));
|
||||||
|
var expected = Snapshot(machine.Id);
|
||||||
|
var driver = new FakeDriver(m => Result<MachineSnapshot>.Ok(expected));
|
||||||
|
|
||||||
|
MachineSnapshot? received = null;
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var loop = NewEngine().RunAsync(machine, driver, s =>
|
||||||
|
{
|
||||||
|
received = s;
|
||||||
|
cts.Cancel();
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
await loop;
|
||||||
|
|
||||||
|
Assert.Same(expected, received);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_DriverReturnsCancelled_StopsLoop()
|
||||||
|
{
|
||||||
|
var machine = MachineWithInterval(TimeSpan.FromMilliseconds(20));
|
||||||
|
int calls = 0;
|
||||||
|
var driver = new FakeDriver(m =>
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref calls);
|
||||||
|
return Result<MachineSnapshot>.Cancelled();
|
||||||
|
});
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var loop = NewEngine().RunAsync(machine, driver, _ => { }, cts.Token);
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(loop, Task.Delay(1000)) == loop;
|
||||||
|
Assert.True(completed, "loop did not stop on cancelled result");
|
||||||
|
await loop;
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hand-rolled fake driver; behavior supplied by a delegate.</summary>
|
||||||
|
private sealed class FakeDriver : IProtocolDriver
|
||||||
|
{
|
||||||
|
private readonly Func<Machine, Result<MachineSnapshot>> _behavior;
|
||||||
|
|
||||||
|
public FakeDriver(Func<Machine, Result<MachineSnapshot>> behavior)
|
||||||
|
{
|
||||||
|
_behavior = behavior;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProtocolId => "fake";
|
||||||
|
|
||||||
|
public Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.FromResult(_behavior(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
tests/Junction.Tests/Unit/ProtocolContractTests.cs
Normal file
139
tests/Junction.Tests/Unit/ProtocolContractTests.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Contract/shape tests for the plugin protocol contract. Pure compile + behavior
|
||||||
|
/// against Moq doubles; no real IO.
|
||||||
|
/// </summary>
|
||||||
|
public class ProtocolContractTests
|
||||||
|
{
|
||||||
|
private static Machine SampleMachine(IReadOnlyDictionary<string, string>? config = null) =>
|
||||||
|
new Machine(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"M1",
|
||||||
|
"mtconnect",
|
||||||
|
config,
|
||||||
|
TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
private static MachineSnapshot SampleSnapshot(Guid machineId) =>
|
||||||
|
new MachineSnapshot(
|
||||||
|
machineId,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
ConnectionState.Connected,
|
||||||
|
Array.Empty<DataItem>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Driver_ProtocolId_ReturnsConfiguredValue()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IProtocolDriver>();
|
||||||
|
mock.SetupGet(d => d.ProtocolId).Returns("mtconnect");
|
||||||
|
|
||||||
|
Assert.Equal("mtconnect", mock.Object.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_ReadCurrentAsync_Ok_ReturnsSnapshotResult()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var snapshot = SampleSnapshot(machineId);
|
||||||
|
var mock = new Mock<IProtocolDriver>();
|
||||||
|
mock.Setup(d => d.ReadCurrentAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<MachineSnapshot>.Ok(snapshot));
|
||||||
|
|
||||||
|
Result<MachineSnapshot> result = await mock.Object.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Same(snapshot, result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_ReadCurrentAsync_Fail_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var error = OperationError.Of("driver", "read failed");
|
||||||
|
var mock = new Mock<IProtocolDriver>();
|
||||||
|
mock.Setup(d => d.ReadCurrentAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<MachineSnapshot>.Fail(error));
|
||||||
|
|
||||||
|
Result<MachineSnapshot> result = await mock.Object.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_ReadCurrentAsync_ForwardsCancellationToken()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var mock = new Mock<IProtocolDriver>();
|
||||||
|
mock.Setup(d => d.ReadCurrentAsync(cts.Token))
|
||||||
|
.ReturnsAsync(Result<MachineSnapshot>.Cancelled());
|
||||||
|
|
||||||
|
Result<MachineSnapshot> result = await mock.Object.ReadCurrentAsync(cts.Token);
|
||||||
|
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
mock.Verify(d => d.ReadCurrentAsync(cts.Token), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_ProtocolId_ReturnsConfiguredValue()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IProtocolDriverFactory>();
|
||||||
|
mock.SetupGet(f => f.ProtocolId).Returns("mtconnect");
|
||||||
|
|
||||||
|
Assert.Equal("mtconnect", mock.Object.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_Create_Ok_WrapsDriver()
|
||||||
|
{
|
||||||
|
var driver = new Mock<IProtocolDriver>().Object;
|
||||||
|
var factory = new Mock<IProtocolDriverFactory>();
|
||||||
|
factory.Setup(f => f.Create(It.IsAny<Machine>()))
|
||||||
|
.Returns(Result<IProtocolDriver>.Ok(driver));
|
||||||
|
|
||||||
|
Result<IProtocolDriver> result = factory.Object.Create(SampleMachine());
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Same(driver, result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_Create_InvalidConfig_ReturnsFail()
|
||||||
|
{
|
||||||
|
// Simulate protocol-specific validation: missing required key => Fail.
|
||||||
|
var factory = new Mock<IProtocolDriverFactory>();
|
||||||
|
factory.Setup(f => f.Create(It.Is<Machine>(m => !m.ConnectionConfig.ContainsKey("endpoint"))))
|
||||||
|
.Returns(Result<IProtocolDriver>.Fail(
|
||||||
|
OperationError.Of("factory", "missing 'endpoint'")));
|
||||||
|
|
||||||
|
Result<IProtocolDriver> result = factory.Object.Create(SampleMachine());
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_Create_ValidConfig_ReturnsOk()
|
||||||
|
{
|
||||||
|
var driver = new Mock<IProtocolDriver>().Object;
|
||||||
|
var factory = new Mock<IProtocolDriverFactory>();
|
||||||
|
factory.Setup(f => f.Create(It.Is<Machine>(m => m.ConnectionConfig.ContainsKey("endpoint"))))
|
||||||
|
.Returns(Result<IProtocolDriver>.Ok(driver));
|
||||||
|
|
||||||
|
var machine = SampleMachine(new Dictionary<string, string> { ["endpoint"] = "http://x" });
|
||||||
|
Result<IProtocolDriver> result = factory.Object.Create(machine);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Same(driver, result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
tests/Junction.Tests/Unit/ResultTests.cs
Normal file
127
tests/Junction.Tests/Unit/ResultTests.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
public class ResultTests
|
||||||
|
{
|
||||||
|
private static OperationError Err(string msg = "boom") =>
|
||||||
|
new OperationError("E", "Src", msg);
|
||||||
|
|
||||||
|
// ---- Result<T> ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generic_Ok_HasValue_IsSuccess_EmptyErrors()
|
||||||
|
{
|
||||||
|
var r = Result<int>.Ok(42);
|
||||||
|
|
||||||
|
Assert.True(r.IsSuccess);
|
||||||
|
Assert.False(r.WasCancelled);
|
||||||
|
Assert.Equal(42, r.Value);
|
||||||
|
Assert.NotNull(r.Errors);
|
||||||
|
Assert.Empty(r.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generic_FailSingle_CarriesError_NotSuccess()
|
||||||
|
{
|
||||||
|
var r = Result<int>.Fail(Err());
|
||||||
|
|
||||||
|
Assert.False(r.IsSuccess);
|
||||||
|
Assert.False(r.WasCancelled);
|
||||||
|
Assert.Single(r.Errors);
|
||||||
|
Assert.NotNull(r.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generic_FailMany_CarriesAllErrors()
|
||||||
|
{
|
||||||
|
var errors = new List<OperationError> { Err("a"), Err("b") };
|
||||||
|
var r = Result<int>.Fail(errors);
|
||||||
|
|
||||||
|
Assert.False(r.IsSuccess);
|
||||||
|
Assert.Equal(2, r.Errors.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generic_Failed_ValueIsDefault_NoThrow()
|
||||||
|
{
|
||||||
|
var rInt = Result<int>.Fail(Err());
|
||||||
|
var rRef = Result<string>.Fail(Err());
|
||||||
|
|
||||||
|
Assert.Equal(0, rInt.Value);
|
||||||
|
Assert.Null(rRef.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generic_Cancelled_IsCancelled_NotSuccess_NotTreatedAsFailure()
|
||||||
|
{
|
||||||
|
var r = Result<int>.Cancelled();
|
||||||
|
|
||||||
|
Assert.True(r.WasCancelled);
|
||||||
|
Assert.False(r.IsSuccess);
|
||||||
|
// cancellation is NOT a failure: no errors recorded
|
||||||
|
Assert.Empty(r.Errors);
|
||||||
|
Assert.NotNull(r.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generic_ErrorsNeverNull_InAnyState()
|
||||||
|
{
|
||||||
|
Assert.NotNull(Result<int>.Ok(1).Errors);
|
||||||
|
Assert.NotNull(Result<int>.Fail(Err()).Errors);
|
||||||
|
Assert.NotNull(Result<int>.Cancelled().Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- non-generic Result ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Void_Ok_IsSuccess_EmptyErrors()
|
||||||
|
{
|
||||||
|
var r = Result.Ok();
|
||||||
|
|
||||||
|
Assert.True(r.IsSuccess);
|
||||||
|
Assert.False(r.WasCancelled);
|
||||||
|
Assert.NotNull(r.Errors);
|
||||||
|
Assert.Empty(r.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Void_FailSingle_CarriesError_NotSuccess()
|
||||||
|
{
|
||||||
|
var r = Result.Fail(Err());
|
||||||
|
|
||||||
|
Assert.False(r.IsSuccess);
|
||||||
|
Assert.False(r.WasCancelled);
|
||||||
|
Assert.Single(r.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Void_FailMany_CarriesAllErrors()
|
||||||
|
{
|
||||||
|
var r = Result.Fail(new[] { Err("a"), Err("b") });
|
||||||
|
|
||||||
|
Assert.False(r.IsSuccess);
|
||||||
|
Assert.Equal(2, r.Errors.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Void_Cancelled_IsCancelled_NotSuccess_NotTreatedAsFailure()
|
||||||
|
{
|
||||||
|
var r = Result.Cancelled();
|
||||||
|
|
||||||
|
Assert.True(r.WasCancelled);
|
||||||
|
Assert.False(r.IsSuccess);
|
||||||
|
Assert.Empty(r.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Void_ErrorsNeverNull_InAnyState()
|
||||||
|
{
|
||||||
|
Assert.NotNull(Result.Ok().Errors);
|
||||||
|
Assert.NotNull(Result.Fail(Err()).Errors);
|
||||||
|
Assert.NotNull(Result.Cancelled().Errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs
Normal file
244
tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Integration-style tests for <see cref="SqliteMachineRepository"/> against a REAL temp-file
|
||||||
|
/// SQLite database. Proves the full round-trip (config dict, poll interval, snapshots, items)
|
||||||
|
/// and the only-latest replace policy on the Linux net8.0 host.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqliteMachineRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
private readonly SqliteConnectionFactory _factory;
|
||||||
|
private readonly SqliteMachineRepository _repo;
|
||||||
|
|
||||||
|
public SqliteMachineRepositoryTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), "junction_repo_test_" + Guid.NewGuid().ToString("N") + ".db");
|
||||||
|
var connectionString = "Data Source=" + _dbPath;
|
||||||
|
_factory = new SqliteConnectionFactory(connectionString);
|
||||||
|
|
||||||
|
Result schema = SqliteSchema.EnsureCreated(_factory);
|
||||||
|
Assert.True(schema.IsSuccess, Describe(schema));
|
||||||
|
|
||||||
|
_repo = new SqliteMachineRepository(_factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_dbPath))
|
||||||
|
{
|
||||||
|
File.Delete(_dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upsert_NewMachine_GetById_RoundTripsAllFields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var config = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["url"] = "http://mill:5000",
|
||||||
|
["device"] = "M1",
|
||||||
|
};
|
||||||
|
var machine = new Machine(id, "Mill 01", "mtconnect", config, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Result upsert = await _repo.UpsertAsync(machine, CancellationToken.None);
|
||||||
|
Assert.True(upsert.IsSuccess, Describe(upsert));
|
||||||
|
|
||||||
|
Result<Machine> got = await _repo.GetByIdAsync(id, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
|
||||||
|
var loaded = got.Value;
|
||||||
|
Assert.Equal(id, loaded.Id);
|
||||||
|
Assert.Equal("Mill 01", loaded.Name);
|
||||||
|
Assert.Equal("mtconnect", loaded.ProtocolId);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(5), loaded.PollInterval);
|
||||||
|
Assert.Equal(2, loaded.ConnectionConfig.Count);
|
||||||
|
Assert.Equal("http://mill:5000", loaded.ConnectionConfig["url"]);
|
||||||
|
Assert.Equal("M1", loaded.ConnectionConfig["device"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upsert_ExistingId_Updates_GetAllCountStable()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var original = new Machine(id, "Old Name", "mtconnect", null, TimeSpan.FromSeconds(2));
|
||||||
|
await _repo.UpsertAsync(original, CancellationToken.None);
|
||||||
|
|
||||||
|
var updated = new Machine(
|
||||||
|
id,
|
||||||
|
"New Name",
|
||||||
|
"opcua",
|
||||||
|
new Dictionary<string, string> { ["k"] = "v" },
|
||||||
|
TimeSpan.FromSeconds(10));
|
||||||
|
Result upsert = await _repo.UpsertAsync(updated, CancellationToken.None);
|
||||||
|
Assert.True(upsert.IsSuccess, Describe(upsert));
|
||||||
|
|
||||||
|
Result<IReadOnlyList<Machine>> all = await _repo.GetAllAsync(CancellationToken.None);
|
||||||
|
Assert.True(all.IsSuccess, Describe(all));
|
||||||
|
Assert.Single(all.Value);
|
||||||
|
|
||||||
|
Result<Machine> got = await _repo.GetByIdAsync(id, CancellationToken.None);
|
||||||
|
Assert.Equal("New Name", got.Value.Name);
|
||||||
|
Assert.Equal("opcua", got.Value.ProtocolId);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(10), got.Value.PollInterval);
|
||||||
|
Assert.Equal("v", got.Value.ConnectionConfig["k"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_Missing_Fails_NotFound()
|
||||||
|
{
|
||||||
|
Result<Machine> got = await _repo.GetByIdAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(got.IsSuccess);
|
||||||
|
Assert.False(got.WasCancelled);
|
||||||
|
Assert.NotEmpty(got.Errors);
|
||||||
|
Assert.Equal("not_found", got.Errors[0].Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAll_Empty_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
Result<IReadOnlyList<Machine>> all = await _repo.GetAllAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(all.IsSuccess, Describe(all));
|
||||||
|
Assert.Empty(all.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveSnapshot_GetLatest_RoundTripsItems()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var capturedAt = new DateTimeOffset(2026, 7, 21, 10, 30, 15, TimeSpan.FromHours(2));
|
||||||
|
var itemTs = new DateTimeOffset(2026, 7, 21, 10, 30, 14, TimeSpan.FromHours(2));
|
||||||
|
|
||||||
|
var items = new List<DataItem>
|
||||||
|
{
|
||||||
|
new DataItem("d1", "Spindle Speed", "1200", "Sample", itemTs),
|
||||||
|
new DataItem("d2", "Program", "O1000", "Event", itemTs),
|
||||||
|
};
|
||||||
|
var snapshot = new MachineSnapshot(machineId, capturedAt, ConnectionState.Connected, items);
|
||||||
|
|
||||||
|
Result save = await _repo.SaveSnapshotAsync(snapshot, CancellationToken.None);
|
||||||
|
Assert.True(save.IsSuccess, Describe(save));
|
||||||
|
|
||||||
|
Result<MachineSnapshot> got = await _repo.GetLatestSnapshotAsync(machineId, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
|
||||||
|
var loaded = got.Value;
|
||||||
|
Assert.Equal(machineId, loaded.MachineId);
|
||||||
|
Assert.Equal(capturedAt, loaded.CapturedAt);
|
||||||
|
Assert.Equal(ConnectionState.Connected, loaded.ConnectionState);
|
||||||
|
Assert.Equal(2, loaded.Items.Count);
|
||||||
|
|
||||||
|
var d1 = loaded.Items.Single(i => i.Id == "d1");
|
||||||
|
Assert.Equal("Spindle Speed", d1.Name);
|
||||||
|
Assert.Equal("1200", d1.Value);
|
||||||
|
Assert.Equal("Sample", d1.Category);
|
||||||
|
Assert.Equal(itemTs, d1.Timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveSnapshot_Twice_ReplacesItems_OnlyLatest()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var ts = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var first = new MachineSnapshot(machineId, ts, ConnectionState.Connected, new List<DataItem>
|
||||||
|
{
|
||||||
|
new DataItem("d1", "A", "1", "Sample", ts),
|
||||||
|
new DataItem("d2", "B", "2", "Sample", ts),
|
||||||
|
new DataItem("d3", "C", "3", "Sample", ts),
|
||||||
|
});
|
||||||
|
await _repo.SaveSnapshotAsync(first, CancellationToken.None);
|
||||||
|
|
||||||
|
var second = new MachineSnapshot(machineId, ts.AddSeconds(1), ConnectionState.Disconnected, new List<DataItem>
|
||||||
|
{
|
||||||
|
new DataItem("d1", "A", "99", "Sample", ts.AddSeconds(1)),
|
||||||
|
});
|
||||||
|
Result save = await _repo.SaveSnapshotAsync(second, CancellationToken.None);
|
||||||
|
Assert.True(save.IsSuccess, Describe(save));
|
||||||
|
|
||||||
|
Result<MachineSnapshot> got = await _repo.GetLatestSnapshotAsync(machineId, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
|
||||||
|
var loaded = got.Value;
|
||||||
|
Assert.Single(loaded.Items);
|
||||||
|
Assert.Equal("d1", loaded.Items[0].Id);
|
||||||
|
Assert.Equal("99", loaded.Items[0].Value);
|
||||||
|
Assert.Equal(ConnectionState.Disconnected, loaded.ConnectionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLatestSnapshot_None_Fails_NotFound()
|
||||||
|
{
|
||||||
|
Result<MachineSnapshot> got = await _repo.GetLatestSnapshotAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(got.IsSuccess);
|
||||||
|
Assert.NotEmpty(got.Errors);
|
||||||
|
Assert.Equal("not_found", got.Errors[0].Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_RemovesMachine_AndItsSnapshot()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
await _repo.UpsertAsync(new Machine(id, "M", "mtconnect", null, TimeSpan.FromSeconds(1)), CancellationToken.None);
|
||||||
|
await _repo.SaveSnapshotAsync(
|
||||||
|
new MachineSnapshot(id, DateTimeOffset.UtcNow, ConnectionState.Connected, new List<DataItem>
|
||||||
|
{
|
||||||
|
new DataItem("d1", "A", "1", "Sample", DateTimeOffset.UtcNow),
|
||||||
|
}),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Result delete = await _repo.DeleteAsync(id, CancellationToken.None);
|
||||||
|
Assert.True(delete.IsSuccess, Describe(delete));
|
||||||
|
|
||||||
|
Result<Machine> machine = await _repo.GetByIdAsync(id, CancellationToken.None);
|
||||||
|
Assert.False(machine.IsSuccess);
|
||||||
|
Assert.Equal("not_found", machine.Errors[0].Code);
|
||||||
|
|
||||||
|
Result<MachineSnapshot> snapshot = await _repo.GetLatestSnapshotAsync(id, CancellationToken.None);
|
||||||
|
Assert.False(snapshot.IsSuccess);
|
||||||
|
Assert.Equal("not_found", snapshot.Errors[0].Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upsert_Cancelled_ReturnsCancelled()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
Result result = await _repo.UpsertAsync(
|
||||||
|
new Machine(Guid.NewGuid(), "M", "mtconnect", null, TimeSpan.FromSeconds(1)),
|
||||||
|
cts.Token);
|
||||||
|
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe<T>(Result<T> result) =>
|
||||||
|
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
||||||
|
|
||||||
|
private static string Describe(Result result) =>
|
||||||
|
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
134
tests/Junction.Tests/Unit/SqliteSchemaTests.cs
Normal file
134
tests/Junction.Tests/Unit/SqliteSchemaTests.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.IO;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Integration-style tests against a REAL SQLite file. Proves the native e_sqlite3
|
||||||
|
/// library loads and runs on the Linux net8.0 test host (relevant to the net48 story).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqliteSchemaTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public SqliteSchemaTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), "junction_test_" + Guid.NewGuid().ToString("N") + ".db");
|
||||||
|
_connectionString = "Data Source=" + _dbPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_dbPath))
|
||||||
|
{
|
||||||
|
File.Delete(_dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionFactory_OpensUsableConnection()
|
||||||
|
{
|
||||||
|
var factory = new SqliteConnectionFactory(_connectionString);
|
||||||
|
|
||||||
|
using (IDbConnection connection = factory.CreateOpenConnection())
|
||||||
|
{
|
||||||
|
Assert.Equal(ConnectionState.Open, connection.State);
|
||||||
|
|
||||||
|
using (var cmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT 1;";
|
||||||
|
var value = Convert.ToInt64(cmd.ExecuteScalar());
|
||||||
|
Assert.Equal(1L, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureCreated_CreatesAllThreeTables()
|
||||||
|
{
|
||||||
|
var factory = new SqliteConnectionFactory(_connectionString);
|
||||||
|
|
||||||
|
Result result = SqliteSchema.EnsureCreated(factory);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, DescribeErrors(result));
|
||||||
|
|
||||||
|
var tables = QueryTableNames();
|
||||||
|
Assert.Contains("machines", tables);
|
||||||
|
Assert.Contains("latest_snapshots", tables);
|
||||||
|
Assert.Contains("snapshot_items", tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureCreated_IsIdempotent()
|
||||||
|
{
|
||||||
|
var factory = new SqliteConnectionFactory(_connectionString);
|
||||||
|
|
||||||
|
Result first = SqliteSchema.EnsureCreated(factory);
|
||||||
|
Result second = SqliteSchema.EnsureCreated(factory);
|
||||||
|
|
||||||
|
Assert.True(first.IsSuccess, DescribeErrors(first));
|
||||||
|
Assert.True(second.IsSuccess, DescribeErrors(second));
|
||||||
|
|
||||||
|
var tables = QueryTableNames();
|
||||||
|
Assert.Contains("machines", tables);
|
||||||
|
Assert.Contains("latest_snapshots", tables);
|
||||||
|
Assert.Contains("snapshot_items", tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureCreated_OnOpenConnection_Works()
|
||||||
|
{
|
||||||
|
var factory = new SqliteConnectionFactory(_connectionString);
|
||||||
|
|
||||||
|
using (IDbConnection connection = factory.CreateOpenConnection())
|
||||||
|
{
|
||||||
|
Result result = SqliteSchema.EnsureCreated(connection);
|
||||||
|
Assert.True(result.IsSuccess, DescribeErrors(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> QueryTableNames()
|
||||||
|
{
|
||||||
|
var names = new List<string>();
|
||||||
|
var factory = new SqliteConnectionFactory(_connectionString);
|
||||||
|
|
||||||
|
using (IDbConnection connection = factory.CreateOpenConnection())
|
||||||
|
using (var cmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table';";
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
names.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeErrors(Result result)
|
||||||
|
{
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("; ", System.Linq.Enumerable.Select(result.Errors, e => e.ToString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue