commit 507753f82e2960cd6ac2eebf6bc4eab3b0f55b95 Author: dtrentin Date: Tue Jul 21 23:32:56 2026 +0200 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, 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b35f024 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..f6ad630 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + latest + enable + disable + + + + + + + + diff --git a/Junction.sln b/Junction.sln new file mode 100644 index 0000000..0bf0d83 --- /dev/null +++ b/Junction.sln @@ -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 diff --git a/global.json b/global.json new file mode 100644 index 0000000..dca39eb --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.129", + "rollForward": "latestFeature" + } +} diff --git a/mock/README.md b/mock/README.md new file mode 100644 index 0000000..0846eb6 --- /dev/null +++ b/mock/README.md @@ -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=&count=&path=`. + +## 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. diff --git a/mock/docker-compose.yml b/mock/docker-compose.yml new file mode 100644 index 0000000..e9a1725 --- /dev/null +++ b/mock/docker-compose.yml @@ -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 diff --git a/src/Junction.App/App.axaml b/src/Junction.App/App.axaml new file mode 100644 index 0000000..ed9b70f --- /dev/null +++ b/src/Junction.App/App.axaml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/Junction.App/App.axaml.cs b/src/Junction.App/App.axaml.cs new file mode 100644 index 0000000..4e3ec30 --- /dev/null +++ b/src/Junction.App/App.axaml.cs @@ -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>(); + logger.LogInformation("Junction starting. Db={Db} Plugins={Plugins}", + Bootstrapper.DatabasePath, Bootstrapper.PluginsDirectory); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainVm = _services.GetRequiredService(); + 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(); + + // 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(); + 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(); + 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 { ["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(); + monitor.StopAsync().GetAwaiter().GetResult(); + + var dashboard = _services.GetRequiredService(); + dashboard.Dispose(); + } + + logger.LogInformation("Junction stopped."); + } + catch (Exception ex) + { + logger.LogError(ex, "Shutdown error."); + } + } + } +} diff --git a/src/Junction.App/Bootstrapper.cs b/src/Junction.App/Bootstrapper.cs new file mode 100644 index 0000000..b976eb6 --- /dev/null +++ b/src/Junction.App/Bootstrapper.cs @@ -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 +{ + /// + /// Composition root. Wires logging (NLog), persistence (SQLite), the Core monitoring stack, + /// and the app view-models into a single . + /// + public static class Bootstrapper + { + /// Absolute path of the SQLite db, next to the running app. + public static string DatabasePath => + Path.Combine(AppContext.BaseDirectory, "junction.db"); + + /// Absolute path of the plugins directory the monitor scans. + public static string PluginsDirectory => + Path.Combine(AppContext.BaseDirectory, "plugins"); + + /// Builds the app-wide service provider. Call once at startup. + 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(); + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Junction.App/Junction.App.csproj b/src/Junction.App/Junction.App.csproj new file mode 100644 index 0000000..5491596 --- /dev/null +++ b/src/Junction.App/Junction.App.csproj @@ -0,0 +1,55 @@ + + + + + net48;net8.0 + + WinExe + Junction.App + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Junction.App/MainWindow.axaml b/src/Junction.App/MainWindow.axaml new file mode 100644 index 0000000..e2299d6 --- /dev/null +++ b/src/Junction.App/MainWindow.axaml @@ -0,0 +1,11 @@ + + + + diff --git a/src/Junction.App/MainWindow.axaml.cs b/src/Junction.App/MainWindow.axaml.cs new file mode 100644 index 0000000..6e40650 --- /dev/null +++ b/src/Junction.App/MainWindow.axaml.cs @@ -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); + } +} diff --git a/src/Junction.App/Program.cs b/src/Junction.App/Program.cs new file mode 100644 index 0000000..8c64985 --- /dev/null +++ b/src/Junction.App/Program.cs @@ -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() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} diff --git a/src/Junction.App/ViewLocator.cs b/src/Junction.App/ViewLocator.cs new file mode 100644 index 0000000..5a3e0ff --- /dev/null +++ b/src/Junction.App/ViewLocator.cs @@ -0,0 +1,35 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Junction.App.ViewModels; + +namespace Junction.App +{ + /// + /// Convention view locator: ViewModels.XxxViewModel → Views.XxxView. + /// Registered in App.axaml DataTemplates so a bound view-model renders its matching view. + /// + 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; + } +} diff --git a/src/Junction.App/ViewModels/DashboardViewModel.cs b/src/Junction.App/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..11f022a --- /dev/null +++ b/src/Junction.App/ViewModels/DashboardViewModel.cs @@ -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 +{ + /// + /// Live machine dashboard. Loads configured machines from the repository, seeds each row from + /// the monitor's latest-snapshot cache, and refreshes rows as + /// fires. Snapshot events arrive on poll-loop threads and are marshalled onto the UI thread. + /// + public sealed partial class DashboardViewModel : ViewModelBase, IDisposable + { + private readonly IMachineRepository _repository; + private readonly IMachineMonitor _monitor; + private readonly ILogger _logger; + private readonly Dictionary _rowsById = new Dictionary(); + private bool _subscribed; + private bool _disposed; + + public string Title => "Junction — Machines"; + + public ObservableCollection Machines { get; } = new ObservableCollection(); + + public DashboardViewModel(IMachineRepository repository, IMachineMonitor monitor, ILogger logger) + { + _repository = repository; + _monitor = monitor; + _logger = logger; + } + + /// Loads machines, seeds latest snapshots, and subscribes to live updates. + 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; + } + } + } +} diff --git a/src/Junction.App/ViewModels/MachineRowViewModel.cs b/src/Junction.App/ViewModels/MachineRowViewModel.cs new file mode 100644 index 0000000..64c4efa --- /dev/null +++ b/src/Junction.App/ViewModels/MachineRowViewModel.cs @@ -0,0 +1,83 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using Junction.Domain.Models; + +namespace Junction.App.ViewModels +{ + /// + /// One dashboard row = one configured machine + its latest snapshot projection. + /// Mutated only on the UI thread (see marshalling). + /// + 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 = "—"; + } + + /// Projects a snapshot onto this row. Call on the UI thread. + public void Apply(MachineSnapshot snapshot) + { + if (snapshot == null) + { + return; + } + + ConnectionState = snapshot.ConnectionState; + LastUpdated = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss"); + LastDatum = Representative(snapshot); + } + + /// + /// Picks a human-meaningful datum to show: prefer an availability/execution item, + /// else the first item's value, else an em-dash placeholder. + /// + 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; + } + } +} diff --git a/src/Junction.App/ViewModels/MainWindowViewModel.cs b/src/Junction.App/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..c406baf --- /dev/null +++ b/src/Junction.App/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,22 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Junction.App.ViewModels +{ + /// + /// Shell view-model. Hosts the currently shown page. Only the dashboard is used this slice; + /// is the seam for detail (T21) / config (T22) navigation. + /// + public sealed partial class MainWindowViewModel : ViewModelBase + { + [ObservableProperty] + private object? _currentPage; + + public MainWindowViewModel(DashboardViewModel dashboard) + { + _currentPage = dashboard; + } + + /// Navigation seam. Swaps the hosted page. (Only Dashboard wired now.) + public void Navigate(object viewModel) => CurrentPage = viewModel; + } +} diff --git a/src/Junction.App/ViewModels/ViewModelBase.cs b/src/Junction.App/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..e917805 --- /dev/null +++ b/src/Junction.App/ViewModels/ViewModelBase.cs @@ -0,0 +1,9 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Junction.App.ViewModels +{ + /// Base for all view-models. INotifyPropertyChanged via CommunityToolkit.Mvvm. + public abstract class ViewModelBase : ObservableObject + { + } +} diff --git a/src/Junction.App/Views/DashboardView.axaml b/src/Junction.App/Views/DashboardView.axaml new file mode 100644 index 0000000..6e7e244 --- /dev/null +++ b/src/Junction.App/Views/DashboardView.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Junction.App/Views/DashboardView.axaml.cs b/src/Junction.App/Views/DashboardView.axaml.cs new file mode 100644 index 0000000..91782b8 --- /dev/null +++ b/src/Junction.App/Views/DashboardView.axaml.cs @@ -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); + } +} diff --git a/src/Junction.App/nlog.config b/src/Junction.App/nlog.config new file mode 100644 index 0000000..db258da --- /dev/null +++ b/src/Junction.App/nlog.config @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Junction.App/plugins/mtconnect/Junction.Protocols.MTConnect.dll b/src/Junction.App/plugins/mtconnect/Junction.Protocols.MTConnect.dll new file mode 100644 index 0000000..559eb89 Binary files /dev/null and b/src/Junction.App/plugins/mtconnect/Junction.Protocols.MTConnect.dll differ diff --git a/src/Junction.App/plugins/mtconnect/plugin.manifest.json b/src/Junction.App/plugins/mtconnect/plugin.manifest.json new file mode 100644 index 0000000..26ed967 --- /dev/null +++ b/src/Junction.App/plugins/mtconnect/plugin.manifest.json @@ -0,0 +1,7 @@ +{ + "protocolId": "mtconnect", + "displayName": "MTConnect", + "assemblyFile": "Junction.Protocols.MTConnect.dll", + "entryTypeName": "Junction.Protocols.MTConnect.MtconnectDriverFactory", + "apiVersion": "1.0" +} diff --git a/src/Junction.Core/Junction.Core.csproj b/src/Junction.Core/Junction.Core.csproj new file mode 100644 index 0000000..305703a --- /dev/null +++ b/src/Junction.Core/Junction.Core.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + Junction.Core + + + + + + + + + + + + + diff --git a/src/Junction.Core/Monitoring/IMachineMonitor.cs b/src/Junction.Core/Monitoring/IMachineMonitor.cs new file mode 100644 index 0000000..89506a3 --- /dev/null +++ b/src/Junction.Core/Monitoring/IMachineMonitor.cs @@ -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 +{ + /// + /// Top-level orchestration hub: loads protocol plugins, resolves a driver per configured + /// machine, and runs one poll loop per + /// machine. Persists every produced snapshot, keeps an in-memory latest-snapshot cache for + /// the dashboard, and raises as fresh data arrives. + /// + /// 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 . + /// + /// + public interface IMachineMonitor + { + /// + /// 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. + /// + event EventHandler SnapshotUpdated; + + /// + /// Live, thread-safe view of the most recent snapshot per machine (keyed by + /// ). Reads are safe from any thread; the dashboard uses this + /// to render each machine's last datum. + /// + IReadOnlyDictionary LatestSnapshots { get; } + + /// + /// Loads plugins from , loads configured machines, + /// and starts a poll loop for each machine with a matching protocol driver. + /// + /// Returns even when some machines were skipped (partial start). + /// Returns only on hard infrastructure failures (plugin + /// directory missing/invalid, repository read failure). Returns + /// if cancellation trips before start completes. + /// + /// + Task StartAsync(string pluginsDirectory, CancellationToken cancellationToken); + + /// + /// Cancels every running poll loop and awaits their clean shutdown. Safe to call when + /// not started. After it returns, no further snapshots are produced. + /// + Task StopAsync(); + } +} diff --git a/src/Junction.Core/Monitoring/MachineMonitor.cs b/src/Junction.Core/Monitoring/MachineMonitor.cs new file mode 100644 index 0000000..3cfa0ff --- /dev/null +++ b/src/Junction.Core/Monitoring/MachineMonitor.cs @@ -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 +{ + /// + /// Default . See the interface for the orchestration contract + /// and partial-start policy. + /// + /// PollingEngine-per-machine: each machine gets its own built + /// from an injected factory. The factory keeps the + /// monitor decoupled from engine construction (and from + /// wiring), which makes the monitor trivially unit-testable with a stub engine. DI registers + /// the default factory as () => new PollingEngine(loggerFactory.CreateLogger<PollingEngine>()). + /// + /// + /// Thread-safety: snapshots arrive concurrently from multiple poll loops. The latest-snapshot + /// cache is a (lock-free reads for + /// the dashboard), and the event is raised through a captured + /// local delegate. Start/Stop mutate the running-loop set under a private lock. + /// + /// + public sealed class MachineMonitor : IMachineMonitor + { + private const string Source = "MachineMonitor"; + + private readonly IMachineRepository _repository; + private readonly IPluginLoader _pluginLoader; + private readonly Func _engineFactory; + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _snapshots = + new ConcurrentDictionary(); + + private readonly object _lifecycleLock = new object(); + private readonly List _running = new List(); + + public MachineMonitor( + IMachineRepository repository, + IPluginLoader pluginLoader, + Func engineFactory, + ILogger 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)); + } + + /// + public event EventHandler? SnapshotUpdated; + + /// + public IReadOnlyDictionary LatestSnapshots => _snapshots; + + /// + public async Task 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> 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> 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 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(); + } + + /// + public async Task StopAsync() + { + List loops; + lock (_lifecycleLock) + { + loops = new List(_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); + } + + /// + /// 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. + /// + private void OnSnapshot(MachineSnapshot snapshot, CancellationToken cancellationToken) + { + _snapshots[snapshot.MachineId] = snapshot; + + EventHandler? 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); + } + } + + /// + /// Maps ProtocolId (case-insensitive) to factory. On duplicate protocol ids the first + /// loaded plugin wins; the collision is logged. + /// + private Dictionary BuildFactoryMap(IReadOnlyList plugins) + { + var map = new Dictionary(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 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); + } + + /// A running per-machine poll loop with its cancellation source. + 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; + } + } + } +} diff --git a/src/Junction.Core/Plugins/IPluginLoader.cs b/src/Junction.Core/Plugins/IPluginLoader.cs new file mode 100644 index 0000000..c79c110 --- /dev/null +++ b/src/Junction.Core/Plugins/IPluginLoader.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using Junction.Domain; + +namespace Junction.Core.Plugins +{ + /// + /// Discovers and loads protocol plugins from a directory at host startup. + /// Implementations use + /// (net48-compatible; no AssemblyLoadContext, no runtime unload). Reflection stays + /// inside the loader so the rest of the app stays reflection-free. + /// + public interface IPluginLoader + { + /// + /// Scan (and its immediate subdirectories) for + /// plugin.manifest.json sidecars and load each declared plugin. + /// + /// Partial-success policy: a single plugin failing to load (missing/bad manifest, + /// dll not found, entry type not found or not an , + /// ctor throwing) is logged and skipped; the remaining plugins still load. The call + /// returns with the successfully-loaded subset even when + /// some plugins failed. It returns only when the + /// plugins directory itself is missing. It never throws to the caller. + /// + /// + Result> LoadFrom(string pluginsDirectory); + } +} diff --git a/src/Junction.Core/Plugins/LoadedPlugin.cs b/src/Junction.Core/Plugins/LoadedPlugin.cs new file mode 100644 index 0000000..aa16f7d --- /dev/null +++ b/src/Junction.Core/Plugins/LoadedPlugin.cs @@ -0,0 +1,25 @@ +using Junction.Domain.Protocols; + +namespace Junction.Core.Plugins +{ + /// + /// A successfully loaded protocol plugin: the Domain + /// (pure data: manifest + resolved assembly path) paired with the Core-level resolved + /// instance created via reflection. + /// The reflection concern lives here in Core so the Domain descriptor stays Type-free. + /// + public sealed class LoadedPlugin + { + /// The plugin's manifest + resolved assembly path. + public PluginDescriptor Descriptor { get; } + + /// The instantiated factory entrypoint for this plugin. + public IProtocolDriverFactory Factory { get; } + + public LoadedPlugin(PluginDescriptor descriptor, IProtocolDriverFactory factory) + { + Descriptor = descriptor; + Factory = factory; + } + } +} diff --git a/src/Junction.Core/Plugins/PluginLoader.cs b/src/Junction.Core/Plugins/PluginLoader.cs new file mode 100644 index 0000000..ff08d8e --- /dev/null +++ b/src/Junction.Core/Plugins/PluginLoader.cs @@ -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 +{ + /// + /// Default . Discovers plugin.manifest.json sidecars + /// under a plugins directory, loads each declared assembly via + /// (net48-compatible; no AssemblyLoadContext, + /// no runtime unload), instantiates its entry via + /// reflection, and returns the successfully-loaded set. Per-plugin failures are logged + /// and skipped (see for the partial-success policy). + /// + public sealed class PluginLoader : IPluginLoader + { + /// Well-known sidecar file name discovered beside each plugin dll. + public const string ManifestFileName = "plugin.manifest.json"; + + private const string Source = "PluginLoader"; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger; + + public PluginLoader(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public Result> 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>.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>.Fail(err); + } + + var loaded = new List(); + + 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>.Ok(loaded); + } + + /// + /// Candidate manifest paths: the root directory itself, plus each immediate + /// subdirectory (the conventional one-directory-per-plugin layout). + /// + private static IEnumerable 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 TryLoadPlugin(string manifestPath) + { + PluginManifest manifest; + try + { + var json = File.ReadAllText(manifestPath); + var dto = JsonSerializer.Deserialize(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.Ok(new LoadedPlugin(descriptor, factory)); + } + + private static Result Fail(string code, string message) => + Result.Fail(new OperationError(code, Source, message)); + + /// + /// Internal DTO used only to deserialize the attribute-free + /// shape with case-insensitive property matching. + /// + 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; } + } + } +} diff --git a/src/Junction.Core/Polling/IPollingEngine.cs b/src/Junction.Core/Polling/IPollingEngine.cs new file mode 100644 index 0000000..a6965dc --- /dev/null +++ b/src/Junction.Core/Polling/IPollingEngine.cs @@ -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 +{ + /// + /// Drives the poll loop for a SINGLE machine: repeatedly reads the machine's current + /// values through its and forwards each successful + /// to a caller-supplied callback. + /// + /// One instance polls one machine. A higher-level component (MachineMonitor) owns + /// one engine per machine. + /// + /// + /// Fault tolerance: a driver read that fails is logged and skipped; the loop keeps + /// running. Only cancellation (via the token or a cancelled ) + /// stops the loop, and it stops cleanly without leaking an exception. + /// + /// + public interface IPollingEngine + { + /// + /// Runs the poll loop until is cancelled. + /// The returned represents the running loop; awaiting it + /// completes (never faults on driver errors or cancellation) once the loop stops. + /// + /// Machine to poll. Supplies the poll interval. + /// Driver bound to . + /// + /// Invoked once per successful read with the produced snapshot. Never invoked for + /// failed or cancelled reads. + /// + /// Stops the loop when cancelled. + Task RunAsync( + Machine machine, + IProtocolDriver driver, + Action onSnapshot, + CancellationToken cancellationToken); + } +} diff --git a/src/Junction.Core/Polling/PollingEngine.cs b/src/Junction.Core/Polling/PollingEngine.cs new file mode 100644 index 0000000..101a96b --- /dev/null +++ b/src/Junction.Core/Polling/PollingEngine.cs @@ -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 +{ + /// + /// Default . Single-machine poll loop. + /// + /// API shape: a RunAsync(machine, driver, onSnapshot, ct) method taking an + /// 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. + /// + /// + /// The loop never throws for control flow: driver failures are logged and skipped, + /// cancellation exits cleanly. + /// + /// + public sealed class PollingEngine : IPollingEngine + { + private readonly ILogger _logger; + + public PollingEngine(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task RunAsync( + Machine machine, + IProtocolDriver driver, + Action 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 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 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); + } + } +} diff --git a/src/Junction.Core/ServiceCollectionExtensions.cs b/src/Junction.Core/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..66d7f1d --- /dev/null +++ b/src/Junction.Core/ServiceCollectionExtensions.cs @@ -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 +{ + /// + /// DI registration for the Junction.Core orchestration stack. + /// + public static class ServiceCollectionExtensions + { + /// + /// Registers the Core services: + /// + /// (singleton; scanned once at startup). + /// (transient; one loop per machine), + /// plus a factory the monitor uses to build engines on demand. + /// (singleton; the app-wide hub). + /// + /// Does NOT register IMachineRepository (that is AddJunctionPersistence) nor any concrete + /// logging provider (the app host wires NLog). Logging is consumed via the + /// Microsoft.Extensions.Logging abstractions only. + /// + /// The service collection to add to. + /// The same collection, for chaining. + /// is null. + public static IServiceCollection AddJunctionCore(this IServiceCollection services) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddSingleton(); + + // One engine per machine: transient, plus a factory the monitor calls per machine. + services.AddTransient(); + services.AddSingleton>(sp => () => sp.GetRequiredService()); + + services.AddSingleton(); + + return services; + } + } +} diff --git a/src/Junction.Domain/Junction.Domain.csproj b/src/Junction.Domain/Junction.Domain.csproj new file mode 100644 index 0000000..a488e91 --- /dev/null +++ b/src/Junction.Domain/Junction.Domain.csproj @@ -0,0 +1,8 @@ + + + + netstandard2.0 + Junction.Domain + + + diff --git a/src/Junction.Domain/Models/ConnectionState.cs b/src/Junction.Domain/Models/ConnectionState.cs new file mode 100644 index 0000000..58b44eb --- /dev/null +++ b/src/Junction.Domain/Models/ConnectionState.cs @@ -0,0 +1,15 @@ +namespace Junction.Domain.Models +{ + /// + /// Protocol-agnostic connection status of a machine. + /// MTConnect availability (AVAILABLE/UNAVAILABLE) maps onto Connected/Disconnected. + /// + public enum ConnectionState + { + Unknown = 0, + Connecting = 1, + Connected = 2, + Disconnected = 3, + Error = 4 + } +} diff --git a/src/Junction.Domain/Models/DataItem.cs b/src/Junction.Domain/Models/DataItem.cs new file mode 100644 index 0000000..2cdf2f5 --- /dev/null +++ b/src/Junction.Domain/Models/DataItem.cs @@ -0,0 +1,41 @@ +using System; + +namespace Junction.Domain.Models +{ + /// + /// One current telemetry/sample value read from a machine (only-latest, no history). + /// Immutable, protocol-agnostic. + /// + public sealed class DataItem + { + /// Stable identifier of the datum (e.g. MTConnect dataItemId). + public string Id { get; } + + /// Human-readable name of the datum. + public string Name { get; } + + /// + /// Value kept as string to stay type-agnostic across protocols; + /// callers parse to the concrete type they need. + /// + public string Value { get; } + + /// + /// 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. + /// + public string Category { get; } + + /// Instant the value was reported/observed. + 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; + } + } +} diff --git a/src/Junction.Domain/Models/Machine.cs b/src/Junction.Domain/Models/Machine.cs new file mode 100644 index 0000000..6135df0 --- /dev/null +++ b/src/Junction.Domain/Models/Machine.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Junction.Domain.Models +{ + /// + /// 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. + /// + public sealed class Machine + { + private static readonly IReadOnlyDictionary EmptyConfig = + new Dictionary(0); + + /// Stable unique identifier. + public Guid Id { get; } + + /// Human-readable name. + public string Name { get; } + + /// Protocol identifier matching the driver/plugin (e.g. "mtconnect"). + public string ProtocolId { get; } + + /// Generic connection config. Never null; the protocol validates its own keys. + public IReadOnlyDictionary ConnectionConfig { get; } + + /// How often to poll the machine. + public TimeSpan PollInterval { get; } + + public Machine( + Guid id, + string name, + string protocolId, + IReadOnlyDictionary? connectionConfig, + TimeSpan pollInterval) + { + Id = id; + Name = name ?? ""; + ProtocolId = protocolId ?? ""; + ConnectionConfig = connectionConfig ?? EmptyConfig; + PollInterval = pollInterval; + } + + /// Returns a copy with the given fields overridden; null args keep the current value. + public Machine With( + string? name = null, + string? protocolId = null, + IReadOnlyDictionary? connectionConfig = null, + TimeSpan? pollInterval = null) + { + return new Machine( + Id, + name ?? Name, + protocolId ?? ProtocolId, + connectionConfig ?? ConnectionConfig, + pollInterval ?? PollInterval); + } + } +} diff --git a/src/Junction.Domain/Models/MachineSnapshot.cs b/src/Junction.Domain/Models/MachineSnapshot.cs new file mode 100644 index 0000000..bc13ca5 --- /dev/null +++ b/src/Junction.Domain/Models/MachineSnapshot.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; + +namespace Junction.Domain.Models +{ + /// + /// Full current read of a single machine (only-latest datums, no history). + /// Immutable. is never null. + /// + public sealed class MachineSnapshot + { + private static readonly IReadOnlyList EmptyItems = new DataItem[0]; + + /// Identifier of the machine this snapshot belongs to. + public Guid MachineId { get; } + + /// Instant the snapshot was captured. + public DateTimeOffset CapturedAt { get; } + + /// Connection status at capture time. + public ConnectionState ConnectionState { get; } + + /// Current datums. Never null; empty is allowed. + public IReadOnlyList Items { get; } + + public MachineSnapshot( + Guid machineId, + DateTimeOffset capturedAt, + ConnectionState connectionState, + IReadOnlyList? items) + { + MachineId = machineId; + CapturedAt = capturedAt; + ConnectionState = connectionState; + Items = items ?? EmptyItems; + } + + /// True when the snapshot carries no datums. + public bool IsEmpty => Items.Count == 0; + + /// + /// Returns the datum with the given , or null if absent. + /// Used by the dashboard to display a specific "last datum". + /// + 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; + } + } +} diff --git a/src/Junction.Domain/OperationError.cs b/src/Junction.Domain/OperationError.cs new file mode 100644 index 0000000..e2e0f11 --- /dev/null +++ b/src/Junction.Domain/OperationError.cs @@ -0,0 +1,28 @@ +using System; + +namespace Junction.Domain +{ + /// Immutable error describing a single failure of an operation. + 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; + } + + /// Convenience factory for an error without a specific code. + public static OperationError Of(string source, string message) => + new OperationError("", source, message); + + public override string ToString() => + "[" + Code + "] " + Source + ": " + Message; + } +} diff --git a/src/Junction.Domain/Persistence/IMachineRepository.cs b/src/Junction.Domain/Persistence/IMachineRepository.cs new file mode 100644 index 0000000..9f1d524 --- /dev/null +++ b/src/Junction.Domain/Persistence/IMachineRepository.cs @@ -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 +{ + /// + /// 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. + /// + /// + /// 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 types and BCL primitives cross this port. + /// Expected failures (not found, store unavailable, constraint violation) are reported via + /// the / return types, not by throwing. + /// Cancellation is surfaced through / . + /// + public interface IMachineRepository + { + /// Returns all configured machines. Empty list when none; never a null value on success. + Task>> GetAllAsync(CancellationToken cancellationToken); + + /// Returns the machine with the given id, or a failed result when absent. + Task> GetByIdAsync(Guid id, CancellationToken cancellationToken); + + /// Inserts or updates the machine (keyed by ). + Task UpsertAsync(Machine machine, CancellationToken cancellationToken); + + /// Deletes the machine with the given id. + Task DeleteAsync(Guid id, CancellationToken cancellationToken); + + /// + /// Stores the snapshot as the latest for its machine, overwriting any previous one. + /// Only-latest policy: no history is retained. + /// + Task SaveSnapshotAsync(MachineSnapshot snapshot, CancellationToken cancellationToken); + + /// Returns the latest stored snapshot for the machine, or a failed result when none exists. + Task> GetLatestSnapshotAsync(Guid machineId, CancellationToken cancellationToken); + } +} diff --git a/src/Junction.Domain/Protocols/IProtocolDriver.cs b/src/Junction.Domain/Protocols/IProtocolDriver.cs new file mode 100644 index 0000000..1c30196 --- /dev/null +++ b/src/Junction.Domain/Protocols/IProtocolDriver.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using Junction.Domain.Models; + +namespace Junction.Domain.Protocols +{ + /// + /// A per-machine protocol driver. Bound to a single at creation + /// time (via ) and used to read that machine's + /// current values. One instance per machine. + /// + public interface IProtocolDriver + { + /// Protocol identifier this driver serves (e.g. "mtconnect"). + string ProtocolId { get; } + + /// + /// Read the current values of the machine this driver was created for. + /// Returns a failed on error, cancelled when the token trips. + /// + /// Cancellation token. Mandatory. + Task> ReadCurrentAsync(CancellationToken cancellationToken); + } +} diff --git a/src/Junction.Domain/Protocols/IProtocolDriverFactory.cs b/src/Junction.Domain/Protocols/IProtocolDriverFactory.cs new file mode 100644 index 0000000..6d37ce2 --- /dev/null +++ b/src/Junction.Domain/Protocols/IProtocolDriverFactory.cs @@ -0,0 +1,23 @@ +using Junction.Domain.Models; + +namespace Junction.Domain.Protocols +{ + /// + /// Plugin entrypoint contract. The plugin loader resolves this type from a loaded + /// assembly and uses it to build per-machine instances. + /// The factory owns protocol-specific config validation, keeping the Domain + /// protocol-agnostic (config is a generic string bag on ). + /// + public interface IProtocolDriverFactory + { + /// Protocol identifier this factory produces drivers for (e.g. "mtconnect"). + string ProtocolId { get; } + + /// + /// Build a driver bound to the given machine's . + /// Validates protocol-specific config here; returns failure + /// on bad or missing keys. + /// + Result Create(Machine machine); + } +} diff --git a/src/Junction.Domain/Protocols/PluginDescriptor.cs b/src/Junction.Domain/Protocols/PluginDescriptor.cs new file mode 100644 index 0000000..151b41f --- /dev/null +++ b/src/Junction.Domain/Protocols/PluginDescriptor.cs @@ -0,0 +1,23 @@ +namespace Junction.Domain.Protocols +{ + /// + /// Immutable POCO pairing a validated with the resolved + /// absolute path of its assembly on disk. Produced by the Core loader after locating + /// the plugin. Deliberately holds no loaded or factory + /// instance (that is a Core loader concern) so the Domain stays reflection-free. + /// + public sealed class PluginDescriptor + { + /// The plugin's declared manifest. + public PluginManifest Manifest { get; } + + /// Absolute path to the plugin assembly (dll) on disk. + public string AssemblyPath { get; } + + public PluginDescriptor(PluginManifest manifest, string assemblyPath) + { + Manifest = manifest; + AssemblyPath = assemblyPath ?? ""; + } + } +} diff --git a/src/Junction.Domain/Protocols/PluginManifest.cs b/src/Junction.Domain/Protocols/PluginManifest.cs new file mode 100644 index 0000000..916e698 --- /dev/null +++ b/src/Junction.Domain/Protocols/PluginManifest.cs @@ -0,0 +1,44 @@ +namespace Junction.Domain.Protocols +{ + /// + /// Immutable POCO describing a protocol plugin, as declared in its + /// plugin.manifest.json 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 + /// members so the Domain stays package-free. + /// + public sealed class PluginManifest + { + /// Protocol identifier this plugin provides (e.g. "mtconnect"). + public string ProtocolId { get; } + + /// Human-readable plugin name for UI/logging. + public string DisplayName { get; } + + /// File name of the plugin assembly (dll), relative to the manifest. + public string AssemblyFile { get; } + + /// + /// Fully-qualified type name of the + /// implementation to instantiate as the plugin entrypoint. + /// + public string EntryTypeName { get; } + + /// Plugin contract (API) version this plugin was built against (e.g. "1.0"). + 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 ?? ""; + } + } +} diff --git a/src/Junction.Domain/Result.cs b/src/Junction.Domain/Result.cs new file mode 100644 index 0000000..46e5679 --- /dev/null +++ b/src/Junction.Domain/Result.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Junction.Domain +{ + /// Result wrapper for operations that produce a value. + public sealed class Result + { + private readonly T _value; + + public bool IsSuccess { get; } + public bool WasCancelled { get; } + public IReadOnlyList Errors { get; } + + /// The produced value. Meaningful only when is true; otherwise default. + public T Value => _value; + + private Result(bool isSuccess, T value, IReadOnlyList? errors, bool cancelled) + { + IsSuccess = isSuccess; + _value = value; + Errors = errors ?? (IReadOnlyList)Array.Empty(); + WasCancelled = cancelled; + } + + public static Result Ok(T value) => + new Result(true, value, null, false); + + public static Result Fail(OperationError error) => + new Result(false, default!, new[] { error }, false); + + public static Result Fail(IEnumerable errors) => + new Result(false, default!, errors.ToArray(), false); + + public static Result Cancelled() => + new Result(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"; + } + } + + /// Result wrapper for void-style operations (no produced value). + public sealed class Result + { + public bool IsSuccess { get; } + public bool WasCancelled { get; } + public IReadOnlyList Errors { get; } + + private Result(bool isSuccess, IReadOnlyList? errors, bool cancelled) + { + IsSuccess = isSuccess; + Errors = errors ?? (IReadOnlyList)Array.Empty(); + 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 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"; + } + } +} diff --git a/src/Junction.Persistence/ConnectionFactory.cs b/src/Junction.Persistence/ConnectionFactory.cs new file mode 100644 index 0000000..5b6b17a --- /dev/null +++ b/src/Junction.Persistence/ConnectionFactory.cs @@ -0,0 +1,50 @@ +using System; +using System.Data; +using Microsoft.Data.Sqlite; + +namespace Junction.Persistence +{ + /// + /// 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 . + /// + public interface IConnectionFactory + { + /// + /// Creates and opens a new connection. Caller owns the returned connection and + /// must dispose it. + /// + IDbConnection CreateOpenConnection(); + } + + /// + /// SQLite-backed . Central place the connection string + /// lives; the rest of the app never sees a . + /// + public sealed class SqliteConnectionFactory : IConnectionFactory + { + private readonly string _connectionString; + + /// + /// A Microsoft.Data.Sqlite connection string (e.g. "Data Source=junction.db"). + /// + public SqliteConnectionFactory(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Connection string must not be empty.", nameof(connectionString)); + } + + _connectionString = connectionString; + } + + /// + public IDbConnection CreateOpenConnection() + { + var connection = new SqliteConnection(_connectionString); + connection.Open(); + return connection; + } + } +} diff --git a/src/Junction.Persistence/Junction.Persistence.csproj b/src/Junction.Persistence/Junction.Persistence.csproj new file mode 100644 index 0000000..79eae1d --- /dev/null +++ b/src/Junction.Persistence/Junction.Persistence.csproj @@ -0,0 +1,25 @@ + + + + netstandard2.0 + Junction.Persistence + + win-x64;win-x86 + + + + + + + + + + + + + + + + + + diff --git a/src/Junction.Persistence/ServiceCollectionExtensions.cs b/src/Junction.Persistence/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a322ac1 --- /dev/null +++ b/src/Junction.Persistence/ServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using System; +using Junction.Domain; +using Junction.Domain.Persistence; +using Microsoft.Extensions.DependencyInjection; + +namespace Junction.Persistence +{ + /// + /// DI registration for the SQLite-backed persistence adapter. + /// + public static class ServiceCollectionExtensions + { + /// + /// Registers the SQLite persistence stack: + /// () and + /// (), and ensures the + /// schema exists (idempotent) at registration time. + /// + /// The service collection to add to. + /// A Microsoft.Data.Sqlite connection string (e.g. "Data Source=junction.db"). + /// The same collection, for chaining. + /// is null. + /// is null/empty. + /// Schema initialization failed. + 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(connectionFactory); + services.AddSingleton(); + + return services; + } + } +} diff --git a/src/Junction.Persistence/SqliteMachineRepository.cs b/src/Junction.Persistence/SqliteMachineRepository.cs new file mode 100644 index 0000000..0c69716 --- /dev/null +++ b/src/Junction.Persistence/SqliteMachineRepository.cs @@ -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 +{ + /// + /// Dapper + SQLite adapter for . + /// + /// + /// Provider-swap seam: all SQLite/Dapper/ADO.NET types stay inside this class. Only + /// types and BCL primitives cross the boundary. + /// + /// Column mapping (see DDL): + /// + /// <-> TEXT via ToString("D") (canonical 8-4-4-4-12). + /// <-> INTEGER ticks (). + /// <-> TEXT JSON of a Dictionary<string,string> (System.Text.Json). Null/blank JSON rehydrates to an empty dictionary. + /// <-> TEXT ISO 8601 round-trip ("O"). + /// <-> INTEGER (enum cast). + /// + /// + /// Not-found policy: and return + /// Result.Fail carrying an with code "not_found" — never throw, + /// never a success with a null value. + /// + /// Errors: expected failures are reported via /. Store + /// exceptions are caught and mapped to Result.Fail; cancellation is mapped to Result.Cancelled. + /// + 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>> GetAllAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Result>.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(command).ConfigureAwait(false); + + var machines = new List(); + foreach (var row in rows) + { + machines.Add(MapMachine(row)); + } + + return Result>.Ok(machines); + } + } + catch (OperationCanceledException) + { + return Result>.Cancelled(); + } + catch (Exception ex) + { + return Result>.Fail( + OperationError.Of(Source, "GetAll failed: " + ex.Message)); + } + } + + public async Task> GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Result.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(command).ConfigureAwait(false); + + if (row == null) + { + return Result.Fail(new OperationError( + NotFoundCode, Source, "Machine not found: " + GuidText(id))); + } + + return Result.Ok(MapMachine(row)); + } + } + catch (OperationCanceledException) + { + return Result.Cancelled(); + } + catch (Exception ex) + { + return Result.Fail( + OperationError.Of(Source, "GetById failed: " + ex.Message)); + } + } + + public async Task 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 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 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(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> GetLatestSnapshotAsync(Guid machineId, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Result.Cancelled(); + } + + try + { + using (var connection = OpenConnection()) + { + var machineIdText = GuidText(machineId); + + var header = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + "SELECT MachineId, CapturedAt, ConnectionState FROM latest_snapshots WHERE MachineId = @MachineId;", + new { MachineId = machineIdText }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + + if (header == null) + { + return Result.Fail(new OperationError( + NotFoundCode, Source, "Snapshot not found for machine: " + machineIdText)); + } + + var itemRows = await connection.QueryAsync(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(); + 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.Ok(snapshot); + } + } + catch (OperationCanceledException) + { + return Result.Cancelled(); + } + catch (Exception ex) + { + return Result.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 config) + { + Dictionary dict; + if (config is Dictionary concrete) + { + dict = concrete; + } + else + { + dict = new Dictionary(config.Count); + foreach (var pair in config) + { + dict[pair.Key] = pair.Value; + } + } + + return JsonSerializer.Serialize(dict, JsonOptions); + } + + private static IReadOnlyDictionary DeserializeConfig(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new Dictionary(0); + } + + var dict = JsonSerializer.Deserialize>(json!, JsonOptions); + return dict ?? new Dictionary(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; } = ""; + } + } +} diff --git a/src/Junction.Persistence/SqliteSchema.cs b/src/Junction.Persistence/SqliteSchema.cs new file mode 100644 index 0000000..f141432 --- /dev/null +++ b/src/Junction.Persistence/SqliteSchema.cs @@ -0,0 +1,104 @@ +using System; +using System.Data; +using Junction.Domain; + +namespace Junction.Persistence +{ + /// + /// 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. + /// + 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) + );"; + + /// + /// Creates all tables if they do not already exist. Idempotent. + /// Returns a failed instead of throwing on store errors. + /// + 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)); + } + } + + /// Convenience overload: opens a connection from the factory and runs . + 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(); + } + } + } +} diff --git a/src/Junction.Protocols.MTConnect/Junction.Protocols.MTConnect.csproj b/src/Junction.Protocols.MTConnect/Junction.Protocols.MTConnect.csproj new file mode 100644 index 0000000..a30c202 --- /dev/null +++ b/src/Junction.Protocols.MTConnect/Junction.Protocols.MTConnect.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + Junction.Protocols.MTConnect + + + + + + + + + + + + diff --git a/src/Junction.Protocols.MTConnect/MtconnectDriver.cs b/src/Junction.Protocols.MTConnect/MtconnectDriver.cs new file mode 100644 index 0000000..30f79f3 --- /dev/null +++ b/src/Junction.Protocols.MTConnect/MtconnectDriver.cs @@ -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 +{ + /// + /// Per-machine MTConnect protocol driver. Reads the agent's /current probe stream + /// over HTTP and parses it into a via + /// . Network-only concern: parsing lives in T15 parsers. + /// + /// The is injected so the transport can be stubbed in tests + /// (no live network). The factory supplies a real client with a sane timeout. + /// + /// Never throws for expected transport/parse failures; maps them to + /// and honours cancellation via + /// . + /// + 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; + + /// Protocol identifier this driver serves. + public string ProtocolId => "mtconnect"; + + /// + /// Builds a driver bound to a single machine. + /// + /// Machine this driver reads for; stamped onto the snapshot. + /// Base MTConnect agent URL (e.g. "http://host:5000"). Non-null, absolute. + /// HTTP transport. Injected for testability; owned by the caller/factory. + 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); + } + + /// + public async Task> ReadCurrentAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Result.Cancelled(); + } + + HttpResponseMessage response; + try + { + response = await _http.GetAsync(_currentUri, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Caller-requested cancellation. + return Result.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.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 Fail(string code, string message) => + Result.Fail(new OperationError(code, Source, message)); + } +} diff --git a/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs b/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs new file mode 100644 index 0000000..00349b9 --- /dev/null +++ b/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs @@ -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 +{ + /// + /// Plugin entrypoint for the MTConnect protocol. The Core plugin loader (T12) resolves + /// this type by name from plugin.manifest.json and instantiates it via + /// , so it MUST have a public parameterless + /// constructor. + /// + /// Owns MTConnect-specific config validation and builds a real + /// for the produced , keeping the Domain protocol-agnostic. + /// + /// Expected keys (case-insensitive): + /// + /// AgentUrl (required) — absolute base URL of the MTConnect agent, e.g. "http://host:5000". + /// TimeoutSeconds (optional) — HTTP timeout in whole seconds; defaults to 10. + /// + /// + 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); + + /// Protocol identifier this factory produces drivers for. + public string ProtocolId => "mtconnect"; + + /// Required by the plugin loader (Activator.CreateInstance). + public MtconnectDriverFactory() + { + } + + /// + public Result 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.Ok(driver); + } + + private static string? GetValue(System.Collections.Generic.IReadOnlyDictionary 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 Fail(string code, string message) => + Result.Fail(new OperationError(code, Source, message)); + } +} diff --git a/src/Junction.Protocols.MTConnect/Parsing/MtconnectCurrentParser.cs b/src/Junction.Protocols.MTConnect/Parsing/MtconnectCurrentParser.cs new file mode 100644 index 0000000..0de5070 --- /dev/null +++ b/src/Junction.Protocols.MTConnect/Parsing/MtconnectCurrentParser.cs @@ -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 +{ + /// + /// Pure parser for an MTConnect current (MTConnectStreams) document. + /// Network-free: operates on an XML string. Version-agnostic: matches by local name only. + /// + 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"; + + /// + /// Parses a current XML document into a stamped with + /// . Never throws: malformed/unexpected input yields + /// . + /// + public static Result 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(); + 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.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 Fail(string code, string message) => + Result.Fail(new OperationError(code, Source, message)); + } +} diff --git a/src/Junction.Protocols.MTConnect/Parsing/MtconnectProbeParser.cs b/src/Junction.Protocols.MTConnect/Parsing/MtconnectProbeParser.cs new file mode 100644 index 0000000..70aae40 --- /dev/null +++ b/src/Junction.Protocols.MTConnect/Parsing/MtconnectProbeParser.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Xml; +using System.Xml.Linq; +using Junction.Domain; + +namespace Junction.Protocols.MTConnect.Parsing +{ + /// + /// Pure parser for an MTConnect probe (MTConnectDevices) document. + /// Network-free: operates on an XML string. Version-agnostic: matches by local name only. + /// + public static class MtconnectProbeParser + { + private const string Source = "MtconnectProbeParser"; + + /// + /// Parses a probe XML document into a flat list of + /// (every DataItem across every Device, at any nesting depth). + /// Never throws: malformed/unexpected input yields . + /// + public static Result> 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(); + + 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>.Ok(descriptors); + } + + private static Result> Fail(string code, string message) => + Result>.Fail(new OperationError(code, Source, message)); + } +} diff --git a/src/Junction.Protocols.MTConnect/Parsing/ProbeDataItemDescriptor.cs b/src/Junction.Protocols.MTConnect/Parsing/ProbeDataItemDescriptor.cs new file mode 100644 index 0000000..4e65897 --- /dev/null +++ b/src/Junction.Protocols.MTConnect/Parsing/ProbeDataItemDescriptor.cs @@ -0,0 +1,59 @@ +namespace Junction.Protocols.MTConnect.Parsing +{ + /// + /// 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. + /// + public sealed class ProbeDataItemDescriptor + { + /// id of the owning Device element. + public string DeviceId { get; } + + /// name of the owning Device element. + public string DeviceName { get; } + + /// uuid of the owning Device element. + public string DeviceUuid { get; } + + /// DataItem id (stable identifier used to correlate current observations). + public string Id { get; } + + /// DataItem name attribute (may be empty). + public string Name { get; } + + /// DataItem type (e.g. POSITION, EXECUTION, AVAILABILITY). + public string Type { get; } + + /// DataItem category (SAMPLE, EVENT, CONDITION). + public string Category { get; } + + /// DataItem units (may be empty). + public string Units { get; } + + /// DataItem subType (e.g. ACTUAL, COMMANDED; may be empty). + 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 ?? ""; + } + } +} diff --git a/src/Junction.Protocols.MTConnect/Parsing/XmlLocalName.cs b/src/Junction.Protocols.MTConnect/Parsing/XmlLocalName.cs new file mode 100644 index 0000000..d97d547 --- /dev/null +++ b/src/Junction.Protocols.MTConnect/Parsing/XmlLocalName.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace Junction.Protocols.MTConnect.Parsing +{ + /// + /// Local-name-only XML helpers. The whole MTConnect parser matches elements and attributes + /// by so it stays agnostic to the namespace VERSION + /// (urn:mtconnect.org:...:1.7 vs :2.0). No namespace URI is ever hardcoded. + /// + internal static class XmlLocalName + { + /// True when the element's local name matches (ordinal, case-sensitive). + public static bool Is(this XElement element, string localName) => + element.Name.LocalName == localName; + + /// Direct children whose local name equals . + public static IEnumerable ElementsLocal(this XElement element, string localName) => + element.Elements().Where(e => e.Name.LocalName == localName); + + /// All descendants (any depth) whose local name equals . + public static IEnumerable DescendantsLocal(this XElement element, string localName) => + element.Descendants().Where(e => e.Name.LocalName == localName); + + /// First direct or nested descendant with the given local name, or null. + public static XElement? FirstDescendantLocal(this XElement element, string localName) => + element.Descendants().FirstOrDefault(e => e.Name.LocalName == localName); + + /// Attribute value matched by local name (attributes are namespace-less here), or "". + public static string Attr(this XElement element, string localName) + { + foreach (var a in element.Attributes()) + { + if (a.Name.LocalName == localName) + { + return a.Value; + } + } + + return ""; + } + } +} diff --git a/src/Junction.Protocols.MTConnect/plugin.manifest.json b/src/Junction.Protocols.MTConnect/plugin.manifest.json new file mode 100644 index 0000000..26ed967 --- /dev/null +++ b/src/Junction.Protocols.MTConnect/plugin.manifest.json @@ -0,0 +1,7 @@ +{ + "protocolId": "mtconnect", + "displayName": "MTConnect", + "assemblyFile": "Junction.Protocols.MTConnect.dll", + "entryTypeName": "Junction.Protocols.MTConnect.MtconnectDriverFactory", + "apiVersion": "1.0" +} diff --git a/tests/Junction.Tests/Fixtures/mtconnect/current.xml b/tests/Junction.Tests/Fixtures/mtconnect/current.xml new file mode 100644 index 0000000..39d4b57 --- /dev/null +++ b/tests/Junction.Tests/Fixtures/mtconnect/current.xml @@ -0,0 +1,69 @@ + + +
+ + + + + AVAILABLE + + + + + 125.4300 + 125.5000 + 42.1 + + + + + -88.7600 + + + + + 15.0020 + + + + + 3200.0 + 3200.0 + 61.5 + + + SPINDLE + + + + + + + + AUTOMATIC + ARMED + + + + + + + + ACTIVE + O1234.NC + 142 + + + 85.0 + + + + + + + + diff --git a/tests/Junction.Tests/Fixtures/mtconnect/current_malformed.xml b/tests/Junction.Tests/Fixtures/mtconnect/current_malformed.xml new file mode 100644 index 0000000..85904a1 --- /dev/null +++ b/tests/Junction.Tests/Fixtures/mtconnect/current_malformed.xml @@ -0,0 +1,15 @@ + + +
+ + + + + 125.4300 + 42.1 + + + + + 3200.0 diff --git a/tests/Junction.Tests/Fixtures/mtconnect/current_unavailable.xml b/tests/Junction.Tests/Fixtures/mtconnect/current_unavailable.xml new file mode 100644 index 0000000..9bcc067 --- /dev/null +++ b/tests/Junction.Tests/Fixtures/mtconnect/current_unavailable.xml @@ -0,0 +1,69 @@ + + +
+ + + + + UNAVAILABLE + + + + + UNAVAILABLE + UNAVAILABLE + UNAVAILABLE + + + + + UNAVAILABLE + + + + + UNAVAILABLE + + + + + UNAVAILABLE + UNAVAILABLE + UNAVAILABLE + + + UNAVAILABLE + + + + + + + + UNAVAILABLE + UNAVAILABLE + + + + + + + + UNAVAILABLE + UNAVAILABLE + UNAVAILABLE + + + UNAVAILABLE + + + + + + + + diff --git a/tests/Junction.Tests/Fixtures/mtconnect/probe.xml b/tests/Junction.Tests/Fixtures/mtconnect/probe.xml new file mode 100644 index 0000000..0677d2b --- /dev/null +++ b/tests/Junction.Tests/Fixtures/mtconnect/probe.xml @@ -0,0 +1,68 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Junction.Tests/Integration/MtconnectEndToEndTests.cs b/tests/Junction.Tests/Integration/MtconnectEndToEndTests.cs new file mode 100644 index 0000000..cd9035b --- /dev/null +++ b/tests/Junction.Tests/Integration/MtconnectEndToEndTests.cs @@ -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 +{ + /// + /// Docker-gated end-to-end integration tests: real -> + /// live ladder99 MTConnect agent (docker mock at http://localhost:5000) -> snapshot + /// round-trip through the real SQLite repository. + /// + /// These tests assume the mock is ALREADY running (they do NOT auto-start docker): + /// docker compose -f mock/docker-compose.yml up -d. When the agent is not + /// reachable (no docker in CI, mock down) each test SKIPS (never fails), so a CI + /// without docker stays green. + /// + /// xUnit v2 2.9.3 has no Assert.Skip (v3-only), so a self-contained skippable-fact + /// discoverer ( + ) provides a + /// genuine runtime SKIP result — no extra NuGet package, no csproj change. + /// Filter with dotnet test --filter Category=Docker. + /// + [Trait("Category", "Docker")] + public sealed class MtconnectEndToEndTests + { + private const string AgentUrl = "http://localhost:5000"; + + /// + /// 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". + /// + 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; + } + } + + /// Skips the current test (does not fail) when the docker MTConnect mock is down. + 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 { ["AgentUrl"] = AgentUrl }, + TimeSpan.FromSeconds(1)); + + [DockerFact] + public async Task Driver_ReadsCurrent_FromLiveAgent_VersionAgnostic() + { + SkipIfAgentUnavailable(); + + var machine = NewMtconnectMachine(); + + var factory = new MtconnectDriverFactory(); + Result created = factory.Create(machine); + Assert.True(created.IsSuccess, Describe(created)); + + IProtocolDriver driver = created.Value; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + Result 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 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 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 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(Result 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. + // --------------------------------------------------------------------------------------- + + /// Thrown to skip a test at runtime (dynamic skip for xUnit v2). + public sealed class SkipTestException : Exception + { + public SkipTestException(string reason) : base(reason) { } + } + + /// A whose tests may skip at runtime via . + [XunitTestCaseDiscoverer("Junction.Tests.Integration.DockerFactDiscoverer", "Junction.Tests")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public sealed class DockerFactAttribute : FactAttribute + { + } + + /// Discovers -decorated methods as skippable test cases. + 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 Discover( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo factAttribute) + { + yield return new SkippableFactTestCase( + SkippingExceptionNames, + _diagnosticMessageSink, + discoveryOptions.MethodDisplayOrDefault(), + discoveryOptions.MethodDisplayOptionsOrDefault(), + testMethod); + } + } + + /// Test case that converts a designated exception into a skip result. + public sealed class SkippableFactTestCase : XunitTestCase + { + private string[] _skippingExceptionNames = Array.Empty(); + + [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(nameof(_skippingExceptionNames)); + } + + public override async Task 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; + } + } + + /// Rewrites carrying a skipping exception into . + 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(); + } +} diff --git a/tests/Junction.Tests/Junction.Tests.csproj b/tests/Junction.Tests/Junction.Tests.csproj new file mode 100644 index 0000000..cd57b89 --- /dev/null +++ b/tests/Junction.Tests/Junction.Tests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + Junction.Tests + false + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/tests/Junction.Tests/Unit/DataItemTests.cs b/tests/Junction.Tests/Unit/DataItemTests.cs new file mode 100644 index 0000000..a0dbc1d --- /dev/null +++ b/tests/Junction.Tests/Unit/DataItemTests.cs @@ -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)); + } + } +} diff --git a/tests/Junction.Tests/Unit/MachineMonitorTests.cs b/tests/Junction.Tests/Unit/MachineMonitorTests.cs new file mode 100644 index 0000000..e09228b --- /dev/null +++ b/tests/Junction.Tests/Unit/MachineMonitorTests.cs @@ -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 +{ + /// + /// Behavior tests for . Repository and plugin loader are mocked; + /// drivers/factories are hand-rolled fakes. The real drives the + /// loops. Timings are deliberately generous to avoid CI flake. + /// + 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()); + + private static Func RealEngineFactory() => + () => new PollingEngine(NullLogger.Instance); + + private static MachineMonitor NewMonitor(IMachineRepository repo, IPluginLoader loader) => + new MachineMonitor(repo, loader, RealEngineFactory(), NullLogger.Instance); + + private static Mock LoaderReturning(params IProtocolDriverFactory[] factories) + { + var loaded = new List(); + 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(); + mock.Setup(l => l.LoadFrom(It.IsAny())) + .Returns(Result>.Ok(loaded)); + return mock; + } + + private static Mock RepoReturning(params Machine[] machines) + { + var mock = new Mock(); + mock.Setup(r => r.GetAllAsync(It.IsAny())) + .ReturnsAsync(Result>.Ok(machines)); + mock.Setup(r => r.SaveSnapshotAsync(It.IsAny(), It.IsAny())) + .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.Ok( + new FakeDriver("test", () => Result.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(s => s.MachineId == machine.Id), + It.IsAny()), 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.Ok( + new FakeDriver("test", () => Result.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.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(); + loader.Setup(l => l.LoadFrom(It.IsAny())) + .Returns(Result>.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()), Times.Never); + } + + [Fact] + public async Task StartAsync_RepoGetAllFails_ReturnsFail() + { + var factory = new FakeFactory("test", m => Result.Ok( + new FakeDriver("test", () => Result.Ok(Snapshot(Guid.NewGuid()))))); + var loader = LoaderReturning(factory); + + var repo = new Mock(); + repo.Setup(r => r.GetAllAsync(It.IsAny())) + .ReturnsAsync(Result>.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.Ok( + new FakeDriver("test", () => Result.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(); + } + + /// Hand-rolled factory; behavior supplied by a delegate. + private sealed class FakeFactory : IProtocolDriverFactory + { + private readonly Func> _create; + + public FakeFactory(string protocolId, Func> create) + { + ProtocolId = protocolId; + _create = create; + } + + public string ProtocolId { get; } + + public Result Create(Machine machine) => _create(machine); + } + + /// Hand-rolled driver; behavior supplied by a delegate. + private sealed class FakeDriver : IProtocolDriver + { + private readonly Func> _read; + + public FakeDriver(string protocolId, Func> read) + { + ProtocolId = protocolId; + _read = read; + } + + public string ProtocolId { get; } + + public Task> ReadCurrentAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_read()); + } + } + } +} diff --git a/tests/Junction.Tests/Unit/MachineRepositoryContractTests.cs b/tests/Junction.Tests/Unit/MachineRepositoryContractTests.cs new file mode 100644 index 0000000..e584e23 --- /dev/null +++ b/tests/Junction.Tests/Unit/MachineRepositoryContractTests.cs @@ -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 +{ + /// + /// Shape/contract tests for using a Moq mock. + /// No real database: these assert the async signatures and Result shapes only. + /// + 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 { SampleMachine(Guid.NewGuid()) }; + var mock = new Mock(); + mock.Setup(r => r.GetAllAsync(It.IsAny())) + .ReturnsAsync(Result>.Ok(machines)); + + Result> 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(); + mock.Setup(r => r.GetAllAsync(It.IsAny())) + .ReturnsAsync(Result>.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(); + mock.Setup(r => r.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(Result.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(); + mock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.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(); + mock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.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(); + mock.Setup(r => r.UpsertAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok()); + + Result result = await mock.Object.UpsertAsync(SampleMachine(Guid.NewGuid()), CancellationToken.None); + + Assert.IsType(result); + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task UpsertAsync_Fail_CarriesErrors() + { + var mock = new Mock(); + mock.Setup(r => r.UpsertAsync(It.IsAny(), It.IsAny())) + .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(); + mock.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok()); + + Result result = await mock.Object.DeleteAsync(Guid.NewGuid(), CancellationToken.None); + + Assert.IsType(result); + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task DeleteAsync_Cancelled_SetsWasCancelled() + { + var mock = new Mock(); + mock.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())) + .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(); + mock.Setup(r => r.SaveSnapshotAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok()); + + Result result = await mock.Object.SaveSnapshotAsync(SampleSnapshot(machineId), CancellationToken.None); + + Assert.IsType(result); + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task SaveSnapshotAsync_Fail_CarriesErrors() + { + var mock = new Mock(); + mock.Setup(r => r.SaveSnapshotAsync(It.IsAny(), It.IsAny())) + .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(); + mock.Setup(r => r.GetLatestSnapshotAsync(machineId, It.IsAny())) + .ReturnsAsync(Result.Ok(SampleSnapshot(machineId))); + + Result 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(); + mock.Setup(r => r.GetLatestSnapshotAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.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(); + + mock.Setup(r => r.GetAllAsync(token)) + .ReturnsAsync(Result>.Ok(new List())); + mock.Setup(r => r.GetByIdAsync(It.IsAny(), token)) + .ReturnsAsync(Result.Ok(SampleMachine(Guid.NewGuid()))); + mock.Setup(r => r.UpsertAsync(It.IsAny(), token)).ReturnsAsync(Result.Ok()); + mock.Setup(r => r.DeleteAsync(It.IsAny(), token)).ReturnsAsync(Result.Ok()); + mock.Setup(r => r.SaveSnapshotAsync(It.IsAny(), token)).ReturnsAsync(Result.Ok()); + mock.Setup(r => r.GetLatestSnapshotAsync(It.IsAny(), token)) + .ReturnsAsync(Result.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(), token), Times.Once); + mock.Verify(r => r.UpsertAsync(It.IsAny(), token), Times.Once); + mock.Verify(r => r.DeleteAsync(It.IsAny(), token), Times.Once); + mock.Verify(r => r.SaveSnapshotAsync(It.IsAny(), token), Times.Once); + mock.Verify(r => r.GetLatestSnapshotAsync(It.IsAny(), token), Times.Once); + } + } +} diff --git a/tests/Junction.Tests/Unit/MachineSnapshotTests.cs b/tests/Junction.Tests/Unit/MachineSnapshotTests.cs new file mode 100644 index 0000000..541ffec --- /dev/null +++ b/tests/Junction.Tests/Unit/MachineSnapshotTests.cs @@ -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)); + } + } +} diff --git a/tests/Junction.Tests/Unit/MachineTests.cs b/tests/Junction.Tests/Unit/MachineTests.cs new file mode 100644 index 0000000..36d58de --- /dev/null +++ b/tests/Junction.Tests/Unit/MachineTests.cs @@ -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 + { + ["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); + } + } +} diff --git a/tests/Junction.Tests/Unit/MtconnectDriverTests.cs b/tests/Junction.Tests/Unit/MtconnectDriverTests.cs new file mode 100644 index 0000000..8602b3b --- /dev/null +++ b/tests/Junction.Tests/Unit/MtconnectDriverTests.cs @@ -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); + } + + /// Stub handler: canned response or thrown exception, per configuration. + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func> _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> responder) + { + _responder = responder; + } + + protected override Task 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, "")); + 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? 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()); + + 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 { ["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 { ["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 { ["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 + { + ["AgentUrl"] = AgentUrl, + ["TimeoutSeconds"] = "not-a-number", + }); + + var result = factory.Create(machine); + + Assert.False(result.IsSuccess); + } + } +} diff --git a/tests/Junction.Tests/Unit/MtconnectParserTests.cs b/tests/Junction.Tests/Unit/MtconnectParserTests.cs new file mode 100644 index 0000000..e327b03 --- /dev/null +++ b/tests/Junction.Tests/Unit/MtconnectParserTests.cs @@ -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(""); + + 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); + } + } +} diff --git a/tests/Junction.Tests/Unit/OperationErrorTests.cs b/tests/Junction.Tests/Unit/OperationErrorTests.cs new file mode 100644 index 0000000..f06a0a6 --- /dev/null +++ b/tests/Junction.Tests/Unit/OperationErrorTests.cs @@ -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()); + } + } +} diff --git a/tests/Junction.Tests/Unit/PluginLoaderTests.cs b/tests/Junction.Tests/Unit/PluginLoaderTests.cs new file mode 100644 index 0000000..b4b1013 --- /dev/null +++ b/tests/Junction.Tests/Unit/PluginLoaderTests.cs @@ -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 +{ + /// + /// Tests for . 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 + /// plugin.manifest.json whose EntryTypeName points at + /// below. Because the copy shares the already-loaded test assembly's identity, + /// Assembly.LoadFrom resolves the running assembly and the type is found. + /// + public sealed class PluginLoaderTests : IDisposable + { + // ---- Fake plugin types defined IN the test project ---- + + /// Valid, loadable factory used for the happy path. + public sealed class FakeFactory : IProtocolDriverFactory + { + public string ProtocolId => "fake"; + + public Result Create(Machine machine) => + Result.Ok(new FakeDriver()); + } + + public sealed class FakeDriver : IProtocolDriver + { + public string ProtocolId => "fake"; + + public Task> ReadCurrentAsync(CancellationToken cancellationToken) => + Task.FromResult(Result.Ok( + new MachineSnapshot( + Guid.NewGuid(), + DateTimeOffset.UtcNow, + ConnectionState.Connected, + Array.Empty()))); + } + + /// Type that does NOT implement the factory contract. + public sealed class NotAFactory + { + } + + /// Factory whose ctor throws (activation failure path). + public sealed class ThrowingFactory : IProtocolDriverFactory + { + public ThrowingFactory() => throw new InvalidOperationException("boom"); + public string ProtocolId => "throwing"; + public Result 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? logger = null) => + new PluginLoader(logger ?? NullLogger.Instance); + + /// + /// Create a plugin subdir with a copy of the test dll (so AssemblyFile resolves) + /// and a manifest pointing at . + /// + 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(); + 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(); + 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(); + 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 : ILogger + { + public readonly List<(LogLevel Level, string Message)> Entries = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } + } + } +} diff --git a/tests/Junction.Tests/Unit/PluginManifestTests.cs b/tests/Junction.Tests/Unit/PluginManifestTests.cs new file mode 100644 index 0000000..b080460 --- /dev/null +++ b/tests/Junction.Tests/Unit/PluginManifestTests.cs @@ -0,0 +1,75 @@ +using Junction.Domain.Protocols; +using Xunit; + +namespace Junction.Tests.Unit +{ + /// + /// 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 . + /// + 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)); + } + } +} diff --git a/tests/Junction.Tests/Unit/PollingEngineTests.cs b/tests/Junction.Tests/Unit/PollingEngineTests.cs new file mode 100644 index 0000000..fe8604e --- /dev/null +++ b/tests/Junction.Tests/Unit/PollingEngineTests.cs @@ -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 +{ + /// + /// Behavior tests for using a hand-rolled fake driver. + /// Timings are deliberately generous to avoid CI flake. + /// + public class PollingEngineTests + { + private static PollingEngine NewEngine() => + new PollingEngine(NullLogger.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()); + + [Fact] + public async Task RunAsync_PollsRepeatedly_OverShortInterval() + { + var machine = MachineWithInterval(TimeSpan.FromMilliseconds(25)); + var driver = new FakeDriver(m => Result.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.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(); + + // 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.Fail(OperationError.Of("fake", "boom")); + } + + return Result.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.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.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); + } + + /// Hand-rolled fake driver; behavior supplied by a delegate. + private sealed class FakeDriver : IProtocolDriver + { + private readonly Func> _behavior; + + public FakeDriver(Func> behavior) + { + _behavior = behavior; + } + + public string ProtocolId => "fake"; + + public Task> ReadCurrentAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_behavior(null!)); + } + } + } +} diff --git a/tests/Junction.Tests/Unit/ProtocolContractTests.cs b/tests/Junction.Tests/Unit/ProtocolContractTests.cs new file mode 100644 index 0000000..7f89a4d --- /dev/null +++ b/tests/Junction.Tests/Unit/ProtocolContractTests.cs @@ -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 +{ + /// + /// Contract/shape tests for the plugin protocol contract. Pure compile + behavior + /// against Moq doubles; no real IO. + /// + public class ProtocolContractTests + { + private static Machine SampleMachine(IReadOnlyDictionary? 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()); + + [Fact] + public void Driver_ProtocolId_ReturnsConfiguredValue() + { + var mock = new Mock(); + 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(); + mock.Setup(d => d.ReadCurrentAsync(It.IsAny())) + .ReturnsAsync(Result.Ok(snapshot)); + + Result 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(); + mock.Setup(d => d.ReadCurrentAsync(It.IsAny())) + .ReturnsAsync(Result.Fail(error)); + + Result 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(); + mock.Setup(d => d.ReadCurrentAsync(cts.Token)) + .ReturnsAsync(Result.Cancelled()); + + Result 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(); + mock.SetupGet(f => f.ProtocolId).Returns("mtconnect"); + + Assert.Equal("mtconnect", mock.Object.ProtocolId); + } + + [Fact] + public void Factory_Create_Ok_WrapsDriver() + { + var driver = new Mock().Object; + var factory = new Mock(); + factory.Setup(f => f.Create(It.IsAny())) + .Returns(Result.Ok(driver)); + + Result 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(); + factory.Setup(f => f.Create(It.Is(m => !m.ConnectionConfig.ContainsKey("endpoint")))) + .Returns(Result.Fail( + OperationError.Of("factory", "missing 'endpoint'"))); + + Result result = factory.Object.Create(SampleMachine()); + + Assert.False(result.IsSuccess); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Factory_Create_ValidConfig_ReturnsOk() + { + var driver = new Mock().Object; + var factory = new Mock(); + factory.Setup(f => f.Create(It.Is(m => m.ConnectionConfig.ContainsKey("endpoint")))) + .Returns(Result.Ok(driver)); + + var machine = SampleMachine(new Dictionary { ["endpoint"] = "http://x" }); + Result result = factory.Object.Create(machine); + + Assert.True(result.IsSuccess); + Assert.Same(driver, result.Value); + } + } +} diff --git a/tests/Junction.Tests/Unit/ResultTests.cs b/tests/Junction.Tests/Unit/ResultTests.cs new file mode 100644 index 0000000..f5db787 --- /dev/null +++ b/tests/Junction.Tests/Unit/ResultTests.cs @@ -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 ---- + + [Fact] + public void Generic_Ok_HasValue_IsSuccess_EmptyErrors() + { + var r = Result.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.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 { Err("a"), Err("b") }; + var r = Result.Fail(errors); + + Assert.False(r.IsSuccess); + Assert.Equal(2, r.Errors.Count); + } + + [Fact] + public void Generic_Failed_ValueIsDefault_NoThrow() + { + var rInt = Result.Fail(Err()); + var rRef = Result.Fail(Err()); + + Assert.Equal(0, rInt.Value); + Assert.Null(rRef.Value); + } + + [Fact] + public void Generic_Cancelled_IsCancelled_NotSuccess_NotTreatedAsFailure() + { + var r = Result.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.Ok(1).Errors); + Assert.NotNull(Result.Fail(Err()).Errors); + Assert.NotNull(Result.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); + } + } +} diff --git a/tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs b/tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs new file mode 100644 index 0000000..e2e38f8 --- /dev/null +++ b/tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs @@ -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 +{ + /// + /// Integration-style tests for 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. + /// + 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 + { + ["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 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 { ["k"] = "v" }, + TimeSpan.FromSeconds(10)); + Result upsert = await _repo.UpsertAsync(updated, CancellationToken.None); + Assert.True(upsert.IsSuccess, Describe(upsert)); + + Result> all = await _repo.GetAllAsync(CancellationToken.None); + Assert.True(all.IsSuccess, Describe(all)); + Assert.Single(all.Value); + + Result 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 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> 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 + { + 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 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 + { + 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 + { + new DataItem("d1", "A", "99", "Sample", ts.AddSeconds(1)), + }); + Result save = await _repo.SaveSnapshotAsync(second, CancellationToken.None); + Assert.True(save.IsSuccess, Describe(save)); + + Result 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 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 + { + 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 = await _repo.GetByIdAsync(id, CancellationToken.None); + Assert.False(machine.IsSuccess); + Assert.Equal("not_found", machine.Errors[0].Code); + + Result 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(Result 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())); + } +} diff --git a/tests/Junction.Tests/Unit/SqliteSchemaTests.cs b/tests/Junction.Tests/Unit/SqliteSchemaTests.cs new file mode 100644 index 0000000..c518523 --- /dev/null +++ b/tests/Junction.Tests/Unit/SqliteSchemaTests.cs @@ -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 +{ + /// + /// 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). + /// + 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 QueryTableNames() + { + var names = new List(); + 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())); + } + } +}