junction/tests/Junction.Tests/Unit/PluginLoaderTests.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

289 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Junction.Core.Plugins;
using Junction.Domain;
using Junction.Domain.Models;
using Junction.Domain.Protocols;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Junction.Tests.Unit
{
/// <summary>
/// Tests for <see cref="PluginLoader"/>. A real loadable plugin is synthesized at test
/// time from THIS test assembly: a temp plugin dir gets a copy of the test dll plus a
/// <c>plugin.manifest.json</c> whose EntryTypeName points at <see cref="FakeFactory"/>
/// below. Because the copy shares the already-loaded test assembly's identity,
/// <c>Assembly.LoadFrom</c> resolves the running assembly and the type is found.
/// </summary>
public sealed class PluginLoaderTests : IDisposable
{
// ---- Fake plugin types defined IN the test project ----
/// <summary>Valid, loadable factory used for the happy path.</summary>
public sealed class FakeFactory : IProtocolDriverFactory
{
public string ProtocolId => "fake";
public Result<IProtocolDriver> Create(Machine machine) =>
Result<IProtocolDriver>.Ok(new FakeDriver());
}
public sealed class FakeDriver : IProtocolDriver
{
public string ProtocolId => "fake";
public Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken) =>
Task.FromResult(Result<MachineSnapshot>.Ok(
new MachineSnapshot(
Guid.NewGuid(),
DateTimeOffset.UtcNow,
ConnectionState.Connected,
Array.Empty<DataItem>())));
}
/// <summary>Type that does NOT implement the factory contract.</summary>
public sealed class NotAFactory
{
}
/// <summary>Factory whose ctor throws (activation failure path).</summary>
public sealed class ThrowingFactory : IProtocolDriverFactory
{
public ThrowingFactory() => throw new InvalidOperationException("boom");
public string ProtocolId => "throwing";
public Result<IProtocolDriver> Create(Machine machine) => throw new NotImplementedException();
}
private static readonly string TestAssemblyPath =
typeof(PluginLoaderTests).Assembly.Location;
private static readonly string TestAssemblyFileName =
Path.GetFileName(TestAssemblyPath);
private readonly string _root;
public PluginLoaderTests()
{
_root = Path.Combine(Path.GetTempPath(), "junction-plugintests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
public void Dispose()
{
try
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
catch
{
// best-effort cleanup
}
}
// ---- helpers ----
private static PluginLoader NewLoader(ILogger<PluginLoader>? logger = null) =>
new PluginLoader(logger ?? NullLogger<PluginLoader>.Instance);
/// <summary>
/// Create a plugin subdir with a copy of the test dll (so AssemblyFile resolves)
/// and a manifest pointing at <paramref name="entryTypeName"/>.
/// </summary>
private string CreatePluginDir(string dirName, string entryTypeName, string protocolId = "fake", bool copyDll = true)
{
var dir = Path.Combine(_root, dirName);
Directory.CreateDirectory(dir);
if (copyDll)
File.Copy(TestAssemblyPath, Path.Combine(dir, TestAssemblyFileName), overwrite: true);
var manifest =
"{\n" +
$" \"protocolId\": \"{protocolId}\",\n" +
" \"displayName\": \"Fake Plugin\",\n" +
$" \"assemblyFile\": \"{TestAssemblyFileName}\",\n" +
$" \"entryTypeName\": \"{entryTypeName}\",\n" +
" \"apiVersion\": \"1.0\"\n" +
"}";
File.WriteAllText(Path.Combine(dir, PluginLoader.ManifestFileName), manifest);
return dir;
}
private void CreateBadJsonPluginDir(string dirName)
{
var dir = Path.Combine(_root, dirName);
Directory.CreateDirectory(dir);
File.Copy(TestAssemblyPath, Path.Combine(dir, TestAssemblyFileName), overwrite: true);
File.WriteAllText(Path.Combine(dir, PluginLoader.ManifestFileName), "{ this is : not valid json ]");
}
// ---- tests ----
[Fact]
public void LoadFrom_ValidPlugin_ReturnsLoadedPluginWithWorkingFactory()
{
CreatePluginDir("good", typeof(FakeFactory).FullName!);
var result = NewLoader().LoadFrom(_root);
Assert.True(result.IsSuccess);
var plugin = Assert.Single(result.Value);
Assert.Equal("fake", plugin.Descriptor.Manifest.ProtocolId);
Assert.Equal("fake", plugin.Factory.ProtocolId);
Assert.True(File.Exists(plugin.Descriptor.AssemblyPath));
var machine = new Machine(Guid.NewGuid(), "M1", "fake", null, TimeSpan.FromSeconds(1));
var created = plugin.Factory.Create(machine);
Assert.True(created.IsSuccess);
Assert.Equal("fake", created.Value.ProtocolId);
}
[Fact]
public void LoadFrom_RootManifest_IsDiscovered()
{
// manifest + dll directly in root (no subdir)
File.Copy(TestAssemblyPath, Path.Combine(_root, TestAssemblyFileName), overwrite: true);
File.WriteAllText(
Path.Combine(_root, PluginLoader.ManifestFileName),
"{ \"protocolId\": \"fake\", \"displayName\": \"d\", " +
$"\"assemblyFile\": \"{TestAssemblyFileName}\", " +
$"\"entryTypeName\": \"{typeof(FakeFactory).FullName}\", \"apiVersion\": \"1.0\" }}");
var result = NewLoader().LoadFrom(_root);
Assert.True(result.IsSuccess);
Assert.Single(result.Value);
}
[Fact]
public void LoadFrom_BadJson_SkipsThatPlugin_LoadsOthers()
{
CreateBadJsonPluginDir("bad");
CreatePluginDir("good", typeof(FakeFactory).FullName!);
var logger = new CapturingLogger<PluginLoader>();
var result = NewLoader(logger).LoadFrom(_root);
Assert.True(result.IsSuccess);
var plugin = Assert.Single(result.Value); // only the good one
Assert.Equal("fake", plugin.Descriptor.Manifest.ProtocolId);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error);
}
[Fact]
public void LoadFrom_MissingDll_SkipsPlugin_NoThrow()
{
// manifest present, but do not copy the dll
CreatePluginDir("nodll", typeof(FakeFactory).FullName!, copyDll: false);
var logger = new CapturingLogger<PluginLoader>();
var result = NewLoader(logger).LoadFrom(_root);
Assert.True(result.IsSuccess);
Assert.Empty(result.Value);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error);
}
[Fact]
public void LoadFrom_UnknownEntryType_SkipsPlugin_NoThrow()
{
CreatePluginDir("badtype", "Junction.Tests.Unit.NoSuchType");
var logger = new CapturingLogger<PluginLoader>();
var result = NewLoader(logger).LoadFrom(_root);
Assert.True(result.IsSuccess);
Assert.Empty(result.Value);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error);
}
[Fact]
public void LoadFrom_EntryTypeNotAFactory_SkipsPlugin()
{
CreatePluginDir("wrongcontract", typeof(NotAFactory).FullName!);
var result = NewLoader().LoadFrom(_root);
Assert.True(result.IsSuccess);
Assert.Empty(result.Value);
}
[Fact]
public void LoadFrom_FactoryCtorThrows_SkipsPlugin()
{
CreatePluginDir("throwctor", typeof(ThrowingFactory).FullName!);
var result = NewLoader().LoadFrom(_root);
Assert.True(result.IsSuccess);
Assert.Empty(result.Value);
}
[Fact]
public void LoadFrom_MissingDirectory_ReturnsFail()
{
var missing = Path.Combine(_root, "does-not-exist");
var result = NewLoader().LoadFrom(missing);
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "PLUGIN_DIR_MISSING");
}
[Fact]
public void LoadFrom_EmptyPath_ReturnsFail()
{
var result = NewLoader().LoadFrom("");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "PLUGIN_DIR_MISSING");
}
[Fact]
public void LoadFrom_MultiplePlugins_LoadsAllValid()
{
CreatePluginDir("p1", typeof(FakeFactory).FullName!);
CreatePluginDir("p2", typeof(FakeFactory).FullName!);
CreateBadJsonPluginDir("bad");
var result = NewLoader().LoadFrom(_root);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Count);
}
// ---- capturing logger ----
private sealed class CapturingLogger<T> : ILogger<T>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
}