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

226 lines
7.8 KiB
C#

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);
}
/// <summary>Stub handler: canned response or thrown exception, per configuration.</summary>
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
_responder = responder;
}
protected override Task<HttpResponseMessage> 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, "<x/>"));
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<string, string>? 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<string, string>());
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<string, string> { ["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<string, string> { ["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<string, string> { ["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<string, string>
{
["AgentUrl"] = AgentUrl,
["TimeoutSeconds"] = "not-a-number",
});
var result = factory.Create(machine);
Assert.False(result.IsSuccess);
}
}
}