diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs index 5631d413..c69d04b1 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs @@ -1,4 +1,5 @@ using CliFx.Attributes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli; @@ -48,7 +49,10 @@ public abstract class AbCipCommandBase : DriverCommandBase /// /// Build an with the device + tag list a subclass /// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the - /// probe loop would race the operator's own reads. + /// probe loop would race the operator's own reads. Each typed tag is serialised to the v3 + /// shape (RawPath = tag name, TagConfig blob via + /// , DeviceName = the tag's device routing key) + /// — the same seam the deploy artifact uses. /// /// The list of tag definitions to include in the options. /// The constructed . @@ -58,7 +62,11 @@ public abstract class AbCipCommandBase : DriverCommandBase HostAddress: Gateway, PlcFamily: Family, DeviceName: $"cli-{Family}")], - Tags = tags, + RawTags = [.. tags.Select(t => new RawTagEntry( + RawPath: t.Name, + TagConfig: AbCipTagDefinitionFactory.ToTagConfig(t), + WriteIdempotent: t.WriteIdempotent, + DeviceName: t.DeviceHostAddress))], Timeout = Timeout, Probe = new AbCipProbeOptions { Enabled = false }, EnableControllerBrowse = false, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs index 775af04d..7c949862 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs @@ -1,10 +1,12 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// /// AB CIP / EtherNet-IP driver configuration, bound from the driver's DriverConfig /// JSON at DriverHost.RegisterAsync. One instance supports N devices (PLCs) behind -/// the same driver; per-device routing is keyed on -/// via IPerCallHostResolver. +/// the same driver; per-device routing is keyed on the tag's device routing key (the RawPath's +/// device segment, delivered on ). /// public sealed class AbCipDriverOptions { @@ -12,14 +14,23 @@ public sealed class AbCipDriverOptions /// PLCs this driver instance talks to. Each device contributes its own /// string as the hostName key used by resilience pipelines and the Admin UI. /// + /// + /// Wave C moves the device's live connection host into the Device row's DeviceConfig; + /// for Wave B the list stays bound from DriverConfig as today, and a tag routes to its device + /// by matching against the device's name () + /// or its . + /// public IReadOnlyList Devices { get; init; } = []; /// - /// Pre-declared tag map across all devices. Pre-declared tags always emit during - /// discovery; opt in to controller-side discovery via + /// Authored raw tags this driver serves. The deploy artifact hands each authored raw + /// Tag as a (RawPath identity + driver TagConfig blob + + /// WriteIdempotent flag + DeviceName routing key); the driver maps each through + /// into its RawPath → definition table. + /// Pre-declared tags always emit during discovery; opt in to controller-side discovery via /// . /// - public IReadOnlyList Tags { get; init; } = []; + public IReadOnlyList RawTags { get; init; } = []; /// Per-device probe settings. Falls back to defaults when omitted. public AbCipProbeOptions Probe { get; init; } = new(); @@ -111,8 +122,13 @@ public sealed record AbCipDeviceOptions( /// /// One AB-backed OPC UA variable. Mirrors the ModbusTagDefinition shape. /// -/// Tag name; becomes the OPC UA browse name and full reference. -/// Which device () this tag lives on. +/// Tag name; becomes the OPC UA browse name and full reference (the RawPath in v3). +/// The tag's device routing key — the RawPath's device segment, +/// threaded in from by the driver at table-build time (the mapper +/// leaves it empty; device placement is no longer in the TagConfig blob). Resolved against the driver's +/// devices by matching or . +/// TODO(v3 WaveC): once the connection host moves to the Device row's DeviceConfig this is a pure logical +/// device name; the name still reads DeviceHostAddress to avoid churn across the driver + CLI. /// Logix symbolic path (controller or program scope). /// Logix atomic type, or for UDT-typed tags. /// When true and the tag's ExternalAccess permits writes, IWritable routes writes here. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs deleted file mode 100644 index 82e77ee4..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Text.Json; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; - -/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI -/// AbCipTagConfigModel) into a transient whose -/// equals the reference string itself, so a value the -/// driver publishes back keys the runtime's forward router correctly. -public static class AbCipEquipmentTagParser -{ - /// Attempts to parse an equipment-tag reference into a transient definition. - /// The equipment tag's TagConfig JSON (also used as the def identity). - /// The transient definition when parsing succeeds. - /// when is an AbCip TagConfig object. - /// - /// is read from the optional "writable" - /// boolean field in the TagConfig JSON; it defaults to true when the field is absent, - /// matching the record's documented default and the behaviour of pre-declared tags. Operators - /// who need a read-only OPC UA surface can author "writable":false in the TagConfig; - /// the PLC's ExternalAccess attribute remains the effective write gate at the wire level. - /// - public static bool TryParse(string reference, out AbCipTagDefinition def) - { - def = null!; - // Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob. - if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false; - try - { - using var doc = JsonDocument.Parse(reference); - var root = doc.RootElement; - // AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path. - if (root.ValueKind != JsonValueKind.Object - || !root.TryGetProperty("tagPath", out var tagPathEl) - || tagPathEl.ValueKind != JsonValueKind.String) - return false; - var tagPath = tagPathEl.GetString(); - if (string.IsNullOrWhiteSpace(tagPath)) return false; - - var deviceHostAddress = ReadString(root, "deviceHostAddress"); - // A "dataType":"Structure" input is accepted and produces a Structure-typed definition - // with Members:null. The driver treats this as a black-box dotted-path read: libplctag - // resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members. - // The address space emits a placeholder String variable; UDT member declarations are - // not supported in the equipment-tag flow. - // Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown) - // instead of silently defaulting to a wrong-width Good. - if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbCipDataType.DInt, out var dataType)) return false; - // Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A - // 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the - // foundation materialises a [1] OPC UA array node — so it must read as an array, not a - // scalar. ElementCount can't carry the signal (a scalar and a 1-element array both - // have a count of 1), so the explicit IsArray flag does. - var (isArray, elementCount) = ReadArrayShape(root); - // "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default. - // Now via the shared TagConfigJson.ReadWritable (explicit-false-only), byte-identical to the - // hand-rolled read it replaces. - var writable = TagConfigJson.ReadWritable(root); - def = new AbCipTagDefinition( - Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath, - DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray); - return true; - } - catch (JsonException) { return false; } - catch (FormatException) { return false; } - catch (InvalidOperationException) { return false; } - } - - /// - /// Resolve the 1-D array shape from an isArray / arrayLength pair (the foundation - /// contract carrier). The canonical rule: the tag is an ARRAY ⟺ isArray is truthy AND - /// arrayLength is a number >= 1. Any other combination (isArray absent/false, - /// or isArray:true with arrayLength missing/invalid/<1) is a SCALAR and returns - /// (IsArray: false, ElementCount: 1). This matches every other driver (Modbus, S7, - /// TwinCAT, AbLegacy) which also return scalar for that degenerate input. - /// - private static (bool IsArray, int ElementCount) ReadArrayShape(JsonElement o) - { - var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True; - if (!isArray) return (false, 1); - if (o.TryGetProperty("arrayLength", out var len) - && len.ValueKind == JsonValueKind.Number - && len.TryGetInt32(out var n) - && n >= 1) - return (true, n); - // isArray:true but arrayLength missing/invalid/<1 — canonical rule: scalar (matches all other drivers). - return (false, 1); - } - - /// - /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently - /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean - /// or not an equipment-tag object. Never throws. - /// - /// The equipment tag's TagConfig JSON. - /// The warnings; empty when clean. - public static IReadOnlyList Inspect(string reference) - { - var warnings = new List(); - if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; - try - { - using var doc = JsonDocument.Parse(reference); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) - { - warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); - return warnings; - } - var w = TagConfigJson.DescribeInvalidEnum(root, "dataType"); - if (w is not null) warnings.Add(w); - } - catch (JsonException) - { - warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); - } - return warnings; - } - - private static string ReadString(JsonElement o, string name) - => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String - ? e.GetString() ?? "" : ""; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTagDefinitionFactory.cs new file mode 100644 index 00000000..eaeaa4fb --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTagDefinitionFactory.cs @@ -0,0 +1,226 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// v3 pure mapper: turns an authored raw tag's TagConfig JSON (the shape produced by the +/// AdminUI AbCipTagConfigModel) into an . Under v3 a tag's +/// identity is its RawPath (a cluster-scoped slash path), not the address blob — so the +/// produced definition's is the RawPath the driver was handed, +/// which is exactly the wire reference the driver's RawPath → def resolver keys on. The driver +/// builds that table by mapping each the deploy artifact delivers through +/// ; is the inverse, used by authoring paths (the +/// Client CLI + the driver-test AbCipRawTags helper) that hold a typed definition and need to +/// synthesise the TagConfig blob for a . +/// +/// +/// +/// Device placement is NOT in the blob. The pre-v3 parser read a deviceHostAddress +/// key off the TagConfig; v3 drops it — a tag's device is the RawPath's device segment, delivered +/// out-of-band on and threaded onto the definition's +/// (the device routing key) by the driver at +/// table-build time. therefore leaves that field empty. +/// +/// +/// Array / member / safety round-trip. Today's production authoring surface +/// (AbCipTagConfigModel) emits only tagPath / dataType / writable, so a +/// real equipment tag maps to a scalar atomic definition. The additional isArray / +/// arrayLength / safetyTag / members keys are read (and emitted by +/// ) so the retired pre-declared-tag coverage — arrays and declared UDT +/// member fan-out — survives the migration to the RawPath seam. They are additive: absent keys map +/// to the scalar / no-member / non-safety defaults. +/// +/// +public static class AbCipTagDefinitionFactory +{ + /// + /// Maps an authored TagConfig object to a typed definition keyed by . + /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3). + /// The mandatory addressing field is the Logix tagPath; dataType is read STRICTLY — a + /// present-but-invalid (typo'd) value rejects the whole tag (returns ⇒ the + /// driver surfaces BadNodeIdUnknown) rather than silently defaulting to a wrong-width Good. + /// and + /// are NOT read from the blob — the driver threads them in from the tag's . + /// + /// The authored equipment-tag TagConfig JSON. + /// The tag's RawPath — becomes the definition's identity (Name). + /// The mapped definition when this returns . + /// when is a valid AbCip address object. + /// + /// is read from the optional "writable" boolean; + /// it defaults to true when absent, matching the record's documented default. Operators who + /// need a read-only OPC UA surface can author "writable":false; the PLC's ExternalAccess + /// attribute remains the effective write gate at the wire level. + /// + public static bool FromTagConfig(string tagConfig, string rawPath, out AbCipTagDefinition def) + { + def = null!; + if (string.IsNullOrWhiteSpace(tagConfig)) return false; + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + // AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path. + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("tagPath", out var tagPathEl) + || tagPathEl.ValueKind != JsonValueKind.String) + return false; + var tagPath = tagPathEl.GetString(); + if (string.IsNullOrWhiteSpace(tagPath)) return false; + + // Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown) + // instead of silently defaulting to a wrong-width Good. + if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbCipDataType.DInt, out var dataType)) return false; + // Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A 1-element + // array is a VALID 1-element array (the foundation materialises a [1] OPC UA array node). + var (isArray, elementCount) = ReadArrayShape(root); + // "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default. + var writable = TagConfigJson.ReadWritable(root); + var safetyTag = ReadBool(root, "safetyTag"); + + // Optional declared UDT member fan-out (round-trips the retired pre-declared coverage). A + // malformed member (missing name / typo'd dataType) rejects the whole tag, mirroring the + // strict top-level contract. + IReadOnlyList? members = null; + if (root.TryGetProperty("members", out var membersEl) && membersEl.ValueKind == JsonValueKind.Array) + { + var list = new List(); + foreach (var m in membersEl.EnumerateArray()) + { + if (m.ValueKind != JsonValueKind.Object + || !m.TryGetProperty("name", out var nameEl) + || nameEl.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(nameEl.GetString())) + return false; + if (!TagConfigJson.TryReadEnumStrict(m, "dataType", AbCipDataType.DInt, out var memberType)) return false; + var (memberIsArray, memberCount) = ReadArrayShape(m); + list.Add(new AbCipStructureMember( + Name: nameEl.GetString()!, + DataType: memberType, + Writable: ReadBoolDefaultTrue(m, "writable"), + WriteIdempotent: ReadBool(m, "writeIdempotent"), + ElementCount: memberCount, + IsArray: memberIsArray)); + } + if (list.Count > 0) members = list; + } + + def = new AbCipTagDefinition( + Name: rawPath, DeviceHostAddress: "", TagPath: tagPath!, + DataType: dataType, Writable: writable, WriteIdempotent: false, + Members: members, SafetyTag: safetyTag, ElementCount: elementCount, IsArray: isArray); + return true; + } + catch (JsonException) { return false; } + catch (FormatException) { return false; } + catch (InvalidOperationException) { return false; } + } + + /// + /// Inverse of : serialises a typed definition back to the camelCase + /// TagConfig JSON the mapper reads. Used by authoring paths (the Client CLI, the driver-test + /// AbCipRawTags helper) that construct an and need the + /// TagConfig string to hand the driver as a . Enum values are written + /// as their name strings; optional fields are emitted only when non-default so the blob stays minimal. + /// (device routing key) and + /// are NOT emitted — they travel on the + /// , not inside the blob. + /// + /// The definition to serialise. + /// The camelCase TagConfig JSON string. + public static string ToTagConfig(AbCipTagDefinition def) + { + ArgumentNullException.ThrowIfNull(def); + var o = new JsonObject + { + ["tagPath"] = def.TagPath, + ["dataType"] = def.DataType.ToString(), + ["writable"] = def.Writable, + }; + if (def.IsArray) + { + o["isArray"] = true; + o["arrayLength"] = Math.Max(1, def.ElementCount); + } + if (def.SafetyTag) o["safetyTag"] = true; + if (def.Members is { Count: > 0 } members) + { + var arr = new JsonArray(); + foreach (var m in members) + { + var mo = new JsonObject + { + ["name"] = m.Name, + ["dataType"] = m.DataType.ToString(), + ["writable"] = m.Writable, + }; + if (m.WriteIdempotent) mo["writeIdempotent"] = true; + if (m.IsArray) + { + mo["isArray"] = true; + mo["arrayLength"] = Math.Max(1, m.ElementCount); + } + arr.Add(mo); + } + o["members"] = arr; + } + return o.ToJsonString(); + } + + /// + /// Resolve the 1-D array shape from an isArray / arrayLength pair (the foundation + /// contract carrier). The canonical rule: the tag is an ARRAY ⟺ isArray is truthy AND + /// arrayLength is a number >= 1. Any other combination is a SCALAR and returns + /// (IsArray: false, ElementCount: 1) — matching every other driver. + /// + private static (bool IsArray, int ElementCount) ReadArrayShape(JsonElement o) + { + var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True; + if (!isArray) return (false, 1); + if (o.TryGetProperty("arrayLength", out var len) + && len.ValueKind == JsonValueKind.Number + && len.TryGetInt32(out var n) + && n >= 1) + return (true, n); + // isArray:true but arrayLength missing/invalid/<1 — canonical rule: scalar (matches all other drivers). + return (false, 1); + } + + /// + /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently + /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean + /// or not an equipment-tag object. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + try + { + using var doc = JsonDocument.Parse(reference); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + var w = TagConfigJson.DescribeInvalidEnum(root, "dataType"); + if (w is not null) warnings.Add(w); + } + catch (JsonException) + { + warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + } + return warnings; + } + + private static bool ReadBool(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True; + + private static bool ReadBoolDefaultTrue(JsonElement o, string name) + => !o.TryGetProperty(name, out var e) || e.ValueKind != JsonValueKind.False; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 8b2cd78f..05ad2904 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -82,12 +82,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, _discoveredUdtShapes[UdtShapeKey(deviceHostAddress, structName)] = shape; private readonly PollGroupEngine _poll; + // Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics). private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); + // Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias) + // → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on + // the RawPath's device segment. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection + // host moves into the Device row's DeviceConfig. + private readonly Dictionary _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase); + // v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive + // ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper. + private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal); + // Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order — + // the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths. + private readonly List _declaredTags = new(); - // Resolves a read/write/subscribe fullReference to a tag definition, bridging the two - // authoring models: an authored tag-table entry (by name) OR an equipment tag whose - // reference is its raw TagConfig JSON (parsed once via AbCipEquipmentTagParser, cached). + // Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a + // single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback + // is retired — see EquipmentTagRefResolver). private readonly EquipmentTagRefResolver _resolver; private readonly ILogger _logger; @@ -128,8 +139,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, _templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory(); _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( - r => _tagsByName.TryGetValue(r, out var t) ? t : null, - r => AbCipEquipmentTagParser.TryParse(r, out var d) ? d : null); + r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => @@ -248,7 +258,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, if (!string.IsNullOrWhiteSpace(driverConfigJson)) { var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); - if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0) + if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0) { _options = parsed; _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger); @@ -261,18 +271,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ?? throw new InvalidOperationException( $"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'."); var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily); - _devices[device.HostAddress] = new DeviceState(addr, device, profile); + var state = new DeviceState(addr, device, profile); + _devices[device.HostAddress] = state; + // Route index: a tag reaches this device by matching its DeviceName routing key OR its + // host address. Devices with no explicit DeviceName are identified purely by host today. + _devicesByRoutingKey[device.HostAddress] = state; + if (!string.IsNullOrEmpty(device.DeviceName)) + _devicesByRoutingKey[device.DeviceName] = state; } - foreach (var tag in _options.Tags) + + // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob + // is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are + // threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is + // skipped (logged), never thrown — a bad tag must not fail the whole driver init. + foreach (var entry in _options.RawTags) { - // Duplicate-key check: a collision means two configured tags have the same name. + if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped)) + { + _logger.LogWarning( + "AbCip tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + _driverInstanceId, entry.RawPath); + continue; + } + var tag = mapped with { DeviceHostAddress = entry.DeviceName, WriteIdempotent = entry.WriteIdempotent }; + + // Duplicate-key check: a collision means two authored tags share the same RawPath. // Fail fast at init time with a diagnostic rather than silently clobbering. - if (_tagsByName.TryGetValue(tag.Name, out var existingTag)) + if (_tagsByRawPath.TryGetValue(tag.Name, out var existingTag)) throw new InvalidOperationException( $"AbCip tag name collision: '{tag.Name}' is declared more than once. " + $"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " + $"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate."); - _tagsByName[tag.Name] = tag; + _tagsByRawPath[tag.Name] = tag; + _declaredTags.Add(tag); if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 }) { @@ -295,12 +326,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Member fan-out duplicate check: a member-path collision means two // configured structure tags produce the same member path, or a member // name collides with an independently-declared tag. - if (_tagsByName.TryGetValue(memberTag.Name, out var existingMember)) + if (_tagsByRawPath.TryGetValue(memberTag.Name, out var existingMember)) throw new InvalidOperationException( $"AbCip tag name collision: '{memberTag.Name}' is produced by both " + $"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " + $"'{existingMember.Name}'. Rename one of the configured tags to resolve."); - _tagsByName[memberTag.Name] = memberTag; + _tagsByRawPath[memberTag.Name] = memberTag; } } } @@ -383,8 +414,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, state.DisposeHandles(); } _devices.Clear(); - _tagsByName.Clear(); - _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses + _devicesByRoutingKey.Clear(); + _tagsByRawPath.Clear(); + _declaredTags.Clear(); + _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } @@ -502,19 +535,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // ---- IPerCallHostResolver ---- /// - /// Resolve a read/write/subscribe reference to the device host that keys its per-host - /// resilience (bulkhead / circuit breaker). CONV-4: routes through - /// so an equipment-tag reference (raw TagConfig - /// JSON) resolves to its OWN authored device rather than the first-device fallback — else a - /// broken device B would trip device A's breaker. The resolver caches parses, so this adds - /// no per-call cost after first use. + /// Resolve a read/write/subscribe reference (a RawPath in v3) to the device host that keys its + /// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through the authored table so a + /// tag resolves to its OWN device's host rather than the first-device fallback — else a broken + /// device B would trip device A's breaker. The tag's device routing key is resolved to the device's + /// actual host address (the resilience key), matching by DeviceName-or-host. /// - /// The tag reference (authored name or equipment-tag JSON). + /// The tag RawPath reference. /// The device host key. public string ResolveHost(string fullReference) { - if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress)) - return def.DeviceHostAddress; + if (_resolver.TryResolve(fullReference, out var def) + && _devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var state)) + return state.Options.HostAddress; return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId; } @@ -536,7 +569,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may // reorder UDT members vs declaration order, so the fast path is opt-in only. var plan = AbCipUdtReadPlanner.Build( - fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping); + fullReferences, _tagsByRawPath, _options.EnableDeclarationOnlyUdtGrouping); foreach (var group in plan.Groups) await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false); @@ -564,7 +597,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now); return; } - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device)) { results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); return; @@ -646,7 +679,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { var parent = group.ParentDefinition; - if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device)) + if (!_devicesByRoutingKey.TryGetValue(parent.DeviceHostAddress, out var device)) { StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown); return; @@ -736,7 +769,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable); continue; } - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); continue; @@ -1017,9 +1050,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Pre-declared tags — always emitted; the primary config path. UDT tags with declared // Members fan out into a sub-folder + one Variable per member instead of a single // Structure Variable (Structure has no useful scalar value + member-addressable paths - // are what downstream consumers actually want). - var preDeclared = _options.Tags.Where(t => - string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); + // are what downstream consumers actually want). A tag reaches this device when its + // device routing key matches the device's DeviceName or its host address. + var preDeclared = _declaredTags.Where(t => RoutesToDevice(t, device)); foreach (var tag in preDeclared) { if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue; @@ -1235,6 +1268,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // a legacy definition carrying only ElementCount > 1 stays an array for back-compat. private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1; + // A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the + // RawTagEntry.DeviceName) matches the device's DeviceName or its host address. TODO(v3 WaveC): + // collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig. + private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) => + string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(device.DeviceName) + && string.Equals(tag.DeviceHostAddress, device.DeviceName, StringComparison.OrdinalIgnoreCase)); + private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs index 5761cb95..da0fd469 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; @@ -68,9 +68,11 @@ public static class AbCipDriverFactoryExtensions AllowPacking: d.AllowPacking, ConnectionSize: d.ConnectionSize))] : [], - Tags = dto.Tags is { Count: > 0 } - ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] - : [], + // v3: the deploy artifact (Wave C) populates RawTags with the cluster's authored raw AbCip + // tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each entry + // through AbCipTagDefinitionFactory.FromTagConfig at Initialize; the legacy pre-declared DTO + // tag path (structured Tags[] → BuildTag) is retired. + RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [], Probe = new AbCipProbeOptions { Enabled = dto.Probe?.Enabled ?? true, @@ -100,32 +102,6 @@ public static class AbCipDriverFactoryExtensions private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) => TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs); - private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) => - new( - Name: t.Name ?? throw new InvalidOperationException( - $"AB CIP config for '{driverInstanceId}' has a tag missing Name"), - DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException( - $"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"), - TagPath: t.TagPath ?? throw new InvalidOperationException( - $"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"), - DataType: ParseEnum(t.DataType, t.Name, driverInstanceId, "DataType"), - Writable: t.Writable ?? true, - WriteIdempotent: t.WriteIdempotent ?? false, - Members: t.Members is { Count: > 0 } - ? [.. t.Members.Select(m => new AbCipStructureMember( - Name: m.Name ?? throw new InvalidOperationException( - $"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"), - DataType: ParseEnum(m.DataType, t.Name, driverInstanceId, - $"Members[{m.Name}].DataType"), - Writable: m.Writable ?? true, - WriteIdempotent: m.WriteIdempotent ?? false, - ElementCount: m.ElementCount is > 0 ? m.ElementCount.Value : 1, - IsArray: m.IsArray))] - : null, - SafetyTag: t.SafetyTag ?? false, - ElementCount: t.ElementCount is > 0 ? t.ElementCount.Value : 1, - IsArray: t.IsArray); - private static T ParseEnum(string? raw, string? tagName, string driverInstanceId, string field, T? fallback = null) where T : struct, Enum { @@ -182,9 +158,10 @@ public static class AbCipDriverFactoryExtensions public List? Devices { get; init; } /// - /// Gets or sets the list of tags to monitor. + /// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName) + /// the deploy artifact delivers. The driver maps each through the tag-definition factory at Initialize. /// - public List? Tags { get; init; } + public List? RawTags { get; init; } /// /// Gets or sets the probe configuration. @@ -220,100 +197,6 @@ public static class AbCipDriverFactoryExtensions public int? ConnectionSize { get; init; } } - internal sealed class AbCipTagDto - { - /// - /// Gets or sets the tag name. - /// - public string? Name { get; init; } - - /// - /// Gets or sets the device host address. - /// - public string? DeviceHostAddress { get; init; } - - /// - /// Gets or sets the tag path. - /// - public string? TagPath { get; init; } - - /// - /// Gets or sets the data type. - /// - public string? DataType { get; init; } - - /// - /// Gets or sets whether the tag is writable. - /// - public bool? Writable { get; init; } - - /// - /// Gets or sets whether write is idempotent. - /// - public bool? WriteIdempotent { get; init; } - - /// - /// Gets or sets the list of structure members. - /// - public List? Members { get; init; } - - /// - /// Gets or sets whether this is a safety tag. - /// - public bool? SafetyTag { get; init; } - - /// - /// Gets or sets the explicit 1-D array signal — true ⟺ the tag is an array (even a - /// 1-element one). Mirrors AbCipTagDefinition.IsArray; optional (absent ⇒ scalar). - /// - [JsonPropertyName("isArray")] - public bool IsArray { get; init; } - - /// - /// Gets or sets the number of elements for a 1-D array tag; 1 (or absent) for a scalar. - /// Mirrors AbCipTagDefinition.ElementCount; only positive values are honoured. - /// - [JsonPropertyName("elementCount")] - public int? ElementCount { get; init; } - } - - internal sealed class AbCipMemberDto - { - /// - /// Gets or sets the member name. - /// - public string? Name { get; init; } - - /// - /// Gets or sets the data type. - /// - public string? DataType { get; init; } - - /// - /// Gets or sets whether the member is writable. - /// - public bool? Writable { get; init; } - - /// - /// Gets or sets whether write is idempotent. - /// - public bool? WriteIdempotent { get; init; } - - /// - /// Gets or sets the explicit 1-D array signal for the member — true ⟺ the member is - /// an array (even a 1-element one). Mirrors AbCipStructureMember.IsArray; optional. - /// - [JsonPropertyName("isArray")] - public bool IsArray { get; init; } - - /// - /// Gets or sets the number of elements for a 1-D array member; 1 (or absent) for a - /// scalar. Mirrors AbCipStructureMember.ElementCount; only positive values are honoured. - /// - [JsonPropertyName("elementCount")] - public int? ElementCount { get; init; } - } - internal sealed class AbCipProbeDto { /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs index 1621ecec..58e8a0ec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -89,14 +89,22 @@ public sealed class AbCipDriverProbe : IDriverProbe // Forward Open to confirm the remote endpoint speaks CIP, not just TCP. var profile = AbCipPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbCipPlcFamily.ControlLogix); - // Prefer the first declared tag path as the probe tag name (it must exist on the PLC). - // Fall back to a benign system attribute that is present on all ControlLogix/CompactLogix - // controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix, which - // is sufficient to confirm CIP reachability. - var tagName = opts.Tags.FirstOrDefault( - t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase)) - ?.TagPath - ?? opts.Tags.FirstOrDefault()?.TagPath + // Prefer the first authored tag path as the probe tag name (it must exist on the PLC). v3: the + // tags arrive as RawTagEntry blobs — map each through the pure factory to recover its TagPath + + // device routing key. Fall back to a benign system attribute present on all ControlLogix / + // CompactLogix controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix, + // which is sufficient to confirm CIP reachability. + var mappedTags = opts.RawTags + .Select(e => AbCipTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d) + ? (Ok: true, Device: e.DeviceName, d.TagPath) + : (Ok: false, Device: e.DeviceName, TagPath: null!)) + .Where(x => x.Ok) + .ToList(); + var tagName = mappedTags.FirstOrDefault( + x => string.Equals(x.Device, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase) + || (firstDevice?.DeviceName is { } dn && string.Equals(x.Device, dn, StringComparison.OrdinalIgnoreCase))) + .TagPath + ?? mappedTags.FirstOrDefault().TagPath ?? "@raw_cpu_type"; var p = new AbCipTagCreateParams( diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs index fd729773..28bc181f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs @@ -18,9 +18,10 @@ public static class AbCipUdtReadPlanner { /// /// Split into whole-UDT groups + per-tag leftovers. - /// is the driver's _tagsByName map — both parent - /// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase - /// to match the driver's dictionary semantics. When + /// is the driver's _tagsByRawPath map (RawPath → def) — + /// both parent UDT rows and their fanned-out member rows live there. Tag lookup honours the + /// passed dictionary's comparer (v3: case-sensitive Ordinal RawPaths); the internal + /// parent-name grouping is OrdinalIgnoreCase. When /// is false no groups are /// formed — every reference goes to the per-tag fallback path so member decoding never /// relies on declaration-order offsets that may not match the controller layout. diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipCommandBaseTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipCommandBaseTests.cs index e8e6052f..bfc5a028 100644 --- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipCommandBaseTests.cs +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipCommandBaseTests.cs @@ -135,9 +135,10 @@ public sealed class AbCipCommandBaseTests var options = cmd.InvokeBuildOptions(tags); - options.Tags.Count.ShouldBe(2); - options.Tags[0].Name.ShouldBe("t1"); - options.Tags[1].Name.ShouldBe("t2"); + // v3: BuildOptions projects each typed tag to a RawTagEntry (RawPath = tag name). + options.RawTags.Count.ShouldBe(2); + options.RawTags[0].RawPath.ShouldBe("t1"); + options.RawTags[1].RawPath.ShouldBe("t2"); } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs index bf26f406..d7c253cc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs @@ -56,7 +56,7 @@ public sealed class AbCipAlarmProjectionTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [AlmdTag("HighTemp")], + RawTags = AbCipRawTags.From(AlmdTag("HighTemp")), EnableAlarmProjection = false, // explicit; also the default }; var drv = new AbCipDriver(opts, "drv-1", factory); @@ -83,7 +83,7 @@ public sealed class AbCipAlarmProjectionTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [AlmdTag("HighTemp")], + RawTags = AbCipRawTags.From(AlmdTag("HighTemp")), EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(20), // The ALMD projection here drives the parent-UDT runtime via offset-keyed values, @@ -138,7 +138,7 @@ public sealed class AbCipAlarmProjectionTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [AlmdTag("HighTemp")], + RawTags = AbCipRawTags.From(AlmdTag("HighTemp")), EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(20), EnableDeclarationOnlyUdtGrouping = true, @@ -189,7 +189,7 @@ public sealed class AbCipAlarmProjectionTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [AlmdTag("HighTemp")], + RawTags = AbCipRawTags.From(AlmdTag("HighTemp")), EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(20), // The ALMD projection here drives the parent-UDT runtime via offset-keyed values, @@ -230,7 +230,7 @@ public sealed class AbCipAlarmProjectionTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [AlmdTag("HighTemp")], + RawTags = AbCipRawTags.From(AlmdTag("HighTemp")), EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(20), // The ALMD projection here drives the parent-UDT runtime via offset-keyed values, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs index 1df0b24f..e0e0c08f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs @@ -9,20 +9,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips /// / /// for an array atomic tag + an array UDT member; the read path returns a typed CLR array -/// boxed as ; and the equipment-tag resolver threads -/// arrayLength from the TagConfig into the transient definition's element count so -/// an isArray equipment tag reads the whole array. +/// boxed as ; and the v3 tag mapper threads arrayLength from the +/// TagConfig into the definition's element count so an isArray tag reads the whole array. /// [Trait("Category", "Unit")] public sealed class AbCipArrayTests { + private const string Device = "ab://10.0.0.5/1,0"; + private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags) { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { - Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = tags, + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From(tags), }; var drv = new AbCipDriver(opts, "drv-array", factory); return (drv, factory); @@ -36,8 +37,8 @@ public sealed class AbCipArrayTests { var builder = new RecordingBuilder(); var (drv, _) = NewDriver( - new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 10), - new AbCipTagDefinition("Single", "ab://10.0.0.5/1,0", "Single", AbCipDataType.DInt)); + new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 10, IsArray: true), + new AbCipTagDefinition("Single", Device, "Single", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); @@ -57,10 +58,10 @@ public sealed class AbCipArrayTests { var builder = new RecordingBuilder(); var (drv, _) = NewDriver( - new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure, + new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: [ - new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4), + new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4, IsArray: true), new AbCipStructureMember("Speed", AbCipDataType.DInt), ])); await drv.InitializeAsync("{}", CancellationToken.None); @@ -83,7 +84,7 @@ public sealed class AbCipArrayTests public async Task Array_DInt_read_returns_typed_int_array() { var (drv, factory) = NewDriver( - new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 4)); + new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 4, IsArray: true)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 11, 22, 33, 44 }); @@ -101,7 +102,7 @@ public sealed class AbCipArrayTests public async Task Array_Real_read_returns_typed_float_array() { var (drv, factory) = NewDriver( - new AbCipTagDefinition("Floats", "ab://10.0.0.5/1,0", "Floats", AbCipDataType.Real, ElementCount: 3)); + new AbCipTagDefinition("Floats", Device, "Floats", AbCipDataType.Real, ElementCount: 3, IsArray: true)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f }); @@ -116,7 +117,7 @@ public sealed class AbCipArrayTests public async Task Array_Bool_read_returns_typed_bool_array() { var (drv, factory) = NewDriver( - new AbCipTagDefinition("Flags", "ab://10.0.0.5/1,0", "Flags", AbCipDataType.Bool, ElementCount: 3)); + new AbCipTagDefinition("Flags", Device, "Flags", AbCipDataType.Bool, ElementCount: 3, IsArray: true)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new bool[] { true, false, true }); @@ -131,7 +132,7 @@ public sealed class AbCipArrayTests public async Task Array_String_read_returns_typed_string_array() { var (drv, factory) = NewDriver( - new AbCipTagDefinition("Names", "ab://10.0.0.5/1,0", "Names", AbCipDataType.String, ElementCount: 2)); + new AbCipTagDefinition("Names", Device, "Names", AbCipDataType.String, ElementCount: 2, IsArray: true)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new string[] { "a", "b" }); @@ -146,7 +147,7 @@ public sealed class AbCipArrayTests public async Task Scalar_read_path_unchanged_for_element_count_one() { var (drv, factory) = NewDriver( - new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)); + new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 }; @@ -158,20 +159,18 @@ public sealed class AbCipArrayTests /// /// Driver.AbCip-016 — a DECLARED UDT member that is a 1-D array (Setpoints : REAL[4]) - /// must READ as a typed CLR array, matching the array node it discovers as. Before the fix - /// the member fan-out in InitializeAsync dropped the member's ElementCount / - /// IsArray, so the fanned-out runtime definition defaulted to scalar and the read - /// returned a single element (or null) instead of the array — a declared-type-vs-runtime-value - /// mismatch. + /// must READ as a typed CLR array, matching the array node it discovers as. The member fan-out + /// in InitializeAsync threads the member's ElementCount / IsArray into the + /// fanned-out runtime definition. /// [Fact] public async Task Declared_udt_array_member_reads_as_typed_array() { var (drv, factory) = NewDriver( - new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure, + new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: [ - new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4), + new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4, IsArray: true), new AbCipStructureMember("Speed", AbCipDataType.DInt), ])); await drv.InitializeAsync("{}", CancellationToken.None); @@ -187,24 +186,18 @@ public sealed class AbCipArrayTests factory.Tags["Motor.Setpoints"].CreationParams.IsArray.ShouldBeTrue(); } - // ---- Resolver: arrayLength threading ---- + // ---- Mapper: arrayLength threading ---- - /// The equipment-tag resolver threads arrayLength into the def's ElementCount. + /// An authored array tag reads as a typed CLR array end-to-end. [Fact] - public async Task Equipment_ref_with_arrayLength_reads_as_a_typed_array() + public async Task Authored_array_tag_reads_as_a_typed_array() { - var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":4}"""; - var factory = new FakeAbCipTagFactory(); - var opts = new AbCipDriverOptions - { - Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [], - }; - var drv = new AbCipDriver(opts, "abcip-eq-array", factory); + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 4, IsArray: true)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 7, 8, 9, 10 }); - var snapshots = await drv.ReadAsync([json], CancellationToken.None); + var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); var value = snapshots.Single().Value.ShouldBeOfType(); @@ -212,80 +205,75 @@ public sealed class AbCipArrayTests factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4); } - /// The parser threads arrayLength into the transient definition's ElementCount and sets IsArray. + /// The mapper threads arrayLength into the definition's ElementCount and sets IsArray. [Fact] - public void Parser_threads_arrayLength_into_ElementCount() + public void Mapper_threads_arrayLength_into_ElementCount() { var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":8}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue(); def!.ElementCount.ShouldBe(8); def.IsArray.ShouldBeTrue(); } - /// A non-array equipment ref defaults ElementCount to 1 (scalar) and IsArray false. + /// A non-array TagConfig defaults ElementCount to 1 (scalar) and IsArray false. [Fact] - public void Parser_defaults_ElementCount_to_one_when_not_an_array() + public void Mapper_defaults_ElementCount_to_one_when_not_an_array() { var json = """{"tagPath":"Recipe","dataType":"DInt"}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue(); def!.ElementCount.ShouldBe(1); def.IsArray.ShouldBeFalse(); } /// /// Review finding I-2 — isArray:true with NO arrayLength is a DEGENERATE input - /// that must parse as SCALAR (IsArray false, ElementCount 1), matching every other driver - /// (Modbus, S7, TwinCAT, AbLegacy). The AdminUI validator blocks authoring this combination, - /// but the parser contract must still be consistent cross-driver. + /// that must map as SCALAR (IsArray false, ElementCount 1), matching every other driver. /// [Fact] - public void Parser_isArray_true_without_arrayLength_parses_as_scalar() + public void Mapper_isArray_true_without_arrayLength_maps_as_scalar() { var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue(); def!.IsArray.ShouldBeFalse(); def.ElementCount.ShouldBe(1); } /// /// Review finding I-2 — isArray:true with an invalid (zero) arrayLength also - /// parses as SCALAR, consistent with the cross-driver rule. + /// maps as SCALAR, consistent with the cross-driver rule. /// [Fact] - public void Parser_isArray_true_with_zero_arrayLength_parses_as_scalar() + public void Mapper_isArray_true_with_zero_arrayLength_maps_as_scalar() { var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":0}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue(); def!.IsArray.ShouldBeFalse(); def.ElementCount.ShouldBe(1); } /// /// Review finding I-1 — a 1-element array (isArray:true, arrayLength:1) is a valid - /// 1-element array, NOT a scalar: the parser sets - /// true and 1. + /// 1-element array, NOT a scalar: the mapper sets IsArray true and ElementCount 1. /// [Fact] - public void Parser_treats_isArray_with_arrayLength_one_as_a_one_element_array() + public void Mapper_treats_isArray_with_arrayLength_one_as_a_one_element_array() { var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue(); def!.IsArray.ShouldBeTrue(); def.ElementCount.ShouldBe(1); } /// - /// Review finding I-1 — isArray:true, arrayLength:1 must DISCOVER as a [1] array node - /// (IsArray + ArrayDim 1), matching the foundation's materialisation, not as a scalar. + /// Review finding I-1 — a 1-element array tag must DISCOVER as a [1] array node (IsArray + + /// ArrayDim 1), matching the foundation's materialisation, not as a scalar. /// [Fact] - public async Task Equipment_ref_isArray_arrayLength_one_discovers_as_one_element_array() + public async Task Authored_one_element_array_discovers_as_one_element_array() { - var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - var builder = new RecordingBuilder(); - var (drv, _) = NewDriver(def!); + var (drv, _) = NewDriver( + new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 1, IsArray: true)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); @@ -296,25 +284,17 @@ public sealed class AbCipArrayTests } /// - /// Review finding I-1 — the I-1 case: an isArray:true, arrayLength:1 equipment tag - /// reads a 1-ELEMENT typed array, NOT a scalar. On current code (gate ElementCount > 1) - /// this reads a scalar; the explicit IsArray flag fixes it. + /// Review finding I-1 — a 1-element array tag reads a 1-ELEMENT typed array, NOT a scalar. /// [Fact] - public async Task Equipment_ref_isArray_arrayLength_one_reads_as_one_element_array() + public async Task Authored_one_element_array_reads_as_one_element_array() { - var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}"""; - var factory = new FakeAbCipTagFactory(); - var opts = new AbCipDriverOptions - { - Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [], - }; - var drv = new AbCipDriver(opts, "abcip-eq-array1", factory); + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 1, IsArray: true)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 99 }); - var snapshots = await drv.ReadAsync([json], CancellationToken.None); + var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); var value = snapshots.Single().Value.ShouldBeOfType(); @@ -324,24 +304,18 @@ public sealed class AbCipArrayTests } /// - /// Regression — a genuinely scalar equipment ref (isArray:false) reads a boxed - /// scalar via , never an array. + /// Regression — a genuinely scalar tag (isArray:false) reads a boxed scalar via + /// , never an array. /// [Fact] - public async Task Equipment_ref_isArray_false_reads_as_scalar() + public async Task Scalar_tag_reads_as_scalar() { - var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Speed","dataType":"DInt","isArray":false}"""; - var factory = new FakeAbCipTagFactory(); - var opts = new AbCipDriverOptions - { - Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [], - }; - var drv = new AbCipDriver(opts, "abcip-eq-scalar", factory); + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 }; - var snapshots = await drv.ReadAsync([json], CancellationToken.None); + var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None); snapshots.Single().Value.ShouldBe(4200); snapshots.Single().Value.ShouldNotBeOfType(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs index ad0f73c2..21bd9ddd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs @@ -29,10 +29,9 @@ public sealed class AbCipBoolInDIntRmwTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ - new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool), - ], + RawTags = AbCipRawTags.From( + new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)) + , Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -60,7 +59,7 @@ public sealed class AbCipBoolInDIntRmwTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -86,7 +85,7 @@ public sealed class AbCipBoolInDIntRmwTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = tags, + RawTags = AbCipRawTags.From(tags), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -108,11 +107,10 @@ public sealed class AbCipBoolInDIntRmwTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool), - new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool), - ], + new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool)) + , Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -135,11 +133,10 @@ public sealed class AbCipBoolInDIntRmwTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool), - new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool), - ], + new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool)) + , Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipConnectBackoffTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipConnectBackoffTests.cs index 5c5ae0fd..4e1d4900 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipConnectBackoffTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipConnectBackoffTests.cs @@ -17,7 +17,7 @@ public sealed class AbCipConnectBackoffTests private static AbCipDriverOptions Options(bool probeEnabled) => new() { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = probeEnabled, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverCodeReviewRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverCodeReviewRegressionTests.cs index 477a6bb6..dbc39685 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverCodeReviewRegressionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverCodeReviewRegressionTests.cs @@ -18,17 +18,18 @@ public sealed class AbCipDriverCodeReviewRegressionTests // ---- Driver.AbCip-001 — ReinitializeAsync must apply a changed config JSON ---- - /// Tests that InitializeAsync applies devices and tags from the config JSON. + /// Tests that InitializeAsync applies devices and raw tags from the config JSON. [Fact] public async Task InitializeAsync_applies_devices_and_tags_from_the_config_json() { - // Constructed with NO devices/tags — the JSON is the only source of config. + // Constructed with NO devices/tags — the JSON is the only source of config. v3: tags arrive as + // RawTags (RawPath + TagConfig blob + DeviceName routing key). var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1"); const string json = """ { "Devices": [ { "HostAddress": "ab://10.0.0.9/1,0", "PlcFamily": "ControlLogix" } ], - "Tags": [ { "Name": "Speed", "DeviceHostAddress": "ab://10.0.0.9/1,0", - "TagPath": "Speed", "DataType": "DInt" } ] + "RawTags": [ { "RawPath": "line1/speed", "TagConfig": "{\"tagPath\":\"Speed\",\"dataType\":\"DInt\"}", + "WriteIdempotent": false, "DeviceName": "ab://10.0.0.9/1,0" } ] } """; @@ -92,14 +93,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Speed", AbCipDataType.DInt), new AbCipStructureMember("Torque", AbCipDataType.Real), - ]), - ], + ])), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -186,7 +185,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -228,7 +227,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.UDInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.UDInt)), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -254,14 +253,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Speed", AbCipDataType.DInt), new AbCipStructureMember("Torque", AbCipDataType.Real), - ]), - ], + ])), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -281,11 +278,9 @@ public sealed class AbCipDriverCodeReviewRegressionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt), - new AbCipTagDefinition("Speed", Device, "SpeedAlias", AbCipDataType.Real), // same name - ], + new AbCipTagDefinition("Speed", Device, "SpeedAlias", AbCipDataType.Real)), // same name }, "drv-1"); Should.Throw(() => @@ -301,14 +296,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Speed", AbCipDataType.DInt), ]), - new AbCipTagDefinition("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt), // collision - ], + new AbCipTagDefinition("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt)), // collision }, "drv-1"); Should.Throw(() => @@ -336,7 +329,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs index 8c6e3342..8e860f9e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs @@ -19,11 +19,9 @@ public sealed class AbCipDriverDiscoveryTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt), - new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false), - ], + new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false)), }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); @@ -61,12 +59,10 @@ public sealed class AbCipDriverDiscoveryTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt), new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt), - new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt), - ], + new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt)), }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); @@ -83,7 +79,7 @@ public sealed class AbCipDriverDiscoveryTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)), }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); @@ -418,21 +414,19 @@ public sealed class AbCipDriverDiscoveryTests } /// - /// A discovered member-path read goes end-to-end through the driver: with NO pre-declared - /// tag for the member, resolves the authored TagConfig - /// JSON blob (FullName = Parent.Member) via the equipment-tag resolver, materialises a - /// runtime on the device, reads it, and decodes the member's atomic value — proving discovered - /// members are readable with no extra registration. The libplctag tag name the driver builds - /// for the member equals the dotted member path. + /// A member-path read goes end-to-end through the driver: an authored raw tag whose TagConfig + /// addresses a dotted member path (Motor1.Status.Code) is delivered as a + /// , resolves by its RawPath, materialises a runtime on the device, and + /// decodes the member's atomic value. The libplctag tag name the driver builds equals the dotted + /// member path (the TagConfig's tagPath), independent of the RawPath identity. /// [Fact] - public async Task Discovered_member_path_reads_through_driver_as_member_atomic_type() + public async Task Member_path_tag_reads_through_driver_as_member_atomic_type() { const string device = "ab://10.0.0.5/1,0"; - // The wire reference handed to ReadAsync for a discovered member is the authored TagConfig - // JSON blob (FullName = Motor1.Status.Code, atomic type Real here). No tag is pre-declared. - const string tagConfig = - "{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\",\"deviceHostAddress\":\"ab://10.0.0.5/1,0\"}"; + const string rawPath = "line1/motor1/status/code"; + // The authored TagConfig addresses the dotted member path (atomic Real here). + const string tagConfig = "{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\"}"; var tagFactory = new FakeAbCipTagFactory { @@ -443,16 +437,17 @@ public sealed class AbCipDriverDiscoveryTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(device)], + RawTags = [new RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)], }, "drv-1", tagFactory: tagFactory); await drv.InitializeAsync("{}", CancellationToken.None); - var results = await drv.ReadAsync([tagConfig], CancellationToken.None); + var results = await drv.ReadAsync([rawPath], CancellationToken.None); results.Count.ShouldBe(1); results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); results[0].Value.ShouldBe(12.5f); // The driver built a runtime under the dotted member tag name — confirming the member-path - // read addresses the member directly (no parent-UDT registration needed). + // read addresses the member directly. tagFactory.Tags.ShouldContainKey("Motor1.Status.Code"); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs index e37b1a0f..735a9c35 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs @@ -15,7 +15,7 @@ public sealed class AbCipDriverReadTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = tags, + RawTags = AbCipRawTags.From(tags), }; var drv = new AbCipDriver(opts, "drv-1", factory); return (drv, factory); @@ -42,7 +42,7 @@ public sealed class AbCipDriverReadTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)), }; var drv = new AbCipDriver(opts, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs index 56f48150..b5403ca9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs @@ -19,7 +19,7 @@ public sealed class AbCipDriverWholeUdtReadTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = tags, + RawTags = AbCipRawTags.From(tags), // Whole-UDT grouping is opt-in (Driver.AbCip-003) — these tests exercise the // grouping fast path, so they switch it on explicitly. EnableDeclarationOnlyUdtGrouping = true, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs index 9340dec3..2417399b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs @@ -15,7 +15,7 @@ public sealed class AbCipDriverWriteTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = tags, + RawTags = AbCipRawTags.From(tags), }, "drv-1", factory); return (drv, factory); } @@ -74,7 +74,7 @@ public sealed class AbCipDriverWriteTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -115,7 +115,7 @@ public sealed class AbCipDriverWriteTests var drv2 = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)), }, "drv-2", factory); await drv2.InitializeAsync("{}", CancellationToken.None); @@ -133,7 +133,7 @@ public sealed class AbCipDriverWriteTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -167,12 +167,11 @@ public sealed class AbCipDriverWriteTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt), new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt, Writable: false), - new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt), - ], + new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt)) + , }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserStrictnessTests.cs index 0441f976..94b6c845 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserStrictnessTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserStrictnessTests.cs @@ -12,14 +12,14 @@ public sealed class AbCipEquipmentTagParserStrictnessTests [Fact] public void Typo_dataType_rejects_the_tag() { - AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", out _) + AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", "line1/eq/tag", out _) .ShouldBeFalse(); } [Fact] public void Valid_dataType_still_parses() { - AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def) + AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", "line1/eq/tag", out var def) .ShouldBeTrue(); def.DataType.ShouldBe(AbCipDataType.DInt); } @@ -27,7 +27,7 @@ public sealed class AbCipEquipmentTagParserStrictnessTests [Fact] public void Inspect_reports_typo_dataType() { - var warnings = AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}"); + var warnings = AbCipTagDefinitionFactory.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}"); warnings.ShouldHaveSingleItem(); warnings[0].ShouldContain("DIntt"); warnings[0].ShouldContain("dataType"); @@ -36,13 +36,13 @@ public sealed class AbCipEquipmentTagParserStrictnessTests [Fact] public void Inspect_clean_config_has_no_warnings() { - AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty(); + AbCipTagDefinitionFactory.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty(); } [Fact] public void Writable_false_is_honoured_bit_identical() { - AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", out var def) + AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", "line1/eq/tag", out var def) .ShouldBeTrue(); def.Writable.ShouldBeFalse(); } @@ -50,7 +50,7 @@ public sealed class AbCipEquipmentTagParserStrictnessTests [Fact] public void Writable_absent_defaults_to_true() { - AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def) + AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", "line1/eq/tag", out var def) .ShouldBeTrue(); def.Writable.ShouldBeTrue(); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserTests.cs deleted file mode 100644 index c434d1b4..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.AbCip; - -namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; - -/// -/// Dedicated unit tests for . -/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array, -/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath, -/// the writable field, and the Structure dataType path (Driver.AbCip.Contracts-004). -/// -[Trait("Category", "Unit")] -public class AbCipEquipmentTagParserTests -{ - // ── Happy-path scalar ──────────────────────────────────────────────────────────────── - - [Fact] - public void Valid_scalar_round_trip_parses_all_fields() - { - var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.Name.ShouldBe(json); - def.TagPath.ShouldBe("Motor.Speed"); - def.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0"); - def.DataType.ShouldBe(AbCipDataType.Real); - def.Writable.ShouldBeTrue(); - def.IsArray.ShouldBeFalse(); - def.ElementCount.ShouldBe(1); - } - - // ── Array shape ────────────────────────────────────────────────────────────────────── - - [Fact] - public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar() - { - var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.IsArray.ShouldBeTrue(); - def.ElementCount.ShouldBe(1); - } - - [Fact] - public void N_element_array_isArray_true_arrayLength_N_parses_correctly() - { - var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.IsArray.ShouldBeTrue(); - def.ElementCount.ShouldBe(8); - } - - [Fact] - public void IsArray_true_arrayLength_0_is_canonical_scalar() - { - // Canonical rule: isArray:true AND arrayLength < 1 → scalar. - var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.IsArray.ShouldBeFalse(); - def.ElementCount.ShouldBe(1); - } - - [Fact] - public void IsArray_true_arrayLength_absent_is_canonical_scalar() - { - // Canonical rule: isArray:true but arrayLength absent → scalar. - var json = """{"tagPath":"PT_101","isArray":true}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.IsArray.ShouldBeFalse(); - def.ElementCount.ShouldBe(1); - } - - // ── Rejection paths ────────────────────────────────────────────────────────────────── - - [Fact] - public void Non_JSON_input_returns_false() - => AbCipEquipmentTagParser.TryParse("not json at all", out _).ShouldBeFalse(); - - [Fact] - public void Non_object_JSON_array_returns_false() - => AbCipEquipmentTagParser.TryParse("""["tagPath","foo"]""", out _).ShouldBeFalse(); - - [Fact] - public void Non_object_JSON_string_returns_false() - => AbCipEquipmentTagParser.TryParse("\"Motor.Speed\"", out _).ShouldBeFalse(); - - [Fact] - public void Missing_tagPath_returns_false() - => AbCipEquipmentTagParser.TryParse("""{"dataType":"DInt"}""", out _).ShouldBeFalse(); - - [Fact] - public void Blank_tagPath_returns_false() - => AbCipEquipmentTagParser.TryParse("""{"tagPath":" "}""", out _).ShouldBeFalse(); - - [Fact] - public void TagPath_as_number_returns_false() - => AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse(); - - // ── Writable field (Driver.AbCip.Contracts-001) ─────────────────────────────────────── - - [Fact] - public void Writable_false_is_honoured() - { - var json = """{"tagPath":"Sensor.Val","writable":false}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.Writable.ShouldBeFalse(); - } - - [Fact] - public void Writable_absent_defaults_to_true() - { - var json = """{"tagPath":"Sensor.Val"}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.Writable.ShouldBeTrue(); - } - - [Fact] - public void Writable_true_explicit_is_honoured() - { - var json = """{"tagPath":"Sensor.Val","writable":true}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.Writable.ShouldBeTrue(); - } - - // ── Structure dataType (Driver.AbCip.Contracts-001 Structure concern) ───────────────── - - /// - /// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed - /// definition with Members:null. The driver treats the tag path as a black-box dotted-path - /// read (libplctag resolves the full path); UDT member declarations are not supported in the - /// equipment-tag flow. This test documents current behaviour so a future change to reject - /// Structure is a conscious choice. - /// - [Fact] - public void Structure_dataType_is_accepted_with_null_Members_and_returns_true() - { - var json = """{"tagPath":"Motor","dataType":"Structure"}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.DataType.ShouldBe(AbCipDataType.Structure); - def.Members.ShouldBeNull(); - def.TagPath.ShouldBe("Motor"); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagTests.cs index af351073..bfb8fa69 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagTests.cs @@ -7,14 +7,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; [Trait("Category", "Unit")] public class AbCipEquipmentTagTests { + private const string Raw = "line1/eq/tag"; + [Fact] - public void Parses_equipment_tagconfig_into_a_transient_definition() + public void Parses_equipment_tagconfig_into_a_definition() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"Real"}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.Name.ShouldBe(json); + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.Name.ShouldBe(Raw); def.TagPath.ShouldBe("Motor1.Speed"); - def.DeviceHostAddress.ShouldBe("ab://10.0.0.5/1,0"); + def.DeviceHostAddress.ShouldBe(""); // v3: mapper never reads deviceHostAddress def.DataType.ShouldBe(AbCipDataType.Real); def.Writable.ShouldBeTrue(); } @@ -23,83 +25,82 @@ public class AbCipEquipmentTagTests public void Defaults_optional_fields_when_only_the_mandatory_tagPath_is_present() { var json = """{"tagPath":"PT_101"}"""; - AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.Name.ShouldBe(json); + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.Name.ShouldBe(Raw); def.TagPath.ShouldBe("PT_101"); - def.DeviceHostAddress.ShouldBe(""); // absent → empty, matching AbCipTagConfigModel default + def.DeviceHostAddress.ShouldBe(""); // routing key travels on the RawTagEntry, not the blob def.DataType.ShouldBe(AbCipDataType.DInt); // AbCipTagConfigModel's default atomic type } [Fact] public void Rejects_a_non_abcip_blob() - => AbCipEquipmentTagParser.TryParse("""{"region":"x"}""", out _).ShouldBeFalse(); + => AbCipTagDefinitionFactory.FromTagConfig("""{"region":"x"}""", Raw, out _).ShouldBeFalse(); [Fact] public void Rejects_a_blob_with_an_empty_tagPath() - => AbCipEquipmentTagParser.TryParse("""{"tagPath":" ","dataType":"DInt"}""", out _).ShouldBeFalse(); + => AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":" ","dataType":"DInt"}""", Raw, out _).ShouldBeFalse(); [Fact] public void Rejects_tagPath_given_as_a_non_string() - => AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse(); + => AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":42}""", Raw, out _).ShouldBeFalse(); [Fact] public void Rejects_garbage() - => AbCipEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse(); - - [Fact] - public void Rejects_a_plain_authored_name_without_a_leading_brace() - => AbCipEquipmentTagParser.TryParse("Motor1.Speed", out _).ShouldBeFalse(); + => AbCipTagDefinitionFactory.FromTagConfig("not json", Raw, out _).ShouldBeFalse(); /// - /// End-to-end driver-level proof: an AbCip driver with NO authored tags can still read an - /// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient - /// definition (whose DeviceHostAddress names a configured device) and the read goes to the - /// wire instead of returning BadNodeIdUnknown. + /// End-to-end driver-level proof: an authored raw AbCip tag (RawPath + TagConfig blob delivered as a + /// RawTagEntry) resolves by its RawPath and the read goes to the wire instead of returning + /// BadNodeIdUnknown. This is the v3 replacement for the retired blob-parse-fallback resolver. /// [Fact] - public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown() + public async Task Driver_resolves_a_raw_tag_and_reads_instead_of_BadNodeIdUnknown() { - var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"DInt"}"""; + const string device = "ab://10.0.0.5/1,0"; + const string rawPath = "line1/motor/speed"; + var tagConfig = """{"tagPath":"Motor1.Speed","dataType":"DInt"}"""; var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { - Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [], + Devices = [new AbCipDeviceOptions(device)], + RawTags = [new global::ZB.MOM.WW.OtOpcUa.Core.Abstractions.RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)], }; var drv = new AbCipDriver(opts, "abcip-eq", factory); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 4242 }; - var snapshots = await drv.ReadAsync([json], CancellationToken.None); + var snapshots = await drv.ReadAsync([rawPath], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); snapshots.Single().StatusCode.ShouldNotBe(AbCipStatusMapper.BadNodeIdUnknown); snapshots.Single().Value.ShouldBe(4242); + // The runtime is keyed by the resolved libplctag tag path, not the RawPath identity. + factory.Tags.ShouldContainKey("Motor1.Speed"); } /// - /// End-to-end driver-level proof of the R2-11 Phase C flip: an equipment-tag ref carrying a - /// TYPO'd dataType enum now REJECTS at the resolver (strict parse ⇒ TryParse false) - /// so the read surfaces — instead of the old - /// lenient behaviour that silently defaulted to DInt and published a wrong-width - /// Good. Same driver + device wiring as the clean-read proof above; only the enum is - /// mistyped. + /// R2-11 Phase C strictness end-to-end: a raw tag whose TagConfig carries a TYPO'd dataType + /// fails to map (FromTagConfig ⇒ false) so it never enters the driver's RawPath table; a read + /// of that RawPath surfaces instead of a + /// silently-defaulted wrong-width Good. /// [Fact] - public async Task Driver_read_of_a_typod_dataType_ref_surfaces_BadNodeIdUnknown() + public async Task Driver_read_of_a_typod_dataType_tag_surfaces_BadNodeIdUnknown() { - var typoJson = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"DIntt"}"""; + const string device = "ab://10.0.0.5/1,0"; + const string rawPath = "line1/motor/speed"; + var typoConfig = """{"tagPath":"Motor1.Speed","dataType":"DIntt"}"""; var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { - Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [], + Devices = [new AbCipDeviceOptions(device)], + RawTags = [new global::ZB.MOM.WW.OtOpcUa.Core.Abstractions.RawTagEntry(rawPath, typoConfig, WriteIdempotent: false, DeviceName: device)], }; var drv = new AbCipDriver(opts, "abcip-eq", factory); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 4242 }; - var snapshots = await drv.ReadAsync([typoJson], CancellationToken.None); + var snapshots = await drv.ReadAsync([rawPath], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFactoryArrayTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFactoryArrayTagTests.cs index bda261fc..101d1154 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFactoryArrayTagTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFactoryArrayTagTests.cs @@ -5,18 +5,18 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// -/// Regression coverage for Driver.AbCip-018 — the driver-config factory path -/// () dropped the array shape of a -/// pre-declared tag. A tags[] entry with "isArray": true, "elementCount": 4 -/// deserialised into a scalar (because AbCipTagDto / -/// AbCipMemberDto had no ElementCount / IsArray fields), so the tag read -/// as a single scalar despite the explicit array declaration. These tests assert the array -/// shape now survives factory deserialization for both top-level tags and UDT members, and -/// that omitting the fields keeps the legacy scalar default (additive change — no break). +/// ParseOptions timeout hardening + the array-shape round-trip through the v3 tag mapper. Under v3 the +/// factory no longer builds typed tag definitions (it carries authored RawTags blobs verbatim); +/// the array/member shape survives the TagConfig round-trip in +/// instead. These tests assert the array shape +/// survives mapping for both top-level tags and declared UDT members, and that omitting the fields keeps +/// the scalar default. /// [Trait("Category", "Unit")] public sealed class AbCipFactoryArrayTagTests { + private const string Raw = "line1/eq/tag"; + /// /// A non-positive TimeoutMs clamps to the 2 s default rather than reaching libplctag's /// Tag.Timeout setter (which throws for a @@ -44,103 +44,56 @@ public sealed class AbCipFactoryArrayTagTests opts.Timeout.ShouldBe(TimeSpan.FromMilliseconds(500)); } - /// A driver-config tag declaring isArray/elementCount produces an array definition. + /// A TagConfig declaring isArray/arrayLength maps to an array definition. [Fact] - public void ParseOptions_threads_isArray_and_elementCount_into_tag_definition() + public void FromTagConfig_threads_isArray_and_arrayLength_into_tag_definition() { - const string json = """ - { - "Tags": [ { - "Name": "Setpoints", - "DeviceHostAddress": "ab://10.0.0.5/1,0", - "TagPath": "Setpoints", - "DataType": "Real", - "isArray": true, - "elementCount": 4 - } ] - } - """; + const string tagConfig = """{"tagPath":"Setpoints","dataType":"Real","isArray":true,"arrayLength":4}"""; - var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json); + AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue(); - var tag = opts.Tags.Single(); tag.IsArray.ShouldBeTrue(); tag.ElementCount.ShouldBe(4); } - /// A driver-config UDT member declaring isArray/elementCount produces an array member. + /// A declared UDT member declaring isArray/arrayLength maps to an array member. [Fact] - public void ParseOptions_threads_isArray_and_elementCount_into_structure_member() + public void FromTagConfig_threads_isArray_and_arrayLength_into_structure_member() { - const string json = """ - { - "Tags": [ { - "Name": "Motor", - "DeviceHostAddress": "ab://10.0.0.5/1,0", - "TagPath": "Motor", - "DataType": "Structure", - "Members": [ { - "Name": "Setpoints", - "DataType": "Real", - "isArray": true, - "elementCount": 4 - } ] - } ] - } + const string tagConfig = """ + {"tagPath":"Motor","dataType":"Structure", + "members":[{"name":"Setpoints","dataType":"Real","isArray":true,"arrayLength":4}]} """; - var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json); + AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue(); - var member = opts.Tags.Single().Members!.Single(); + var member = tag.Members!.Single(); member.IsArray.ShouldBeTrue(); member.ElementCount.ShouldBe(4); } - /// A driver-config tag without array fields stays scalar (additive change — no break). + /// A TagConfig without array fields stays scalar. [Fact] - public void ParseOptions_defaults_to_scalar_when_array_fields_absent() + public void FromTagConfig_defaults_to_scalar_when_array_fields_absent() { - const string json = """ - { - "Tags": [ { - "Name": "Speed", - "DeviceHostAddress": "ab://10.0.0.5/1,0", - "TagPath": "Speed", - "DataType": "DInt" - } ] - } - """; + const string tagConfig = """{"tagPath":"Speed","dataType":"DInt"}"""; - var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json); + AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue(); - var tag = opts.Tags.Single(); tag.IsArray.ShouldBeFalse(); tag.ElementCount.ShouldBe(1); } - /// A non-positive elementCount falls back to the scalar count of 1 (positive-value guard). + /// A non-positive arrayLength falls back to the scalar count of 1 (canonical rule: scalar). [Fact] - public void ParseOptions_guards_against_non_positive_elementCount() + public void FromTagConfig_guards_against_non_positive_arrayLength() { - const string json = """ - { - "Tags": [ { - "Name": "Speed", - "DeviceHostAddress": "ab://10.0.0.5/1,0", - "TagPath": "Speed", - "DataType": "DInt", - "isArray": true, - "elementCount": 0 - } ] - } - """; + const string tagConfig = """{"tagPath":"Speed","dataType":"DInt","isArray":true,"arrayLength":0}"""; - var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json); + AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue(); - var tag = opts.Tags.Single(); - // elementCount <= 0 is degenerate — count clamps to 1; the explicit IsArray flag still - // carries (a 1-element array is valid), mirroring the equipment-tag parser's contract. + // arrayLength <= 0 with isArray:true is degenerate — canonical rule collapses to scalar. tag.ElementCount.ShouldBe(1); - tag.IsArray.ShouldBeTrue(); + tag.IsArray.ShouldBeFalse(); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHostProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHostProbeTests.cs index 4e8366ec..bbb25483 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHostProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHostProbeTests.cs @@ -172,11 +172,10 @@ public sealed class AbCipHostProbeTests new AbCipDeviceOptions("ab://10.0.0.5/1,0"), new AbCipDeviceOptions("ab://10.0.0.6/1,0"), ], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt), - new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt), - ], + new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt)) + , Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); @@ -216,11 +215,10 @@ public sealed class AbCipHostProbeTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.7/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Motor1", "ab://10.0.0.7/1,0", "Motor1", AbCipDataType.Structure, - Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]), - ], + Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)])) + , Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLoggingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLoggingTests.cs index 73f2f5b8..11123518 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLoggingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLoggingTests.cs @@ -93,7 +93,7 @@ public sealed class AbCipLoggingTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory, logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); @@ -124,7 +124,7 @@ public sealed class AbCipLoggingTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory, logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPerDeviceConnectionOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPerDeviceConnectionOptionsTests.cs index c290672f..78077e41 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPerDeviceConnectionOptionsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPerDeviceConnectionOptionsTests.cs @@ -29,7 +29,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device, AllowPacking: false)], - Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -49,7 +49,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -69,7 +69,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(micro, AbCipPlcFamily.Micro800)], - Tags = [new AbCipTagDefinition("X", micro, "X", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("X", micro, "X", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -88,7 +88,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device, ConnectionSize: 504)], - Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -106,7 +106,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], - Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs index 78b496c2..13acd500 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs @@ -109,7 +109,7 @@ public sealed class AbCipPlcFamilyTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)], - Tags = [new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -141,12 +141,11 @@ public sealed class AbCipPlcFamilyTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("NormalTag", "ab://10.0.0.5/1,0", "N", AbCipDataType.DInt), new AbCipTagDefinition("SafetyTag", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt, - Writable: true, SafetyTag: true), - ], + Writable: true, SafetyTag: true)) + , Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); @@ -167,11 +166,10 @@ public sealed class AbCipPlcFamilyTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("SafetySet", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt, - Writable: true, SafetyTag: true), - ], + Writable: true, SafetyTag: true)) + , Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRawTags.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRawTags.cs new file mode 100644 index 00000000..f663a2c4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRawTags.cs @@ -0,0 +1,26 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Test helper bridging the pre-v3 pre-declared-tag authoring style (a typed +/// ) to the v3 RawPath seam the driver now consumes +/// (). Each typed def is serialised to its TagConfig blob via +/// , and the def's identity (Name → RawPath), +/// device routing key (DeviceHostAddress) and write-idempotence +/// travel on the entry — exactly the shape the deploy artifact delivers. Lets the driver's read / write / +/// discovery suites keep expressing fixtures as typed definitions while exercising the v3 +/// RawTagsFromTagConfig table-build path. +/// +internal static class AbCipRawTags +{ + /// Projects typed definitions to the driver's RawTags option shape. + /// The typed definitions to project. + /// The equivalent list. + public static IReadOnlyList From(params AbCipTagDefinition[] defs) => + [.. defs.Select(d => new RawTagEntry( + RawPath: d.Name, + TagConfig: AbCipTagDefinitionFactory.ToTagConfig(d), + WriteIdempotent: d.WriteIdempotent, + DeviceName: d.DeviceHostAddress))]; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipResolveHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipResolveHostTests.cs index fd305b8f..687acbc6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipResolveHostTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipResolveHostTests.cs @@ -1,14 +1,15 @@ using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// CONV-4 — keys per-host resilience (bulkhead / circuit -/// breaker). It must resolve an equipment-tag reference (raw TagConfig JSON) to its OWN device -/// host, not fall back to the first device — otherwise a broken device B trips device A's -/// breaker. Authored tags and unknown refs keep the current fallback. +/// breaker). It must resolve an authored tag (by its RawPath) to its OWN device host, not fall back +/// to the first device — otherwise a broken device B trips device A's breaker. Unknown refs keep the +/// current first-device fallback. /// [Trait("Category", "Unit")] public sealed class AbCipResolveHostTests @@ -16,21 +17,27 @@ public sealed class AbCipResolveHostTests private const string DeviceA = "ab://10.0.0.5/1,0"; private const string DeviceB = "ab://10.0.0.6/1,0"; - private static AbCipDriver NewDriver() => new( + private static AbCipDriver NewDriver(params RawTagEntry[] rawTags) => new( new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(DeviceA), new AbCipDeviceOptions(DeviceB)], + RawTags = rawTags, Probe = new AbCipProbeOptions { Enabled = false }, }, "abcip-1", new FakeAbCipTagFactory()); - /// An equipment-tag ref naming device B resolves to device B's host, not the first device. + /// An authored tag routed to device B resolves to device B's host, not the first device. [Fact] - public void ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost() + public async Task ResolveHost_TagRoutedToDeviceB_ReturnsItsOwnDeviceHost() { - var drv = NewDriver(); - var json = $$"""{"deviceHostAddress":"{{DeviceB}}","tagPath":"Motor1.Speed","dataType":"DInt"}"""; - drv.ResolveHost(json).ShouldBe(DeviceB); + var drv = NewDriver(new RawTagEntry( + RawPath: "line1/motorB/speed", + TagConfig: """{"tagPath":"Motor1.Speed","dataType":"DInt"}""", + WriteIdempotent: false, + DeviceName: DeviceB)); + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.ResolveHost("line1/motorB/speed").ShouldBe(DeviceB); } /// An unresolvable ref keeps the current first-device fallback. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs index 118ad5a9..8c9bb24f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs @@ -70,7 +70,7 @@ public sealed class AbCipRuntimeConcurrencyTests var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = tags, + RawTags = AbCipRawTags.From(tags), }; return new AbCipDriver(opts, "drv-1", factory); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSubscriptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSubscriptionTests.cs index 1bb8c48c..901589a1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSubscriptionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSubscriptionTests.cs @@ -15,7 +15,7 @@ public sealed class AbCipSubscriptionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = tags, + RawTags = AbCipRawTags.From(tags), }, "drv-1", factory); return (drv, factory); } @@ -161,11 +161,10 @@ public sealed class AbCipSubscriptionTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, - Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]), - ], + Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)])) + , }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => p.TagName == "Motor1.Speed" diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagDefinitionFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagDefinitionFactoryTests.cs new file mode 100644 index 00000000..62468459 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagDefinitionFactoryTests.cs @@ -0,0 +1,182 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Dedicated unit tests for . +/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array, +/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath, +/// the writable field, and the Structure dataType path. Under v3 the definition's +/// identity (Name) is the RawPath the mapper is handed, not the blob, and the mapper does +/// NOT read deviceHostAddress (device placement travels on the RawTagEntry). +/// +[Trait("Category", "Unit")] +public class AbCipTagDefinitionFactoryTests +{ + private const string Raw = "line1/eq/tag"; + + // ── Happy-path scalar ──────────────────────────────────────────────────────────────── + + [Fact] + public void Valid_scalar_round_trip_parses_all_fields() + { + var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.Name.ShouldBe(Raw); + def.TagPath.ShouldBe("Motor.Speed"); + def.DeviceHostAddress.ShouldBe(""); // v3: mapper never reads deviceHostAddress + def.DataType.ShouldBe(AbCipDataType.Real); + def.Writable.ShouldBeTrue(); + def.IsArray.ShouldBeFalse(); + def.ElementCount.ShouldBe(1); + } + + // ── Array shape ────────────────────────────────────────────────────────────────────── + + [Fact] + public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar() + { + var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.IsArray.ShouldBeTrue(); + def.ElementCount.ShouldBe(1); + } + + [Fact] + public void N_element_array_isArray_true_arrayLength_N_parses_correctly() + { + var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.IsArray.ShouldBeTrue(); + def.ElementCount.ShouldBe(8); + } + + [Fact] + public void IsArray_true_arrayLength_0_is_canonical_scalar() + { + // Canonical rule: isArray:true AND arrayLength < 1 → scalar. + var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.IsArray.ShouldBeFalse(); + def.ElementCount.ShouldBe(1); + } + + [Fact] + public void IsArray_true_arrayLength_absent_is_canonical_scalar() + { + // Canonical rule: isArray:true but arrayLength absent → scalar. + var json = """{"tagPath":"PT_101","isArray":true}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.IsArray.ShouldBeFalse(); + def.ElementCount.ShouldBe(1); + } + + // ── Rejection paths ────────────────────────────────────────────────────────────────── + + [Fact] + public void Non_JSON_input_returns_false() + => AbCipTagDefinitionFactory.FromTagConfig("not json at all", Raw, out _).ShouldBeFalse(); + + [Fact] + public void Non_object_JSON_array_returns_false() + => AbCipTagDefinitionFactory.FromTagConfig("""["tagPath","foo"]""", Raw, out _).ShouldBeFalse(); + + [Fact] + public void Non_object_JSON_string_returns_false() + => AbCipTagDefinitionFactory.FromTagConfig("\"Motor.Speed\"", Raw, out _).ShouldBeFalse(); + + [Fact] + public void Missing_tagPath_returns_false() + => AbCipTagDefinitionFactory.FromTagConfig("""{"dataType":"DInt"}""", Raw, out _).ShouldBeFalse(); + + [Fact] + public void Blank_tagPath_returns_false() + => AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":" "}""", Raw, out _).ShouldBeFalse(); + + [Fact] + public void TagPath_as_number_returns_false() + => AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":42}""", Raw, out _).ShouldBeFalse(); + + // ── Writable field ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Writable_false_is_honoured() + { + var json = """{"tagPath":"Sensor.Val","writable":false}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.Writable.ShouldBeFalse(); + } + + [Fact] + public void Writable_absent_defaults_to_true() + { + var json = """{"tagPath":"Sensor.Val"}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.Writable.ShouldBeTrue(); + } + + [Fact] + public void Writable_true_explicit_is_honoured() + { + var json = """{"tagPath":"Sensor.Val","writable":true}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.Writable.ShouldBeTrue(); + } + + // ── Structure dataType ──────────────────────────────────────────────────────────────── + + /// + /// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed + /// definition with Members:null. The driver treats the tag path as a black-box dotted-path + /// read (libplctag resolves the full path); UDT member declarations are not authored via the + /// production equipment-tag flow (AbCipTagConfigModel emits no members key). + /// + [Fact] + public void Structure_dataType_is_accepted_with_null_Members_and_returns_true() + { + var json = """{"tagPath":"Motor","dataType":"Structure"}"""; + AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue(); + def!.DataType.ShouldBe(AbCipDataType.Structure); + def.Members.ShouldBeNull(); + def.TagPath.ShouldBe("Motor"); + } + + // ── ToTagConfig / FromTagConfig round-trip (v3 RawTagEntry carrier) ──────────────────── + + /// + /// A typed definition serialised via and + /// re-read via reproduces every per-tag + /// field except the out-of-band device routing key + WriteIdempotent (which travel on the + /// RawTagEntry, not the blob). Covers the array + declared-member round-trip the driver-test + /// AbCipRawTags helper relies on. + /// + [Fact] + public void ToTagConfig_FromTagConfig_round_trips_arrays_and_members() + { + var def = new AbCipTagDefinition( + Name: "Motor1", DeviceHostAddress: "ab://10.0.0.5/1,0", TagPath: "Motor1", + DataType: AbCipDataType.Structure, Writable: false, SafetyTag: true, + Members: + [ + new AbCipStructureMember("Speed", AbCipDataType.DInt), + new AbCipStructureMember("Buf", AbCipDataType.Real, Writable: false, ElementCount: 4, IsArray: true), + ]); + + var blob = AbCipTagDefinitionFactory.ToTagConfig(def); + AbCipTagDefinitionFactory.FromTagConfig(blob, "Motor1", out var back).ShouldBeTrue(); + + back!.Name.ShouldBe("Motor1"); + back.DeviceHostAddress.ShouldBe(""); // routing key not in the blob + back.TagPath.ShouldBe("Motor1"); + back.DataType.ShouldBe(AbCipDataType.Structure); + back.Writable.ShouldBeFalse(); + back.SafetyTag.ShouldBeTrue(); + back.Members!.Count.ShouldBe(2); + back.Members[0].Name.ShouldBe("Speed"); + back.Members[1].IsArray.ShouldBeTrue(); + back.Members[1].ElementCount.ShouldBe(4); + back.Members[1].Writable.ShouldBeFalse(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs index fea9b9f6..50aab447 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs @@ -16,8 +16,7 @@ public sealed class AbCipUdtMemberTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition( Name: "Motor1", DeviceHostAddress: "ab://10.0.0.5/1,0", @@ -28,8 +27,8 @@ public sealed class AbCipUdtMemberTests new AbCipStructureMember("Speed", AbCipDataType.DInt), new AbCipStructureMember("Running", AbCipDataType.Bool, Writable: false), new AbCipStructureMember("SetPoint", AbCipDataType.Real, WriteIdempotent: true), - ]), - ], + ])) + , }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); @@ -63,15 +62,14 @@ public sealed class AbCipUdtMemberTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Speed", AbCipDataType.DInt), new AbCipStructureMember("Running", AbCipDataType.Bool), - ]), - ], + ])) + , }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -91,14 +89,13 @@ public sealed class AbCipUdtMemberTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, Members: [ new AbCipStructureMember("SetPoint", AbCipDataType.Real), - ]), - ], + ])) + , }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -117,14 +114,13 @@ public sealed class AbCipUdtMemberTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false), - ]), - ], + ])) + , }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -145,7 +141,7 @@ public sealed class AbCipUdtMemberTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)), }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); @@ -163,7 +159,7 @@ public sealed class AbCipUdtMemberTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = [new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])), }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); @@ -181,16 +177,15 @@ public sealed class AbCipUdtMemberTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - Tags = - [ + RawTags = AbCipRawTags.From( new AbCipTagDefinition("FlatA", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt), new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Speed", AbCipDataType.DInt), ]), - new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real), - ], + new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real)) + , }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None);