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>
348 lines
14 KiB
C#
348 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Junction.Domain;
|
|
using Junction.Domain.Models;
|
|
using Junction.Domain.Protocols;
|
|
using Junction.Persistence;
|
|
using Junction.Protocols.MTConnect;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
using Xunit.Sdk;
|
|
|
|
namespace Junction.Tests.Integration
|
|
{
|
|
/// <summary>
|
|
/// Docker-gated end-to-end integration tests: real <see cref="MtconnectDriver"/> ->
|
|
/// live ladder99 MTConnect agent (docker mock at http://localhost:5000) -> snapshot
|
|
/// round-trip through the real SQLite repository.
|
|
/// <para>
|
|
/// These tests assume the mock is ALREADY running (they do NOT auto-start docker):
|
|
/// <c>docker compose -f mock/docker-compose.yml up -d</c>. When the agent is not
|
|
/// reachable (no docker in CI, mock down) each test SKIPS (never fails), so a CI
|
|
/// without docker stays green.
|
|
/// </para>
|
|
/// xUnit v2 2.9.3 has no <c>Assert.Skip</c> (v3-only), so a self-contained skippable-fact
|
|
/// discoverer (<see cref="DockerFactAttribute"/> + <see cref="SkipTestException"/>) provides a
|
|
/// genuine runtime SKIP result — no extra NuGet package, no csproj change.
|
|
/// <para>Filter with <c>dotnet test --filter Category=Docker</c>.</para>
|
|
/// </summary>
|
|
[Trait("Category", "Docker")]
|
|
public sealed class MtconnectEndToEndTests
|
|
{
|
|
private const string AgentUrl = "http://localhost:5000";
|
|
|
|
/// <summary>
|
|
/// Probes the agent's /probe endpoint with a short timeout. Returns true only when the
|
|
/// agent answers with a success status. Never throws — any failure means "not reachable".
|
|
/// </summary>
|
|
private static bool IsAgentReachable()
|
|
{
|
|
try
|
|
{
|
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
|
using var response = http.GetAsync(AgentUrl + "/probe").GetAwaiter().GetResult();
|
|
return response.IsSuccessStatusCode;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Skips the current test (does not fail) when the docker MTConnect mock is down.</summary>
|
|
private static void SkipIfAgentUnavailable()
|
|
{
|
|
if (!IsAgentReachable())
|
|
{
|
|
throw new SkipTestException(
|
|
"MTConnect docker mock not reachable at " + AgentUrl +
|
|
"/probe. Start it with: docker compose -f mock/docker-compose.yml up -d");
|
|
}
|
|
}
|
|
|
|
private static Machine NewMtconnectMachine() =>
|
|
new Machine(
|
|
Guid.NewGuid(),
|
|
"IT Mock Mill",
|
|
"mtconnect",
|
|
new Dictionary<string, string> { ["AgentUrl"] = AgentUrl },
|
|
TimeSpan.FromSeconds(1));
|
|
|
|
[DockerFact]
|
|
public async Task Driver_ReadsCurrent_FromLiveAgent_VersionAgnostic()
|
|
{
|
|
SkipIfAgentUnavailable();
|
|
|
|
var machine = NewMtconnectMachine();
|
|
|
|
var factory = new MtconnectDriverFactory();
|
|
Result<IProtocolDriver> created = factory.Create(machine);
|
|
Assert.True(created.IsSuccess, Describe(created));
|
|
|
|
IProtocolDriver driver = created.Value;
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
Result<MachineSnapshot> read = await driver.ReadCurrentAsync(cts.Token);
|
|
|
|
Assert.True(read.IsSuccess, Describe(read));
|
|
|
|
var snapshot = read.Value;
|
|
Assert.Equal(machine.Id, snapshot.MachineId);
|
|
Assert.True(snapshot.Items.Count > 0, "expected at least one datum from the live agent");
|
|
|
|
// Agent reports AVAILABLE -> Connected. Real MTConnect 2.0 output must parse.
|
|
Assert.Equal(ConnectionState.Connected, snapshot.ConnectionState);
|
|
|
|
// A meaningful datum with a non-empty value must be present. The parser is
|
|
// version-agnostic, so match on the datum Name (parsed from the agent's real feed).
|
|
var meaningful = snapshot.Items.FirstOrDefault(i =>
|
|
!string.IsNullOrWhiteSpace(i.Value) &&
|
|
(Contains(i.Name, "Position") ||
|
|
Contains(i.Name, "Execution") ||
|
|
Contains(i.Name, "Availability")));
|
|
|
|
Assert.True(
|
|
meaningful != null,
|
|
"expected a Position/Execution/Availability datum with a non-empty value; got: " +
|
|
string.Join(", ", snapshot.Items.Take(20).Select(i => i.Name + "=" + i.Value)));
|
|
}
|
|
finally
|
|
{
|
|
(driver as IDisposable)?.Dispose();
|
|
}
|
|
}
|
|
|
|
[DockerFact]
|
|
public async Task Driver_To_Repository_RoundTrip_PersistsSnapshot()
|
|
{
|
|
SkipIfAgentUnavailable();
|
|
|
|
var machine = NewMtconnectMachine();
|
|
|
|
var dbPath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"junction_it_" + Guid.NewGuid().ToString("N") + ".db");
|
|
var connectionFactory = new SqliteConnectionFactory("Data Source=" + dbPath);
|
|
|
|
try
|
|
{
|
|
Result schema = SqliteSchema.EnsureCreated(connectionFactory);
|
|
Assert.True(schema.IsSuccess, Describe(schema));
|
|
|
|
var repo = new SqliteMachineRepository(connectionFactory);
|
|
|
|
Result upsert = await repo.UpsertAsync(machine, CancellationToken.None);
|
|
Assert.True(upsert.IsSuccess, Describe(upsert));
|
|
|
|
// Take a live snapshot via the real driver.
|
|
var driverFactory = new MtconnectDriverFactory();
|
|
Result<IProtocolDriver> created = driverFactory.Create(machine);
|
|
Assert.True(created.IsSuccess, Describe(created));
|
|
|
|
IProtocolDriver driver = created.Value;
|
|
MachineSnapshot snapshot;
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
Result<MachineSnapshot> read = await driver.ReadCurrentAsync(cts.Token);
|
|
Assert.True(read.IsSuccess, Describe(read));
|
|
snapshot = read.Value;
|
|
}
|
|
finally
|
|
{
|
|
(driver as IDisposable)?.Dispose();
|
|
}
|
|
|
|
Assert.True(snapshot.Items.Count > 0, "live snapshot must carry datums to persist");
|
|
|
|
// Persist and read back the latest snapshot.
|
|
Result save = await repo.SaveSnapshotAsync(snapshot, CancellationToken.None);
|
|
Assert.True(save.IsSuccess, Describe(save));
|
|
|
|
Result<MachineSnapshot> loadedResult =
|
|
await repo.GetLatestSnapshotAsync(machine.Id, CancellationToken.None);
|
|
Assert.True(loadedResult.IsSuccess, Describe(loadedResult));
|
|
|
|
var loaded = loadedResult.Value;
|
|
Assert.Equal(machine.Id, loaded.MachineId);
|
|
Assert.Equal(snapshot.ConnectionState, loaded.ConnectionState);
|
|
Assert.Equal(snapshot.Items.Count, loaded.Items.Count);
|
|
|
|
// Every persisted datum is retrievable with its value intact (last datum).
|
|
var expected = snapshot.Items[0];
|
|
var actual = loaded.TryGetItem(expected.Id);
|
|
Assert.NotNull(actual);
|
|
Assert.Equal(expected.Value, actual!.Value);
|
|
Assert.Equal(expected.Name, actual.Name);
|
|
Assert.Equal(expected.Category, actual.Category);
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(dbPath);
|
|
}
|
|
}
|
|
|
|
private static bool Contains(string haystack, string needle) =>
|
|
haystack != null && haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
|
|
private static void TryDelete(string path)
|
|
{
|
|
try
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// best-effort cleanup
|
|
}
|
|
}
|
|
|
|
private static string Describe<T>(Result<T> result) =>
|
|
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
|
|
|
private static string Describe(Result result) =>
|
|
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// Self-contained skippable-fact support for xUnit v2 (no Assert.Skip, no extra package).
|
|
// A test throws SkipTestException at runtime; the custom test case rewrites the resulting
|
|
// TestFailed message into a TestSkipped message, so the runner reports a genuine SKIP.
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
/// <summary>Thrown to skip a test at runtime (dynamic skip for xUnit v2).</summary>
|
|
public sealed class SkipTestException : Exception
|
|
{
|
|
public SkipTestException(string reason) : base(reason) { }
|
|
}
|
|
|
|
/// <summary>A <see cref="FactAttribute"/> whose tests may skip at runtime via <see cref="SkipTestException"/>.</summary>
|
|
[XunitTestCaseDiscoverer("Junction.Tests.Integration.DockerFactDiscoverer", "Junction.Tests")]
|
|
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
|
public sealed class DockerFactAttribute : FactAttribute
|
|
{
|
|
}
|
|
|
|
/// <summary>Discovers <see cref="DockerFactAttribute"/>-decorated methods as skippable test cases.</summary>
|
|
public sealed class DockerFactDiscoverer : IXunitTestCaseDiscoverer
|
|
{
|
|
private static readonly string[] SkippingExceptionNames = { typeof(SkipTestException).FullName! };
|
|
|
|
private readonly IMessageSink _diagnosticMessageSink;
|
|
|
|
public DockerFactDiscoverer(IMessageSink diagnosticMessageSink)
|
|
{
|
|
_diagnosticMessageSink = diagnosticMessageSink;
|
|
}
|
|
|
|
public IEnumerable<IXunitTestCase> Discover(
|
|
ITestFrameworkDiscoveryOptions discoveryOptions,
|
|
ITestMethod testMethod,
|
|
IAttributeInfo factAttribute)
|
|
{
|
|
yield return new SkippableFactTestCase(
|
|
SkippingExceptionNames,
|
|
_diagnosticMessageSink,
|
|
discoveryOptions.MethodDisplayOrDefault(),
|
|
discoveryOptions.MethodDisplayOptionsOrDefault(),
|
|
testMethod);
|
|
}
|
|
}
|
|
|
|
/// <summary>Test case that converts a designated exception into a skip result.</summary>
|
|
public sealed class SkippableFactTestCase : XunitTestCase
|
|
{
|
|
private string[] _skippingExceptionNames = Array.Empty<string>();
|
|
|
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
|
[Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")]
|
|
public SkippableFactTestCase()
|
|
{
|
|
}
|
|
|
|
public SkippableFactTestCase(
|
|
string[] skippingExceptionNames,
|
|
IMessageSink diagnosticMessageSink,
|
|
TestMethodDisplay defaultMethodDisplay,
|
|
TestMethodDisplayOptions defaultMethodDisplayOptions,
|
|
ITestMethod testMethod,
|
|
object[]? testMethodArguments = null)
|
|
: base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments)
|
|
{
|
|
_skippingExceptionNames = skippingExceptionNames;
|
|
}
|
|
|
|
public override void Serialize(IXunitSerializationInfo data)
|
|
{
|
|
base.Serialize(data);
|
|
data.AddValue(nameof(_skippingExceptionNames), _skippingExceptionNames);
|
|
}
|
|
|
|
public override void Deserialize(IXunitSerializationInfo data)
|
|
{
|
|
base.Deserialize(data);
|
|
_skippingExceptionNames = data.GetValue<string[]>(nameof(_skippingExceptionNames));
|
|
}
|
|
|
|
public override async Task<RunSummary> RunAsync(
|
|
IMessageSink diagnosticMessageSink,
|
|
IMessageBus messageBus,
|
|
object[] constructorArguments,
|
|
ExceptionAggregator aggregator,
|
|
CancellationTokenSource cancellationTokenSource)
|
|
{
|
|
var interceptor = new SkippableTestMessageBus(messageBus, _skippingExceptionNames);
|
|
var result = await base.RunAsync(
|
|
diagnosticMessageSink, interceptor, constructorArguments, aggregator, cancellationTokenSource);
|
|
|
|
result.Failed -= interceptor.SkippedCount;
|
|
result.Skipped += interceptor.SkippedCount;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// <summary>Rewrites <see cref="ITestFailed"/> carrying a skipping exception into <see cref="ITestSkipped"/>.</summary>
|
|
public sealed class SkippableTestMessageBus : IMessageBus
|
|
{
|
|
private readonly IMessageBus _inner;
|
|
private readonly string[] _skippingExceptionNames;
|
|
|
|
public SkippableTestMessageBus(IMessageBus inner, string[] skippingExceptionNames)
|
|
{
|
|
_inner = inner;
|
|
_skippingExceptionNames = skippingExceptionNames;
|
|
}
|
|
|
|
public int SkippedCount { get; private set; }
|
|
|
|
public bool QueueMessage(IMessageSinkMessage message)
|
|
{
|
|
if (message is ITestFailed failed)
|
|
{
|
|
var exceptionType = failed.ExceptionTypes.FirstOrDefault();
|
|
if (exceptionType != null && _skippingExceptionNames.Contains(exceptionType))
|
|
{
|
|
SkippedCount++;
|
|
var reason = failed.Messages != null && failed.Messages.Length > 0
|
|
? failed.Messages[0]
|
|
: "skipped";
|
|
return _inner.QueueMessage(new TestSkipped(failed.Test, reason));
|
|
}
|
|
}
|
|
|
|
return _inner.QueueMessage(message);
|
|
}
|
|
|
|
public void Dispose() => _inner.Dispose();
|
|
}
|
|
}
|