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