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
{
///
/// Docker-gated end-to-end integration tests: real ->
/// live ladder99 MTConnect agent (docker mock at http://localhost:5000) -> snapshot
/// round-trip through the real SQLite repository.
///
/// These tests assume the mock is ALREADY running (they do NOT auto-start docker):
/// docker compose -f mock/docker-compose.yml up -d. When the agent is not
/// reachable (no docker in CI, mock down) each test SKIPS (never fails), so a CI
/// without docker stays green.
///
/// xUnit v2 2.9.3 has no Assert.Skip (v3-only), so a self-contained skippable-fact
/// discoverer ( + ) provides a
/// genuine runtime SKIP result — no extra NuGet package, no csproj change.
/// Filter with dotnet test --filter Category=Docker.
///
[Trait("Category", "Docker")]
public sealed class MtconnectEndToEndTests
{
private const string AgentUrl = "http://localhost:5000";
///
/// 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".
///
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;
}
}
/// Skips the current test (does not fail) when the docker MTConnect mock is down.
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 { ["AgentUrl"] = AgentUrl },
TimeSpan.FromSeconds(1));
[DockerFact]
public async Task Driver_ReadsCurrent_FromLiveAgent_VersionAgnostic()
{
SkipIfAgentUnavailable();
var machine = NewMtconnectMachine();
var factory = new MtconnectDriverFactory();
Result created = factory.Create(machine);
Assert.True(created.IsSuccess, Describe(created));
IProtocolDriver driver = created.Value;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
Result 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 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 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 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(Result 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.
// ---------------------------------------------------------------------------------------
/// Thrown to skip a test at runtime (dynamic skip for xUnit v2).
public sealed class SkipTestException : Exception
{
public SkipTestException(string reason) : base(reason) { }
}
/// A whose tests may skip at runtime via .
[XunitTestCaseDiscoverer("Junction.Tests.Integration.DockerFactDiscoverer", "Junction.Tests")]
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class DockerFactAttribute : FactAttribute
{
}
/// Discovers -decorated methods as skippable test cases.
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 Discover(
ITestFrameworkDiscoveryOptions discoveryOptions,
ITestMethod testMethod,
IAttributeInfo factAttribute)
{
yield return new SkippableFactTestCase(
SkippingExceptionNames,
_diagnosticMessageSink,
discoveryOptions.MethodDisplayOrDefault(),
discoveryOptions.MethodDisplayOptionsOrDefault(),
testMethod);
}
}
/// Test case that converts a designated exception into a skip result.
public sealed class SkippableFactTestCase : XunitTestCase
{
private string[] _skippingExceptionNames = Array.Empty();
[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(nameof(_skippingExceptionNames));
}
public override async Task 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;
}
}
/// Rewrites carrying a skipping exception into .
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();
}
}