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>
164 lines
5.5 KiB
C#
164 lines
5.5 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Junction.Domain.Models;
|
|
using Junction.Protocols.MTConnect.Parsing;
|
|
using Xunit;
|
|
|
|
namespace Junction.Tests.Unit
|
|
{
|
|
public sealed class MtconnectParserTests
|
|
{
|
|
private static readonly Guid MachineId = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
|
|
|
private static string LoadFixture(string name)
|
|
{
|
|
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "mtconnect", name);
|
|
return File.ReadAllText(path);
|
|
}
|
|
|
|
// ---- PROBE ----
|
|
|
|
[Fact]
|
|
public void Probe_ParsesDataItemDescriptors()
|
|
{
|
|
var result = MtconnectProbeParser.Parse(LoadFixture("probe.xml"));
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var items = result.Value;
|
|
Assert.True(items.Count > 0);
|
|
|
|
// Spot-check a known DataItem from the fixture.
|
|
var pos = items.SingleOrDefault(d => d.Id == "x1_pos");
|
|
Assert.NotNull(pos);
|
|
Assert.Equal("POSITION", pos!.Type);
|
|
Assert.Equal("SAMPLE", pos.Category);
|
|
Assert.Equal("ACTUAL", pos.SubType);
|
|
Assert.Equal("MILLIMETER", pos.Units);
|
|
Assert.Equal("dev1", pos.DeviceId);
|
|
Assert.Equal("junction-vmc-01", pos.DeviceUuid);
|
|
|
|
// Availability descriptor present.
|
|
Assert.Contains(items, d => d.Id == "dev1_avail" && d.Type == "AVAILABILITY");
|
|
}
|
|
|
|
[Fact]
|
|
public void Probe_MalformedRoot_Fails()
|
|
{
|
|
var result = MtconnectProbeParser.Parse("<NotAProbe/>");
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.False(result.WasCancelled);
|
|
Assert.NotEmpty(result.Errors);
|
|
}
|
|
|
|
// ---- CURRENT ----
|
|
|
|
[Fact]
|
|
public void Current_ParsesSnapshot_WithKnownObservations()
|
|
{
|
|
var result = MtconnectCurrentParser.Parse(LoadFixture("current.xml"), MachineId);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var snapshot = result.Value;
|
|
|
|
Assert.Equal(MachineId, snapshot.MachineId);
|
|
Assert.False(snapshot.IsEmpty);
|
|
|
|
// Known position value.
|
|
var xPos = snapshot.TryGetItem("x1_pos");
|
|
Assert.NotNull(xPos);
|
|
Assert.Equal("125.4300", xPos!.Value);
|
|
Assert.Equal("Position", xPos.Name);
|
|
Assert.Equal("Samples", xPos.Category);
|
|
|
|
// EXECUTION = ACTIVE.
|
|
var exec = snapshot.TryGetItem("path1_exec");
|
|
Assert.NotNull(exec);
|
|
Assert.Equal("ACTIVE", exec!.Value);
|
|
|
|
// Condition state carried as value.
|
|
var tempCond = snapshot.TryGetItem("c1_temp_cond");
|
|
Assert.NotNull(tempCond);
|
|
Assert.Equal("Condition", tempCond!.Category);
|
|
Assert.Equal("Normal", tempCond.Value);
|
|
|
|
// AVAILABILITY -> Connected.
|
|
Assert.Equal(ConnectionState.Connected, snapshot.ConnectionState);
|
|
|
|
// CapturedAt = latest observed timestamp (10240 seq @ .240Z).
|
|
Assert.Equal(
|
|
DateTimeOffset.Parse("2026-07-21T09:15:30.240Z"),
|
|
snapshot.CapturedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public void Current_Unavailable_YieldsDisconnected()
|
|
{
|
|
var result = MtconnectCurrentParser.Parse(LoadFixture("current_unavailable.xml"), MachineId);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(ConnectionState.Disconnected, result.Value.ConnectionState);
|
|
}
|
|
|
|
[Fact]
|
|
public void Current_Malformed_FailsWithoutThrowing()
|
|
{
|
|
// current_malformed.xml is truncated (unclosed elements) -> XmlException internally.
|
|
var result = MtconnectCurrentParser.Parse(LoadFixture("current_malformed.xml"), MachineId);
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.False(result.WasCancelled);
|
|
Assert.NotEmpty(result.Errors);
|
|
}
|
|
|
|
// ---- VERSION-AGNOSTIC PROOF ----
|
|
|
|
[Fact]
|
|
public void Current_IsNamespaceVersionAgnostic()
|
|
{
|
|
var v17 = LoadFixture("current.xml");
|
|
var v20 = v17.Replace(":1.7", ":2.0");
|
|
Assert.Contains(":2.0", v20);
|
|
Assert.DoesNotContain(":1.7", v20);
|
|
|
|
var r17 = MtconnectCurrentParser.Parse(v17, MachineId);
|
|
var r20 = MtconnectCurrentParser.Parse(v20, MachineId);
|
|
|
|
Assert.True(r17.IsSuccess);
|
|
Assert.True(r20.IsSuccess);
|
|
|
|
var s17 = r17.Value;
|
|
var s20 = r20.Value;
|
|
|
|
Assert.Equal(s17.Items.Count, s20.Items.Count);
|
|
Assert.Equal(s17.ConnectionState, s20.ConnectionState);
|
|
Assert.Equal(s17.CapturedAt, s20.CapturedAt);
|
|
|
|
// Item-by-item identical (id, value, category, timestamp).
|
|
foreach (var a in s17.Items)
|
|
{
|
|
var b = s20.TryGetItem(a.Id);
|
|
Assert.NotNull(b);
|
|
Assert.Equal(a.Value, b!.Value);
|
|
Assert.Equal(a.Category, b.Category);
|
|
Assert.Equal(a.Name, b.Name);
|
|
Assert.Equal(a.Timestamp, b.Timestamp);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Probe_IsNamespaceVersionAgnostic()
|
|
{
|
|
var v17 = LoadFixture("probe.xml");
|
|
var v20 = v17.Replace(":1.7", ":2.0");
|
|
|
|
var r17 = MtconnectProbeParser.Parse(v17);
|
|
var r20 = MtconnectProbeParser.Parse(v20);
|
|
|
|
Assert.True(r17.IsSuccess);
|
|
Assert.True(r20.IsSuccess);
|
|
Assert.Equal(r17.Value.Count, r20.Value.Count);
|
|
}
|
|
}
|
|
}
|