Users pick which data items each machine monitors. Catalog comes from the protocol probe; selection is opt-in and filters persistence (only selected items are read/kept/saved). Whole solution green, 138 tests + 2 docker integration. Domain: - Machine.MonitoredItemIds (opt-in, empty = monitor nothing); backward-compatible optional ctor param + With override. - DataItemDescriptor (protocol-agnostic catalog entry). - IProtocolDriver.ProbeAsync → full unfiltered item catalog. MTConnect: - MtconnectDriver.ProbeAsync (GET /probe → parser → descriptors). - ReadCurrentAsync filters snapshot items to selected ids (ConnectionState preserved); factory passes selection into driver. Persistence: - machines.MonitoredItemIdsJson column + idempotent ALTER-if-missing migration. - Repository maps selection (System.Text.Json); round-tripped. Core: - IMachineMonitor.ProbeAsync(machine) exposes catalog to the UI via the plugin factory. App: - Config: "Load items" probes the machine, shows a checklist (select all/none), pre-selects existing choices in edit mode, offline fallback, saves selection. - Detail: empty-state hint when a machine has no monitored items. PAUL: v0.3 Phase 3 shipped; Phase 3.1 (theming/UX) next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
292 lines
11 KiB
C#
292 lines
11 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>())));
|
|
|
|
public Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken) =>
|
|
Task.FromResult(Result<IReadOnlyList<DataItemDescriptor>>.Ok(Array.Empty<DataItemDescriptor>()));
|
|
}
|
|
|
|
/// <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() { }
|
|
}
|
|
}
|
|
}
|
|
}
|