junction/tests/Junction.Tests/Unit/MachineMonitorTests.cs
dtrentin 507753f82e init: scaffold Junction multi-protocol machine monitor (M1 walking skeleton)
Greenfield C# app reading industrial machine data over pluggable protocols.
M1 walking skeleton: MTConnect plugin polls a machine, Avalonia dashboard
shows live last datum. Build 0 warn/0 err on net48 + net8.0 (Linux),
115 unit tests + 2 docker-gated integration tests.

Projects:
- Junction.Domain (netstandard2.0): Result<T>, models, IProtocolDriver,
  IMachineRepository, plugin manifest. Zero package deps.
- Junction.Core (netstandard2.0): PollingEngine, PluginLoader
  (Assembly.LoadFrom), MachineMonitor, DI extensions.
- Junction.Persistence (netstandard2.0): SqliteMachineRepository (Dapper),
  schema, connection factory. Provider-swap seam to SQL Server.
- Junction.Protocols.MTConnect (netstandard2.0): HTTP driver + namespace-
  version-agnostic parser (MTConnect 1.7 + 2.0). Runtime plugin.
- Junction.App (net48;net8.0): Avalonia MVVM, live dashboard, NLog, DI root.
- Junction.Tests (net8.0): xUnit + Moq, 117 tests.
- mock/: docker-compose MTConnect agent (ladder99/agent).

Target net48 for Windows 7/8 fleet compatibility. Avalonia pinned 11.3.x.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:32:56 +02:00

244 lines
9.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Junction.Core.Monitoring;
using Junction.Core.Plugins;
using Junction.Core.Polling;
using Junction.Domain;
using Junction.Domain.Models;
using Junction.Domain.Persistence;
using Junction.Domain.Protocols;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Junction.Tests.Unit
{
/// <summary>
/// Behavior tests for <see cref="MachineMonitor"/>. Repository and plugin loader are mocked;
/// drivers/factories are hand-rolled fakes. The real <see cref="PollingEngine"/> drives the
/// loops. Timings are deliberately generous to avoid CI flake.
/// </summary>
public class MachineMonitorTests
{
private const string PluginsDir = "/fake/plugins";
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(25);
private static Machine MachineWith(string protocolId) =>
new Machine(Guid.NewGuid(), "M-" + protocolId, protocolId, null, FastPoll);
private static MachineSnapshot Snapshot(Guid machineId) =>
new MachineSnapshot(machineId, DateTimeOffset.UtcNow, ConnectionState.Connected, Array.Empty<DataItem>());
private static Func<IPollingEngine> RealEngineFactory() =>
() => new PollingEngine(NullLogger<PollingEngine>.Instance);
private static MachineMonitor NewMonitor(IMachineRepository repo, IPluginLoader loader) =>
new MachineMonitor(repo, loader, RealEngineFactory(), NullLogger<MachineMonitor>.Instance);
private static Mock<IPluginLoader> LoaderReturning(params IProtocolDriverFactory[] factories)
{
var loaded = new List<LoadedPlugin>();
foreach (var f in factories)
{
var manifest = new PluginManifest(f.ProtocolId, f.ProtocolId, "x.dll", "X", "1.0");
loaded.Add(new LoadedPlugin(new PluginDescriptor(manifest, "/fake/x.dll"), f));
}
var mock = new Mock<IPluginLoader>();
mock.Setup(l => l.LoadFrom(It.IsAny<string>()))
.Returns(Result<IReadOnlyList<LoadedPlugin>>.Ok(loaded));
return mock;
}
private static Mock<IMachineRepository> RepoReturning(params Machine[] machines)
{
var mock = new Mock<IMachineRepository>();
mock.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Ok(machines));
mock.Setup(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
return mock;
}
[Fact]
public async Task StartAsync_PollsMachine_RaisesEvent_UpdatesCache_Persists()
{
var machine = MachineWith("test");
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(machine.Id)))));
var loader = LoaderReturning(factory);
var repo = RepoReturning(machine);
var monitor = NewMonitor(repo.Object, loader.Object);
var eventRaised = new ManualResetEventSlim(false);
monitor.SnapshotUpdated += (_, snap) =>
{
if (snap.MachineId == machine.Id) eventRaised.Set();
};
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
Assert.True(start.IsSuccess);
Assert.True(eventRaised.Wait(TimeSpan.FromSeconds(2)), "SnapshotUpdated was not raised");
await monitor.StopAsync();
Assert.True(monitor.LatestSnapshots.ContainsKey(machine.Id));
Assert.Equal(machine.Id, monitor.LatestSnapshots[machine.Id].MachineId);
repo.Verify(r => r.SaveSnapshotAsync(
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id),
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
}
[Fact]
public async Task StartAsync_UnknownProtocol_SkipsMachine_OthersRun_StillOk()
{
var known = MachineWith("test");
var unknown = MachineWith("nope");
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(known.Id)))));
var loader = LoaderReturning(factory);
var repo = RepoReturning(unknown, known);
var monitor = NewMonitor(repo.Object, loader.Object);
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
Assert.True(start.IsSuccess);
// Give the known machine time to produce.
var sw = System.Diagnostics.Stopwatch.StartNew();
while (!monitor.LatestSnapshots.ContainsKey(known.Id) && sw.Elapsed < TimeSpan.FromSeconds(2))
{
await Task.Delay(20);
}
await monitor.StopAsync();
Assert.True(monitor.LatestSnapshots.ContainsKey(known.Id), "known machine did not run");
Assert.False(monitor.LatestSnapshots.ContainsKey(unknown.Id), "unknown-protocol machine must be skipped");
}
[Fact]
public async Task StartAsync_DriverCreateFails_SkipsMachine_StillOk()
{
var machine = MachineWith("test");
var factory = new FakeFactory("test", m =>
Result<IProtocolDriver>.Fail(OperationError.Of("test", "bad config")));
var loader = LoaderReturning(factory);
var repo = RepoReturning(machine);
var monitor = NewMonitor(repo.Object, loader.Object);
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
Assert.True(start.IsSuccess);
await monitor.StopAsync();
Assert.False(monitor.LatestSnapshots.ContainsKey(machine.Id));
}
[Fact]
public async Task StartAsync_LoaderFails_ReturnsFail()
{
var loader = new Mock<IPluginLoader>();
loader.Setup(l => l.LoadFrom(It.IsAny<string>()))
.Returns(Result<IReadOnlyList<LoadedPlugin>>.Fail(
new OperationError("PLUGIN_DIR_MISSING", "PluginLoader", "no dir")));
var repo = RepoReturning();
var monitor = NewMonitor(repo.Object, loader.Object);
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
Assert.False(start.IsSuccess);
Assert.NotEmpty(start.Errors);
repo.Verify(r => r.GetAllAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task StartAsync_RepoGetAllFails_ReturnsFail()
{
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(Guid.NewGuid())))));
var loader = LoaderReturning(factory);
var repo = new Mock<IMachineRepository>();
repo.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Fail(
new OperationError("STORE_UNAVAILABLE", "repo", "db down")));
var monitor = NewMonitor(repo.Object, loader.Object);
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
Assert.False(start.IsSuccess);
Assert.NotEmpty(start.Errors);
}
[Fact]
public async Task StopAsync_CancelsLoops_Cleanly()
{
var machine = MachineWith("test");
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(machine.Id)))));
var loader = LoaderReturning(factory);
var repo = RepoReturning(machine);
var monitor = NewMonitor(repo.Object, loader.Object);
await monitor.StartAsync(PluginsDir, CancellationToken.None);
await Task.Delay(80);
var stop = monitor.StopAsync();
var completed = await Task.WhenAny(stop, Task.Delay(2000)) == stop;
Assert.True(completed, "StopAsync did not complete promptly");
await stop; // rethrows if faulted
// Idempotent: second stop is a no-op and must not throw.
await monitor.StopAsync();
}
/// <summary>Hand-rolled factory; behavior supplied by a delegate.</summary>
private sealed class FakeFactory : IProtocolDriverFactory
{
private readonly Func<Machine, Result<IProtocolDriver>> _create;
public FakeFactory(string protocolId, Func<Machine, Result<IProtocolDriver>> create)
{
ProtocolId = protocolId;
_create = create;
}
public string ProtocolId { get; }
public Result<IProtocolDriver> Create(Machine machine) => _create(machine);
}
/// <summary>Hand-rolled driver; behavior supplied by a delegate.</summary>
private sealed class FakeDriver : IProtocolDriver
{
private readonly Func<Result<MachineSnapshot>> _read;
public FakeDriver(string protocolId, Func<Result<MachineSnapshot>> read)
{
ProtocolId = protocolId;
_read = read;
}
public string ProtocolId { get; }
public Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_read());
}
}
}
}