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>
384 lines
15 KiB
C#
384 lines
15 KiB
C#
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",
|
|
};
|
|
|
|
/// <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 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<string>? 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, "<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);
|
|
}
|
|
|
|
// ---- 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<string>());
|
|
|
|
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<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);
|
|
}
|
|
}
|
|
}
|