Merge b626-mtconnect (batch #626+)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:43:45 -04:00
12 changed files with 373 additions and 54 deletions
@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Gitea #628 (decision b): an MTConnect driver instance binds every tag through one agent client, so a
/// second <c>/raw</c> Device under it is a deploy error — while the normal single-Device layout stays valid.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorMTConnectDeviceTests
{
private const string Code = DraftValidator.MTConnectMultipleDevicesCode;
private static DriverInstance Driver(string id, string type) => new()
{
DriverInstanceId = id, ClusterId = "c", Name = id, DriverType = type, DriverConfig = "{}",
};
private static Device Dev(string id, string driverId) => new()
{
DeviceId = id, DriverInstanceId = driverId, Name = id, DeviceConfig = "{}",
};
private static DraftSnapshot Draft(DriverInstance[] drivers, params Device[] devices) => new()
{
GenerationId = 1, ClusterId = "c", DriverInstances = drivers, Devices = devices,
};
[Fact]
public void Two_devices_under_one_MTConnect_driver_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft([Driver("mt", "MTConnect")], Dev("d1", "mt"), Dev("d2", "mt")));
var e = errors.Where(x => x.Code == Code).ShouldHaveSingleItem();
e.Context.ShouldBe("mt");
e.Message.ShouldContain("one MTConnect driver per agent device");
}
[Fact]
public void The_normal_single_device_layout_stays_valid()
=> DraftValidator.Validate(Draft([Driver("mt", "MTConnect")], Dev("d1", "mt")))
.ShouldNotContain(x => x.Code == Code);
[Fact]
public void A_driver_with_no_device_is_not_this_rules_concern()
=> DraftValidator.Validate(Draft([Driver("mt", "MTConnect")]))
.ShouldNotContain(x => x.Code == Code);
[Fact]
public void Several_devices_under_a_non_MTConnect_driver_are_unaffected_and_devices_are_counted_per_driver()
{
var errors = DraftValidator.Validate(Draft(
[Driver("mb", "Modbus"), Driver("mt1", "MTConnect"), Driver("mt2", "MTConnect")],
Dev("a", "mb"), Dev("b", "mb"), Dev("c", "mt1"), Dev("d", "mt2")));
errors.ShouldNotContain(x => x.Code == Code);
}
}
@@ -30,6 +30,7 @@ public sealed class MTConnectObservationIndexTests
private const uint BadOutOfRange = 0x803C0000u;
private const uint BadNotSupported = 0x803D0000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadDecodingError = 0x80070000u;
/// <summary>Every dataItemId both /current fixtures report.</summary>
private static readonly string[] AllFixtureDataItemIds =
@@ -48,7 +49,7 @@ public sealed class MTConnectObservationIndexTests
/// <summary>Wraps one synthetic observation in the minimal legal streams result.</summary>
private static MTConnectStreamsResult Synthetic(
string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false) =>
string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false, int? sampleCount = null) =>
new(
InstanceId: 1L,
NextSequence: 2L,
@@ -57,7 +58,8 @@ public sealed class MTConnectObservationIndexTests
dataItemId,
value,
timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc),
isStructured)]);
isStructured,
sampleCount)]);
private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
@@ -412,13 +414,9 @@ public sealed class MTConnectObservationIndexTests
// -------------------------------------------------------------------------------- TIME_SERIES
/// <summary>
/// P1 defers TIME_SERIES array materialization to P1.5. An array-authored tag therefore
/// reports BadNotSupported rather than pretending: the vector is real data this build
/// cannot represent, which is neither a type mismatch nor a comms failure.
/// </summary>
/// <summary>#627: a Float64 array tag serves the whole vector as <c>double[]</c>, Good.</summary>
[Fact]
public void Time_series_authored_as_an_array_reports_BadNotSupported()
public void Time_series_authored_as_a_float64_array_parses_to_double_array()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
@@ -426,25 +424,93 @@ public sealed class MTConnectObservationIndexTests
var snap = idx.Get("dev1_vibration_ts");
snap.StatusCode.ShouldBe(BadNotSupported);
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<double[]>()
.ShouldBe([1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0]);
}
/// <summary>The variable-length shape (no declared ArrayDim) is served the same way.</summary>
[Fact]
public void Time_series_without_a_declared_length_is_served_variable_length()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", "1 2.5\n 3"));
var snap = idx.Get("ts");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<double[]>().ShouldBe([1.0, 2.5, 3.0]);
}
[Fact]
public void Time_series_count_mismatching_the_observations_sampleCount_is_Bad()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", "1 2 3", sampleCount: 4));
var snap = idx.Get("ts");
snap.StatusCode.ShouldBe(BadDecodingError);
snap.Value.ShouldBeNull();
}
/// <summary>
/// The falsifiable half of the deferral: the vector must never be silently reduced to its
/// first element. A Good 1.1 here would be a wrong value an operator would trust.
/// </summary>
[Fact]
public void Time_series_vector_is_never_silently_parsed_as_its_first_element()
public void Time_series_count_mismatching_a_fixed_ArrayDim_is_Bad()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true, ArrayDim: 5)]);
idx.Apply(Synthetic("ts", "1 2 3"));
idx.Get("ts").StatusCode.ShouldBe(BadDecodingError);
}
[Theory]
[InlineData("1 2 abc")]
[InlineData("1 NaN 3")]
[InlineData("1 UNAVAILABLE 3")]
public void Time_series_with_an_unusable_token_is_Bad_never_a_partial_vector(string text)
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", text));
var snap = idx.Get("ts");
snap.StatusCode.ShouldNotBe(Good);
snap.Value.ShouldBeNull();
}
[Fact]
public void Empty_time_series_reports_no_communication()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", " "));
idx.Get("ts").StatusCode.ShouldBe(BadNoCommunication);
}
[Fact]
public void A_non_float64_array_tag_stays_BadNotSupported()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Int32, IsArray: true)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_vibration_ts");
idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNotSupported);
}
snap.Value.ShouldNotBe(1.1);
snap.StatusCode.ShouldNotBe(Good);
/// <summary>The parser carries the observation's own sampleCount / sampleRate attributes.</summary>
[Fact]
public void Parser_carries_time_series_sampleCount_and_sampleRate()
{
var obs = ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_vibration_ts");
obs.SampleCount.ShouldBe(10);
obs.SampleRate.ShouldBe(100.0);
ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_pos").SampleCount.ShouldBeNull();
}
/// <summary>
@@ -452,7 +518,7 @@ public sealed class MTConnectObservationIndexTests
/// representable state and must report it rather than the deferral code.
/// </summary>
[Fact]
public void Unavailable_wins_over_the_unsupported_array_shape()
public void Unavailable_array_tag_reports_no_communication()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
@@ -219,15 +219,16 @@ public sealed class MTConnectReadTests
res[0].SourceTimestampUtc.ShouldNotBeNull();
}
/// <summary>A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral).</summary>
/// <summary>A probe-declared TIME_SERIES item is read end to end as a <c>double[]</c> (#627).</summary>
[Fact]
public async Task Read_of_a_time_series_ref_reports_BadNotSupported()
public async Task Read_of_a_time_series_ref_returns_the_double_vector()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["dev1_vibration_ts"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadNotSupported);
res[0].StatusCode.ShouldBe(0u);
res[0].Value.ShouldBeOfType<double[]>().Length.ShouldBe(10);
}
/// <summary>A blank reference is data, not a programming error — it codes Bad, it does not throw.</summary>
@@ -328,4 +328,15 @@ public sealed class MTConnectTagConfigModelTests
public void Warnings_is_a_noop_without_probe_metadata()
=> MTConnectTagConfigModel.FromJson("{\"fullName\":\"x\",\"dataType\":\"Boolean\"}")
.Warnings().ShouldBeEmpty();
[Fact]
public void Warnings_accepts_a_float64_array_for_time_series_and_flags_other_array_types()
{
MTConnectTagConfigModel.FromJson("{\"fullName\":\"x\",\"dataType\":\"Float64\",\"isArray\":true}")
.Warnings().ShouldBeEmpty();
var m = MTConnectTagConfigModel.FromJson("{\"fullName\":\"x\",\"dataType\":\"Int32\",\"isArray\":true}");
m.Warnings().ShouldHaveSingleItem().ShouldContain("Float64");
m.Validate().ShouldBeNull(); // advisory only
}
}
@@ -421,6 +421,25 @@ public sealed class RawTreeServiceWaveBTests
result.Error!.ShouldContain("already exists");
}
[Fact]
public async Task CreateDevice_refuses_a_second_device_under_an_MTConnect_driver_but_not_under_others()
{
var (service, dbName) = Seeded();
var mt = await service.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "mt-1", "MTConnect", "{}");
mt.Ok.ShouldBeTrue();
// CreateDriver auto-creates Device1, so the very next device is the ambiguous second one (#628).
var refused = await service.CreateDeviceAsync(mt.CreatedId!, "Device2", "{}");
refused.Ok.ShouldBeFalse();
refused.Error!.ShouldContain("one MTConnect driver per agent device");
// Positive control: the same call against a non-MTConnect driver still succeeds.
(await service.CreateDeviceAsync(RawTreeTestDb.FolderedDriverId, "Device9", "{}")).Ok.ShouldBeTrue();
using var db = RawTreeTestDb.CreateNamed(dbName);
db.Devices.Count(d => d.DriverInstanceId == mt.CreatedId).ShouldBe(1);
}
// ------------------------------------------------------------- Merged probe config (Test connect)
[Fact]