Merge branch 'master' into b626-opcuaclient

This commit is contained in:
Joseph Doherty
2026-10-08 14:47:49 -04:00
72 changed files with 3775 additions and 343 deletions
@@ -105,4 +105,61 @@ public sealed class S7DriverPageFormSerializationTests
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
roundTripped.ProbeTimeoutSeconds.ShouldBe(20);
}
[Fact]
public void FormModel_RoundTrip_PreservesTsapOverrides()
{
var opts = new S7DriverOptions
{
Host = "192.168.1.50",
LocalTsap = "01.00",
RemoteTsap = "03.01",
};
var back = S7DriverForm.FormModel.FromOptions(opts).ToOptions();
back.LocalTsap.ShouldBe("01.00");
back.RemoteTsap.ShouldBe("03.01");
back.ConnectionType.ShouldBeNull();
var withType = S7DriverForm.FormModel.FromOptions(new S7DriverOptions { ConnectionType = S7ConnectionType.OP }).ToOptions();
withType.ConnectionType.ShouldBe(S7ConnectionType.OP);
// Defaults stay absent: a blank form must not invent an override.
var blank = S7DriverForm.FormModel.FromOptions(new S7DriverOptions()).ToOptions();
blank.LocalTsap.ShouldBeNull();
blank.RemoteTsap.ShouldBeNull();
blank.ConnectionType.ShouldBeNull();
}
[Fact]
public void ConnectionType_serialises_as_a_string_the_factory_binds()
{
var json = JsonSerializer.Serialize(
new S7DriverOptions { ConnectionType = S7ConnectionType.PG },
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
json.ShouldContain("\"connectionType\":\"PG\"");
}
[Fact]
public void Numeric_tsap_loads_into_the_form_model_instead_of_falling_back_to_defaults()
{
// The factory accepts a JSON number; the form binds the same document the same way, so a Save
// cannot silently replace the authored config with defaults.
var opts = JsonSerializer.Deserialize<S7DriverOptions>(
"""{"remoteTsap":769,"localTsap":"01.00","cpuType":"S7300"}""",
new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } });
opts.ShouldNotBeNull();
var back = S7DriverForm.FormModel.FromOptions(opts).ToOptions();
back.RemoteTsap.ShouldBe("03.01");
back.LocalTsap.ShouldBe("01.00");
back.CpuType.ShouldBe(S7CpuType.S7300);
}
[Fact]
public void Form_reports_the_factory_conflict_error_for_remote_tsap_plus_connection_type()
{
var form = new S7DriverForm.FormModel { RemoteTsap = "03.01", ConnectionTypeText = "PG" };
form.TsapConflictError().ShouldNotBeNull().ShouldContain(S7DriverOptions.TsapConflictDetail);
new S7DriverForm.FormModel { RemoteTsap = "03.01" }.TsapConflictError().ShouldBeNull();
new S7DriverForm.FormModel { ConnectionTypeText = "PG" }.TsapConflictError().ShouldBeNull();
}
}
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>#244/#245/#247: one address authority (AbLegacyAddress.Validate) behind the editor and the deploy gate.</summary>
public sealed class AbLegacyFamilyAddressGateTests
{
private static string Cfg(string address) => "{\"address\":\"" + address + "\",\"dataType\":\"Int\"}";
[Theory]
[InlineData("{\"PlcFamily\":\"Plc5\"}", "I:018", true)] // octal 8 on PLC-5
[InlineData("{\"PlcFamily\":\"Plc5\"}", "I:017/07", false)]
[InlineData("{\"PlcFamily\":\"Slc500\"}", "I:018", false)] // control: decimal on SLC
[InlineData("{\"plcFamily\":\"plc5\"}", "I:018", true)] // key + value case-insensitive
[InlineData("{\"PlcFamily\":\"Slc500\"}", "RTC:0.HR", true)] // function file off MicroLogix
[InlineData("{\"PlcFamily\":\"MicroLogix\"}", "RTC:0.HR", false)]
[InlineData(null, "RTC:0.HR", true)] // absent family = runtime default SLC 500
[InlineData("{\"PlcFamily\":\"Bogus\"}", "I:018", false)] // unknown family: never stricter than the runtime
[InlineData("{\"PlcFamily\":\"Slc500\"}", "Q9:0", true)]
public void Gate_checks_the_address_against_the_device_family(string? deviceConfig, string address, bool warns)
{
var w = EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg(address), deviceConfig);
(w.Count > 0).ShouldBe(warns, string.Join("; ", w));
}
[Fact]
public void Gate_warns_about_indirect_addresses_which_never_read()
{
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg("N7:[N7:0]"), "{\"PlcFamily\":\"Slc500\"}")
.ShouldContain(x => x.Contains("indirect"));
}
[Theory]
[InlineData("N7:0", null)]
[InlineData("I:018", null)] // family unknown to the editor: another family accepts it
[InlineData("RTC:0.HR", null)] // MicroLogix accepts it
[InlineData("Q9:0", "any PLC family")]
public void Editor_validates_only_the_family_independent_shape(string address, string? error)
{
var result = AbLegacyTagConfigModel.FromJson(Cfg(address)).Validate();
if (error is null) result.ShouldBeNull(); else result!.ShouldContain(error);
}
[Fact]
public void Editor_is_never_stricter_than_the_gate()
{
const string address = "Q9:0";
AbLegacyTagConfigModel.FromJson(Cfg(address)).Validate().ShouldNotBeNull();
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg(address), null).ShouldNotBeEmpty();
}
}
@@ -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
}
}
@@ -346,7 +346,7 @@ public sealed class RawTagConfigGateTests
public void A_browse_committed_config_passes_the_gate(string driverType)
{
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
driverType, "SomeAddress", BrowseFieldsFor(driverType));
driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType));
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
$"browse-commit for {driverType} must not author a config the import refuses");
@@ -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]
@@ -112,6 +112,14 @@ public sealed class S7TagConfigModelTests
[InlineData("Float64")]
[InlineData("String")]
[InlineData("DateTime")]
[InlineData("WString")]
[InlineData("Char")]
[InlineData("WChar")]
[InlineData("Dtl")]
[InlineData("Date")]
[InlineData("Tod")]
[InlineData("Time")]
[InlineData("S5Time")]
public void Validate_rejects_wide_type_arrays(string dataType)
{
var m = S7TagConfigModel.FromJson(