using System; using System.Collections.Generic; using System.IO; using System.Linq; 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); } private static string LoadProbeFixture() { var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "mtconnect", "probe.xml"); return File.ReadAllText(path); } // All dataItem ids present in current.xml. Used as the default selection so item-agnostic // tests keep observing a non-empty snapshot despite the opt-in ("monitor nothing") default. private static readonly string[] AllCurrentItemIds = { "dev1_avail", "x1_pos", "x1_pos_cmd", "x1_load", "y1_pos", "z1_pos", "c1_spindle_speed", "c1_spindle_speed_cmd", "c1_load", "c1_rot_mode", "c1_temp_cond", "cn1_mode", "cn1_estop", "cn1_system", "path1_exec", "path1_program", "path1_line", "path1_feed", "path1_logic", }; /// Stub handler: canned response or thrown exception, per configuration. private sealed class StubHandler : HttpMessageHandler { private readonly Func> _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> responder) { _responder = responder; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastRequestUri = request.RequestUri; return _responder(request, cancellationToken); } } private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30); // Shared client with infinite global timeout mirrors production; per-request timeout enforced in driver. // Defaults the monitored selection to every current.xml id so item-agnostic tests still see items. private static MtconnectDriver DriverWith( HttpMessageHandler handler, TimeSpan? requestTimeout = null, IReadOnlyCollection? monitoredItemIds = null) => new MtconnectDriver( MachineId, AgentUrl, new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }, requestTimeout ?? GenerousTimeout, monitoredItemIds ?? AllCurrentItemIds); // ---- 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, "")); 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); } // ---- ReadCurrentAsync: per-request timeout (linked CTS) ---- [Fact] public async Task ReadCurrentAsync_RequestExceedsPerRequestTimeout_ReturnsFailCurrentTimeoutNotCancelled() { // Handler stalls until its token trips; only the driver's linked CTS (CancelAfter) can trip it. var handler = new StubHandler(async (_, ct) => { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); return new HttpResponseMessage(HttpStatusCode.OK); }); var driver = DriverWith(handler, TimeSpan.FromMilliseconds(50)); // Caller token never cancelled: any cancellation here is the per-request timeout. var result = await driver.ReadCurrentAsync(CancellationToken.None); Assert.False(result.IsSuccess); Assert.False(result.WasCancelled); Assert.NotEmpty(result.Errors); Assert.Contains(result.Errors, e => e.Code == "CURRENT_TIMEOUT"); } [Fact] public async Task ReadCurrentAsync_CallerCancelBeatsTimeout_ReturnsCancelled() { var handler = new StubHandler(async (_, ct) => { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); return new HttpResponseMessage(HttpStatusCode.OK); }); // Generous per-request timeout so the caller cancel wins the race, not the timeout. var driver = DriverWith(handler, GenerousTimeout); 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); } // ---- ProbeAsync: catalog ---- [Fact] public async Task ProbeAsync_200WithValidProbeXml_ReturnsFullCatalog() { var handler = new StubHandler(HttpStatusCode.OK, LoadProbeFixture()); var driver = DriverWith(handler); var result = await driver.ProbeAsync(CancellationToken.None); Assert.True(result.IsSuccess); Assert.False(result.WasCancelled); Assert.NotNull(result.Value); Assert.True(result.Value.Count > 0); // Probe returns the FULL (unfiltered) catalog. Spot-check a known descriptor. var pos = result.Value.Single(d => d.Id == "x1_pos"); Assert.Equal("POSITION", pos.Type); Assert.Equal("SAMPLE", pos.Category); Assert.Equal("MILLIMETER", pos.Units); // Driver hits the agent's /probe endpoint. Assert.NotNull(handler.LastRequestUri); Assert.EndsWith("/probe", handler.LastRequestUri!.AbsoluteUri); } [Fact] public async Task ProbeAsync_NonSuccessStatus_ReturnsFailNoThrow() { var handler = new StubHandler(HttpStatusCode.InternalServerError, "irrelevant"); var driver = DriverWith(handler); var result = await driver.ProbeAsync(CancellationToken.None); Assert.False(result.IsSuccess); Assert.Contains(result.Errors, e => e.Code == "PROBE_HTTP_STATUS"); } [Fact] public async Task ProbeAsync_CancelledBeforeCall_ReturnsCancelled() { var handler = new StubHandler(HttpStatusCode.OK, LoadProbeFixture()); var driver = DriverWith(handler); using var cts = new CancellationTokenSource(); cts.Cancel(); var result = await driver.ProbeAsync(cts.Token); Assert.False(result.IsSuccess); Assert.True(result.WasCancelled); } // ---- ReadCurrentAsync: per-machine selection filter (opt-in) ---- [Fact] public async Task ReadCurrentAsync_SelectionSubset_KeepsOnlySelectedItems_PreservesConnectionState() { var handler = new StubHandler(HttpStatusCode.OK, LoadCurrentFixture()); var selection = new[] { "x1_pos", "c1_load" }; var driver = DriverWith(handler, monitoredItemIds: selection); var result = await driver.ReadCurrentAsync(CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(2, result.Value.Items.Count); Assert.Contains(result.Value.Items, i => i.Id == "x1_pos"); Assert.Contains(result.Value.Items, i => i.Id == "c1_load"); // dev1_avail is not selected: it is filtered out of Items, but still drove the state. Assert.DoesNotContain(result.Value.Items, i => i.Id == "dev1_avail"); Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState); Assert.Equal(MachineId, result.Value.MachineId); } [Fact] public async Task ReadCurrentAsync_EmptySelection_ReturnsEmptyItems_PreservesConnectionState() { var handler = new StubHandler(HttpStatusCode.OK, LoadCurrentFixture()); var driver = DriverWith(handler, monitoredItemIds: Array.Empty()); var result = await driver.ReadCurrentAsync(CancellationToken.None); Assert.True(result.IsSuccess); Assert.Empty(result.Value.Items); // Opt-in: nothing kept, but availability-derived state is preserved. Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState); } // ---- Factory: config validation ---- private static Machine MachineWithConfig(IReadOnlyDictionary? 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()); 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 { ["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 { ["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 { ["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 { ["AgentUrl"] = AgentUrl, ["TimeoutSeconds"] = "not-a-number", }); var result = factory.Create(machine); Assert.False(result.IsSuccess); } } }