feat(mtconnect): one agent client per driver — a second /raw Device is a deploy error (#628)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:29:50 -04:00
parent da33f27544
commit dcc827ca8c
5 changed files with 144 additions and 14 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);
}
}
@@ -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]