Merge b626-ablegacy-focas (batch #626+)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:43:44 -04:00
17 changed files with 946 additions and 176 deletions
@@ -0,0 +1,320 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>Family-aware addressing: PLC-5 octal I/O (#244), MicroLogix function files (#245), indirect (#247).</summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyFamilyAddressingTests
{
// ---- #244 PLC-5 octal I/O ----
/// <summary>PLC-5 I/O word and bit indices parse as octal and are stored decimal.</summary>
[Theory]
[InlineData("I:017/07", "I", 15, 7, "I:15/7")]
[InlineData("I:017/17", "I", 15, 15, "I:15/15")]
[InlineData("O:010/10", "O", 8, 8, "O:8/8")]
[InlineData("I:000", "I", 0, null, "I:0")]
[InlineData("I:12", "I", 10, null, "I:10")]
public void Plc5_io_indices_are_octal(string text, string letter, int word, int? bit, string libplctag)
{
var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldNotBeNull();
a.FileLetter.ShouldBe(letter);
a.WordNumber.ShouldBe(word);
a.BitIndex.ShouldBe(bit);
a.ToLibplctagName().ShouldBe(libplctag);
}
/// <summary>The decimal-normalised name round-trips family-neutrally, so the native transport reads back the same numbers.</summary>
[Fact]
public void Plc5_octal_address_round_trips_through_the_family_neutral_parser()
{
var a = AbLegacyAddress.TryParse("I:017/07", AbLegacyPlcFamily.Plc5)!;
AbLegacyAddress.TryParse(a.ToLibplctagName()).ShouldBe(a);
}
/// <summary>A digit 8 or 9 is not octal: a clear parse failure, not a silent misread.</summary>
[Theory]
[InlineData("I:018")]
[InlineData("I:019/07")]
[InlineData("I:017/08")]
[InlineData("O:9/1")]
[InlineData("O:1/9")]
public void Plc5_io_rejects_8_and_9(string text) =>
AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldBeNull();
/// <summary>Control: the same text under SLC / MicroLogix / Logix-PCCC / no family is decimal.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500)]
[InlineData(AbLegacyPlcFamily.MicroLogix)]
[InlineData(AbLegacyPlcFamily.LogixPccc)]
public void Non_plc5_io_stays_decimal(AbLegacyPlcFamily family)
{
AbLegacyAddress.TryParse("I:12/9", family).ShouldNotBeNull().WordNumber.ShouldBe(12);
AbLegacyAddress.TryParse("I:12/9", family)!.BitIndex.ShouldBe(9);
AbLegacyAddress.TryParse("I:12/9").ShouldNotBeNull().WordNumber.ShouldBe(12);
}
/// <summary>PLC-5 N-files (and bit indices on them) stay decimal — only I/O is octal.</summary>
[Fact]
public void Plc5_non_io_files_stay_decimal()
{
var a = AbLegacyAddress.TryParse("N7:12/9", AbLegacyPlcFamily.Plc5).ShouldNotBeNull();
a.WordNumber.ShouldBe(12);
a.BitIndex.ShouldBe(9);
}
// ---- #245 MicroLogix function files ----
/// <summary>MicroLogix parses the function-file letters, with their sub-elements.</summary>
[Theory]
[InlineData("RTC:0.HR", "RTC", 0, "HR")]
[InlineData("HSC:0.ACC", "HSC", 0, "ACC")]
[InlineData("pto:0.EN", "PTO", 0, "EN")]
[InlineData("STI:0.SPM", "STI", 0, "SPM")]
[InlineData("BHI:0.FRN", "BHI", 0, "FRN")]
public void MicroLogix_parses_function_files(string text, string letter, int element, string sub)
{
var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
a.FileLetter.ShouldBe(letter);
a.FileNumber.ShouldBeNull();
a.WordNumber.ShouldBe(element);
a.SubElement.ShouldBe(sub);
}
/// <summary>Function files are MicroLogix-only; every other family and the neutral grammar reject them.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500)]
[InlineData(AbLegacyPlcFamily.Plc5)]
[InlineData(AbLegacyPlcFamily.LogixPccc)]
public void Other_families_reject_function_files(AbLegacyPlcFamily family)
{
AbLegacyAddress.TryParse("RTC:0.HR", family).ShouldBeNull();
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
}
/// <summary>A function file is a fixed single instance and not bit-addressable.</summary>
[Theory]
[InlineData("RTC1:0.HR")]
[InlineData("RTC:0/3")]
[InlineData("HSC:0.ACC/2")]
public void MicroLogix_function_file_shape_is_validated(string text) =>
AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldBeNull();
/// <summary>The catalogue maps flags to Boolean, numeric members to Int32, and unknown members to Int32.</summary>
[Theory]
[InlineData("RTC", "HR", DriverDataType.Int32)]
[InlineData("RTC", "BL", DriverDataType.Boolean)]
[InlineData("HSC", "ACC", DriverDataType.Int32)]
[InlineData("HSC", "DN", DriverDataType.Boolean)]
[InlineData("PTO", "OUT", DriverDataType.Boolean)]
[InlineData("STI", "SPM", DriverDataType.Int32)]
[InlineData("RTC", "NOPE", DriverDataType.Int32)]
public void Function_file_catalogue(string file, string sub, DriverDataType expected) =>
AbLegacyFunctionFile.SubElementType(file, sub).ShouldBe(expected);
// ---- #247 indirect addressing ----
/// <summary>Both indirect forms parse, carry their inner address, and round-trip their text.</summary>
[Theory]
[InlineData("N7:[N7:0]", false, "N7:[N7:0]")]
[InlineData("N[N7:0]:5", true, "N[N7:0]:5")]
[InlineData("N7:[T4:1.ACC]", false, "N7:[T4:1.ACC]")]
[InlineData("B3:[N7:0]/4", false, "B3:[N7:0]/4")]
public void Indirect_forms_parse(string text, bool fileIndirect, string roundTrip)
{
var a = AbLegacyAddress.TryParse(text).ShouldNotBeNull();
a.IsIndirect.ShouldBeTrue();
(a.IndirectFileSource is not null).ShouldBe(fileIndirect);
(a.IndirectWordSource is not null).ShouldBe(!fileIndirect);
a.ToLibplctagName().ShouldBe(roundTrip);
}
/// <summary>Nested indirection, empty or invalid inner addresses, and unbalanced brackets are rejected.</summary>
[Theory]
[InlineData("N7:[N[N7:0]:1]")]
[InlineData("N7:[]")]
[InlineData("N7:[Q9:0]")]
[InlineData("N7:[N7:0")]
[InlineData("N7:N7:0]")]
[InlineData("N7:[N7:[N7:0]]")]
public void Malformed_indirect_is_rejected(string text) =>
AbLegacyAddress.TryParse(text).ShouldBeNull();
/// <summary>A direct address is not indirect.</summary>
[Fact]
public void Direct_address_is_not_indirect() =>
AbLegacyAddress.TryParse("N7:3/2")!.IsIndirect.ShouldBeFalse();
// ---- driver level ----
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
RawTags = AbLegacyRawTags.Entries(tags),
}, "drv-1", factory);
return (drv, factory);
}
/// <summary>A PLC-5 octal I/O tag reaches the transport as the decimal name, and its bit decodes at the octal-parsed index.</summary>
[Fact]
public async Task Plc5_octal_io_tag_is_handed_to_the_transport_decimal()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Plc5,
new AbLegacyTagDefinition("In", "ab://10.0.0.5/1,0", "I:017/17", AbLegacyDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = true };
var snap = (await drv.ReadAsync(["In"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.ContainsKey("I:15/15").ShouldBeTrue();
factory.Tags["I:15/15"].ReadCount.ShouldBe(1);
}
/// <summary>An indirect tag is answered BadNotSupported on read and write, and no runtime is ever created for it.</summary>
[Fact]
public async Task Indirect_tag_is_BadNotSupported_and_creates_no_runtime()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Ind", "ab://10.0.0.5/1,0", "N7:[N7:0]", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Ok", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5 };
var read = await drv.ReadAsync(["Ind", "Ok"], CancellationToken.None);
var write = await drv.WriteAsync(
[new WriteRequest("Ind", 1), new WriteRequest("Ok", 1)], CancellationToken.None);
read[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
// positive control: the neighbouring direct tag in the same batch still works, so the refusal is per-tag
read[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
write[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.Keys.ShouldNotContain(k => k.Contains('['));
}
/// <summary>A MicroLogix function-file tag reaches the transport under its libplctag name.</summary>
[Fact]
public async Task MicroLogix_function_file_tag_reaches_the_transport()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.MicroLogix,
new AbLegacyTagDefinition("Hour", "ab://10.0.0.5/1,0", "RTC:0.HR", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 14 };
var snap = (await drv.ReadAsync(["Hour"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snap.Value.ShouldBe(14);
factory.Tags.ContainsKey("RTC:0.HR").ShouldBeTrue();
}
// ---- native transport ----
private sealed class NativeHarness : IAsyncDisposable
{
private readonly FakeCipChannel _channel = new();
private readonly RequestScheduler _scheduler;
public NativeAbLegacyTagRuntime Runtime { get; }
public NativeHarness(string plc, string tag)
{
_scheduler = new RequestScheduler(_channel, allowPacking: false);
Runtime = new NativeAbLegacyTagRuntime(
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(2), 1),
_scheduler, 0x1234, 0x01020304);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>The native transport refuses function files and indirect names as Unsupported rather than mis-encoding them.</summary>
[Theory]
[InlineData("RTC:0.HR", "function file")]
[InlineData("N7:[N7:0]", "indirect")]
public async Task Native_transport_refuses_function_file_and_indirect_as_Unsupported(string tag, string why)
{
await using var h = new NativeHarness("micrologix", tag);
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.Unsupported);
ex.Message.ShouldContain(why, Case.Insensitive);
}
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<AbLegacyDriver>
{
public readonly System.Collections.Concurrent.ConcurrentQueue<string> Warnings = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel level, Microsoft.Extensions.Logging.EventId id,
TState state, Exception? ex, Func<TState, Exception?, string> f)
{
if (level == Microsoft.Extensions.Logging.LogLevel.Warning) Warnings.Enqueue(f(state, ex));
}
}
/// <summary>
/// A PLC-5 address that no longer parses (the pre-octal-era decimal 8/9 digit) is a config error: BadNodeIdUnknown on
/// read and write, no runtime created, health untouched, and ONE warning per tag however often it is polled.
/// </summary>
[Fact]
public async Task Malformed_address_is_BadNodeIdUnknown_warned_once_and_never_throws()
{
var factory = new FakeAbLegacyTagFactory();
var logger = new CapturingLogger();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("Bad", "ab://10.0.0.5/1,0", "I:018", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Good", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
var first = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
var second = await drv.ReadAsync(["Bad"], CancellationToken.None);
var write = await drv.WriteAsync([new WriteRequest("Bad", 1)], CancellationToken.None);
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
factory.Tags.Keys.ShouldNotContain(k => k.StartsWith("I:"));
logger.Warnings.Count(w => w.Contains("not valid for the device's PLC family") && w.Contains("I:018")).ShouldBe(1);
}
/// <summary>The connectivity-probe address is normalised like tag paths (PLC-5 octal I/O handed over decimal).</summary>
[Fact]
public async Task Probe_address_is_normalised_like_tag_paths()
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
Probe = new AbLegacyProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(200),
ProbeAddress = "I:017",
},
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!factory.Tags.ContainsKey("I:15") && DateTime.UtcNow < deadline) await Task.Delay(20);
await drv.ShutdownAsync(CancellationToken.None);
factory.Tags.ContainsKey("I:15").ShouldBeTrue();
factory.Tags.ContainsKey("I:017").ShouldBeFalse();
}
}
@@ -69,8 +69,9 @@ public sealed class FocasCapabilityMatrixTests
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
[InlineData(FocasCncSeries.Sixteen_i, "F", true)] // #265: F/G handshakes are documented on 16i ladders
[InlineData(FocasCncSeries.Sixteen_i, "G", true)]
[InlineData(FocasCncSeries.Sixteen_i, "M", false)]
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
@@ -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();
}
}
@@ -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");