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:
dtrentin 2026-07-21 23:32:56 +02:00
commit 507753f82e
78 changed files with 6364 additions and 0 deletions

54
.gitignore vendored Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.129",
"rollForward": "latestFeature"
}
}

33
mock/README.md Normal file
View 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
View 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

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

View 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.");
}
}
}
}

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

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

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

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

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

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

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

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

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

View 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
{
}
}

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

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

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

View file

@ -0,0 +1,7 @@
{
"protocolId": "mtconnect",
"displayName": "MTConnect",
"assemblyFile": "Junction.Protocols.MTConnect.dll",
"entryTypeName": "Junction.Protocols.MTConnect.MtconnectDriverFactory",
"apiVersion": "1.0"
}

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

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

View 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>() =&gt; new PollingEngine(loggerFactory.CreateLogger&lt;PollingEngine&gt;())</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;
}
}
}
}

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

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

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

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

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

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

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<RootNamespace>Junction.Domain</RootNamespace>
</PropertyGroup>
</Project>

View 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
}
}

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

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

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

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

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

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

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

View 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 ?? "";
}
}
}

View 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 ?? "";
}
}
}

View 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";
}
}
}

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

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

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

View 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"/> &lt;-&gt; TEXT via <c>ToString("D")</c> (canonical 8-4-4-4-12).</description></item>
/// <item><description><see cref="Machine.PollInterval"/> &lt;-&gt; INTEGER ticks (<see cref="TimeSpan.Ticks"/>).</description></item>
/// <item><description><see cref="Machine.ConnectionConfig"/> &lt;-&gt; TEXT JSON of a Dictionary&lt;string,string&gt; (System.Text.Json). Null/blank JSON rehydrates to an empty dictionary.</description></item>
/// <item><description><see cref="DateTimeOffset"/> &lt;-&gt; TEXT ISO 8601 round-trip ("O").</description></item>
/// <item><description><see cref="ConnectionState"/> &lt;-&gt; 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; } = "";
}
}
}

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

View file

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

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

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

View file

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

View file

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

View file

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

View 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 "";
}
}
}

View file

@ -0,0 +1,7 @@
{
"protocolId": "mtconnect",
"displayName": "MTConnect",
"assemblyFile": "Junction.Protocols.MTConnect.dll",
"entryTypeName": "Junction.Protocols.MTConnect.MtconnectDriverFactory",
"apiVersion": "1.0"
}

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

View file

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

View 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: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>

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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!));
}
}
}
}

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

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

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

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