diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs index bc994a50..62965d52 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.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.FOCAS.Cli; @@ -54,7 +55,11 @@ public abstract class FocasCommandBase : DriverCommandBase HostAddress: HostAddress, DeviceName: $"cli-{CncHost}:{CncPort}", Series: Series)], - Tags = tags, + // v3: turn each typed def into the RawTagEntry delivery unit — TagConfig blob via + // FocasTagDefinitionFactory.ToTagConfig, keyed by the def's synthesised name (RawPath), routed to + // the device by the def's DeviceHostAddress (which the driver matches against Devices). + RawTags = [.. tags.Select(t => new RawTagEntry( + t.Name, FocasTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, t.DeviceHostAddress))], Timeout = Timeout, Probe = new FocasProbeOptions { Enabled = false }, }; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs index ef60b648..da05b679 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; @@ -10,8 +11,16 @@ public sealed class FocasDriverOptions { /// Gets the list of configured CNC devices. public IReadOnlyList Devices { get; init; } = []; - /// Gets the list of FOCAS tag definitions. - 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, + /// routing each tag to its device by . FOCAS has no online + /// tag-discovery for user tags — the driver serves exactly these (plus the fixed-node tree). + /// + public IReadOnlyList RawTags { get; init; } = []; /// Gets the probe options. public FocasProbeOptions Probe { get; init; } = new(); /// Gets the timeout duration for operations. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs deleted file mode 100644 index 1fe27347..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Text.Json; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; - -/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI -/// FocasTagConfigModel) 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 FocasEquipmentTagParser -{ - /// 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 a FOCAS address object. - public static bool TryParse(string reference, out FocasTagDefinition 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; - // The address string is FOCAS's sole mandatory address field (FocasTagConfigModel.Validate). - if (root.ValueKind != JsonValueKind.Object - || !root.TryGetProperty("address", out var addr) - || addr.ValueKind != JsonValueKind.String) - return false; - var address = addr.GetString(); - if (string.IsNullOrWhiteSpace(address)) 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", FocasDataType.Int32, out var dataType)) return false; - var deviceHostAddress = ReadString(root, "deviceHostAddress") ?? ""; - def = new FocasTagDefinition( - Name: reference, DeviceHostAddress: deviceHostAddress, Address: address, - // FOCAS equipment tags are FORCED read-only (05/UNDER-1): the only wire client - // (WireFocasClient.cs:71-73) returns BadNotWritable for EVERY address, so advertising a - // writable node is a lie — a write that used to reach the backend and always fail with - // BadNotWritable now fails at the driver seam with the same family of Bad status (no - // successful operation changes). Any authored `writable:true` is ignored + warned by - // Inspect. Honour the key here once PMC writes ship in the wire client. - DataType: dataType, Writable: false); - return true; - } - catch (JsonException) { return false; } - catch (FormatException) { return false; } - catch (InvalidOperationException) { return false; } - } - - /// - /// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid dataType - /// (silently defaulted by the lenient runtime), on an authored writable:true (FOCAS writes - /// are unsupported — the tag is forced read-only), 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("FOCAS 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); - if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True) - warnings.Add("FOCAS writes unsupported — 'writable:true' is ignored; the tag is forced read-only until PMC writes exist."); - } - catch (JsonException) - { - warnings.Add("FOCAS 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() : null; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs new file mode 100644 index 00000000..7bef3447 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// v3 pure mapper: turns an authored raw tag's TagConfig JSON (the shape produced by the +/// AdminUI FocasTagConfigModel) 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 FocasTagDefinitionFactory +{ + /// + /// 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. The FOCAS TagConfig carries no device host — the + /// driver resolves the device from the tag's , so the produced + /// definition's is left empty here and filled in by + /// the driver at table-build time. Writability lives on the Tag entity / node ACL (the authored FOCAS + /// TagConfig has no writable key), so an absent key ⇒ read-only; an explicit writable:true + /// is honoured for the driver's internal write gate (the wire itself is still read-only). + /// 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 FOCAS address object. + public static bool FromTagConfig(string tagConfig, string rawPath, out FocasTagDefinition def) + { + def = null!; + if (string.IsNullOrWhiteSpace(tagConfig)) return false; + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + // The address string is FOCAS's sole mandatory address field (FocasTagConfigModel.Validate). + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("address", out var addr) + || addr.ValueKind != JsonValueKind.String) + return false; + var address = addr.GetString(); + if (string.IsNullOrWhiteSpace(address)) 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", FocasDataType.Int32, out var dataType)) return false; + // FOCAS authored tags carry no `writable` key (writability lives on the Tag entity / node ACL): + // absent ⇒ read-only. An explicit writable:true is honoured for the driver's internal write gate + // (default-false, so a missing key stays read-only — the note is preserved). + var writable = root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True; + def = new FocasTagDefinition( + Name: rawPath, + // The device is resolved by the driver from RawTagEntry.DeviceName; empty here. + DeviceHostAddress: "", + Address: address, + DataType: dataType, + Writable: writable, + WriteIdempotent: false); + 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) that construct a + /// from operator flags and need the TagConfig string to hand + /// the driver as a . The enum is written as its name string. Neither the + /// device host (which travels on ) nor WriteIdempotent + /// (which travels on the ) is emitted. + /// + /// The definition to serialise. + /// The camelCase TagConfig JSON string. + public static string ToTagConfig(FocasTagDefinition def) + { + ArgumentNullException.ThrowIfNull(def); + var o = new JsonObject + { + ["address"] = def.Address, + ["dataType"] = def.DataType.ToString(), + ["writable"] = def.Writable, + }; + return o.ToJsonString(); + } + + /// + /// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid dataType + /// (silently defaulted by the lenient runtime), on an authored writable:true (the FOCAS wire + /// is read-only — WriteAsync always returns BadNotWritable), 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("FOCAS 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); + if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True) + warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable."); + } + catch (JsonException) + { + warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + } + return warnings; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 9df8851c..1c3b654c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -27,10 +27,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly PollGroupEngine _poll; private readonly ILogger _logger; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); - // Resolves a read/write 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 FocasEquipmentTagParser, cached). + // v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags + // via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath). + private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal); + // Resolves a read/write fullReference (always 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 blob-parse. private readonly EquipmentTagRefResolver _resolver; // Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync / // WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot @@ -67,8 +68,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _clientFactory = clientFactory ?? new Wire.WireFocasClientFactory(); _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( - r => _tagsByName.TryGetValue(r, out var t) ? t : null, - ResolveEquipmentTagRef); + r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => @@ -118,31 +118,61 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, $"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'."); _devices[device.HostAddress] = new DeviceState(addr, device); } - // Pre-flight: validate every tag's address against the declared CNC - // series so misconfigured addresses fail at init (clear config error) - // instead of producing BadOutOfRange on every read at runtime. - // Series=Unknown short-circuits the matrix; pre-matrix configs stay permissive. - 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 WriteIdempotent flag + DeviceName are + // threaded onto the def (they live on the RawTagEntry, not inside the blob). Per-tag misses + // are tolerant (skipped + logged → BadNodeIdUnknown at read, "a miss is a miss") — a single + // bad tag must not fail the whole driver init. The ONE hard failure is an unknown device + // segment: a tag routed to a device not in the Devices list is a driver-level config error + // that fails fast (Driver.FOCAS-003). + foreach (var entry in _options.RawTags) { - var parsed = FocasAddress.TryParse(tag.Address) - ?? throw new InvalidOperationException( - $"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " + - $"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500."); - if (!_devices.TryGetValue(tag.DeviceHostAddress, out var device)) + if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) + { + _logger.LogWarning( + "FOCAS tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + _driverInstanceId, entry.RawPath); + continue; + } + // TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig + // (keyed by the RawPath's device segment). Until Wave C threads DeviceConfig through, recover + // the host by matching the entry's DeviceName against the configured Devices list. + var deviceHost = ResolveDeviceHost(entry.DeviceName); + if (deviceHost is null) throw new InvalidOperationException( - $"FOCAS tag '{tag.Name}' references device '{tag.DeviceHostAddress}' " + + $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " + $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname)."); + if (!_devices.TryGetValue(deviceHost, out var device)) + throw new InvalidOperationException( + $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " + + $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname)."); + // Per-tag address / capability-matrix validation is tolerant: an unparseable address or a + // capability-matrix violation skips the tag (→ BadNodeIdUnknown at read) rather than failing + // the whole init (R2-11 05/UNDER-6 — the same pre-flight, surfacing a resolver miss). + var parsed = FocasAddress.TryParse(def.Address); + if (parsed is null) + { + _logger.LogWarning( + "FOCAS tag has invalid Address '{Address}'; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + def.Address, _driverInstanceId, entry.RawPath); + continue; + } if (FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason) { - throw new InvalidOperationException( - $"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}"); + _logger.LogWarning( + "FOCAS tag '{RawPath}' ({Address}) rejected by capability matrix: {Reason}; skipping. Driver={DriverInstanceId}", + entry.RawPath, def.Address, reason, _driverInstanceId); + continue; } - _tagsByName[tag.Name] = tag; + _tagsByRawPath[entry.RawPath] = def with + { + DeviceHostAddress = deviceHost, + WriteIdempotent = entry.WriteIdempotent, + }; // Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every - // hot-path call. The address string has already been validated - // by FocasAddress.TryParse above; reusing the parsed record avoids per-tick allocs - // on subscription pollers. - _parsedAddressesByTagName[tag.Name] = parsed; + // hot-path call. The address string has already been validated by FocasAddress.TryParse + // above; reusing the parsed record avoids per-tick allocs on subscription pollers. + _parsedAddressesByTagName[entry.RawPath] = parsed; } if (_options.Probe.Enabled) @@ -223,8 +253,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, state.DisposeClient(); } _devices.Clear(); - _tagsByName.Clear(); - _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses + _tagsByRawPath.Clear(); + _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry _parsedAddressesByTagName.Clear(); Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null)); } @@ -254,18 +284,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, internal bool IsParsedAddressCached(string reference) => _parsedAddressesByTagName.ContainsKey(reference); - // Resolves an equipment-tag TagConfig reference into a FocasTagDefinition, applying the SAME - // capability pre-flight authored tags get at InitializeAsync (:105-119) — R2-11 (05/UNDER-6). A - // reference whose address is unparseable, whose bound device is unknown, or whose address violates - // the device series' capability matrix returns null ⇒ resolver miss ⇒ BadNodeIdUnknown at read time, - // instead of a wrong-status read reaching the wire. The negative is cached by the resolver. - private FocasTagDefinition? ResolveEquipmentTagRef(string reference) + // TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig (keyed by + // the RawPath's device segment). Until Wave C threads DeviceConfig through, recover the host by matching + // the RawTagEntry's DeviceName against the configured Devices list (by DeviceName, falling back to + // HostAddress; and, for a single-device driver, an empty DeviceName routes to the sole device). Returns + // null when no device matches — the caller fails the tag fast (Driver.FOCAS-003). + private string? ResolveDeviceHost(string deviceName) { - if (!FocasEquipmentTagParser.TryParse(reference, out var def)) return null; - var parsed = FocasAddress.TryParse(def.Address); - if (parsed is null) return null; - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) return null; - return FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is null ? def : null; + foreach (var d in _options.Devices) + { + if (string.Equals(d.DeviceName ?? d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase) + || string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)) + return d.HostAddress; + } + if (string.IsNullOrEmpty(deviceName) && _options.Devices.Count == 1) + return _options.Devices[0].HostAddress; + return null; } // Resolves a tag definition to its parsed FocasAddress, caching the result so that @@ -516,7 +550,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } - var tagsForDevice = _options.Tags.Where(t => + var tagsForDevice = _tagsByRawPath.Values.Where(t => string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in tagsForDevice) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs index e878c7a1..733e6cc8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.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; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; @@ -68,18 +69,11 @@ public static class FocasDriverFactoryExtensions Series: ParseSeries(d.Series ?? dto.Series), PositionDecimalPlaces: d.PositionDecimalPlaces ?? 0))] : [], - Tags = dto.Tags is { Count: > 0 } - ? [.. dto.Tags.Select(t => new FocasTagDefinition( - Name: t.Name ?? throw new InvalidOperationException( - $"FOCAS config for '{driverInstanceId}' has a tag missing Name"), - DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException( - $"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"), - Address: t.Address ?? throw new InvalidOperationException( - $"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"), - DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId), - Writable: t.Writable ?? true, - WriteIdempotent: t.WriteIdempotent ?? false))] - : [], + // v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw FOCAS + // tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each + // RawTagEntry's TagConfig blob through FocasTagDefinitionFactory at Initialize and routes it to + // its device by DeviceName; the legacy pre-declared DTO tag path is retired. + RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [], Probe = new FocasProbeOptions { Enabled = dto.Probe?.Enabled ?? true, @@ -190,18 +184,6 @@ public static class FocasDriverFactoryExtensions $"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames())}"); } - private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId) - { - if (string.IsNullOrWhiteSpace(raw)) - throw new InvalidOperationException( - $"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType"); - return Enum.TryParse(raw, ignoreCase: true, out var dt) - ? dt - : throw new InvalidOperationException( - $"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " + - $"Expected one of {string.Join(", ", Enum.GetNames())}"); - } - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -254,8 +236,11 @@ public static class FocasDriverFactoryExtensions /// Gets or sets the list of CNC devices to manage. public List? Devices { get; init; } - /// Gets or sets the list of FOCAS tags to poll. - public List? Tags { get; init; } + /// + /// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName) + /// the driver serves. Bound verbatim into . + /// + public List? RawTags { get; init; } /// Gets or sets the probe configuration options. public FocasProbeDto? Probe { get; init; } @@ -290,27 +275,6 @@ public static class FocasDriverFactoryExtensions public int? PositionDecimalPlaces { get; init; } } - internal sealed class FocasTagDto - { - /// Gets or sets the tag name. - public string? Name { get; init; } - - /// Gets or sets the hostname or IP address of the CNC device for this tag. - public string? DeviceHostAddress { get; init; } - - /// Gets or sets the FOCAS address or path for this tag. - public string? Address { get; init; } - - /// Gets or sets the data type for this tag. - public string? DataType { get; init; } - - /// Gets or sets a value indicating whether this tag is writable. - public bool? Writable { get; init; } - - /// Gets or sets a value indicating whether writes to this tag are idempotent. - public bool? WriteIdempotent { get; init; } - } - internal sealed class FocasProbeDto { /// Gets or sets a value indicating whether probing is enabled. diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs index f2e34733..52061a5d 100644 --- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs @@ -74,9 +74,12 @@ public sealed class FocasCommandBaseBuildOptionsTests options.Devices[0].Series.ShouldBe(FocasCncSeries.Zero_i_F); } - /// Verifies that BuildOptions forwards tag list verbatim. + /// + /// Verifies that BuildOptions turns each typed def into a v3 RawTagEntry — RawPath = def.Name, + /// DeviceName = def.DeviceHostAddress, and a TagConfig blob that round-trips through the mapper. + /// [Fact] - public void BuildOptions_forwards_tag_list_verbatim() + public void BuildOptions_maps_tags_to_rawtag_entries() { var sut = new ProbeOnly { CncHost = "h" }; var tag = new FocasTagDefinition( @@ -88,7 +91,14 @@ public sealed class FocasCommandBaseBuildOptionsTests 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.DeviceName.ShouldBe("focas://h:8193"); + entry.WriteIdempotent.ShouldBeFalse(); + FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var round).ShouldBeTrue(); + round.Address.ShouldBe("R100"); + round.DataType.ShouldBe(FocasDataType.Int16); + round.Writable.ShouldBeFalse(); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs index 31663e29..0bb541b5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs @@ -15,7 +15,7 @@ public sealed class FocasAlarmProjectionTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], - Tags = [], + RawTags = [], Probe = new FocasProbeOptions { Enabled = false }, AlarmProjection = new FocasAlarmProjectionOptions { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs index 2b6597d0..54c6243d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs @@ -19,11 +19,11 @@ public sealed class FocasCapabilityTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")], - Tags = + RawTags = FocasRawTags.Entries( [ new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); @@ -51,7 +51,7 @@ public sealed class FocasCapabilityTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -77,7 +77,7 @@ public sealed class FocasCapabilityTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -181,11 +181,11 @@ public sealed class FocasCapabilityTests new FocasDeviceOptions("focas://10.0.0.5:8193"), new FocasDeviceOptions("focas://10.0.0.6:8193"), ], - Tags = + RawTags = FocasRawTags.Entries( [ new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs index 318ecd1d..42bacb46 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs @@ -17,7 +17,7 @@ public sealed class FocasConnectBackoffTests private static FocasDriverOptions Options(bool probeEnabled) => new() { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = probeEnabled, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverMediumFindingsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverMediumFindingsTests.cs index a3d99867..9a7c517e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverMediumFindingsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverMediumFindingsTests.cs @@ -27,11 +27,11 @@ public sealed class FocasDriverMediumFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = + RawTags = FocasRawTags.Entries( [ - // DeviceHostAddress has a port typo — not in Devices + // DeviceHostAddress (→ RawTagEntry.DeviceName) has a port typo — not in Devices new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); @@ -49,11 +49,11 @@ public sealed class FocasDriverMediumFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = + RawTags = FocasRawTags.Entries( [ // Correct address so address validation passes new FocasTagDefinition("TypoTag", "focas://10.0.0.99:8193", "R100", FocasDataType.Byte), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); @@ -74,11 +74,11 @@ public sealed class FocasDriverMediumFindingsTests new FocasDeviceOptions("focas://10.0.0.5:8193"), new FocasDeviceOptions("focas://10.0.0.6:8193"), ], - Tags = + RawTags = FocasRawTags.Entries( [ new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); @@ -97,12 +97,12 @@ public sealed class FocasDriverMediumFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = + RawTags = FocasRawTags.Entries( [ // Writable: true is the default — must still be ViewOnly new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, Writable: true), new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); @@ -132,11 +132,11 @@ public sealed class FocasDriverMediumFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = + RawTags = FocasRawTags.Entries( [ new FocasTagDefinition("Idempotent", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, WriteIdempotent: true), new FocasTagDefinition("NonIdempotent", "focas://10.0.0.5:8193", "R200", FocasDataType.Int16, WriteIdempotent: false), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); @@ -162,7 +162,7 @@ public sealed class FocasDriverMediumFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -191,7 +191,7 @@ public sealed class FocasDriverMediumFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -227,7 +227,7 @@ public sealed class FocasDriverMediumFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs index bda91d72..0694be12 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs @@ -5,57 +5,54 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// -/// R2-11 (05/UNDER-6): an equipment-tag reference whose address violates the device series' -/// capability matrix must fail to RESOLVE (surfacing BadNodeIdUnknown) — the same pre-flight -/// authored tags get at InitializeAsync — instead of reaching the wire and failing later with a -/// wire error. A capability-valid equipment tag still resolves + reads. +/// R2-11 (05/UNDER-6): an authored raw tag whose address violates the device series' capability matrix +/// must be dropped from the driver's table at InitializeAsync and surface BadNodeIdUnknown +/// at read (a resolver miss) — instead of reaching the wire and failing later with a wire error. A +/// capability-valid tag still resolves + reads. An authored tag routed to an unknown device is a +/// driver-level config error that fails init fast (Driver.FOCAS-003). /// [Trait("Category", "Unit")] public sealed class FocasEquipmentTagCapabilityGateTests { private const string Host = "focas://10.0.0.5:8193"; - private static FocasDriver NewDriver() => new(new FocasDriverOptions + // Series 16i: macro range 0..999 — MACRO:9500 is out of range. + private static FocasDriver NewDriver(params FocasTagDefinition[] tags) => new(new FocasDriverOptions { - // Series 16i: macro range 0..999 — MACRO:9500 is out of range. Devices = [new FocasDeviceOptions(Host, Series: FocasCncSeries.Sixteen_i)], + RawTags = FocasRawTags.Entries(tags), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); - private static string EquipTag(string address) => - $"{{\"address\":\"{address}\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"{Host}\"}}"; - [Fact] - public async Task Capability_violating_equipment_tag_does_not_resolve() + public async Task Capability_violating_authored_tag_does_not_resolve() { - var drv = NewDriver(); + var drv = NewDriver(new FocasTagDefinition("eq/Bad", Host, "MACRO:9500", FocasDataType.Int32)); await drv.InitializeAsync("{}", CancellationToken.None); - var results = await drv.ReadAsync([EquipTag("MACRO:9500")], CancellationToken.None); + var results = await drv.ReadAsync(["eq/Bad"], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); } [Fact] - public async Task Capability_valid_equipment_tag_resolves_and_reads() + public async Task Capability_valid_authored_tag_resolves_and_reads() { - var drv = NewDriver(); + var drv = NewDriver(new FocasTagDefinition("eq/Good", Host, "MACRO:100", FocasDataType.Int32)); await drv.InitializeAsync("{}", CancellationToken.None); - var results = await drv.ReadAsync([EquipTag("MACRO:100")], CancellationToken.None); + var results = await drv.ReadAsync(["eq/Good"], CancellationToken.None); results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown); } [Fact] - public async Task Equipment_tag_for_unknown_device_does_not_resolve() + public async Task Authored_tag_for_unknown_device_fails_init() { - var drv = NewDriver(); - await drv.InitializeAsync("{}", CancellationToken.None); + var drv = NewDriver(new FocasTagDefinition("eq/BadDevice", "focas://10.9.9.9:8193", "MACRO:100", FocasDataType.Int32)); - var badDevice = "{\"address\":\"MACRO:100\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"focas://10.9.9.9:8193\"}"; - var results = await drv.ReadAsync([badDevice], CancellationToken.None); - - results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + var ex = await Should.ThrowAsync( + () => drv.InitializeAsync("{}", CancellationToken.None)); + ex.Message.ShouldContain("not in the Devices list"); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs deleted file mode 100644 index 846b0b52..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; - -namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; - -/// -/// R2-11 Phase C (+ 05/UNDER-1) strictness surface for the FOCAS equipment-tag parser: the runtime is now -/// STRICT — a typo'd dataType rejects the tag (TryParse ⇒ -/// BadNodeIdUnknown); FOCAS tags remain forced read-only (Writable == false regardless of any -/// authored writable:true), and Inspect reports the typo + the write-request. -/// -public sealed class FocasEquipmentTagParserStrictnessTests -{ - [Fact] - public void Typo_dataType_rejects_the_tag() - { - FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int322\"}", out _) - .ShouldBeFalse(); - } - - [Fact] - public void Valid_dataType_still_parses() - { - FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def) - .ShouldBeTrue(); - def.DataType.ShouldBe(FocasDataType.Int32); - } - - [Fact] - public void Writable_is_always_false_even_when_requested_true() - { - FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", out var def) - .ShouldBeTrue(); - def.Writable.ShouldBeFalse(); - } - - [Fact] - public void Writable_is_false_when_absent() - { - FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def) - .ShouldBeTrue(); - def.Writable.ShouldBeFalse(); - } - - [Fact] - public void Inspect_warns_on_writable_true_request() - { - var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}"); - warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable")); - } - - [Fact] - public void Inspect_reports_typo_dataType() - { - var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}"); - warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType")); - } - - [Fact] - public void Inspect_clean_read_only_config_has_no_warnings() - { - FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty(); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs index 70fa441a..86c3825a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs @@ -4,61 +4,68 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; +/// +/// v3 authored-tag surface for FOCAS. Under v3 a tag's wire reference is its RawPath and the +/// driver builds its RawPath → definition table from the authored RawTagEntry list via +/// at InitializeAsync — there is no longer +/// a blob-parse fallback where the read reference IS the raw TagConfig JSON (a miss is a miss ⇒ +/// BadNodeIdUnknown). These tests cover the mapper's parse/reject cases and the driver's authored-tag +/// read path. +/// [Trait("Category", "Unit")] public class FocasEquipmentTagTests { + private const string RawPath = "area/line/lathe/Run"; + [Fact] - public void Parses_equipment_tagconfig_into_a_transient_definition() + public void Maps_tagconfig_into_a_definition_keyed_by_rawpath() { - var json = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}"""; - FocasEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); - def!.Name.ShouldBe(json); - def.DeviceHostAddress.ShouldBe("focas://10.0.0.5:8193"); + var json = """{"address":"R100","dataType":"Byte"}"""; + FocasTagDefinitionFactory.FromTagConfig(json, RawPath, out var def).ShouldBeTrue(); + def!.Name.ShouldBe(RawPath); def.Address.ShouldBe("R100"); def.DataType.ShouldBe(FocasDataType.Byte); } [Fact] public void Rejects_a_non_address_blob() - => FocasEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse(); + => FocasTagDefinitionFactory.FromTagConfig("""{"FullName":"x"}""", RawPath, out _).ShouldBeFalse(); [Fact] public void Rejects_garbage() - => FocasEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse(); + => FocasTagDefinitionFactory.FromTagConfig("not json", RawPath, out _).ShouldBeFalse(); [Fact] public void Rejects_address_as_a_json_number() - => FocasEquipmentTagParser.TryParse( - """{"address":100,"dataType":"Int32"}""", out _).ShouldBeFalse(); + => FocasTagDefinitionFactory.FromTagConfig( + """{"address":100,"dataType":"Int32"}""", RawPath, out _).ShouldBeFalse(); [Fact] public void Rejects_a_blank_address() - => FocasEquipmentTagParser.TryParse( - """{"address":" ","dataType":"Int32"}""", out _).ShouldBeFalse(); + => FocasTagDefinitionFactory.FromTagConfig( + """{"address":" ","dataType":"Int32"}""", RawPath, out _).ShouldBeFalse(); // FOCAS's only mandatory address field is the canonical Address STRING (no narrow numeric // TagConfig field like Modbus's ushort register address), so the Modbus "numeric out-of-range" // rejection case has no analog here — covered instead by the json-number + blank-address cases. /// - /// End-to-end driver-level proof: a FOCAS driver with NO authored tags can still read an - /// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient - /// definition and the read goes to the wire instead of returning BadNodeIdUnknown. + /// End-to-end driver-level proof: a FOCAS driver serving an authored raw tag resolves it by its + /// RawPath and reads the wire instead of returning BadNodeIdUnknown. /// [Fact] - public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown() + public async Task Driver_resolves_an_authored_rawtag_and_reads_instead_of_BadNodeIdUnknown() { - var json = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}"""; var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } } }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition(RawPath, "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "focas-eq", factory); await drv.InitializeAsync("{}", CancellationToken.None); - var r = await drv.ReadAsync([json], CancellationToken.None); + var r = await drv.ReadAsync([RawPath], CancellationToken.None); r[0].StatusCode.ShouldBe(FocasStatusMapper.Good); r[0].StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown); @@ -66,33 +73,47 @@ public class FocasEquipmentTagTests } /// - /// Regression guard for Driver.FOCAS-008 equipment-tag path: the parsed - /// for a resolver-produced (equipment) tag must be - /// cached after the first read so subsequent reads and writes skip re-parsing - /// the raw TagConfig JSON on every call. + /// A read reference that is NOT an authored RawPath (e.g. a raw TagConfig JSON — the retired + /// equipment-tag blob-ref) is a resolver miss and surfaces BadNodeIdUnknown; the blob-parse + /// fallback is gone in v3. /// [Fact] - public async Task Equipment_tag_parsed_address_is_cached_after_first_read() + public async Task Blob_reference_is_a_resolver_miss_in_v3() { - var equipRef = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}"""; - var factory = new FakeFocasClientFactory - { - Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } }, - }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [], + RawTags = [], + Probe = new FocasProbeOptions { Enabled = false }, + }, "focas-eq", new FakeFocasClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + var blob = """{"address":"R100","dataType":"Byte"}"""; + var r = await drv.ReadAsync([blob], CancellationToken.None); + + r[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + } + + /// + /// Regression guard for Driver.FOCAS-008: the parsed for an authored + /// tag is cached at InitializeAsync (v3 pre-caches the whole table) so reads/writes skip + /// re-parsing the address string on every call. + /// + [Fact] + public async Task Authored_tag_parsed_address_is_cached_at_init() + { + var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } } }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition(RawPath, "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "focas-eq-cache", factory); await drv.InitializeAsync("{}", CancellationToken.None); - // Before any read the parsed address must NOT be in the cache. - drv.IsParsedAddressCached(equipRef).ShouldBeFalse(); - - // After a successful read the parsed address MUST be cached. - var r = await drv.ReadAsync([equipRef], CancellationToken.None); + // v3 builds the table eagerly at init, so the parsed address is cached before any read. + drv.IsParsedAddressCached(RawPath).ShouldBeTrue(); + var r = await drv.ReadAsync([RawPath], CancellationToken.None); r[0].StatusCode.ShouldBe(FocasStatusMapper.Good); - drv.IsParsedAddressCached(equipRef).ShouldBeTrue(); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs index ffb64f10..97042231 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs @@ -14,7 +14,7 @@ public sealed class FocasHandleRecycleTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, HandleRecycle = new FocasHandleRecycleOptions { @@ -47,7 +47,7 @@ public sealed class FocasHandleRecycleTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasIoSerializationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasIoSerializationTests.cs index 4bc271f6..a09c85bb 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasIoSerializationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasIoSerializationTests.cs @@ -110,7 +110,7 @@ public sealed class FocasIoSerializationTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64))], Probe = new FocasProbeOptions { Enabled = false }, Timeout = TimeSpan.FromMilliseconds(150), }, "drv-1", factory); @@ -133,7 +133,7 @@ public sealed class FocasIoSerializationTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64))], Probe = new FocasProbeOptions { Enabled = false }, Timeout = TimeSpan.FromMilliseconds(120), }, "drv-1", factory); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasLowFindingsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasLowFindingsTests.cs index 670c8ec1..5e09df86 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasLowFindingsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasLowFindingsTests.cs @@ -43,7 +43,7 @@ public sealed class FocasLowFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -73,11 +73,11 @@ public sealed class FocasLowFindingsTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = + RawTags = FocasRawTags.Entries( [ new FocasTagDefinition( "X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs index edd897f2..f3b357e0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs @@ -59,7 +59,7 @@ public sealed class FocasPmcBitRmwTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = tags, + RawTags = FocasRawTags.Entries(tags), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); return (drv, fake); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs index dd0dc166..f738c275 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs @@ -45,7 +45,8 @@ public sealed class FocasPollErrorHealthTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte)], + // DeviceName "…:9999" is not in Devices → InitializeAsync fails fast → Faulted. + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "focas-1", new FakeFocasClientFactory()); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasRawTags.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasRawTags.cs new file mode 100644 index 00000000..a6e66bf8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasRawTags.cs @@ -0,0 +1,30 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.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 + DeviceName), +/// and the driver rebuilds the typed definitions via and routes +/// each to its device by . 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 and whose DeviceName is the def's DeviceHostAddress — so +/// the round-trip through the real mapper + device match reproduces the same definition the test authored, +/// keyed by the same name the read/write/subscribe call sites use. +/// +internal static class FocasRawTags +{ + /// Serialises one typed definition into its (RawPath = def.Name, DeviceName = def.DeviceHostAddress). + /// The typed definition to convert. + /// The equivalent . + public static RawTagEntry Entry(FocasTagDefinition def) + => new(def.Name, FocasTagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent, def.DeviceHostAddress); + + /// Serialises a sequence of typed definitions into records. + /// The typed definitions to convert. + /// The equivalent raw-tag entries. + public static IReadOnlyList Entries(IEnumerable defs) + => [.. defs.Select(Entry)]; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs index 09df7adc..d95397a1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs @@ -14,7 +14,7 @@ public sealed class FocasReadWriteTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = tags, + RawTags = FocasRawTags.Entries(tags), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); return (drv, factory); @@ -221,11 +221,11 @@ public sealed class FocasReadWriteTests var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], - Tags = + RawTags = FocasRawTags.Entries( [ new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false), - ], + ]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasResolveHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasResolveHostTests.cs index 1fe06c2c..6d66e5fc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasResolveHostTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasResolveHostTests.cs @@ -5,10 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// -/// CONV-4 — must resolve an equipment-tag reference (raw -/// TagConfig JSON) to its OWN device host so per-host breaker keys are correct for equipment -/// tags. An address-less equipment ref (FOCAS coalesces a missing deviceHostAddress to -/// "") must fall back rather than key an empty host. +/// CONV-4 — must resolve an authored raw tag (by its RawPath) +/// to its OWN device host so per-host breaker keys are correct for multi-device drivers. A reference +/// that resolves to no authored tag falls back to the first device rather than keying an empty host. /// [Trait("Category", "Unit")] public sealed class FocasResolveHostTests @@ -16,15 +15,16 @@ public sealed class FocasResolveHostTests private const string DeviceA = "focas://10.0.0.5:8193"; private const string DeviceB = "focas://10.0.0.6:8193"; - // FOCAS's equipment-ref resolver validates against the initialized Devices map, so — unlike - // the other drivers — the driver must be initialized (as it always is when ResolveHost runs at - // runtime) before an equipment ref resolves. Probe off / no connect: init just parses devices. - private static async Task NewDriverAsync() + // The driver builds its RawPath → definition table (with each tag's resolved device host) at + // InitializeAsync, so the driver must be initialized before ResolveHost can resolve a tag — as it + // always is when ResolveHost runs at runtime. Probe off / no connect: init just parses devices + tags. + private static async Task NewDriverAsync(params FocasTagDefinition[] tags) { var drv = new FocasDriver( new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceA), new FocasDeviceOptions(DeviceB)], + RawTags = FocasRawTags.Entries(tags), Probe = new FocasProbeOptions { Enabled = false }, }, "focas-1", new FakeFocasClientFactory()); @@ -32,18 +32,17 @@ public sealed class FocasResolveHostTests return drv; } - /// 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 async Task ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost() + public async Task ResolveHost_AuthoredTag_ReturnsItsOwnDeviceHost() { - var drv = await NewDriverAsync(); - var json = $$"""{"deviceHostAddress":"{{DeviceB}}","address":"R100","dataType":"Byte"}"""; - drv.ResolveHost(json).ShouldBe(DeviceB); + var drv = await NewDriverAsync(new FocasTagDefinition("eq/onB", DeviceB, "R100", FocasDataType.Byte)); + drv.ResolveHost("eq/onB").ShouldBe(DeviceB); } - /// An equipment ref with no deviceHostAddress falls back to the first device, not an empty host. + /// A reference that resolves to no authored tag falls back to the first device, not an empty host. [Fact] - public async Task ResolveHost_EquipmentTagRef_WithoutDeviceHost_FallsBack() + public async Task ResolveHost_UnresolvedRef_FallsBack() { var drv = await NewDriverAsync(); var json = """{"address":"R100","dataType":"Byte"}"""; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasTagDefinitionFactoryStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasTagDefinitionFactoryStrictnessTests.cs new file mode 100644 index 00000000..eb65616d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasTagDefinitionFactoryStrictnessTests.cs @@ -0,0 +1,70 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// R2-11 Phase C (+ 05/UNDER-1) strictness surface for the v3 : +/// the runtime is STRICT — a typo'd dataType rejects the tag (FromTagConfig ⇒ +/// BadNodeIdUnknown); the produced definition's Name is the +/// RawPath (not the blob). Writability lives on the Tag entity / node ACL, so the authored FOCAS +/// TagConfig has no writable key: an absent key ⇒ read-only (Writable == false), while an +/// explicit writable:true is honoured for the driver's internal write gate (the wire is still +/// read-only, which Inspect warns about). +/// +public sealed class FocasTagDefinitionFactoryStrictnessTests +{ + private const string RawPath = "area/line/equip/tag"; + + [Fact] + public void Typo_dataType_rejects_the_tag() + => FocasTagDefinitionFactory.FromTagConfig( + "{\"address\":\"D0100\",\"dataType\":\"Int322\"}", RawPath, out _).ShouldBeFalse(); + + [Fact] + public void Valid_dataType_still_parses_and_names_by_rawPath() + { + FocasTagDefinitionFactory.FromTagConfig( + "{\"address\":\"D0100\",\"dataType\":\"Int32\"}", RawPath, out var def).ShouldBeTrue(); + def.DataType.ShouldBe(FocasDataType.Int32); + def.Name.ShouldBe(RawPath); + def.Address.ShouldBe("D0100"); + // The device host is not carried in the blob — the driver fills it from the RawTagEntry.DeviceName. + def.DeviceHostAddress.ShouldBe(""); + } + + [Fact] + public void Writable_is_honoured_when_requested_true() + { + FocasTagDefinitionFactory.FromTagConfig( + "{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", RawPath, out var def).ShouldBeTrue(); + def.Writable.ShouldBeTrue(); + } + + [Fact] + public void Writable_is_false_when_absent() + { + FocasTagDefinitionFactory.FromTagConfig( + "{\"address\":\"D0100\",\"dataType\":\"Int32\"}", RawPath, out var def).ShouldBeTrue(); + def.Writable.ShouldBeFalse(); + } + + [Fact] + public void Inspect_warns_on_writable_true_request() + { + var warnings = FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}"); + warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable")); + } + + [Fact] + public void Inspect_reports_typo_dataType() + { + var warnings = FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}"); + warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType")); + } + + [Fact] + public void Inspect_clean_read_only_config_has_no_warnings() + => FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty(); +}