diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs index f25180c3..06f1c677 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs @@ -1,5 +1,6 @@ using CliFx.Attributes; using CliFx.Exceptions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli; @@ -48,8 +49,11 @@ public abstract class S7CommandBase : DriverCommandBase /// /// Build an with the endpoint fields this base - /// collected + whatever the subclass declares. Probe - /// disabled — CLI runs are one-shot. + /// collected + whatever the subclass declares. Each typed + /// definition is serialised to its TagConfig blob via + /// and packaged as a + /// (RawPath = the def's Name) — the v3 delivery shape the + /// driver rebuilds definitions from. Probe disabled — CLI runs are one-shot. /// /// The tag definitions to include in the options. /// The constructed . @@ -61,7 +65,7 @@ public abstract class S7CommandBase : DriverCommandBase Rack = Rack, Slot = Slot, Timeout = Timeout, - Tags = tags, + RawTags = [.. tags.Select(t => new RawTagEntry(t.Name, S7TagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent))], Probe = new S7ProbeOptions { Enabled = false }, }; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs index 23c8974b..15a09eb7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7; @@ -53,8 +54,15 @@ public sealed class S7DriverOptions /// Connect + per-operation timeout. public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5); - /// Pre-declared tag map. S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list configured per-site. Address grammar documented in S7AddressParser (PR 63). - public IReadOnlyList Tags { get; init; } = []; + /// + /// Authored raw tags this driver serves. The deploy artifact hands each authored raw + /// Tag as a (RawPath identity + driver TagConfig blob + + /// WriteIdempotent flag); the driver maps each through + /// into its RawPath → definition table. + /// S7 has a symbol-table protocol but S7.Net does not expose it, so the driver serves exactly + /// these authored tags. Address grammar documented in S7AddressParser (PR 63). + /// + public IReadOnlyList RawTags { get; init; } = []; /// /// Background connectivity-probe settings. When enabled, the driver runs a tick loop @@ -104,7 +112,7 @@ public sealed class S7ProbeOptions /// Element count when the tag is a 1-D array; null for a scalar. Any value >= 1 /// (including 1) surfaces as a 1-D OPC UA array node. /// For an equipment tag this is threaded from the TagConfig JSON's arrayLength -/// (honoured only when isArray is true) by . When +/// (honoured only when isArray is true) by . When /// set, the driver issues a single contiguous block read of /// ArrayCount × element-bytes from the tag's start address and decodes each element /// into an element-typed CLR array (short[] / int[] / float[] / etc.). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs similarity index 53% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs index 8f0cdf1c..48044406 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs @@ -1,29 +1,45 @@ using System.Text.Json; +using System.Text.Json.Nodes; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7; -/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI -/// S7TagConfigModeladdress / dataType / stringLength) 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 S7EquipmentTagParser +/// +/// v3 pure mapper: turns an authored raw tag's TagConfig JSON (the shape produced by the +/// AdminUI S7TagConfigModeladdress / dataType / stringLength) into a +/// . 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) that hold a typed +/// definition and need to synthesise the TagConfig blob for a . +/// +public static class S7TagDefinitionFactory { /// S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max). private const int MaxStringLength = 254; - /// 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 S7 address object. - public static bool TryParse(string reference, out S7TagDefinition def) + /// + /// 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 dataType enum 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. is NOT read + /// from the blob — it is a platform flag the caller threads 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 S7 address object. + public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition 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; + if (string.IsNullOrWhiteSpace(tagConfig)) return false; try { - using var doc = JsonDocument.Parse(reference); + using var doc = JsonDocument.Parse(tagConfig); var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("address", out var addrEl) @@ -42,18 +58,19 @@ public static class S7EquipmentTagParser if (dataType == S7DataType.String && (stringLength < 0 || stringLength > MaxStringLength)) return false; // Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray) // honours arrayLength ONLY when isArray is true AND the prop is a JSON number — mirror - // that here so the driver's transient def agrees byte-for-byte with the materialised - // OPC UA node's ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar). + // that here so the driver's def agrees byte-for-byte with the materialised OPC UA node's + // ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar). var arrayCount = ReadArrayCount(root); // "writable" defaults to true when absent (today's value); node-level authz still governs // writes. Honouring the key makes read-only equipment tags authorable (UNDER-6). var writable = TagConfigJson.ReadWritable(root); def = new S7TagDefinition( - Name: reference, + Name: rawPath, Address: address, DataType: dataType, Writable: writable, StringLength: stringLength == 0 ? MaxStringLength : stringLength, + WriteIdempotent: false, ArrayCount: arrayCount); return true; } @@ -62,6 +79,37 @@ public static class S7EquipmentTagParser 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) that construct an + /// from operator flags and need the TagConfig string to hand the + /// driver as a . The dataType enum is written as its name string; + /// stringLength is emitted only when it diverges from the 254 default so the blob stays minimal. + /// Name is NOT emitted — it becomes the . WriteIdempotent + /// is NOT emitted — it travels on the , not inside the blob. + /// + /// The definition to serialise. + /// The camelCase TagConfig JSON string. + public static string ToTagConfig(S7TagDefinition def) + { + ArgumentNullException.ThrowIfNull(def); + var o = new JsonObject + { + ["address"] = def.Address, + ["dataType"] = def.DataType.ToString(), + ["writable"] = def.Writable, + }; + // FromTagConfig maps an absent / zero stringLength to MaxStringLength (254), so a def carrying + // the 254 default round-trips whether or not we emit it — emit only when it diverges. + if (def.StringLength != MaxStringLength) o["stringLength"] = def.StringLength; + if (def.ArrayCount is { } count) + { + o["isArray"] = true; + o["arrayLength"] = count; + } + return o.ToJsonString(); + } + /// /// 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 (a silent diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 476fc283..c7949bb4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -51,8 +51,7 @@ public sealed class S7Driver _plcFactory = plcFactory ?? new S7PlcFactory(); _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( - r => _tagsByName.TryGetValue(r, out var t) ? t : null, - r => S7EquipmentTagParser.TryParse(r, out var d) ? d : null); + r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); // CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff + // drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE), // retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor; @@ -104,12 +103,15 @@ public sealed class S7Driver /// OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault). private const uint StatusBadDeviceFailure = 0x808B0000u; - private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _parsedByName = new(StringComparer.OrdinalIgnoreCase); + // v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name), + // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory. + private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal); + // Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside + // _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault. + private readonly Dictionary _parsedByRawPath = new(StringComparer.Ordinal); - // 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 S7EquipmentTagParser, cached). + // Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single + // authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt). private readonly EquipmentTagRefResolver _resolver; /// @@ -175,6 +177,24 @@ public sealed class S7Driver if (HasConfigBody(driverConfigJson)) _options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + // v3: build the RawPath → definition table from the authored raw tags BEFORE the config + // guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by + // the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on + // the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown — + // one bad tag must not fail the whole driver init. + _tagsByRawPath.Clear(); + _parsedByRawPath.Clear(); + _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry + foreach (var entry in _options.RawTags) + { + if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) + _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent }; + else + _logger.LogWarning( + "S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + _driverInstanceId, entry.RawPath); + } + // Phase 4d combined config guard. Timer/Counter addresses and the wide/structured // types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in // specific shapes — this rejects the genuinely-unsupported configurations @@ -220,21 +240,17 @@ public sealed class S7Driver // Parse every tag's address once at init so config typos fail fast here instead // of surfacing as BadInternalError on every Read against the bad tag. The parser - // also rejects bit-offset > 7, DB 0, unknown area letters, etc. - _tagsByName.Clear(); - _parsedByName.Clear(); - _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses - foreach (var t in _options.Tags) + // also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath + // (== def.Name) so Read/Write can pick up the pre-parsed address. + foreach (var t in _tagsByRawPath.Values) { - var parsed = S7AddressParser.Parse(t.Address); // throws FormatException - _tagsByName[t.Name] = t; - _parsedByName[t.Name] = parsed; + _parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException } _initialized = true; _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); _logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}", - _driverInstanceId, _options.Host, _options.CpuType, _options.Tags.Count); + _driverInstanceId, _options.Host, _options.CpuType, _tagsByRawPath.Count); // Kick off the probe loop once the connection is up. Initial HostState stays // Unknown until the first probe tick succeeds — avoids broadcasting a premature @@ -360,7 +376,7 @@ public sealed class S7Driver /// private void RejectUnsupportedTagConfigs() { - foreach (var t in _options.Tags) + foreach (var t in _tagsByRawPath.Values) { var isWide = ByteAnchoredDataTypes.Contains(t.DataType); @@ -442,7 +458,7 @@ public sealed class S7Driver /// private void RejectUnsupportedTagDataTypes() { - foreach (var t in _options.Tags) + foreach (var t in _tagsByRawPath.Values) { if (UnimplementedDataTypes.Contains(t.DataType)) { @@ -573,12 +589,10 @@ public sealed class S7Driver private async Task ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct) { - // Authored tags pre-parse their address at init (_parsedByName); an equipment-tag ref - // (resolved transiently by _resolver) has no _parsedByName entry, so parse its address - // on demand. S7AddressParser.Parse throws FormatException on a bad address, which the - // caller's catch maps to BadCommunicationError — the same surface a bad authored tag - // would have hit at init (transient defs aren't init-validated). - var addr = _parsedByName.TryGetValue(tag.Name, out var parsed) + // Every tag pre-parses its address at init (_parsedByRawPath); the on-demand + // S7AddressParser.Parse is a defensive fallback for a tag not in that table. Parse throws + // FormatException on a bad address, which the caller's catch maps to BadCommunicationError. + var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed) ? parsed : S7AddressParser.Parse(tag.Address); @@ -1045,16 +1059,16 @@ public sealed class S7Driver results[i] = new WriteResult(StatusBadNotWritable); continue; } - // Timer/Counter transient-write guard: a transient equipment-tag ref is always - // resolved with Writable=true (S7EquipmentTagParser hardcodes it, and node-level - // auth governs writes), so Timer/Counter addresses bypass the !Writable gate above - // and would otherwise reach EncodeScalarBlock, which throws NotSupportedException → - // BadNotSupported. The docs say Timer/Counter writes return BadNotWritable. Detect - // the area here before the call so BOTH authored (caught by guard-d at init) and - // transient Timer/Counter writes consistently return BadNotWritable. + // Timer/Counter write guard: an equipment tag authored writable=true (S7 mapper + // honours the "writable" key, and node-level auth governs writes) whose address is a + // Timer/Counter would bypass the !Writable gate above and reach EncodeScalarBlock, + // which throws NotSupportedException → BadNotSupported. The docs say Timer/Counter + // writes return BadNotWritable. Detect the area here before the call so it + // consistently returns BadNotWritable (init guard-d also rejects a writable + // Timer/Counter, but the mapper doesn't run that guard). // TryParse (not Parse) so a malformed address falls through to the existing // error path rather than throwing here. - if (_parsedByName.TryGetValue(tag.Name, out var tcParsed) + if (_parsedByRawPath.TryGetValue(tag.Name, out var tcParsed) ? tcParsed.Area is S7Area.Timer or S7Area.Counter : (S7AddressParser.TryParse(tag.Address, out tcParsed) && tcParsed.Area is S7Area.Timer or S7Area.Counter)) { @@ -1126,11 +1140,11 @@ public sealed class S7Driver $"S7 array writes are not yet supported (tag '{tag.Name}'). " + "Use individual scalar tags or set Writable=false for read-only arrays."); - // Parse the address the same way ReadOneAsync does: authored tags pre-parse at init - // (_parsedByName); an equipment-tag ref (resolved transiently) parses on demand. Needed - // here so the wide-type write can byte-address the block (the narrow path below addresses - // by the raw address string instead). - var addr = _parsedByName.TryGetValue(tag.Name, out var parsed) + // Parse the address the same way ReadOneAsync does: every tag pre-parses at init + // (_parsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the + // wide-type write can byte-address the block (the narrow path below addresses by the raw + // address string instead). + var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed) ? parsed : S7AddressParser.Parse(tag.Address); @@ -1370,7 +1384,7 @@ public sealed class S7Driver { ArgumentNullException.ThrowIfNull(builder); var folder = builder.Folder("S7", "S7"); - foreach (var t in _options.Tags) + foreach (var t in _tagsByRawPath.Values) { // A tag carrying a non-null array count (>= 1) surfaces as a 1-D OPC UA array node. // A null count stays scalar. A count of 1 IS a valid 1-element array: the foundation diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs index 16b2f45f..5b4670e4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs @@ -1,5 +1,6 @@ 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.S7; @@ -64,9 +65,10 @@ public static class S7DriverFactoryExtensions Rack = dto.Rack ?? 0, Slot = dto.Slot ?? 0, Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 5_000), - 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 S7 + // tags. The driver maps each RawTagEntry's TagConfig blob through S7TagDefinitionFactory + // at Initialize; the legacy pre-declared DTO tag path (Name/Address → BuildTag) is retired. + RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [], Probe = new S7ProbeOptions { Enabled = dto.Probe?.Enabled ?? true, @@ -77,18 +79,6 @@ public static class S7DriverFactoryExtensions }; } - private static S7TagDefinition BuildTag(S7TagDto t, string driverInstanceId) => - new( - Name: t.Name ?? throw new InvalidOperationException( - $"S7 config for '{driverInstanceId}' has a tag missing Name"), - Address: t.Address ?? throw new InvalidOperationException( - $"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"), - DataType: ParseEnum(t.DataType, driverInstanceId, "DataType", - tagName: t.Name), - Writable: t.Writable ?? true, - StringLength: t.StringLength ?? 254, - WriteIdempotent: t.WriteIdempotent ?? false); - private static T ParseEnum(string? raw, string driverInstanceId, string field, string? tagName = null, T? fallback = null) where T : struct, Enum { @@ -127,29 +117,13 @@ public static class S7DriverFactoryExtensions public short? Slot { get; init; } /// Gets the connection timeout in milliseconds. public int? TimeoutMs { get; init; } - /// Gets the list of tag definitions. - public List? Tags { get; init; } + /// Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy + /// artifact delivers. The driver maps each through at Initialize. + public List? RawTags { get; init; } /// Gets the probe configuration. public S7ProbeDto? Probe { get; init; } } - /// Data transfer object for S7 tag definition. - internal sealed class S7TagDto - { - /// Gets the tag name. - public string? Name { get; init; } - /// Gets the S7 address (e.g., DB1.DBD0). - public string? Address { get; init; } - /// Gets the data type name. - public string? DataType { get; init; } - /// Gets a value indicating whether the tag is writable. - public bool? Writable { get; init; } - /// Gets the string length for string types. - public int? StringLength { get; init; } - /// Gets a value indicating whether write is idempotent. - public bool? WriteIdempotent { get; init; } - } - /// Data transfer object for S7 probe configuration. internal sealed class S7ProbeDto { diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/S7CommandBaseBuildOptionsTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/S7CommandBaseBuildOptionsTests.cs index e68ad437..f09ce1b2 100644 --- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/S7CommandBaseBuildOptionsTests.cs +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/S7CommandBaseBuildOptionsTests.cs @@ -87,16 +87,25 @@ public sealed class S7CommandBaseBuildOptionsTests options.Slot.ShouldBe((short)2); } - /// Verifies that BuildOptions forwards the tag list verbatim. + /// Verifies that BuildOptions serialises each typed tag to a RawTagEntry (RawPath = + /// the def's Name) whose TagConfig round-trips back to the same definition (v3 delivery shape). [Fact] - public void BuildOptions_forwards_tag_list_verbatim() + public void BuildOptions_serialises_tags_to_raw_tag_entries() { var sut = new ProbeOnly { Host = "h" }; var tag = new S7TagDefinition("t", "MW0", S7DataType.Int16, Writable: false); var options = sut.Invoke([tag]); - options.Tags.Count.ShouldBe(1); - options.Tags[0].ShouldBeSameAs(tag); + options.RawTags.Count.ShouldBe(1); + var entry = options.RawTags[0]; + entry.RawPath.ShouldBe("t"); + entry.WriteIdempotent.ShouldBe(tag.WriteIdempotent); + + S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var round).ShouldBeTrue(); + round!.Name.ShouldBe("t"); + round.Address.ShouldBe("MW0"); + round.DataType.ShouldBe(S7DataType.Int16); + round.Writable.ShouldBeFalse(); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs index 7e54b178..a98ebe44 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs @@ -180,13 +180,15 @@ public sealed class S7ArrayReadTests var opts = new S7DriverOptions { Host = "192.0.2.1", - Tags = - [ - new("Scalar", "DB1.DBW0", S7DataType.Int16), - new("Arr", "DB1.DBW10", S7DataType.Int16, ArrayCount: 8), - ], + Probe = new S7ProbeOptions { Enabled = false }, + // Array tags are read-only this phase (writable arrays are rejected by the init guard). + RawTags = S7RawTags.Entries( + new S7TagDefinition("Scalar", "DB1.DBW0", S7DataType.Int16), + new S7TagDefinition("Arr", "DB1.DBW10", S7DataType.Int16, Writable: false, ArrayCount: 8)), }; - using var drv = new S7Driver(opts, "s7-arr-disco"); + // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first. + using var drv = new S7Driver(opts, "s7-arr-disco", new ConnectingFakeS7PlcFactory()); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); @@ -209,9 +211,12 @@ public sealed class S7ArrayReadTests var opts = new S7DriverOptions { Host = "192.0.2.1", - Tags = [new("One", "DB1.DBW0", S7DataType.Int16, ArrayCount: 1)], + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(new S7TagDefinition("One", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 1)), }; - using var drv = new S7Driver(opts, "s7-arr-one"); + // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first. + using var drv = new S7Driver(opts, "s7-arr-one", new ConnectingFakeS7PlcFactory()); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); @@ -228,9 +233,12 @@ public sealed class S7ArrayReadTests var opts = new S7DriverOptions { Host = "192.0.2.1", - Tags = [new("Scalar", "DB1.DBW0", S7DataType.Int16, ArrayCount: null)], + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(new S7TagDefinition("Scalar", "DB1.DBW0", S7DataType.Int16, ArrayCount: null)), }; - using var drv = new S7Driver(opts, "s7-arr-null"); + // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first. + using var drv = new S7Driver(opts, "s7-arr-null", new ConnectingFakeS7PlcFactory()); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); @@ -253,32 +261,33 @@ public sealed class S7ArrayReadTests arr[0].ShouldBe((short)100); } - // ── Equipment-tag resolver threads arrayLength → ArrayCount ─────────────────────────── + // ── Tag-definition factory threads arrayLength → ArrayCount ─────────────────────────── - /// Verifies the equipment-tag parser threads isArray/arrayLength into ArrayCount. + /// Verifies the tag-definition factory threads isArray/arrayLength into ArrayCount. [Fact] - public void EquipmentTagParser_threads_array_length_into_ArrayCount() + public void TagDefinitionFactory_threads_array_length_into_ArrayCount() { var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":true,"arrayLength":16}"""; - S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.ArrayCount.ShouldBe(16); + S7TagDefinitionFactory.FromTagConfig(json, "line/eq/arr", out var def).ShouldBeTrue(); + def!.Name.ShouldBe("line/eq/arr", "the definition's identity is the RawPath"); + def.ArrayCount.ShouldBe(16); } /// Verifies arrayLength is ignored when isArray is false (mirrors the sink foundation). [Fact] - public void EquipmentTagParser_ignores_arrayLength_when_isArray_false() + public void TagDefinitionFactory_ignores_arrayLength_when_isArray_false() { var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":false,"arrayLength":16}"""; - S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + S7TagDefinitionFactory.FromTagConfig(json, "line/eq/arr", out var def).ShouldBeTrue(); def!.ArrayCount.ShouldBeNull("arrayLength is honoured only when isArray is true"); } - /// Verifies a scalar equipment tag (no array keys) has a null ArrayCount. + /// Verifies a scalar tag config (no array keys) has a null ArrayCount. [Fact] - public void EquipmentTagParser_scalar_has_null_ArrayCount() + public void TagDefinitionFactory_scalar_has_null_ArrayCount() { var json = """{"address":"DB1.DBW0","dataType":"Int16"}"""; - S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + S7TagDefinitionFactory.FromTagConfig(json, "line/eq/scalar", out var def).ShouldBeTrue(); def!.ArrayCount.ShouldBeNull(); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs index c3195036..08edb602 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs @@ -22,14 +22,16 @@ public sealed class S7DiscoveryAndSubscribeTests var opts = new S7DriverOptions { Host = "192.0.2.1", - Tags = - [ - new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true), - new("FaultBit", "M0.0", S7DataType.Bool, Writable: false), - new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true), - ], + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries( + new S7TagDefinition("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true), + new S7TagDefinition("FaultBit", "M0.0", S7DataType.Bool, Writable: false), + new S7TagDefinition("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true)), }; - using var drv = new S7Driver(opts, "s7-disco"); + // v3: DiscoverAsync projects the RawPath → definition table, which is built at Initialize — + // so the driver must be initialized (with a connecting fake PLC) before Discover. + using var drv = new S7Driver(opts, "s7-disco", new ConnectingFakeS7PlcFactory()); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); @@ -49,13 +51,14 @@ public sealed class S7DiscoveryAndSubscribeTests var opts = new S7DriverOptions { Host = "192.0.2.1", - Tags = - [ - new("SetPoint", "DB1.DBW0", S7DataType.Int16, WriteIdempotent: true), - new("StartBit", "M0.0", S7DataType.Bool), - ], + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries( + new S7TagDefinition("SetPoint", "DB1.DBW0", S7DataType.Int16, WriteIdempotent: true), + new S7TagDefinition("StartBit", "M0.0", S7DataType.Bool)), }; - using var drv = new S7Driver(opts, "s7-idem"); + // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first. + using var drv = new S7Driver(opts, "s7-idem", new ConnectingFakeS7PlcFactory()); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests.cs index 085a1281..9c1c3a8a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests.cs @@ -34,7 +34,7 @@ public sealed class S7DriverCodeReviewFixTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("Quirk", address, S7DataType.Int16)], + RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)), }; using var drv = new S7Driver(opts, "s7-tc"); @@ -58,11 +58,9 @@ public sealed class S7DriverCodeReviewFixTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = - [ + RawTags = S7RawTags.Entries( new S7TagDefinition("Word", "DB1.DBW0", S7DataType.Int16), - new S7TagDefinition("Bit", "M0.0", S7DataType.Bool), - ], + new S7TagDefinition("Bit", "M0.0", S7DataType.Bool)), }; using var drv = new S7Driver(opts, "s7-ok"); @@ -111,7 +109,7 @@ public sealed class S7DriverCodeReviewFixTests const string json = """ { "Host": "192.0.2.1", "TimeoutMs": 250, - "Tags": [ { "Name": "TimerTag", "Address": "T5", "DataType": "Int16" } ] } + "RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] } """; var ex = await Should.ThrowAsync(async () => diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs index 647b13e1..75731de5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs @@ -165,7 +165,7 @@ public sealed class S7DriverCodeReviewFixTests2 // DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape // for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip; // see S7ScalarBlockTests.) - Tags = [new S7TagDefinition("X", "DB1.DBD0", dt)], + RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)), }; using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}"); @@ -197,7 +197,7 @@ public sealed class S7DriverCodeReviewFixTests2 { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("X", addr, dt)], + RawTags = S7RawTags.Entries(new S7TagDefinition("X", addr, dt)), }; using var drv = new S7Driver(opts, $"s7-good-dt-{dt}"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs index 8a22b779..9f59d91c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs @@ -24,7 +24,7 @@ public sealed class S7DriverReadWriteTests { Host = "192.0.2.1", // reserved — will never complete TCP handshake Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)], + RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)), }; using var drv = new S7Driver(opts, "s7-bad-tag"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs index 989d5f22..6fdabdfa 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs @@ -22,7 +22,7 @@ public sealed class S7DriverReconnectTests // Probe off — these tests drive reconnect through the read path deterministically; a // background probe would race the created-connection assertions. Probe = new S7ProbeOptions { Enabled = false }, - Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)], + RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), }; /// @@ -265,7 +265,7 @@ public sealed class S7DriverReconnectTests Host = "192.0.2.1", Timeout = TimeSpan.FromSeconds(5), // long, so the CALLER token fires first, not the internal timeout Probe = new S7ProbeOptions { Enabled = false }, - Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)], + RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), }; var factory = new FakeS7PlcFactory(); using var drv = new S7Driver(opts, "s7-caller-cancel", factory); @@ -367,7 +367,7 @@ public sealed class S7DriverReconnectTests Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Probe = new S7ProbeOptions { Enabled = false }, - Tags = [new S7TagDefinition("WW0", "DB1.DBW0", S7DataType.UInt16, Writable: true)], + RawTags = S7RawTags.Entries(new S7TagDefinition("WW0", "DB1.DBW0", S7DataType.UInt16, Writable: true)), }; var factory = new FakeS7PlcFactory(); using var drv = new S7Driver(opts, "s7-cancel-write", factory); @@ -448,7 +448,7 @@ public sealed class S7DriverReconnectTests Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(250), }, - Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)], + RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), }; /// Polls until it holds or elapses (returns either way). diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs index 92a0f4ae..02a42a86 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs @@ -85,7 +85,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("LReal", "DB1.DBB0", S7DataType.Float64)], + RawTags = S7RawTags.Entries(new S7TagDefinition("LReal", "DB1.DBB0", S7DataType.Float64)), }; using var drv = new S7Driver(opts, "s7-wide-ok"); @@ -105,7 +105,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)], + RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)), }; using var drv = new S7Driver(opts, "s7-wide-array"); @@ -125,7 +125,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)], + RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)), }; using var drv = new S7Driver(opts, "s7-wide-nonbyte"); @@ -145,7 +145,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Int32)], + RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)), }; using var drv = new S7Driver(opts, "s7-timer-bad"); @@ -165,7 +165,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Float32)], + RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)), }; using var drv = new S7Driver(opts, "s7-counter-bad"); @@ -192,7 +192,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)], + RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)), }; using var drv = new S7Driver(opts, "s7-timer-writable"); @@ -213,7 +213,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)], + RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)), }; using var drv = new S7Driver(opts, "s7-counter-writable"); @@ -250,7 +250,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("ArrTag", addr, dt, Writable: true, ArrayCount: count)], + RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", addr, dt, Writable: true, ArrayCount: count)), }; using var drv = new S7Driver(opts, $"s7-writable-arr-{dt}"); @@ -272,7 +272,7 @@ public sealed class S7DriverScaffoldTests { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - Tags = [new S7TagDefinition("ReadArr", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 4)], + RawTags = S7RawTags.Entries(new S7TagDefinition("ReadArr", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 4)), }; using var drv = new S7Driver(opts, "s7-readonly-arr"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagParserStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagParserStrictnessTests.cs deleted file mode 100644 index 83f1d7ff..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagParserStrictnessTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.S7; - -namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; - -/// R2-11 Phase C strictness surface for the S7 equipment-tag parser: the runtime is now STRICT — a -/// typo'd dataType rejects the tag (TryParseBadNodeIdUnknown), -/// Inspect reports the typo, and the writable key is honoured (default true). -public sealed class S7EquipmentTagParserStrictnessTests -{ - [Fact] - public void Typo_dataType_rejects_the_tag() - { - S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", out _) - .ShouldBeFalse(); - } - - [Fact] - public void Valid_dataType_still_parses() - { - S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def) - .ShouldBeTrue(); - def.DataType.ShouldBe(S7DataType.Int16); - } - - [Fact] - public void Inspect_reports_typo_dataType() - { - var warnings = S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}"); - warnings.ShouldHaveSingleItem(); - warnings[0].ShouldContain("Intt16"); - warnings[0].ShouldContain("dataType"); - } - - [Fact] - public void Inspect_clean_config_has_no_warnings() - { - S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty(); - } - - [Fact] - public void Writable_false_is_honoured() - { - S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", out var def) - .ShouldBeTrue(); - def.Writable.ShouldBeFalse(); - } - - [Fact] - public void Writable_absent_defaults_to_true() - { - S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def) - .ShouldBeTrue(); - def.Writable.ShouldBeTrue(); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagTests.cs deleted file mode 100644 index 06074506..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; - -[Trait("Category", "Unit")] -public class S7EquipmentTagTests -{ - [Fact] - public void Parses_equipment_tagconfig_into_a_transient_definition() - { - var json = """{"address":"DB1.DBW0","dataType":"UInt16","stringLength":0}"""; - S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.Name.ShouldBe(json); - def.Address.ShouldBe("DB1.DBW0"); - def.DataType.ShouldBe(S7DataType.UInt16); - } - - [Fact] - public void Rejects_a_non_address_blob() - => S7EquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse(); - - [Fact] - public void Rejects_garbage() - => S7EquipmentTagParser.TryParse("not json", out _).ShouldBeFalse(); - - [Fact] - public void Rejects_address_as_a_json_number() - => S7EquipmentTagParser.TryParse( - """{"address":40001,"dataType":"UInt16"}""", out _).ShouldBeFalse(); - - [Fact] - public void Rejects_string_length_out_of_range() - => S7EquipmentTagParser.TryParse( - """{"address":"DB1.DBW0","dataType":"String","stringLength":300}""", out _).ShouldBeFalse(); -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PollEngineMigrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PollEngineMigrationTests.cs index f3e6d8fc..9da4db9d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PollEngineMigrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PollEngineMigrationTests.cs @@ -58,7 +58,7 @@ public sealed class S7PollEngineMigrationTests Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Probe = new S7ProbeOptions { Enabled = false }, - Tags = [new S7TagDefinition("Arr", "DB1.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 3)], + RawTags = S7RawTags.Entries(new S7TagDefinition("Arr", "DB1.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 3)), }; /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RawTags.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RawTags.cs new file mode 100644 index 00000000..54d9daa1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RawTags.cs @@ -0,0 +1,28 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Test helper bridging the v2 authoring shape (a typed ) to the v3 +/// delivery shape (). Under v3 the driver no longer takes pre-declared +/// definitions — the deploy artifact hands it authored raw tags (RawPath + TagConfig blob), and the +/// driver rebuilds the typed definitions via . These tests keep +/// expressing their intent as typed definitions; this helper serialises each back to its TagConfig blob +/// (via ) and packages it as a +/// whose RawPath is the def's Name — so the round-trip through the real mapper reproduces the same +/// definition the test authored, keyed by the same name the read/write/subscribe call sites use. +/// +internal static class S7RawTags +{ + /// Serialises one typed definition into its (RawPath = def.Name). + /// The typed definition to convert. + /// The equivalent . + public static RawTagEntry Entry(S7TagDefinition def) + => new(def.Name, S7TagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent); + + /// Serialises a sequence of typed definitions into records. + /// The typed definitions to convert. + /// The equivalent raw-tag entries. + public static IReadOnlyList Entries(params S7TagDefinition[] defs) + => [.. defs.Select(Entry)]; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagDefinitionFactoryStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagDefinitionFactoryStrictnessTests.cs new file mode 100644 index 00000000..787e4b62 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagDefinitionFactoryStrictnessTests.cs @@ -0,0 +1,58 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.S7; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// R2-11 Phase C strictness surface for the S7 tag-definition factory: the runtime is STRICT — a +/// typo'd dataType rejects the tag (FromTagConfigBadNodeIdUnknown), +/// Inspect reports the typo, and the writable key is honoured (default true). +public sealed class S7TagDefinitionFactoryStrictnessTests +{ + [Fact] + public void Typo_dataType_rejects_the_tag() + { + S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", "p", out _) + .ShouldBeFalse(); + } + + [Fact] + public void Valid_dataType_still_parses() + { + S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", "line/eq/t", out var def) + .ShouldBeTrue(); + def!.Name.ShouldBe("line/eq/t"); + def.DataType.ShouldBe(S7DataType.Int16); + } + + [Fact] + public void Inspect_reports_typo_dataType() + { + var warnings = S7TagDefinitionFactory.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}"); + warnings.ShouldHaveSingleItem(); + warnings[0].ShouldContain("Intt16"); + warnings[0].ShouldContain("dataType"); + } + + [Fact] + public void Inspect_clean_config_has_no_warnings() + { + S7TagDefinitionFactory.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty(); + } + + [Fact] + public void Writable_false_is_honoured() + { + S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", "p", out var def) + .ShouldBeTrue(); + def!.Writable.ShouldBeFalse(); + } + + [Fact] + public void Writable_absent_defaults_to_true() + { + S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", "p", out var def) + .ShouldBeTrue(); + def!.Writable.ShouldBeTrue(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagDefinitionFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagDefinitionFactoryTests.cs new file mode 100644 index 00000000..6569c4a1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagDefinitionFactoryTests.cs @@ -0,0 +1,68 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +[Trait("Category", "Unit")] +public class S7TagDefinitionFactoryTests +{ + [Fact] + public void Maps_tagconfig_into_a_definition_keyed_by_rawpath() + { + var json = """{"address":"DB1.DBW0","dataType":"UInt16","stringLength":0}"""; + S7TagDefinitionFactory.FromTagConfig(json, "line/eq/tag", out var def).ShouldBeTrue(); + def!.Name.ShouldBe("line/eq/tag", "the definition's identity is the RawPath, not the blob"); + def.Address.ShouldBe("DB1.DBW0"); + def.DataType.ShouldBe(S7DataType.UInt16); + } + + [Fact] + public void Rejects_a_non_address_blob() + => S7TagDefinitionFactory.FromTagConfig("""{"FullName":"x"}""", "p", out _).ShouldBeFalse(); + + [Fact] + public void Rejects_garbage() + => S7TagDefinitionFactory.FromTagConfig("not json", "p", out _).ShouldBeFalse(); + + [Fact] + public void Rejects_empty_tagconfig() + => S7TagDefinitionFactory.FromTagConfig("", "p", out _).ShouldBeFalse(); + + [Fact] + public void Rejects_address_as_a_json_number() + => S7TagDefinitionFactory.FromTagConfig( + """{"address":40001,"dataType":"UInt16"}""", "p", out _).ShouldBeFalse(); + + [Fact] + public void Rejects_string_length_out_of_range() + => S7TagDefinitionFactory.FromTagConfig( + """{"address":"DB1.DBW0","dataType":"String","stringLength":300}""", "p", out _).ShouldBeFalse(); + + [Fact] + public void ToTagConfig_round_trips_through_FromTagConfig() + { + var original = new S7TagDefinition("line/eq/rt", "DB1.DBB0", S7DataType.String, + Writable: false, StringLength: 32, ArrayCount: null); + var blob = S7TagDefinitionFactory.ToTagConfig(original); + + S7TagDefinitionFactory.FromTagConfig(blob, original.Name, out var round).ShouldBeTrue(); + round!.Name.ShouldBe(original.Name); + round.Address.ShouldBe(original.Address); + round.DataType.ShouldBe(original.DataType); + round.Writable.ShouldBe(original.Writable); + round.StringLength.ShouldBe(original.StringLength); + round.ArrayCount.ShouldBe(original.ArrayCount); + } + + [Fact] + public void ToTagConfig_round_trips_an_array_definition() + { + var original = new S7TagDefinition("line/eq/arr", "DB1.DBW0", S7DataType.Int16, + Writable: false, ArrayCount: 8); + var blob = S7TagDefinitionFactory.ToTagConfig(original); + + S7TagDefinitionFactory.FromTagConfig(blob, original.Name, out var round).ShouldBeTrue(); + round!.ArrayCount.ShouldBe(8); + round.DataType.ShouldBe(S7DataType.Int16); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs index 7fda1f8e..cb857905 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using S7NetDataType = global::S7.Net.DataType; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; @@ -63,3 +64,42 @@ internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder => throw new NotImplementedException("S7 driver never calls this — no alarm surfacing"); } } + +/// +/// Minimal whose connection opens cleanly and answers the CPU-status +/// probe, but serves no data. Used by the tests that need the driver Initialized — which in +/// v3 is what builds the RawPath → definition table projects — +/// without a live PLC or wire I/O. +/// +internal sealed class ConnectingFakeS7PlcFactory : IS7PlcFactory +{ + /// Creates a fresh connecting fake connection. + public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) + => new ConnectingFakeS7Plc(); + + private sealed class ConnectingFakeS7Plc : IS7Plc + { + /// Gets a value indicating whether the fake connection is open. + public bool IsConnected { get; private set; } + /// Opens the fake connection. + public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; } + /// Closes the fake connection. + public void Close() => IsConnected = false; + /// Not exercised by these tests. + public Task ReadAsync(string address, CancellationToken ct) + => throw new NotSupportedException("ConnectingFakeS7Plc serves no data"); + /// Not exercised by these tests. + public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct) + => throw new NotSupportedException("ConnectingFakeS7Plc serves no data"); + /// Not exercised by these tests. + public Task WriteAsync(string address, object value, CancellationToken ct) + => throw new NotSupportedException("ConnectingFakeS7Plc serves no data"); + /// Not exercised by these tests. + public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken ct) + => throw new NotSupportedException("ConnectingFakeS7Plc serves no data"); + /// Answers the CPU-status probe. + public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask; + /// Disposes the fake connection. + public void Dispose() => IsConnected = false; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs index 85db687e..2bd0e103 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs @@ -206,9 +206,12 @@ public sealed class S7TypeMappingTests var opts = new S7DriverOptions { Host = "192.0.2.1", - Tags = [new S7TagDefinition("Counter64", "DB1.DBB0", S7DataType.Int64)], + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(new S7TagDefinition("Counter64", "DB1.DBB0", S7DataType.Int64)), }; - using var drv = new S7Driver(opts, "s7-i64-map"); + // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first. + using var drv = new S7Driver(opts, "s7-i64-map", new ConnectingFakeS7PlcFactory()); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); @@ -224,9 +227,12 @@ public sealed class S7TypeMappingTests var opts = new S7DriverOptions { Host = "192.0.2.1", - Tags = [new S7TagDefinition("Total64", "DB1.DBB0", S7DataType.UInt64)], + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(new S7TagDefinition("Total64", "DB1.DBB0", S7DataType.UInt64)), }; - using var drv = new S7Driver(opts, "s7-u64-map"); + // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first. + using var drv = new S7Driver(opts, "s7-u64-map", new ConnectingFakeS7PlcFactory()); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);