diff --git a/Opc.Ua.JsonNodeSet/AddressSpace.Variants.cs b/Opc.Ua.JsonNodeSet/AddressSpace.Variants.cs new file mode 100644 index 0000000..ee70861 --- /dev/null +++ b/Opc.Ua.JsonNodeSet/AddressSpace.Variants.cs @@ -0,0 +1,372 @@ +using Newtonsoft.Json.Linq; +using Opc.Ua.JsonNodeSet.Model; + +namespace Opc.Ua.JsonNodeSet; + +/// +/// Variant canonicalization for . +/// +/// Responsibilities: +/// +/// Maintain indexes that allow schema-aware Variant emission: +/// _encodingToDataType, _dataTypeToXmlEncoding, _modelXmlNamespace. +/// Strip redundant "Default JSON" encoding nodes on request. +/// : rewrite every 's Variant DOM so +/// that structure keys are in order, +/// normalize XML encoding TypeIds to DataType NodeIds, and validate unions. +/// +/// +public partial class AddressSpace +{ + private const string HasEncodingRefId = "i=38"; + private const string DefaultXmlBrowseName = "Default XML"; + private const string DefaultJsonBrowseName = "Default JSON"; + private const string DefaultBinaryBrowseName = "Default Binary"; + + // encoding NodeId → DataType NodeId + private Dictionary? _encodingToDataType; + // DataType NodeId → "Default XML" encoding NodeId + private Dictionary? _dataTypeToXmlEncoding; + // model ModelUri → XmlSchemaUri + private Dictionary? _modelXmlNamespace; + // XmlSchemaUri → ModelUri (reverse) + private Dictionary? _xmlNamespaceToModel; + // DataType BrowseName (bare) → DataType NodeId (first match wins) + private Dictionary? _dataTypeBrowseNameIndex; + + /// + /// (Re)builds the variant-related indexes. Called lazily from the Try... lookups, and + /// invoked explicitly by . Cheap to call — O(nodes). + /// + public void BuildVariantIndexes() + { + _encodingToDataType = new Dictionary(StringComparer.Ordinal); + _dataTypeToXmlEncoding = new Dictionary(StringComparer.Ordinal); + _modelXmlNamespace = new Dictionary(StringComparer.Ordinal); + _xmlNamespaceToModel = new Dictionary(StringComparer.Ordinal); + _dataTypeBrowseNameIndex = new Dictionary(StringComparer.Ordinal); + + // Model XML namespace map: prefer explicit XmlSchemaUri, otherwise synthesize + // "{ModelUri}/Types.xsd" (the common convention). Warnings could be routed through + // a logger if one existed; for now we synthesize silently. + foreach (var kvp in _models) + { + var uri = kvp.Key; + var xmlNs = !string.IsNullOrEmpty(kvp.Value.XmlSchemaUri) + ? kvp.Value.XmlSchemaUri! + : uri.TrimEnd('/') + "/Types.xsd"; + _modelXmlNamespace[uri] = xmlNs; + _xmlNamespaceToModel[xmlNs] = uri; + } + + // HasEncoding references: source is DataType, target is encoding Object node with + // BrowseName "Default XML" / "Default JSON" / "Default Binary". + foreach (var node in _sequence) + { + if (node is UADataType dt && node.NodeId != null) + { + var bn = StripNamespacePrefix(node.BrowseName); + if (!string.IsNullOrEmpty(bn) && !_dataTypeBrowseNameIndex.ContainsKey(bn!)) + _dataTypeBrowseNameIndex[bn!] = node.NodeId; + } + + if (!_forwardRefs.TryGetValue(node.NodeId ?? "", out var fwds)) continue; + foreach (var entry in fwds) + { + if (entry.ReferenceTypeId != HasEncodingRefId) continue; + if (!_nodes.TryGetValue(entry.TargetNodeId, out var encodingNode)) continue; + var encBn = StripNamespacePrefix(encodingNode.BrowseName); + if (encBn == null) continue; + + _encodingToDataType[entry.TargetNodeId] = node.NodeId!; + if (string.Equals(encBn, DefaultXmlBrowseName, StringComparison.Ordinal)) + _dataTypeToXmlEncoding[node.NodeId!] = entry.TargetNodeId; + // Default JSON / Default Binary recorded in _encodingToDataType so reader-side + // normalization still works, but only XML needs the reverse map. + } + } + } + + private static string? StripNamespacePrefix(string? value) + { + if (string.IsNullOrEmpty(value)) return value; + if (!value.StartsWith("nsu=", StringComparison.Ordinal)) return value; + var semi = value.IndexOf(';'); + return semi > 0 ? value.Substring(semi + 1) : value; + } + + private void EnsureVariantIndexes() + { + if (_encodingToDataType == null) BuildVariantIndexes(); + } + + /// + /// If is an encoding NodeId (Default XML/JSON/Binary), returns + /// the underlying DataType NodeId. Otherwise returns unchanged + /// (or null when input is null). + /// + public string? NormalizeEncodingNodeIdToDataType(string? nodeId) + { + if (nodeId == null) return null; + EnsureVariantIndexes(); + if (_nodes.TryGetValue(nodeId, out var node) && node is UADataType) return nodeId; + if (_encodingToDataType!.TryGetValue(nodeId, out var dt)) return dt; + return nodeId; + } + + internal string? TryGetXmlEncodingNodeId(string dataTypeNodeId) + { + EnsureVariantIndexes(); + return _dataTypeToXmlEncoding!.TryGetValue(dataTypeNodeId, out var enc) ? enc : null; + } + + internal string? TryGetXmlSchemaUriForNodeId(string dataTypeNodeId) + { + EnsureVariantIndexes(); + var ns = ExtractNsu(dataTypeNodeId); + if (ns == null) return null; + return _modelXmlNamespace!.TryGetValue(ns, out var xmlNs) ? xmlNs : null; + } + + internal string? TryGetXmlSchemaUriForBrowseName(string browseName) + { + EnsureVariantIndexes(); + if (!_dataTypeBrowseNameIndex!.TryGetValue(browseName, out var nodeId)) return null; + return TryGetXmlSchemaUriForNodeId(nodeId); + } + + internal string? TryGetDataTypeBrowseName(string dataTypeNodeId) + { + if (!_nodes.TryGetValue(dataTypeNodeId, out var node)) return null; + return StripNamespacePrefix(node.BrowseName); + } + + internal UADataType? ResolveDataType(string? typeId) + { + if (typeId == null) return null; + EnsureVariantIndexes(); + if (_nodes.TryGetValue(typeId, out var n) && n is UADataType dt) return dt; + if (_encodingToDataType!.TryGetValue(typeId, out var real) + && _nodes.TryGetValue(real, out var n2) && n2 is UADataType dt2) return dt2; + return null; + } + + /// + /// Remove "Default JSON" encoding objects (and the HasEncoding references pointing at + /// them) from the AddressSpace. The JSON encoding of a Variant always uses the DataType + /// NodeId directly, so these nodes carry no information. + /// + public int StripDefaultJsonEncodings() + { + var toRemove = new List(); + foreach (var node in _sequence) + { + if (node.NodeClass != NodeClass.UAObject) continue; + var bn = StripNamespacePrefix(node.BrowseName); + if (!string.Equals(bn, DefaultJsonBrowseName, StringComparison.Ordinal)) continue; + // Confirm it really is a HasEncoding target of a DataType. + if (node.NodeId != null && + _inverseRefs.TryGetValue(node.NodeId, out var inv) && + inv.Any(e => e.ReferenceTypeId == HasEncodingRefId)) + { + toRemove.Add(node.NodeId); + } + } + foreach (var id in toRemove) RemoveNode(id); + if (toRemove.Count > 0) BuildVariantIndexes(); + return toRemove.Count; + } + + /// + /// Canonicalize every 's Variant DOM: + /// reorder struct keys to match + /// field order, normalize any XML encoding TypeIds encountered in ExtensionObjects + /// to their underlying DataType NodeIds, and validate unions. Idempotent. + /// + public void ResolveVariants() + { + BuildVariantIndexes(); + + foreach (var node in _sequence) + { + if (node is not UAVariable v) continue; + if (v.Value?.Value == null) continue; + + var dtId = NormalizeEncodingNodeIdToDataType(v.DataType); + v.Value.Value = CanonicalizeVariantValue(v.Value.Value, dtId, isArray: v.ValueRank is > 0); + } + } + + private object? CanonicalizeVariantValue(object? value, string? dataTypeNodeId, bool isArray) + { + // Scalars pass through unchanged. + if (value is null) return null; + + // Array: canonicalize each item with the element DataType. + if (value is JArray arr) + { + for (int i = 0; i < arr.Count; i++) + { + arr[i] = CanonicalizeJsonToken(arr[i], dataTypeNodeId) ?? JValue.CreateNull(); + } + return arr; + } + if (value is List list) + { + for (int i = 0; i < list.Count; i++) + { + if (list[i] is JToken tok) + list[i] = CanonicalizeJsonToken(tok, dataTypeNodeId); + else if (list[i] is ExtensionObject eoItem) + list[i] = CanonicalizeExtensionObject(eoItem); + } + return list; + } + + // ExtensionObject wrapper (from fresh XML→JSON conversion). + if (value is ExtensionObject eo) + { + return CanonicalizeExtensionObject(eo); + } + + // Structure JObject (could be an ExtensionObject wrapper in JObject form, or a bare body). + if (value is JObject jo) + { + return CanonicalizeJsonToken(jo, dataTypeNodeId); + } + + return value; + } + + private ExtensionObject CanonicalizeExtensionObject(ExtensionObject eo) + { + // Normalize XML-encoding TypeId → DataType NodeId. + var canonicalTypeId = NormalizeEncodingNodeIdToDataType(eo.TypeId); + eo.TypeId = canonicalTypeId; + if (eo.Body is JObject jo) + { + eo.Body = CanonicalizeStructureBody(jo, canonicalTypeId); + } + return eo; + } + + private JToken? CanonicalizeJsonToken(JToken token, string? dataTypeNodeId) + { + if (token == null || token.Type == JTokenType.Null) return token; + if (token is JArray arr) + { + for (int i = 0; i < arr.Count; i++) + arr[i] = CanonicalizeJsonToken(arr[i], dataTypeNodeId) ?? JValue.CreateNull(); + return arr; + } + if (token is JObject jo) + { + // ExtensionObject JSON form? + if (jo["TypeId"] != null && jo["Body"] is JObject bodyJo) + { + var canonTid = NormalizeEncodingNodeIdToDataType(jo["TypeId"]!.ToString()); + jo["TypeId"] = canonTid; + jo["Body"] = CanonicalizeStructureBody(bodyJo, canonTid); + return jo; + } + return CanonicalizeStructureBody(jo, dataTypeNodeId); + } + return token; + } + + private JObject CanonicalizeStructureBody(JObject body, string? dataTypeNodeId) + { + var dt = ResolveDataType(dataTypeNodeId); + if (dt?.Definition?.Fields == null || dt.Definition.Fields.Count == 0) + { + // No schema — leave DOM untouched. + return body; + } + + // Collect inherited + own fields in base-to-derived order. + var orderedFields = CollectAllFields(dt); + + var canon = new JObject(); + // Preserve sidecars for round-tripping without schema. + var typeNameSidecar = body["$typeName"]?.ToString() + ?? StripNamespacePrefix(dt.BrowseName); + if (typeNameSidecar != null) canon["$typeName"] = typeNameSidecar; + if (body["$typeNs"] != null) canon["$typeNs"] = body["$typeNs"]; + + // Unions: preserve SwitchField first, then the one selected field. + // Detect via either the explicit Definition.IsUnion flag (set by generators that + // emit proper NodeSet2 XML) or the precomputed DataTypeForm which is populated for + // any subtype of the Union base type (i=12756) by ComputeDataTypeForms(). + if (dt.Definition.IsUnion == true || dt.DataTypeForm == "Union") + { + var sf = body["SwitchField"]; + if (sf != null) canon["SwitchField"] = sf; + // Copy whichever union arm key is present. + foreach (var field in orderedFields) + { + if (field.Name == null) continue; + if (body[field.Name] != null) + { + canon[field.Name] = CanonicalizeField(body[field.Name]!, field); + break; + } + } + return canon; + } + + foreach (var field in orderedFields) + { + if (field.Name == null) continue; + if (body[field.Name] == null) continue; + canon[field.Name] = CanonicalizeField(body[field.Name]!, field); + } + + return canon; + } + + private JToken CanonicalizeField(JToken value, DataTypeField field) + { + // Arrays: apply element-wise using the field's DataType. + if (value is JArray arr) + { + var outArr = new JArray(); + foreach (var item in arr) + { + outArr.Add(CanonicalizeJsonToken(item, field.DataType) ?? JValue.CreateNull()); + } + return outArr; + } + if (value is JObject jo) + { + return CanonicalizeJsonToken(jo, field.DataType) ?? jo; + } + return value; + } + + /// + /// Walks the UADataType inheritance chain and concatenates inherited fields before own fields. + /// Stops at the first ancestor without a Definition (e.g. the Structure base type). + /// + private List CollectAllFields(UADataType dt) + { + var chain = new List { dt }; + EnsureSupertypeCache(); + var current = dt.NodeId; + while (current != null && _supertypeCache!.TryGetValue(current, out var parent) && parent != null) + { + if (!_nodes.TryGetValue(parent, out var parentNode)) break; + if (parentNode is not UADataType parentDt) break; + if (parentDt.Definition?.Fields == null || parentDt.Definition.Fields.Count == 0) break; + chain.Add(parentDt); + current = parent; + } + chain.Reverse(); // base → derived + var ordered = new List(); + foreach (var t in chain) + { + if (t.Definition?.Fields != null) + ordered.AddRange(t.Definition.Fields); + } + return ordered; + } +} diff --git a/Opc.Ua.JsonNodeSet/AddressSpace.cs b/Opc.Ua.JsonNodeSet/AddressSpace.cs index 17cf8ac..26dc251 100644 --- a/Opc.Ua.JsonNodeSet/AddressSpace.cs +++ b/Opc.Ua.JsonNodeSet/AddressSpace.cs @@ -10,7 +10,42 @@ public class ReferenceEntry public bool IsForward { get; set; } } - public class AddressSpace + /// + /// Represents an instance declaration from a type hierarchy, + /// wrapping the source UANode with context about which reference and type it came from. + /// + public class InstanceDeclaration : UANode + { + /// The reference type from the type node to this declaration. + public string? ReferenceTypeId { get; set; } + + /// The type node that defined this instance declaration. + public string? SourceTypeNodeId { get; set; } + + /// The original UANode from the address space. + public UANode? SourceNode + { + get => _sourceNode; + set + { + _sourceNode = value; + if (value == null) return; + // Copy relevant fields from source + NodeId = value.NodeId; + NodeClass = value.NodeClass; + BrowseName = value.BrowseName; + DisplayName = value.DisplayName; + Description = value.Description; + ParentId = value.ParentId; + TypeId = value.TypeId; + ModellingRuleId = value.ModellingRuleId; + IsAbstract = value.IsAbstract; + } + } + private UANode? _sourceNode; + } + + public partial class AddressSpace { private const string NsuPrefix = "nsu="; private const string CoreNamespaceUri = "http://opcfoundation.org/UA/"; @@ -1000,6 +1035,524 @@ private static void RemoveFromRefDict(Dictionary> d #endregion + #region Instantiation + + /// + /// Instantiate a type under a parent node. Creates the instance with all mandatory + /// children from the type hierarchy. NodeIds are allocated using the provided function. + /// Returns the list of all created nodes (root instance + mandatory children, recursive). + /// + /// The ObjectType or VariableType to instantiate. + /// The parent node under which the instance is created. + /// The namespace URI for the new instance. + /// BrowseName for the root instance (without namespace prefix). + /// DisplayName for the root instance. + /// Function that returns the next available numeric NodeId for the given namespace. + /// Reference from parent to instance (default: HasComponent i=47). + /// ModellingRule for the instance (default: Mandatory i=78). Null for top-level instances. + public List Instantiate( + string typeNodeId, + string parentNodeId, + string modelUri, + string browseName, + string? displayName, + Func allocateNodeId, + string referenceTypeId = "i=47", + string? modellingRuleId = null) + { + var typeNode = Read(typeNodeId) + ?? throw new ArgumentException($"Type node '{typeNodeId}' not found."); + + if (typeNode.NodeClass != NodeClass.UAObjectType && typeNode.NodeClass != NodeClass.UAVariableType) + throw new ArgumentException($"Node '{typeNodeId}' is not an ObjectType or VariableType."); + + var createdNodes = new List(); + + // Create the root instance + var instanceId = $"nsu={modelUri};i={allocateNodeId(modelUri)}"; + var qualifiedBrowseName = $"nsu={modelUri};{browseName}"; + var display = displayName ?? browseName; + + UANode instance; + if (typeNode.NodeClass == NodeClass.UAObjectType) + { + instance = new UAObject + { + NodeId = instanceId, + NodeClass = NodeClass.UAObject, + BrowseName = qualifiedBrowseName, + DisplayName = MakeLocalizedText(display), + ParentId = parentNodeId, + TypeId = typeNodeId, + ModellingRuleId = modellingRuleId, + References = new List + { + new() { ReferenceTypeId = referenceTypeId, TargetId = parentNodeId, IsForward = false }, + new() { ReferenceTypeId = "i=40", TargetId = typeNodeId, IsForward = true }, + } + }; + } + else + { + var vtNode = typeNode as UAVariableType; + instance = new UAVariable + { + NodeId = instanceId, + NodeClass = NodeClass.UAVariable, + BrowseName = qualifiedBrowseName, + DisplayName = MakeLocalizedText(display), + ParentId = parentNodeId, + TypeId = typeNodeId, + ModellingRuleId = modellingRuleId, + DataType = vtNode?.DataType, + ValueRank = vtNode?.ValueRank, + ArrayDimensions = vtNode?.ArrayDimensions, + References = new List + { + new() { ReferenceTypeId = referenceTypeId, TargetId = parentNodeId, IsForward = false }, + new() { ReferenceTypeId = "i=40", TargetId = typeNodeId, IsForward = true }, + } + }; + } + + if (modellingRuleId != null) + { + instance.References!.Add(new Reference + { + ReferenceTypeId = "i=37", // HasModellingRule + TargetId = modellingRuleId, + IsForward = true, + }); + } + + AddNode(instance, parentNodeId); + createdNodes.Add(instance); + + // Instantiate mandatory children from the type hierarchy. + // If modellingRuleId is set, the root is an instance declaration — children keep their ModellingRules. + // If null, the root is a standalone instance — children should NOT have ModellingRules. + bool isInstanceDeclaration = modellingRuleId != null; + InstantiateMandatoryChildren(typeNodeId, instanceId, modelUri, allocateNodeId, createdNodes, isInstanceDeclaration); + + return createdNodes; + } + + /// + /// Adds the mandatory children of directly under + /// , without creating an extra wrapper node. + /// Use this after a caller has already created the instance node itself + /// (e.g. from CreateChildNode) and just needs the type's mandatory children + /// populated underneath it. + /// + /// Whether the new children keep their ModellingRules is derived from the + /// parent's context: if the parent lives inside an ObjectType/VariableType + /// tree (or is itself an instance declaration), the children are instance + /// declarations and keep their ModellingRules; otherwise they are standalone + /// instance children and their ModellingRules are stripped. + /// + public List ExpandMandatoryChildren( + string parentNodeId, + string typeNodeId, + string modelUri, + Func allocateNodeId) + { + var typeNode = Read(typeNodeId) + ?? throw new ArgumentException($"Type node '{typeNodeId}' not found."); + if (typeNode.NodeClass != NodeClass.UAObjectType && typeNode.NodeClass != NodeClass.UAVariableType) + throw new ArgumentException($"Node '{typeNodeId}' is not an ObjectType or VariableType."); + + var parentNode = Read(parentNodeId) + ?? throw new ArgumentException($"Parent node '{parentNodeId}' not found."); + + var createdNodes = new List(); + InstantiateMandatoryChildren( + typeNodeId, parentNodeId, modelUri, allocateNodeId, createdNodes, + isInstanceDeclaration: IsInsideTypeTree(parentNode)); + return createdNodes; + } + + /// + /// Walks up the parent chain and returns true if the node, or any + /// ancestor, is an ObjectType or VariableType — i.e. the node lives + /// inside a type tree and its descendants are instance declarations. + /// + /// The check intentionally does NOT short-circuit on a non-empty + /// ModellingRuleId: a stale rule on a regular instance (e.g. left + /// over from a prior bug) would otherwise misclassify its descendants + /// as instance declarations. + /// + public bool IsInsideTypeTree(UANode? node) + { + var visited = new HashSet(); + while (node != null && node.NodeId != null && visited.Add(node.NodeId)) + { + if (node.NodeClass == NodeClass.UAObjectType || node.NodeClass == NodeClass.UAVariableType) + return true; + if (string.IsNullOrEmpty(node.ParentId)) return false; + node = Read(node.ParentId); + } + return false; + } + + /// + /// Collects instance declarations from the type hierarchy and creates mandatory children. + /// When isInstanceDeclaration is true, children keep their ModellingRules. + /// When false (standalone instance), ModellingRules are stripped from children. + /// + private void InstantiateMandatoryChildren( + string typeNodeId, + string parentInstanceId, + string modelUri, + Func allocateNodeId, + List createdNodes, + bool isInstanceDeclaration) + { + var declarations = GetInstanceDeclarations(typeNodeId); + var mandatory = declarations.Where(d => + d.ModellingRuleId == "i=78" || // Mandatory + d.ModellingRuleId == "i=11510" // MandatoryPlaceholder + ).ToList(); + + foreach (var decl in mandatory) + { + var bn = StripNsuPrefix(decl.BrowseName ?? ""); + var display = GetNodeDisplayText(decl) ?? bn; + + var childId = $"nsu={modelUri};i={allocateNodeId(modelUri)}"; + // Preserve the original BrowseName namespace from the declaration + var qualBn = decl.BrowseName ?? bn; + var refTypeId = decl.ReferenceTypeId ?? "i=47"; + + // Only keep ModellingRule for instance declarations, not standalone instances + var childModellingRuleId = isInstanceDeclaration ? decl.ModellingRuleId : null; + + UANode child; + if (decl.NodeClass == NodeClass.UAVariable || decl.NodeClass == NodeClass.UAVariableType) + { + // Extract Value, DataType, ValueRank, ArrayDimensions from source + // (source may be UAVariable or UAVariableType) + var srcVar = decl.SourceNode as UAVariable; + var srcVarType = decl.SourceNode as UAVariableType; + child = new UAVariable + { + NodeId = childId, + NodeClass = NodeClass.UAVariable, + BrowseName = qualBn, + DisplayName = MakeLocalizedText(display), + ParentId = parentInstanceId, + TypeId = decl.TypeId, + ModellingRuleId = childModellingRuleId, + Value = srcVar?.Value ?? srcVarType?.Value, + DataType = srcVar?.DataType ?? srcVarType?.DataType, + ValueRank = srcVar?.ValueRank ?? srcVarType?.ValueRank, + ArrayDimensions = srcVar?.ArrayDimensions ?? srcVarType?.ArrayDimensions, + References = new List + { + new() { ReferenceTypeId = refTypeId, TargetId = parentInstanceId, IsForward = false }, + } + }; + if (decl.TypeId != null) + child.References.Add(new Reference { ReferenceTypeId = "i=40", TargetId = decl.TypeId, IsForward = true }); + } + else if (decl.NodeClass == NodeClass.UAMethod) + { + child = new UAMethod + { + NodeId = childId, + NodeClass = NodeClass.UAMethod, + BrowseName = qualBn, + DisplayName = MakeLocalizedText(display), + ParentId = parentInstanceId, + ModellingRuleId = childModellingRuleId, + References = new List + { + new() { ReferenceTypeId = refTypeId, TargetId = parentInstanceId, IsForward = false }, + } + }; + } + else + { + child = new UAObject + { + NodeId = childId, + NodeClass = NodeClass.UAObject, + BrowseName = qualBn, + DisplayName = MakeLocalizedText(display), + ParentId = parentInstanceId, + TypeId = decl.TypeId, + ModellingRuleId = childModellingRuleId, + References = new List + { + new() { ReferenceTypeId = refTypeId, TargetId = parentInstanceId, IsForward = false }, + } + }; + if (decl.TypeId != null) + child.References.Add(new Reference { ReferenceTypeId = "i=40", TargetId = decl.TypeId, IsForward = true }); + } + + if (childModellingRuleId != null) + { + child.References!.Add(new Reference + { + ReferenceTypeId = "i=37", + TargetId = childModellingRuleId, + IsForward = true, + }); + } + + AddNode(child, parentInstanceId); + createdNodes.Add(child); + + // Recurse for children: use the source node's NodeId first (it may have + // overridden children), then the TypeDefinition fills in remaining defaults. + // GetInstanceDeclarations walks the type chain: source node → TypeDef → supertypes, + // so the source node's overrides win via first-seen-wins. + if (decl.NodeClass == NodeClass.UAObject || decl.NodeClass == NodeClass.UAVariable) + { + var sourceNodeId = decl.SourceNode?.NodeId; + if (sourceNodeId != null) + { + // Get children from the source node itself (may have overrides) + // plus any inherited from its TypeDefinition + InstantiateMandatoryChildrenFromSource( + sourceNodeId, decl.TypeId, childId, modelUri, + allocateNodeId, createdNodes, isInstanceDeclaration); + } + else if (decl.TypeId != null) + { + InstantiateMandatoryChildren(decl.TypeId, childId, modelUri, allocateNodeId, createdNodes, isInstanceDeclaration); + } + } + } + } + + /// + /// Collects mandatory children from a source node (which may have overrides) + /// merged with the TypeDefinition's defaults. Source node children win by BrowseName. + /// + private void InstantiateMandatoryChildrenFromSource( + string sourceNodeId, + string? typeDefNodeId, + string parentInstanceId, + string modelUri, + Func allocateNodeId, + List createdNodes, + bool isInstanceDeclaration) + { + // Collect children from the source node + var childMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var sourceRefs = BrowseWithSubtypes(sourceNodeId, "i=33", includeForward: true, includeInverse: false); + foreach (var r in sourceRefs) + { + if (r.ReferenceTypeId == "i=45" || IsTypeOf(r.ReferenceTypeId, "i=45")) continue; + var childNode = Read(r.TargetNodeId); + if (childNode == null) continue; + if (string.IsNullOrEmpty(childNode.ModellingRuleId)) continue; + + var key = StripNsuPrefix(childNode.BrowseName ?? "").ToLowerInvariant(); + if (childMap.ContainsKey(key)) continue; + + childMap[key] = new InstanceDeclaration + { + SourceNode = childNode, + ReferenceTypeId = r.ReferenceTypeId, + SourceTypeNodeId = sourceNodeId, + }; + } + + // Merge in children from the TypeDefinition (if any) — source overrides win + if (typeDefNodeId != null) + { + var typeDecls = GetInstanceDeclarations(typeDefNodeId); + foreach (var td in typeDecls) + { + var key = StripNsuPrefix(td.BrowseName ?? "").ToLowerInvariant(); + if (!childMap.ContainsKey(key)) + childMap[key] = td; + } + } + + // Filter to mandatory and instantiate + var mandatory = childMap.Values.Where(d => + d.ModellingRuleId == "i=78" || d.ModellingRuleId == "i=11510").ToList(); + + // Reuse the same instantiation logic + foreach (var decl in mandatory) + { + var bn = StripNsuPrefix(decl.BrowseName ?? ""); + var display = GetNodeDisplayText(decl) ?? bn; + var childId = $"nsu={modelUri};i={allocateNodeId(modelUri)}"; + var qualBn = decl.BrowseName ?? bn; + var refTypeId = decl.ReferenceTypeId ?? "i=47"; + var childModellingRuleId = isInstanceDeclaration ? decl.ModellingRuleId : null; + + UANode child; + if (decl.NodeClass == NodeClass.UAVariable || decl.NodeClass == NodeClass.UAVariableType) + { + var srcVar = decl.SourceNode as UAVariable; + var srcVarType = decl.SourceNode as UAVariableType; + child = new UAVariable + { + NodeId = childId, + NodeClass = NodeClass.UAVariable, + BrowseName = qualBn, + DisplayName = MakeLocalizedText(display), + ParentId = parentInstanceId, + TypeId = decl.TypeId, + ModellingRuleId = childModellingRuleId, + Value = srcVar?.Value ?? srcVarType?.Value, + DataType = srcVar?.DataType ?? srcVarType?.DataType, + ValueRank = srcVar?.ValueRank ?? srcVarType?.ValueRank, + ArrayDimensions = srcVar?.ArrayDimensions ?? srcVarType?.ArrayDimensions, + References = new List + { + new() { ReferenceTypeId = refTypeId, TargetId = parentInstanceId, IsForward = false }, + } + }; + if (decl.TypeId != null) + child.References.Add(new Reference { ReferenceTypeId = "i=40", TargetId = decl.TypeId, IsForward = true }); + } + else if (decl.NodeClass == NodeClass.UAMethod) + { + child = new UAMethod + { + NodeId = childId, + NodeClass = NodeClass.UAMethod, + BrowseName = qualBn, + DisplayName = MakeLocalizedText(display), + ParentId = parentInstanceId, + ModellingRuleId = childModellingRuleId, + References = new List + { + new() { ReferenceTypeId = refTypeId, TargetId = parentInstanceId, IsForward = false }, + } + }; + } + else + { + child = new UAObject + { + NodeId = childId, + NodeClass = NodeClass.UAObject, + BrowseName = qualBn, + DisplayName = MakeLocalizedText(display), + ParentId = parentInstanceId, + TypeId = decl.TypeId, + ModellingRuleId = childModellingRuleId, + References = new List + { + new() { ReferenceTypeId = refTypeId, TargetId = parentInstanceId, IsForward = false }, + } + }; + if (decl.TypeId != null) + child.References.Add(new Reference { ReferenceTypeId = "i=40", TargetId = decl.TypeId, IsForward = true }); + } + + if (childModellingRuleId != null) + { + child.References!.Add(new Reference + { + ReferenceTypeId = "i=37", + TargetId = childModellingRuleId, + IsForward = true, + }); + } + + AddNode(child, parentInstanceId); + createdNodes.Add(child); + + // Recurse + if (decl.NodeClass == NodeClass.UAObject || decl.NodeClass == NodeClass.UAVariable) + { + var srcNodeId = decl.SourceNode?.NodeId; + if (srcNodeId != null) + { + InstantiateMandatoryChildrenFromSource( + srcNodeId, decl.TypeId, childId, modelUri, + allocateNodeId, createdNodes, isInstanceDeclaration); + } + else if (decl.TypeId != null) + { + InstantiateMandatoryChildren(decl.TypeId, childId, modelUri, allocateNodeId, createdNodes, isInstanceDeclaration); + } + } + } + } + + /// + /// Collects instance declarations from the type hierarchy. + /// Walks from the given type up through its supertypes. + /// Subtype declarations take priority (first seen wins by stripped BrowseName). + /// Only returns children with a ModellingRule set. + /// + public List GetInstanceDeclarations(string typeNodeId) + { + // Walk UP the supertype chain + var typeChain = new List { typeNodeId }; + var current = typeNodeId; + var visited = new HashSet { current }; + while (true) + { + var parentRefs = Browse(current, "i=45", includeForward: false, includeInverse: true); + if (parentRefs.Count == 0) break; + current = parentRefs[0].TargetNodeId; + if (!visited.Add(current)) break; + typeChain.Add(current); + } + + // Collect children; subtype children take priority (first seen by stripped BrowseName) + var childMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var typeId in typeChain) + { + var refs = BrowseWithSubtypes(typeId, "i=33", includeForward: true, includeInverse: false); + foreach (var r in refs) + { + // Skip HasSubtype references + if (r.ReferenceTypeId == "i=45" || IsTypeOf(r.ReferenceTypeId, "i=45")) + continue; + + var childNode = Read(r.TargetNodeId); + if (childNode == null) continue; + if (string.IsNullOrEmpty(childNode.ModellingRuleId)) continue; + + var key = StripNsuPrefix(childNode.BrowseName ?? "").ToLowerInvariant(); + if (childMap.ContainsKey(key)) continue; + + childMap[key] = new InstanceDeclaration + { + SourceNode = childNode, + ReferenceTypeId = r.ReferenceTypeId, + SourceTypeNodeId = typeId, + }; + } + } + + return childMap.Values.ToList(); + } + + private static string StripNsuPrefix(string value) + { + var semi = value.IndexOf(';'); + return semi >= 0 ? value[(semi + 1)..] : value; + } + + private static string? GetNodeDisplayText(UANode node) + { + return node.DisplayName?.T?.FirstOrDefault()?.ElementAtOrDefault(1); + } + + private static LocalizedText MakeLocalizedText(string text) + { + return new LocalizedText + { + T = new List> { new() { "", text } } + }; + } + + #endregion + private void RemoveChildren(ChildList children) { if (children.Objects != null) diff --git a/Opc.Ua.JsonNodeSet/Model/BuiltInType.cs b/Opc.Ua.JsonNodeSet/Model/BuiltInType.cs new file mode 100644 index 0000000..fb8a83e --- /dev/null +++ b/Opc.Ua.JsonNodeSet/Model/BuiltInType.cs @@ -0,0 +1,33 @@ +namespace Opc.Ua.JsonNodeSet.Model; + +/// +/// OPC UA built-in type identifiers (Part 6, Table A.1). +/// Values match the DataType NodeId numeric identifiers. +/// +public enum BuiltInType +{ + Boolean = 1, + SByte = 2, + Byte = 3, + Int16 = 4, + UInt16 = 5, + Int32 = 6, + UInt32 = 7, + Int64 = 8, + UInt64 = 9, + Float = 10, + Double = 11, + String = 12, + DateTime = 13, + Guid = 14, + ByteString = 15, + XmlElement = 16, + NodeId = 17, + ExpandedNodeId = 18, + StatusCode = 19, + QualifiedName = 20, + LocalizedText = 21, + ExtensionObject = 22, + DataValue = 23, + Variant = 24, +} diff --git a/Opc.Ua.JsonNodeSet/Model/ExtensionObject.cs b/Opc.Ua.JsonNodeSet/Model/ExtensionObject.cs new file mode 100644 index 0000000..4029db8 --- /dev/null +++ b/Opc.Ua.JsonNodeSet/Model/ExtensionObject.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Opc.Ua.JsonNodeSet.Model; + +[DataContract] +public class ExtensionObject +{ + [DataMember] + public string? TypeId { get; set; } + + [DataMember] + public object? Body { get; set; } +} diff --git a/Opc.Ua.JsonNodeSet/NodeSetSerializer.cs b/Opc.Ua.JsonNodeSet/NodeSetSerializer.cs index c23c3ed..8a30cd8 100644 --- a/Opc.Ua.JsonNodeSet/NodeSetSerializer.cs +++ b/Opc.Ua.JsonNodeSet/NodeSetSerializer.cs @@ -11,12 +11,16 @@ using System.Formats.Tar; using System.Xml.Linq; using System.Collections.ObjectModel; +using Opc.Ua.JsonNodeSet.Model; +using System.Text.Json.Nodes; +using System.Reflection.Metadata.Ecma335; namespace NodeSetTool { public class NodeSetSerializer { private ServiceMessageContext? m_context; + private Opc.Ua.JsonNodeSet.AddressSpace? m_addressSpace; private Dictionary? m_aliases; private Dictionary? m_models; private Dictionary? m_nodes; @@ -25,6 +29,8 @@ public class NodeSetSerializer private List? m_stubs; private List? m_externalNodeIds; private Dictionary? m_nsPrefixes; // namespace URI → CURIE prefix + + private const string CoreNamespaceUri = "http://opcfoundation.org/UA/"; public IReadOnlyCollection Models => m_models?.Values ?? (IReadOnlyCollection)Array.Empty(); @@ -43,6 +49,7 @@ public AliasToUse(string alias, string nodeId) private AliasToUse[] s_AliasesToUse = new AliasToUse[] { + /* new AliasToUse(BrowseNames.Boolean, DataTypeIds.Boolean), new AliasToUse(BrowseNames.SByte, DataTypeIds.SByte), new AliasToUse(BrowseNames.Byte, DataTypeIds.Byte), @@ -95,6 +102,7 @@ public AliasToUse(string alias, string nodeId) new AliasToUse(BrowseNames.HasAlarmSuppressionGroup, ReferenceTypeIds.HasAlarmSuppressionGroup), new AliasToUse(BrowseNames.AlarmGroupMember, ReferenceTypeIds.AlarmGroupMember), new AliasToUse(BrowseNames.AlarmSuppressionGroupMember, ReferenceTypeIds.AlarmSuppressionGroupMember) + */ }; #endregion @@ -183,29 +191,31 @@ private bool CompareModelRef(Json.ModelReference original, Json.ModelReference t return false; } - if (original.XmlSchemaUri != target.XmlSchemaUri) + if (!String.IsNullOrWhiteSpace(original.XmlSchemaUri) && + !String.IsNullOrWhiteSpace(target.XmlSchemaUri) && + original.XmlSchemaUri != target.XmlSchemaUri) { m_errors.Add(new CompareError(null, $"Model '{context}' XmlSchemaUri", original.XmlSchemaUri, target.XmlSchemaUri)); return false; - } - - if (original.VarVersion != target.VarVersion) - { - m_errors.Add(new CompareError(null, $"Model '{context}' Version", original.VarVersion, target.VarVersion)); - return false; - } - - if (original.ModelVersion != target.ModelVersion) - { - m_errors.Add(new CompareError(null, $"Model '{context}' ModelVersion", original.ModelVersion, target.ModelVersion)); - return false; - } - - if (original.PublicationDate != target.PublicationDate) - { - m_errors.Add(new CompareError(null, $"Model '{context}' PublicationDate", original.PublicationDate, target.PublicationDate)); - return false; - } + } + + //if (original.VarVersion != target.VarVersion) + //{ + // m_errors.Add(new CompareError(null, $"Model '{context}' Version", original.VarVersion, target.VarVersion)); + // return false; + //} + + //if (original.ModelVersion != target.ModelVersion) + //{ + // m_errors.Add(new CompareError(null, $"Model '{context}' ModelVersion", original.ModelVersion, target.ModelVersion)); + // return false; + //} + + //if (original.PublicationDate != target.PublicationDate) + //{ + // m_errors.Add(new CompareError(null, $"Model '{context}' PublicationDate", original.PublicationDate, target.PublicationDate)); + // return false; + //} return true; } @@ -740,7 +750,7 @@ public void SaveArchive(string filePath, int maxNodesPerFile) using (FileStream fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write)) using (GZipStream gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal)) - using (var tarWriter = WriterFactory.Open(gzipStream, ArchiveType.Tar, CompressionType.None)) + using (var tarWriter = WriterFactory.OpenWriter(gzipStream, ArchiveType.Tar, WriterOptions.ForTar(CompressionType.None))) { foreach (var file in files) { @@ -770,7 +780,7 @@ public void SaveArchive(Stream stream, int maxNodesPerFile) var files = Package(nodeset, maxNodesPerFile); using var gzipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true); - using var tarWriter = WriterFactory.Open(gzipStream, ArchiveType.Tar, CompressionType.None); + using var tarWriter = WriterFactory.OpenWriter(gzipStream, ArchiveType.Tar, WriterOptions.ForTar(CompressionType.None)); foreach (var file in files) { @@ -1733,27 +1743,525 @@ private Xml.UAView ToXmlNode(Json.UAView input) return output; } - private Json.Variant? ToJsonVariant(XmlElement? input) + + private object? ToJson(XmlElement? input) { if (input == null) { return null; } - // TODO: implement XML-to-JSON variant conversion without Opc.Ua.Core dependency. + if (input.NamespaceURI == CoreNamespaceUri) + { + switch (input.LocalName) + { + case nameof(BuiltInType.Boolean): + { + if (!Boolean.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a Boolean."); + } + + return value; + } + + case nameof(BuiltInType.SByte): + { + if (!SByte.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a SByte."); + } + + return value; + } + + case nameof(BuiltInType.Byte): + { + if (!Byte.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a Byte."); + } + + return value; + } + + case nameof(BuiltInType.Int16): + { + if (!Int16.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a Int16."); + } + + return value; + } + + case nameof(BuiltInType.UInt16): + { + if (!Int16.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a UInt16."); + } + + return value; + } + + case nameof(BuiltInType.Int32): + { + if (!Int32.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a Int32."); + } + + return value; + } + + case nameof(BuiltInType.UInt32): + { + if (!UInt32.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a UInt32."); + } + + return value; + } + + case nameof(BuiltInType.Int64): + { + if (!Int64.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a Int64."); + } + + return value; + } + + case nameof(BuiltInType.UInt64): + { + if (!UInt64.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a UInt64."); + } + + return value; + } + + case nameof(BuiltInType.Float): + { + if (!Single.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a case nameof(BuiltInType.Float):\r\n."); + } + + return value; + } + + case nameof(BuiltInType.Double): + { + if (!Double.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a Double."); + } + + return value; + } + + case nameof(BuiltInType.String): + { + return input.InnerText; + } + + case nameof(BuiltInType.ByteString): + { + byte[] bytes = new byte[(input.InnerText.Length * 3) / 4]; + + if (!Convert.TryFromBase64String(input.InnerText, bytes, out var bytesWritten)) + { + throw new InvalidDataException($"{input.InnerText} is a ByteString."); + } + + return bytes.AsSpan(0, bytesWritten).ToArray(); + } + + case nameof(BuiltInType.DateTime): + { + try + { + return XmlConvert.ToDateTime(input.InnerText, XmlDateTimeSerializationMode.Utc); + } + catch + { + throw new InvalidDataException($"{input.InnerText} is a DateTime."); + } + } + + case nameof(BuiltInType.Guid): + { + var child = input.FirstChild as XmlElement; + + if (child == null || child.LocalName != "String" || String.IsNullOrEmpty(child.InnerText)) + { + return Guid.Empty; + } + + if (!Guid.TryParse(child.InnerText, out var value)) + { + throw new InvalidDataException($"{child.InnerText} is a Guid."); + } + + return value; + } + + case nameof(BuiltInType.NodeId): + case nameof(BuiltInType.ExpandedNodeId): + { + var child = input.FirstChild as XmlElement; + + if (child == null || child.LocalName != "Identifier") + { + return String.Empty; + } + + return child.InnerText; + } + + case nameof(BuiltInType.StatusCode): + { + var child = input.FirstChild as XmlElement; + + if (child == null || child.LocalName != "Code") + { + return 0U; + } + + if (!UInt32.TryParse(input.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a StatusCode."); + } + + return value; + } + + case nameof(BuiltInType.QualifiedName): + { + ushort ns = 0; + string name = String.Empty; + var child = input.FirstChild as XmlElement; + + if (child != null) + { + if (child.LocalName == "NamespaceIndex") + { + if (!Int16.TryParse(child.InnerText, out var value)) + { + throw new InvalidDataException($"{input.InnerText} is a QualifiedName."); + } + + child = child.NextSibling as XmlElement; + } + } + + if (child != null) + { + if (child.LocalName == "Name") + { + name = child.InnerText.Trim(); + } + } + + return (ns != 0) ? $"{ns}:{name}" : name; + } + + case nameof(BuiltInType.LocalizedText): + { + string locale = String.Empty; + string text = String.Empty; + var child = input.FirstChild as XmlElement; + + if (child != null) + { + if (child.LocalName == "Locale") + { + locale = child.InnerText.Trim(); + child = child.NextSibling as XmlElement; + } + } + + if (child != null) + { + if (child.LocalName == "Text") + { + text = child.InnerText.Trim(); + } + } + + return new LocalizedText + { + T = new List> { new() { locale, text } } + }; + } + + case nameof(BuiltInType.ExtensionObject): + { + // Delegate to the shared converter so the full envelope (TypeId + Body) + // is parsed into a Json.ExtensionObject with a JObject body. + return Opc.Ua.JsonNodeSet.VariantConverter + .ReadVariantFromXml(input, MakeVariantContext())?.Value; + } + + } + } + + // Unknown element namespace/name — return null to signal "not a built-in type". return null; } - private XmlElement? ToXmlVariant(Json.Variant? input) + private Opc.Ua.JsonNodeSet.VariantXmlContext MakeVariantContext() => + new Opc.Ua.JsonNodeSet.VariantXmlContext(m_context, m_addressSpace); + + /// + /// Converts an OPC UA Value XmlElement to a JSON Variant. See + /// for the full conversion contract. + /// + private Json.Variant? ToJsonVariant(XmlElement? input) => + Opc.Ua.JsonNodeSet.VariantConverter.ReadVariantFromXml(input, MakeVariantContext()); + + /// + /// Converts a JSON Variant to an OPC UA Value XmlElement. Delegates to + /// . + /// + private XmlElement? ToXmlVariant(Json.Variant? input) => + Opc.Ua.JsonNodeSet.VariantConverter.WriteVariantToXml(input, MakeVariantContext()); + +#if LEGACY_TOXMLVARIANT + private XmlElement? ToXmlVariantLegacy(Json.Variant? input) { - if (input == null) + if (input?.Value == null) return null; + + var doc = new XmlDocument(); + var uaNs = "http://opcfoundation.org/UA/2008/02/Types.xsd"; + + var builtInType = (BuiltInType)(input.UaType ?? (int)BuiltInType.String); + var typeName = UaTypeToName(builtInType); + + // Helper: creates a typed XML element for a single value per Opc.Ua.Types.xsd + XmlElement MakeValueElement(string tn, BuiltInType bt, object val) { - return null; + var el = doc.CreateElement("uax", tn, uaNs); + var text = Convert.ToString(val, System.Globalization.CultureInfo.InvariantCulture) ?? ""; + + switch (bt) + { + case BuiltInType.Guid: + { + var c = doc.CreateElement("uax", nameof(BuiltInType.String), uaNs); + c.InnerText = text; + el.AppendChild(c); + break; + } + case BuiltInType.NodeId: + case BuiltInType.ExpandedNodeId: + { + // Convert nsu= format to ns= index format for XML + var c = doc.CreateElement("uax", "Identifier", uaNs); + c.InnerText = NsuToNsIndex(text); + el.AppendChild(c); + break; + } + case BuiltInType.StatusCode: + { + var c = doc.CreateElement("uax", "Code", uaNs); + c.InnerText = text; + el.AppendChild(c); + break; + } + case BuiltInType.QualifiedName: + { + // Parse "nsu=uri;Name" or "nsIndex:Name" format + string nsIndex = "0"; + string qnName = text; + + if (text.StartsWith("nsu=")) + { + var semiIdx = text.IndexOf(';'); + if (semiIdx > 4) + { + var uri = text[4..semiIdx]; + nsIndex = m_context!.NamespaceUris.GetIndexOrAppend(uri).ToString(); + qnName = text[(semiIdx + 1)..]; + } + } + else + { + var colonIdx = text.IndexOf(':'); + if (colonIdx > 0 && int.TryParse(text[..colonIdx], out _)) + { + nsIndex = text[..colonIdx]; + qnName = text[(colonIdx + 1)..]; + } + } + + var nsIdxEl = doc.CreateElement("uax", "NamespaceIndex", uaNs); + nsIdxEl.InnerText = nsIndex; + el.AppendChild(nsIdxEl); + var nameEl = doc.CreateElement("uax", "Name", uaNs); + nameEl.InnerText = qnName; + el.AppendChild(nameEl); + break; + } + case BuiltInType.LocalizedText: + { + var c = doc.CreateElement("uax", "Text", uaNs); + c.InnerText = text; + el.AppendChild(c); + break; + } + default: + el.InnerText = text; + break; + } + return el; } -; - return null; + + // Return the typed element directly — the caller (serializer) wraps it in + XmlElement result; + + if (input.Value is System.Collections.IList list) + { + if (input.Dimensions != null && input.Dimensions.Count > 1) + { + // Multi-dimensional: with and + result = doc.CreateElement("uax", "Matrix", uaNs); + + var dimsEl = doc.CreateElement("uax", "Dimensions", uaNs); + foreach (var dim in input.Dimensions) + { + var dimEl = doc.CreateElement("uax", "Int32", uaNs); + dimEl.InnerText = dim.ToString(); + dimsEl.AppendChild(dimEl); + } + result.AppendChild(dimsEl); + + var elemsEl = doc.CreateElement("uax", "Elements", uaNs); + foreach (var item in list) + elemsEl.AppendChild(MakeValueElement(typeName, builtInType, item)); + result.AppendChild(elemsEl); + } + else + { + // One-dimensional: + result = doc.CreateElement("uax", $"ListOf{typeName}", uaNs); + foreach (var item in list) + result.AppendChild(MakeValueElement(typeName, builtInType, item)); + } + } + else + { + result = MakeValueElement(typeName, builtInType, input.Value); + } + + doc.AppendChild(result); + return doc.DocumentElement!; + } +#endif + + /// Parses a string value into the correct CLR type for the given built-in type. + private static object ParseTypedValue(BuiltInType type, string text) => type switch + { + BuiltInType.Boolean => bool.TryParse(text, out var bv) ? (object)bv : false, + BuiltInType.SByte => sbyte.TryParse(text, out var sbv) ? (object)sbv : (sbyte)0, + BuiltInType.Byte => byte.TryParse(text, out var byv) ? (object)byv : (byte)0, + BuiltInType.Int16 => short.TryParse(text, out var sv) ? (object)sv : (short)0, + BuiltInType.UInt16 => ushort.TryParse(text, out var usv) ? (object)usv : (ushort)0, + BuiltInType.Int32 => int.TryParse(text, out var iv) ? (object)iv : 0, + BuiltInType.UInt32 => uint.TryParse(text, out var uiv) ? (object)uiv : 0u, + BuiltInType.Int64 => long.TryParse(text, out var lv) ? (object)lv : 0L, + BuiltInType.UInt64 => ulong.TryParse(text, out var ulv) ? (object)ulv : 0UL, + BuiltInType.Float => float.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, out var fv) ? (object)fv : 0f, + BuiltInType.Double => double.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, out var dv) ? (object)dv : 0d, + _ => (object)text + }; + + /// + /// Extracts a typed value from an XML element, handling complex types + /// that have child elements per Opc.Ua.Types.xsd rather than plain inner text. + /// + private object ExtractValueFromElement(BuiltInType type, XmlElement el) + { + switch (type) + { + case BuiltInType.Guid: + var guidStr = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == nameof(BuiltInType.String)); + return guidStr?.InnerText ?? el.InnerText; + case BuiltInType.NodeId: + case BuiltInType.ExpandedNodeId: + var idEl = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Identifier"); + return NsIndexToNsu(idEl?.InnerText ?? el.InnerText); + case BuiltInType.StatusCode: + var codeEl = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Code"); + return codeEl?.InnerText ?? el.InnerText; + case BuiltInType.QualifiedName: + var qnNsEl = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "NamespaceIndex"); + var qnNameEl = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Name"); + if (qnNsEl != null && qnNameEl != null) + { + if (int.TryParse(qnNsEl.InnerText, out var nsIdx) && nsIdx > 0) + { + var uri = m_context!.NamespaceUris.GetString(nsIdx); + if (uri != null) return $"nsu={uri};{qnNameEl.InnerText}"; + } + return $"{qnNsEl.InnerText}:{qnNameEl.InnerText}"; + } + if (qnNameEl != null) return qnNameEl.InnerText; + return el.InnerText; + case BuiltInType.LocalizedText: + var ltText = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Text"); + return ltText?.InnerText ?? el.InnerText; + default: + return ParseTypedValue(type, el.InnerText); + } + } + + /// + /// Converts a NodeId/ExpandedNodeId from nsu= format to ns= index format. + /// E.g., "nsu=http://example.org/;i=1234" → "ns=3;i=1234" + /// Registers unknown namespace URIs in the namespace table. + /// + private string NsuToNsIndex(string nodeId) + { + if (!nodeId.StartsWith("nsu=")) return nodeId; + var semiIdx = nodeId.IndexOf(';'); + if (semiIdx <= 4) return nodeId; + var uri = nodeId[4..semiIdx]; + var idx = m_context!.NamespaceUris.GetIndexOrAppend(uri); + return idx == 0 ? nodeId[(semiIdx + 1)..] : $"ns={idx};{nodeId[(semiIdx + 1)..]}"; + } + + /// + /// Converts a NodeId/ExpandedNodeId from ns= index format to nsu= URI format. + /// E.g., "ns=3;i=1234" → "nsu=http://example.org/;i=1234" + /// + private string NsIndexToNsu(string nodeId) + { + if (nodeId.StartsWith("ns=")) + { + var semiIdx = nodeId.IndexOf(';'); + if (semiIdx > 3 && int.TryParse(nodeId[3..semiIdx], out var idx)) + { + var uri = m_context!.NamespaceUris.GetString(idx); + if (uri != null) return $"nsu={uri};{nodeId[(semiIdx + 1)..]}"; + } + } + // No namespace prefix — it's in namespace 0 (UA core), return as-is + return nodeId; } + /// Maps a BuiltInType enum value to its XML element name. + private static string UaTypeToName(BuiltInType type) => type.ToString(); + + /// Maps an XML element name to a BuiltInType enum value. + private static BuiltInType NameToUaType(string name) => + Enum.TryParse(name, out var type) ? type : BuiltInType.String; + private string? ToJsonNodeId(string? input) { if (String.IsNullOrEmpty(input)) @@ -2070,11 +2578,23 @@ public void LoadInto(AddressSpace addressSpace) } addressSpace.AddNodeSet(BuildJson()); + // Post-load: pre-compute DataTypeForm (Structure/Union/Enumeration/OptionSet) + // for every UADataType so the Variant canonicalizer can recognise unions via + // dt.DataTypeForm == "Union" even when the source NodeSet did not carry an + // explicit IsUnion flag on the DataTypeDefinition. + addressSpace.ComputeDataTypeForms(); + // Then canonicalize every Variant DOM so structure field order matches + // DataTypeDefinition. Default JSON encoding nodes are NOT stripped automatically + // — they are unused once Variants are canonical, but removing them would break + // round-trip comparisons against the source nodeset. Callers that want them gone + // can invoke AddressSpace.StripDefaultJsonEncodings() explicitly. + addressSpace.ResolveVariants(); } public static NodeSetSerializer FromAddressSpace(AddressSpace addressSpace, string modelUri) { var serializer = new NodeSetSerializer(); + serializer.m_addressSpace = addressSpace; var nodeSet = addressSpace.GetNodeSet(modelUri); serializer.LoadFromNodeSet(nodeSet); return serializer; @@ -2168,6 +2688,26 @@ void IndexList(IEnumerable? nodes) } } } + + // Ensure any newly discovered namespace URIs are added as RequiredModels + // so the address space validator accepts them on reload. + var allRegisteredUris = new HashSet(m_context!.NamespaceUris!.ToArray() ?? []); + + foreach (var model in m_models!.Values) + { + var existingReqs = new HashSet { model.ModelUri! }; + if (model.RequiredModels != null) + foreach (var req in model.RequiredModels) + if (req.ModelUri != null) existingReqs.Add(req.ModelUri); + + foreach (var uri in allRegisteredUris) + { + if (uri == CoreNamespaceUri) continue; // core is always implicit + if (existingReqs.Contains(uri)) continue; + model.RequiredModels ??= new List(); + model.RequiredModels.Add(new Json.ModelReference { ModelUri = uri }); + } + } } private void RegisterNsuUri(string? value) @@ -2380,8 +2920,8 @@ private JObject BuildJsonLdContext() var context = new JObject(); // Core namespace - m_nsPrefixes["http://opcfoundation.org/UA/"] = "opcua"; - context["opcua"] = "http://opcfoundation.org/UA/"; + m_nsPrefixes[CoreNamespaceUri] = "opcua"; + context["opcua"] = CoreNamespaceUri; // Model namespaces and their required models foreach (var model in m_models!.Values) @@ -2461,7 +3001,7 @@ private JObject BuildJsonLdContext() private void RegisterNamespacePrefix(string nsUri, JObject context) { if (m_nsPrefixes!.ContainsKey(nsUri)) return; - if (nsUri == "http://opcfoundation.org/UA/") return; + if (nsUri == CoreNamespaceUri) return; var prefix = DerivePrefix(nsUri); while (m_nsPrefixes.ContainsValue(prefix)) diff --git a/Opc.Ua.JsonNodeSet/Opc.Ua.JsonNodeSet.csproj b/Opc.Ua.JsonNodeSet/Opc.Ua.JsonNodeSet.csproj index 8b8a0c1..426028b 100644 --- a/Opc.Ua.JsonNodeSet/Opc.Ua.JsonNodeSet.csproj +++ b/Opc.Ua.JsonNodeSet/Opc.Ua.JsonNodeSet.csproj @@ -7,8 +7,6 @@ Opc.Ua.JsonNodeSet Opc.Ua.JsonNodeSet OPC UA JSON NodeSet Library - $(DefaultItemExcludes);build\** - $(SolutionDir)build\obj\$(MSBuildProjectName)\$(Configuration)\ @@ -19,21 +17,21 @@ README.md - - $(SolutionDir)build\bin\$(MSBuildProjectName)\$(Configuration)\ - + + + - - $(SolutionDir)build\bin\$(MSBuildProjectName)\$(Configuration)\ - + + + - + + - - + diff --git a/Opc.Ua.JsonNodeSet/ServiceMessageContext.cs b/Opc.Ua.JsonNodeSet/ServiceMessageContext.cs index 0375f17..e0c2a7d 100644 --- a/Opc.Ua.JsonNodeSet/ServiceMessageContext.cs +++ b/Opc.Ua.JsonNodeSet/ServiceMessageContext.cs @@ -22,11 +22,17 @@ internal class StringTable /// Returns the index for the given URI, appending it if not found. /// Index 0 is reserved for the OPC UA namespace and is not stored. /// + private const string OpcUaCoreNamespace = "http://opcfoundation.org/UA/"; + public int GetIndexOrAppend(string uri) { if (string.IsNullOrEmpty(uri)) return 0; + // Index 0 is reserved for the OPC UA core namespace — never store it + if (uri == OpcUaCoreNamespace) + return 0; + for (int i = 0; i < _table.Count; i++) { if (_table[i] == uri) diff --git a/Opc.Ua.JsonNodeSet/VariantConverter.cs b/Opc.Ua.JsonNodeSet/VariantConverter.cs new file mode 100644 index 0000000..dea4d7e --- /dev/null +++ b/Opc.Ua.JsonNodeSet/VariantConverter.cs @@ -0,0 +1,945 @@ +using System.Globalization; +using System.Xml; +using Newtonsoft.Json.Linq; +using Json = Opc.Ua.JsonNodeSet.Model; + +namespace Opc.Ua.JsonNodeSet; + +/// +/// Converts OPC UA Variant / ExtensionObject values between XML and JSON representations. +/// +/// Pass 1 (schema-free): both converters preserve whatever the source carried — +/// element order for XML, key order for JSON — without consulting any +/// . The resulting is +/// round-trippable within a single encoding and good enough for built-in scalars, +/// arrays, and matrices. Structure bodies become s whose keys are +/// the raw field names from the source. +/// +/// Pass 2 (schema-aware) is handled by AddressSpace.ResolveVariants(), which +/// rewrites the DOMs in place so key order matches the . +/// +/// JSON encoding: an is always the +/// DataType NodeId (per the canonical form). XML encoding: the element's namespace is the +/// defining model's XmlSchemaUri, and its TypeId is the "Default XML" +/// encoding NodeId when one exists (resolved in pass 2 via the encoding index). +/// +internal static class VariantConverter +{ + /// + /// Parses a single <ExtensionObject> XML element into a + /// . Thin wrapper for callers that have a naked + /// ExtensionObject (outside a UAVariable Value). + /// + internal static Json.ExtensionObject ToJsonExtensionObject(XmlElement xml, VariantXmlContext ctx) + => ReadExtensionObjectFromXml(xml, ctx); + + /// + /// Emits a as an XML element. When the context + /// carries an the emitted TypeId is the "Default XML" + /// encoding NodeId and the body element uses the defining model's XmlSchemaUri. + /// + internal static XmlElement? ToXmlExtensionObject(Json.ExtensionObject eo, VariantXmlContext ctx) + { + var doc = new XmlDocument(); + var el = WriteExtensionObjectToXml(doc, eo, ctx); + if (el != null) doc.AppendChild(el); + return doc.DocumentElement; + } + + + private const string UaTypesXmlNamespace = "http://opcfoundation.org/UA/2008/02/Types.xsd"; + private const string UaPrefix = "uax"; + + #region XML -> JSON + + /// + /// Parses an XML Variant element (the <Value> wrapper, or the typed + /// element directly) into a schema-free . + /// + public static Json.Variant? ReadVariantFromXml(XmlElement? input, VariantXmlContext ctx) + { + if (input == null) return null; + + // Unwrap the wrapper if present. + XmlElement? typed = input; + if (string.Equals(input.LocalName, "Value", StringComparison.Ordinal)) + { + typed = input.ChildNodes.OfType().FirstOrDefault(); + if (typed == null) return null; + } + + return ReadTypedElement(typed, ctx); + } + + private static Json.Variant ReadTypedElement(XmlElement typed, VariantXmlContext ctx) + { + var name = typed.LocalName; + + // Matrix wrapper: ...... + if (string.Equals(name, "Matrix", StringComparison.Ordinal)) + { + return ReadMatrix(typed, ctx); + } + + // ListOf + if (name.StartsWith("ListOf", StringComparison.Ordinal)) + { + var itemTypeName = name.Substring("ListOf".Length); + return ReadList(typed, itemTypeName, ctx); + } + + // ExtensionObject wrapper + if (string.Equals(name, nameof(Json.BuiltInType.ExtensionObject), StringComparison.Ordinal)) + { + var eo = ReadExtensionObjectFromXml(typed, ctx); + return new Json.Variant((int)Json.BuiltInType.ExtensionObject, eo); + } + + // Built-in scalar? + if (IsBuiltInTypeName(name, out var bt)) + { + var value = ReadBuiltInScalar(bt, typed, ctx); + return new Json.Variant((int)bt, value); + } + + // Unknown element — treat as a structure body directly (no ExtensionObject wrapper). + var body = ReadStructureBody(typed, ctx); + var eoBare = new Json.ExtensionObject { TypeId = null, Body = body }; + return new Json.Variant((int)Json.BuiltInType.ExtensionObject, eoBare); + } + + private static Json.Variant ReadList(XmlElement listEl, string itemTypeName, VariantXmlContext ctx) + { + var items = new List(); + Json.BuiltInType bt; + bool itemIsBuiltIn = IsBuiltInTypeName(itemTypeName, out bt); + + foreach (var child in listEl.ChildNodes.OfType()) + { + if (itemIsBuiltIn) + { + items.Add(ReadBuiltInScalar(bt, child, ctx)); + } + else if (string.Equals(child.LocalName, nameof(Json.BuiltInType.ExtensionObject), StringComparison.Ordinal)) + { + items.Add(ReadExtensionObjectFromXml(child, ctx)); + } + else + { + // Structure body (e.g. ListOfTestConcreteStructure containing items). + items.Add(ReadStructureBody(child, ctx)); + } + } + + if (!itemIsBuiltIn) + { + bt = Json.BuiltInType.ExtensionObject; + } + return new Json.Variant((int)bt, items); + } + + private static Json.Variant ReadMatrix(XmlElement matrix, VariantXmlContext ctx) + { + var dimsEl = matrix.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Dimensions"); + var elemsEl = matrix.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Elements"); + var dims = dimsEl?.ChildNodes.OfType() + .Select(e => int.TryParse(e.InnerText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) ? d : 0) + .ToList() ?? new List(); + + var items = new List(); + var bt = Json.BuiltInType.String; + if (elemsEl != null) + { + var first = elemsEl.ChildNodes.OfType().FirstOrDefault(); + if (first != null) + { + if (!IsBuiltInTypeName(first.LocalName, out bt)) + { + bt = Json.BuiltInType.ExtensionObject; + } + foreach (var e in elemsEl.ChildNodes.OfType()) + { + if (bt == Json.BuiltInType.ExtensionObject) + { + if (string.Equals(e.LocalName, nameof(Json.BuiltInType.ExtensionObject), StringComparison.Ordinal)) + items.Add(ReadExtensionObjectFromXml(e, ctx)); + else + items.Add(ReadStructureBody(e, ctx)); + } + else + { + items.Add(ReadBuiltInScalar(bt, e, ctx)); + } + } + } + } + + return new Json.Variant((int)bt, items, dims); + } + + private static Json.ExtensionObject ReadExtensionObjectFromXml(XmlElement eoEl, VariantXmlContext ctx) + { + string? typeId = null; + JObject? body = null; + + foreach (var child in eoEl.ChildNodes.OfType()) + { + if (string.Equals(child.LocalName, "TypeId", StringComparison.Ordinal)) + { + // TypeId element contains ns=n;i=123 + var idEl = child.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Identifier"); + var raw = idEl?.InnerText ?? child.InnerText; + typeId = ctx.NsIndexToNsu(raw); + // Pass 2 will normalize Default-XML encoding NodeIds to DataType NodeIds. + } + else if (string.Equals(child.LocalName, "Body", StringComparison.Ordinal)) + { + var structEl = child.ChildNodes.OfType().FirstOrDefault(); + if (structEl != null) + { + body = ReadStructureBody(structEl, ctx); + } + } + } + + return new Json.ExtensionObject { TypeId = typeId, Body = body }; + } + + /// + /// Reads an arbitrary structure element into a , preserving the + /// full XML shape of its children (element names, namespaces, text, nesting) so that + /// the writer can reconstruct byte-structural equality without consulting a schema. + /// + /// Encoding rules: + /// + /// $typeName / $typeNs — element local name + namespace URI. + /// Field properties keyed by the child element's local name. Each child value + /// is itself a recursive element JObject (see ). + /// Repeated children with the same local name (lists) become a JArray. + /// + /// + private static JObject ReadStructureBody(XmlElement structEl, VariantXmlContext ctx) + { + return (JObject)ReadElementAsJson(structEl, ctx); + } + + /// + /// Recursively converts an XML element to a JObject/JValue form that round-trips to + /// XML without schema. Sidecars used: + /// + /// $typeName — this element's local name. + /// $typeNs — this element's namespace URI. + /// $text — inner text content, when present (e.g. simple leaves). + /// + /// Returns a (null) when the element carries xsi:nil. + /// + private static JToken ReadElementAsJson(XmlElement el, VariantXmlContext ctx) + { + // xsi:nil → JSON null + var nilAttr = el.GetAttributeNode("nil", "http://www.w3.org/2001/XMLSchema-instance"); + if (nilAttr != null && string.Equals(nilAttr.Value, "true", StringComparison.OrdinalIgnoreCase)) + return JValue.CreateNull(); + + // ExtensionObject elements are hoisted to the canonical Json.ExtensionObject shape + // (TypeId as string, Body as a recursive struct JObject). The writer special-cases + // this shape via WriteExtensionObjectToXml. + if (string.Equals(el.LocalName, "ExtensionObject", StringComparison.Ordinal) + && string.Equals(el.NamespaceURI, UaTypesXmlNamespace, StringComparison.Ordinal)) + { + var eo = ReadExtensionObjectFromXml(el, ctx); + var eoJo = new JObject + { + ["$typeName"] = "ExtensionObject", + ["$typeNs"] = UaTypesXmlNamespace, + }; + if (eo.TypeId != null) eoJo["TypeId"] = eo.TypeId; + if (eo.Body is JObject eoBody) eoJo["Body"] = eoBody; + return eoJo; + } + + var obj = new JObject + { + ["$typeName"] = el.LocalName, + }; + if (!string.IsNullOrEmpty(el.NamespaceURI)) + obj["$typeNs"] = el.NamespaceURI; + + var children = el.ChildNodes.OfType().ToList(); + if (children.Count == 0) + { + // Pure text leaf — record as $text so the writer can emit it as InnerText. + var text = el.InnerText; + obj["$text"] = text ?? string.Empty; + return obj; + } + + // Group children by local name, preserving first-seen order. + var firstOrder = new List(); + var grouped = new Dictionary>(StringComparer.Ordinal); + foreach (var c in children) + { + if (!grouped.TryGetValue(c.LocalName, out var list)) + { + list = new List(); + grouped[c.LocalName] = list; + firstOrder.Add(c.LocalName); + } + list.Add(c); + } + + foreach (var name in firstOrder) + { + var group = grouped[name]; + if (group.Count == 1) + { + obj[name] = ReadElementAsJson(group[0], ctx); + } + else + { + // Homogeneous list → JArray of element JObjects. + var arr = new JArray(); + foreach (var item in group) + arr.Add(ReadElementAsJson(item, ctx)); + obj[name] = arr; + } + } + + return obj; + } + + private static object? ReadBuiltInScalar(Json.BuiltInType type, XmlElement el, VariantXmlContext ctx) + { + var ci = CultureInfo.InvariantCulture; + switch (type) + { + case Json.BuiltInType.Boolean: + return bool.TryParse(el.InnerText, out var b) && b; + case Json.BuiltInType.SByte: + return sbyte.TryParse(el.InnerText, NumberStyles.Integer, ci, out var sb) ? sb : (sbyte)0; + case Json.BuiltInType.Byte: + return byte.TryParse(el.InnerText, NumberStyles.Integer, ci, out var by) ? by : (byte)0; + case Json.BuiltInType.Int16: + return short.TryParse(el.InnerText, NumberStyles.Integer, ci, out var s) ? s : (short)0; + case Json.BuiltInType.UInt16: + return ushort.TryParse(el.InnerText, NumberStyles.Integer, ci, out var us) ? us : (ushort)0; + case Json.BuiltInType.Int32: + return int.TryParse(el.InnerText, NumberStyles.Integer, ci, out var i) ? i : 0; + case Json.BuiltInType.UInt32: + return uint.TryParse(el.InnerText, NumberStyles.Integer, ci, out var ui) ? ui : 0u; + case Json.BuiltInType.Int64: + return long.TryParse(el.InnerText, NumberStyles.Integer, ci, out var l) ? l : 0L; + case Json.BuiltInType.UInt64: + return ulong.TryParse(el.InnerText, NumberStyles.Integer, ci, out var ul) ? ul : 0UL; + case Json.BuiltInType.Float: + return float.TryParse(el.InnerText, NumberStyles.Float, ci, out var f) ? f : 0f; + case Json.BuiltInType.Double: + return double.TryParse(el.InnerText, NumberStyles.Float, ci, out var d) ? d : 0d; + case Json.BuiltInType.String: + return el.InnerText; + // Types below are intentionally kept as raw strings rather than being parsed + // into their .NET CLR equivalents. Downstream consumers (the REST API layer, + // PostgreSQL JSONB persistence via System.Text.Json, and the existing + // NodeSetConverter.JsonToXmlElement round-trip) expect string-shaped values — + // auto-parsing into DateTime/Guid/byte[] causes asymmetric serialization. + // See: NodeSetEditor.Server.Tests.UaRestWorkspaceTests.Model_AddPropertiesForEveryDataType. + case Json.BuiltInType.DateTime: + return el.InnerText; + case Json.BuiltInType.Guid: + { + // Guid is wire-encoded as {uuid}; return the + // inner string uniformly (fall back to direct inner text if the wrapper is absent). + var gs = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "String"); + return gs?.InnerText ?? el.InnerText; + } + case Json.BuiltInType.ByteString: + return el.InnerText; // base64 string as-is + case Json.BuiltInType.XmlElement: + { + var inner = el.ChildNodes.OfType().FirstOrDefault(); + return inner?.OuterXml ?? el.InnerXml; + } + case Json.BuiltInType.NodeId: + case Json.BuiltInType.ExpandedNodeId: + { + var idEl = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Identifier"); + var raw = idEl?.InnerText ?? el.InnerText; + return ctx.NsIndexToNsu(raw); + } + case Json.BuiltInType.StatusCode: + { + var codeEl = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Code"); + // Kept as raw string for consistent round-trip through the REST layer. + return codeEl?.InnerText ?? el.InnerText; + } + case Json.BuiltInType.QualifiedName: + { + var nsEl = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "NamespaceIndex"); + var nameEl = el.ChildNodes.OfType().FirstOrDefault(e => e.LocalName == "Name"); + if (nameEl == null) return el.InnerText; + if (nsEl != null && int.TryParse(nsEl.InnerText, out var idx) && idx > 0) + { + var uri = ctx.GetNamespaceUri(idx); + if (!string.IsNullOrEmpty(uri)) return $"nsu={uri};{nameEl.InnerText}"; + return $"{idx}:{nameEl.InnerText}"; + } + return nameEl.InnerText; + } + case Json.BuiltInType.LocalizedText: + { + // Return just the Text portion as a plain string — matches the pre-refactor + // behaviour and what the REST layer / NodeSetConverter expect. + var ltText = el.ChildNodes.OfType().FirstOrDefault(c => c.LocalName == "Text"); + return ltText?.InnerText ?? el.InnerText; + } + case Json.BuiltInType.ExtensionObject: + return ReadExtensionObjectFromXml(el, ctx); + case Json.BuiltInType.Variant: + { + // A element normally wraps a typed child (e.g. 42), + // but the REST pipeline round-trips ListOfVariant items as plain text nodes + // (VariantToTypedJson stores them as a JSON string array, then + // JsonToXmlElement re-emits them as value). Fall back to + // raw text when no child element is present. + var inner = el.ChildNodes.OfType().FirstOrDefault(); + if (inner != null) return ReadTypedElement(inner, ctx); + return el.InnerText; + } + default: + return el.InnerText; + } + } + + #endregion + + #region JSON -> XML + + /// + /// Emits a back to an XML typed element (without the + /// <Value> wrapper — callers are responsible for the wrapper). + /// + public static XmlElement? WriteVariantToXml(Json.Variant? input, VariantXmlContext ctx) + { + if (input?.Value == null) return null; + + var doc = new XmlDocument(); + var bt = (Json.BuiltInType)(input.UaType ?? (int)Json.BuiltInType.String); + XmlElement? result; + + // Matrix + if (input.Dimensions is { Count: > 1 } && input.Value is System.Collections.IEnumerable matrixItems) + { + result = doc.CreateElement(UaPrefix, "Matrix", UaTypesXmlNamespace); + var dimsEl = doc.CreateElement(UaPrefix, "Dimensions", UaTypesXmlNamespace); + foreach (var dim in input.Dimensions) + { + var de = doc.CreateElement(UaPrefix, "Int32", UaTypesXmlNamespace); + de.InnerText = dim.ToString(CultureInfo.InvariantCulture); + dimsEl.AppendChild(de); + } + result.AppendChild(dimsEl); + + var elemsEl = doc.CreateElement(UaPrefix, "Elements", UaTypesXmlNamespace); + var typeName = UaTypeToName(bt); + foreach (var item in matrixItems) + { + var itemEl = WriteBuiltInOrStruct(doc, typeName, bt, item, ctx); + if (itemEl != null) elemsEl.AppendChild(itemEl); + } + result.AppendChild(elemsEl); + doc.AppendChild(result); + return doc.DocumentElement; + } + + // 1-D array + if (IsArrayValue(input.Value, out var listItems)) + { + var typeName = UaTypeToName(bt); + result = doc.CreateElement(UaPrefix, $"ListOf{typeName}", UaTypesXmlNamespace); + foreach (var item in listItems!) + { + var itemEl = WriteBuiltInOrStruct(doc, typeName, bt, item, ctx); + if (itemEl != null) result.AppendChild(itemEl); + } + doc.AppendChild(result); + return doc.DocumentElement; + } + + // Scalar + result = WriteBuiltInOrStruct(doc, UaTypeToName(bt), bt, input.Value, ctx); + if (result != null && result.OwnerDocument == doc && result.ParentNode == null) + { + doc.AppendChild(result); + } + return doc.DocumentElement; + } + + private static bool IsArrayValue(object value, out System.Collections.IEnumerable? items) + { + if (value is JArray ja) + { + items = ja; + return true; + } + if (value is string) { items = null; return false; } // strings are IEnumerable + if (value is JObject) { items = null; return false; } + if (value is Json.ExtensionObject) { items = null; return false; } + if (value is Json.LocalizedText) { items = null; return false; } + if (value is byte[]) { items = null; return false; } + if (value is System.Collections.IList list) + { + items = list; + return true; + } + items = null; + return false; + } + + private static XmlElement? WriteBuiltInOrStruct(XmlDocument doc, string typeName, Json.BuiltInType bt, object? value, VariantXmlContext ctx) + { + if (bt == Json.BuiltInType.ExtensionObject) + { + return WriteExtensionObjectToXml(doc, value, ctx); + } + return WriteBuiltInScalar(doc, typeName, bt, value, ctx); + } + + private static XmlElement WriteBuiltInScalar(XmlDocument doc, string typeName, Json.BuiltInType bt, object? value, VariantXmlContext ctx) + { + var el = doc.CreateElement(UaPrefix, typeName, UaTypesXmlNamespace); + var ci = CultureInfo.InvariantCulture; + + if (value is JValue jv) value = jv.Value; + + switch (bt) + { + case Json.BuiltInType.Boolean: + el.InnerText = (value is bool b ? b : Convert.ToBoolean(value ?? false, ci)) ? "true" : "false"; + break; + case Json.BuiltInType.DateTime: + var dt = value is DateTime dd ? dd : (DateTime.TryParse(Convert.ToString(value, ci), out var parsed) ? parsed : default); + el.InnerText = XmlConvert.ToString(dt, XmlDateTimeSerializationMode.Utc); + break; + case Json.BuiltInType.ByteString: + byte[] bytes = value switch + { + byte[] bs => bs, + string s64 => TryFromBase64(s64), + _ => Array.Empty() + }; + el.InnerText = Convert.ToBase64String(bytes); + break; + case Json.BuiltInType.Guid: + { + var gs = value?.ToString() ?? ""; + var g = doc.CreateElement(UaPrefix, "String", UaTypesXmlNamespace); + g.InnerText = gs; + el.AppendChild(g); + break; + } + case Json.BuiltInType.NodeId: + case Json.BuiltInType.ExpandedNodeId: + { + var idEl = doc.CreateElement(UaPrefix, "Identifier", UaTypesXmlNamespace); + idEl.InnerText = ctx.NsuToNsIndex(Convert.ToString(value, ci) ?? ""); + el.AppendChild(idEl); + break; + } + case Json.BuiltInType.StatusCode: + { + var codeEl = doc.CreateElement(UaPrefix, "Code", UaTypesXmlNamespace); + codeEl.InnerText = Convert.ToString(value ?? 0u, ci) ?? "0"; + el.AppendChild(codeEl); + break; + } + case Json.BuiltInType.QualifiedName: + { + ParseQualifiedName(Convert.ToString(value, ci) ?? "", ctx, out var nsIdx, out var qnName); + var nsEl = doc.CreateElement(UaPrefix, "NamespaceIndex", UaTypesXmlNamespace); + nsEl.InnerText = nsIdx.ToString(ci); + el.AppendChild(nsEl); + var nameEl = doc.CreateElement(UaPrefix, "Name", UaTypesXmlNamespace); + nameEl.InnerText = qnName; + el.AppendChild(nameEl); + break; + } + case Json.BuiltInType.LocalizedText: + { + string locale = "", text = ""; + if (value is Json.LocalizedText lt && lt.T != null && lt.T.Count > 0 && lt.T[0].Count > 1) + { + locale = lt.T[0][0] ?? ""; + text = lt.T[0][1] ?? ""; + } + else if (value is JObject jo) + { + // The model's [JsonProperty] uses lowercase "t". Accept either case. + var tArr = (jo["t"] ?? jo["T"]) as JArray; + if (tArr != null && tArr.Count > 0 && tArr[0] is JArray first && first.Count > 1) + { + locale = first[0]?.ToString() ?? ""; + text = first[1]?.ToString() ?? ""; + } + } + else + { + text = Convert.ToString(value, ci) ?? ""; + } + var localeEl = doc.CreateElement(UaPrefix, "Locale", UaTypesXmlNamespace); + localeEl.InnerText = locale; + el.AppendChild(localeEl); + var textEl = doc.CreateElement(UaPrefix, "Text", UaTypesXmlNamespace); + textEl.InnerText = text; + el.AppendChild(textEl); + break; + } + default: + el.InnerText = Convert.ToString(value, ci) ?? ""; + break; + } + return el; + } + + private static byte[] TryFromBase64(string s) + { + try { return Convert.FromBase64String(s); } + catch { return Array.Empty(); } + } + + private static void ParseQualifiedName(string text, VariantXmlContext ctx, out int nsIdx, out string name) + { + nsIdx = 0; + name = text; + if (text.StartsWith("nsu=", StringComparison.Ordinal)) + { + var semi = text.IndexOf(';'); + if (semi > 4) + { + var uri = text.Substring(4, semi - 4); + nsIdx = ctx.GetOrAppendNamespaceIndex(uri); + name = text.Substring(semi + 1); + } + return; + } + var colon = text.IndexOf(':'); + if (colon > 0 && int.TryParse(text.Substring(0, colon), out var idx)) + { + nsIdx = idx; + name = text.Substring(colon + 1); + } + } + + private static XmlElement? WriteExtensionObjectToXml(XmlDocument doc, object? value, VariantXmlContext ctx) + { + // Value may be either a Json.ExtensionObject or a raw JObject structure body. + string? typeId; + JObject? body; + + if (value is Json.ExtensionObject eoObj) + { + typeId = eoObj.TypeId; + body = eoObj.Body as JObject; + } + else if (value is JObject jo) + { + // Look for TypeId+Body shape inside the JObject (ExtensionObject JSON form). + if (jo["TypeId"] != null && jo["Body"] is JObject bodyJo) + { + typeId = jo["TypeId"]?.ToString(); + body = bodyJo; + } + else + { + // Bare structure body. + typeId = null; + body = jo; + } + } + else + { + return null; + } + + var eoEl = doc.CreateElement(UaPrefix, nameof(Json.BuiltInType.ExtensionObject), UaTypesXmlNamespace); + + // TypeId: in XML form we prefer the "Default XML" encoding NodeId. + // Pass 2 only sets DataType NodeIds on the DOM; if an AddressSpace is attached we + // translate here, otherwise emit the stored TypeId as-is (warn-and-emit). + var xmlTypeId = ctx.ResolveXmlEncodingNodeId(typeId) ?? typeId; + if (xmlTypeId != null) + { + var typeIdEl = doc.CreateElement(UaPrefix, "TypeId", UaTypesXmlNamespace); + var idEl = doc.CreateElement(UaPrefix, "Identifier", UaTypesXmlNamespace); + idEl.InnerText = ctx.NsuToNsIndex(xmlTypeId); + typeIdEl.AppendChild(idEl); + eoEl.AppendChild(typeIdEl); + } + + if (body != null) + { + var bodyEl = doc.CreateElement(UaPrefix, "Body", UaTypesXmlNamespace); + // The struct element goes inside . Its namespace is the defining model's + // XmlSchemaUri; resolved via ctx from the ExtensionObject's DataType NodeId. + var modelXmlNs = ctx.ResolveXmlSchemaUri(typeId) ?? UaTypesXmlNamespace; + WriteStructureBody(doc, bodyEl, body, modelXmlNs, typeId, ctx); + eoEl.AppendChild(bodyEl); + } + + return eoEl; + } + + /// + /// Writes a structure as a typed XML element inside . + /// Walks JObject properties in insertion order — pass 2 guarantees this matches + /// order, so schema walking is unnecessary at emit time. + /// + /// + /// Writes a structure body (produced by ) + /// as a typed XML element inside . Honours the sidecar + /// encoding emitted by the reader: $typeName, $typeNs, $text. + /// + private static void WriteStructureBody(XmlDocument doc, XmlElement parent, JObject body, string xmlNs, string? dataTypeId, VariantXmlContext ctx) + { + var el = WriteElementFromJson(doc, body, defaultNs: xmlNs, defaultName: null, dataTypeId, ctx); + if (el != null) parent.AppendChild(el); + } + + /// + /// Recursively rebuilds an XML element tree from a JObject produced by + /// . Honours $typeName, $typeNs, and + /// $text sidecars. Child properties (non-sidecar keys) are emitted in JObject + /// insertion order. JArray values map to repeated child elements. + /// + private static XmlElement? WriteElementFromJson(XmlDocument doc, JObject obj, string defaultNs, string? defaultName, string? dataTypeId, VariantXmlContext ctx) + { + var name = obj["$typeName"]?.ToString() + ?? defaultName + ?? ctx.ResolveDataTypeBrowseName(dataTypeId) + ?? "Body"; + var ns = obj["$typeNs"]?.ToString() + ?? ctx.ResolveXmlSchemaUriByTypeName(name) + ?? defaultNs; + + var el = doc.CreateElement("t", name, ns); + + // Leaf text — emit as InnerText and return. + var textTok = obj["$text"]; + if (textTok != null && textTok.Type != JTokenType.Null) + { + el.InnerText = JTokenToInvariantString(textTok); + return el; + } + + // Iterate non-sidecar properties in insertion order. + foreach (var prop in obj.Properties()) + { + if (prop.Name.StartsWith("$", StringComparison.Ordinal)) continue; + + // Special case: ExtensionObject body shape produced by ReadExtensionObjectFromXml + // stores the TypeId + Body keys under the struct. When the JObject looks like an + // ExtensionObject wrapper, route through the dedicated writer. + // Detected via presence of sibling "Body" JObject alongside a "TypeId" property, + // but only when THIS JObject *is* the ExtensionObject (has $typeName == ExtensionObject). + // Regular struct fields named "TypeId"/"Body" go through the default path below. + + WriteElementChild(doc, el, prop.Name, prop.Value, ns, ctx); + } + + return el; + } + + private static void WriteElementChild(XmlDocument doc, XmlElement parent, string fieldName, JToken value, string xmlNs, VariantXmlContext ctx) + { + // Null / JValue(null) → xsi:nil wrapper element. + if (value == null || value.Type == JTokenType.Null) + { + var nilEl = doc.CreateElement("t", fieldName, xmlNs); + nilEl.SetAttribute("nil", "http://www.w3.org/2001/XMLSchema-instance", "true"); + parent.AppendChild(nilEl); + return; + } + + switch (value.Type) + { + case JTokenType.Array: + { + // Each array item is an element JObject whose $typeName equals fieldName + // (that's how ReadElementAsJson groups repeated children). Append each item + // directly as a child of `parent` — do NOT wrap them in another field element, + // which would double-wrap. + foreach (var item in (JArray)value) + { + if (item is JObject itemObj) + { + var itemEl = WriteElementFromJson(doc, itemObj, defaultNs: xmlNs, defaultName: fieldName, dataTypeId: null, ctx); + if (itemEl != null) parent.AppendChild(itemEl); + } + else if (item != null && item.Type != JTokenType.Null) + { + // Primitive (canonical-form) array item — emit as a field-named + // element carrying the primitive text. + var itemEl = doc.CreateElement("t", fieldName, xmlNs); + itemEl.InnerText = JTokenToInvariantString(item); + parent.AppendChild(itemEl); + } + } + break; + } + case JTokenType.Object: + { + var jo = (JObject)value; + + // ExtensionObject wrapper shape? + // Case 1: The JObject has a "$typeName" == "ExtensionObject" — round-trip path + // for ExtensionObject elements seen during schema-free reads. + // Case 2: Pass-2 canonical form — a JObject with TypeId+Body keys and no $typeName. + var typeName = jo["$typeName"]?.ToString(); + bool isExtensionObjectWrapper = + typeName == "ExtensionObject" + || (typeName == null && jo["TypeId"] != null && jo["Body"] is JObject); + + if (isExtensionObjectWrapper) + { + // Emit the element directly under the parent. The caller + // has already created the enclosing field element; double-wrapping would + // produce …. + var eoEl = WriteExtensionObjectToXml(doc, jo, ctx); + if (eoEl != null) parent.AppendChild(eoEl); + break; + } + + // Struct/leaf — the child is itself a recursive element JObject. Its own + // $typeName/$typeNs may override the field name/namespace (e.g. nested struct + // from a different model). Default to the field name when $typeName is missing. + // + // The convention from ReadElementAsJson: a field element's child JObject + // represents EITHER the field element ITSELF (with $typeName == fieldName) OR + // a single grand-child. Because ReadStructureBody's caller sets the property + // key to el.LocalName and the value is ReadElementAsJson(el), the JObject's + // $typeName equals fieldName. So we can emit directly. + var childEl = WriteElementFromJson(doc, jo, defaultNs: xmlNs, defaultName: fieldName, dataTypeId: null, ctx); + if (childEl != null) parent.AppendChild(childEl); + break; + } + default: + { + // Primitive (canonical JSON form without sidecars) — emit as simple text element. + var primEl = doc.CreateElement("t", fieldName, xmlNs); + primEl.InnerText = JTokenToInvariantString(value); + parent.AppendChild(primEl); + break; + } + } + } + + private static string JTokenToInvariantString(JToken t) + { + if (t is JValue jv) + { + if (jv.Value is bool b) return b ? "true" : "false"; + // DateTime must be ISO 8601 UTC — Newtonsoft may have promoted an XML date + // string to a DateTime JValue during JSON deserialization of object-typed fields. + if (jv.Value is DateTime dt) return XmlConvert.ToString(dt, XmlDateTimeSerializationMode.Utc); + if (jv.Value is DateTimeOffset dto) return XmlConvert.ToString(dto.UtcDateTime, XmlDateTimeSerializationMode.Utc); + if (jv.Value is IFormattable f) return f.ToString(null, CultureInfo.InvariantCulture); + return jv.Value?.ToString() ?? ""; + } + return t.ToString(Newtonsoft.Json.Formatting.None); + } + + #endregion + + #region Built-in Type Name Mapping + + private static bool IsBuiltInTypeName(string name, out Json.BuiltInType type) => + Enum.TryParse(name, out type); + + private static string UaTypeToName(Json.BuiltInType type) => type.ToString(); + + #endregion +} + +/// +/// Carries context needed for Variant XML <-> JSON conversion: namespace index table +/// (for ns=nsu= translation) and — optionally — an +/// for schema-aware emission (DataType → "Default XML" encoding NodeId, DataType → XmlSchemaUri, +/// cross-model namespace lookup). +/// +internal sealed class VariantXmlContext +{ + private readonly ServiceMessageContext? _nsContext; + private readonly AddressSpace? _addressSpace; + + public VariantXmlContext(ServiceMessageContext? nsContext, AddressSpace? addressSpace = null) + { + _nsContext = nsContext; + _addressSpace = addressSpace; + } + + public string NsIndexToNsu(string nodeId) + { + if (_nsContext == null) return nodeId; + if (nodeId.StartsWith("ns=", StringComparison.Ordinal)) + { + var semi = nodeId.IndexOf(';'); + if (semi > 3 && int.TryParse(nodeId.AsSpan(3, semi - 3), out var idx)) + { + var uri = _nsContext.NamespaceUris.GetString(idx); + if (uri != null) return $"nsu={uri};{nodeId[(semi + 1)..]}"; + } + } + return nodeId; + } + + public string NsuToNsIndex(string nodeId) + { + if (_nsContext == null) return nodeId; + if (!nodeId.StartsWith("nsu=", StringComparison.Ordinal)) return nodeId; + var semi = nodeId.IndexOf(';'); + if (semi <= 4) return nodeId; + var uri = nodeId.Substring(4, semi - 4); + var idx = _nsContext.NamespaceUris.GetIndexOrAppend(uri); + var rest = nodeId.Substring(semi + 1); + return idx == 0 ? rest : $"ns={idx};{rest}"; + } + + public string? GetNamespaceUri(int index) => _nsContext?.NamespaceUris.GetString(index); + + public int GetOrAppendNamespaceIndex(string uri) => + _nsContext != null ? (int)_nsContext.NamespaceUris.GetIndexOrAppend(uri) : 0; + + /// + /// For XML emission: given a canonical DataType NodeId, return the "Default XML" + /// encoding NodeId if the AddressSpace knows one, otherwise null (caller falls back). + /// + public string? ResolveXmlEncodingNodeId(string? dataTypeNodeId) + { + if (_addressSpace == null || dataTypeNodeId == null) return null; + return _addressSpace.TryGetXmlEncodingNodeId(dataTypeNodeId); + } + + /// + /// For XML emission: given a canonical DataType NodeId, return the XmlSchemaUri of + /// its defining model (for element namespace of the struct body). + /// + public string? ResolveXmlSchemaUri(string? dataTypeNodeId) + { + if (_addressSpace == null || dataTypeNodeId == null) return null; + return _addressSpace.TryGetXmlSchemaUriForNodeId(dataTypeNodeId); + } + + /// + /// Lookup by BrowseName when only a sidecar type name is available (nested structs + /// whose TypeId was not carried by the DOM). + /// + public string? ResolveXmlSchemaUriByTypeName(string? typeName) + { + if (_addressSpace == null || typeName == null) return null; + return _addressSpace.TryGetXmlSchemaUriForBrowseName(typeName); + } + + public string? ResolveDataTypeBrowseName(string? dataTypeNodeId) + { + if (_addressSpace == null || dataTypeNodeId == null) return null; + return _addressSpace.TryGetDataTypeBrowseName(dataTypeNodeId); + } +} diff --git a/Opc.Ua.NodeSetTool.Tests/VariantConversionTests.cs b/Opc.Ua.NodeSetTool.Tests/VariantConversionTests.cs new file mode 100644 index 0000000..71ae174 --- /dev/null +++ b/Opc.Ua.NodeSetTool.Tests/VariantConversionTests.cs @@ -0,0 +1,991 @@ +using System.Xml; +using Newtonsoft.Json.Linq; +using Opc.Ua; +using Opc.Ua.JsonNodeSet; +using Opc.Ua.JsonNodeSet.Model; +using Xunit; +using JsonVariant = Opc.Ua.JsonNodeSet.Model.Variant; + +namespace Opc.Ua.NodeSetTool.Tests; + +/// +/// Standalone tests for (pass 1 — schema-free XML↔JSON) +/// and (pass 2 — canonicalization). +/// Fixtures are constructed inline so each test is self-contained. +/// +public class VariantConversionTests +{ + private const string UaNs = "http://opcfoundation.org/UA/2008/02/Types.xsd"; + private const string TestModelUri = "urn:opcfoundation.org:2024-01:TestModel"; + private const string TestModelXmlNs = "urn:opcfoundation.org:2024-01:TestModelTypes.xsd"; + + private static XmlElement ParseXml(string xml) + { + var doc = new XmlDocument(); + doc.LoadXml(xml); + return doc.DocumentElement!; + } + + private static VariantXmlContext Ctx(AddressSpace? space = null) + => new VariantXmlContext(new ServiceMessageContext(), space); + + #region Pass 1 — built-in scalars + + [Fact] + public void Int32_Scalar_RoundTrips() + { + var xml = ParseXml($"42"); + + var v = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.NotNull(v); + Assert.Equal((int)BuiltInType.Int32, v!.UaType); + Assert.Equal(42, Convert.ToInt32(v.Value)); + + var back = VariantConverter.WriteVariantToXml(v, Ctx()); + Assert.NotNull(back); + Assert.Equal("Int32", back!.LocalName); + Assert.Equal(UaNs, back.NamespaceURI); + Assert.Equal("42", back.InnerText); + } + + [Fact] + public void String_Scalar_RoundTrips() + { + var xml = ParseXml($"hello"); + var v = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.Equal("hello", v!.Value); + + var back = VariantConverter.WriteVariantToXml(v, Ctx()); + Assert.Equal("hello", back!.InnerText); + Assert.Equal("String", back.LocalName); + } + + [Fact] + public void Boolean_Scalar_RoundTrips() + { + var xml = ParseXml($"true"); + var v = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.Equal(true, v!.Value); + + var back = VariantConverter.WriteVariantToXml(v, Ctx()); + Assert.Equal("true", back!.InnerText); + } + + [Fact] + public void Double_Scalar_UsesInvariantCulture() + { + var xml = ParseXml($"3.14159"); + var v = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.Equal(3.14159, Convert.ToDouble(v!.Value)); + + var back = VariantConverter.WriteVariantToXml(v, Ctx()); + Assert.Contains(".", back!.InnerText); + } + + [Fact] + public void LocalizedText_Scalar_ReturnsTextPortionAsString() + { + // LocalizedText reads as the plain Text portion only (not a LocalizedText wrapper) — + // matches the pre-refactor behaviour that the REST API layer depends on for + // symmetric PUT/GET value round-trips. See UaRestWorkspaceTests.Model_AddPropertiesForEveryDataType. + var xml = ParseXml( + $"" + + "en-US" + + "Hello" + + ""); + var v = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.Equal("Hello", v!.Value); + + var back = VariantConverter.WriteVariantToXml(v, Ctx()); + Assert.Contains("Hello", back!.InnerXml); + // Locale is lost on the round-trip — schema-free readers can't preserve it + // without carrying the full LocalizedText shape. + } + + #endregion + + #region Pass 1 — arrays & matrix + + [Fact] + public void ListOfInt32_RoundTrips() + { + var xml = ParseXml( + $"" + + "123" + + ""); + var v = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.Equal((int)BuiltInType.Int32, v!.UaType); + var list = Assert.IsAssignableFrom(v.Value); + Assert.Equal(3, list.Count); + + var back = VariantConverter.WriteVariantToXml(v, Ctx()); + Assert.Equal("ListOfInt32", back!.LocalName); + Assert.Equal(3, back.ChildNodes.Count); + } + + [Fact] + public void Matrix_Int32_2x3_RoundTrips() + { + var xml = ParseXml( + $"" + + "23" + + "" + + "123" + + "456" + + "" + + ""); + var v = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.NotNull(v!.Dimensions); + Assert.Equal(new[] { 2, 3 }, v.Dimensions!.ToArray()); + + var back = VariantConverter.WriteVariantToXml(v, Ctx()); + Assert.Equal("Matrix", back!.LocalName); + } + + #endregion + + #region Pass 1 — ExtensionObject / structure body + + /// + /// A minimal ExtensionObject carrying a TestScalarStructure-shaped body. + /// Tests: TypeId is parsed; body is captured as an ordered JObject; re-emission + /// preserves the DOM key order. + /// + [Fact] + public void ExtensionObject_StructureBody_RoundTrips_PreservesOrder() + { + var xml = ParseXml( + $"" + + "i=5001" + + "" + + $"" + + "true" + + "-7" + + "abc" + + "" + + "" + + ""); + + var v = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.Equal((int)BuiltInType.ExtensionObject, v!.UaType); + var eo = Assert.IsType(v.Value); + Assert.Equal("i=5001", eo.TypeId); + + var body = Assert.IsType(eo.Body); + // Field order preserved. + var propNames = body.Properties() + .Select(p => p.Name) + .Where(n => !n.StartsWith("$")) + .ToArray(); + Assert.Equal(new[] { "A", "D", "N" }, propNames); + // Each field is now a recursive element JObject with $text sidecar. + Assert.Equal("true", body["A"]?["$text"]?.ToString()); + Assert.Equal("-7", body["D"]?["$text"]?.ToString()); + Assert.Equal("abc", body["N"]?["$text"]?.ToString()); + + var back = VariantConverter.WriteVariantToXml(v, Ctx()); + Assert.Equal("ExtensionObject", back!.LocalName); + // The Body wraps a typed struct element — whose name was preserved via the sidecar. + Assert.Contains("TestScalarStructure", back.InnerXml); + // Fields appear in the same order. + var aIdx = back.InnerXml.IndexOf("A", StringComparison.Ordinal); + var dIdx = back.InnerXml.IndexOf("D", StringComparison.Ordinal); + var nIdx = back.InnerXml.IndexOf("N", StringComparison.Ordinal); + Assert.True(aIdx >= 0 && dIdx > aIdx && nIdx > dIdx, "Field element order should be A,D,N"); + } + + #endregion + + #region XML text ↔ JSON text round-trip (with DOM compare) + + /// + /// Comprehensive XML fixture covering most built-in scalar types, a ListOf array, + /// a Matrix, and a nested ExtensionObject body with ordered struct fields. Used as + /// the canonical input for the round-trip tests below. + /// + private static string BuildComprehensiveVariantXml() + { + // Wrapped inside a harmless root so multiple top-level elements can be carried + // together via a helper; tests pick individual children out to feed to the converter. + return $@" + true + -128 + 255 + -32768 + 65535 + -2147483648 + 4294967295 + -9223372036854775808 + 18446744073709551615 + 3.14 + 2.718281828 + hello world + 2024-01-15T12:34:56Z + 12345678-1234-1234-1234-123456789012 + SGVsbG8= + i=42 + 0 + Hi + + 123 + + + 23 + + 123 + 456 + + + + i=5001 + + + true + -5 + 200 + -1000 + nested + + + +"; + } + + /// + /// XML text → Variant → (serialize to JSON text via Newtonsoft) → Variant → + /// WriteVariantToXml → DOM compare with the original. Structural equality only — + /// ignores whitespace, attribute order and namespace-prefix choices. + /// + [Theory] + [InlineData("uax:Boolean")] + [InlineData("uax:Int32")] + [InlineData("uax:Double")] + [InlineData("uax:String")] + [InlineData("uax:LocalizedText")] + [InlineData("uax:NodeId")] + [InlineData("uax:ListOfInt32")] + [InlineData("uax:Matrix")] + [InlineData("uax:ExtensionObject")] + public void XmlText_ToJsonText_AndBack_PreservesDomStructure(string elementQname) + { + var rootDoc = new XmlDocument(); + rootDoc.LoadXml(BuildComprehensiveVariantXml()); + + // Pick the requested element out of the fixture by local-name. + var localName = elementQname.Split(':')[1]; + var original = rootDoc.DocumentElement! + .ChildNodes + .OfType() + .First(e => e.LocalName == localName); + + // XML → Variant + var v = VariantConverter.ReadVariantFromXml(original, Ctx()); + Assert.NotNull(v); + + // Variant → JSON text → Variant (matches how the nodeset is saved to disk) + var settings = new Newtonsoft.Json.JsonSerializerSettings + { + NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore, + }; + var jsonText = Newtonsoft.Json.JsonConvert.SerializeObject(v, settings); + var vReloaded = Newtonsoft.Json.JsonConvert.DeserializeObject(jsonText, settings); + Assert.NotNull(vReloaded); + + // Variant → XML + var rebuilt = VariantConverter.WriteVariantToXml(vReloaded, Ctx()); + Assert.NotNull(rebuilt); + + // DOM compare (structural) + Assert.True( + XmlDomEquals(original, rebuilt!, out var diff), + $"XML DOM mismatch after round-trip: {diff}\n\nORIGINAL:\n{original.OuterXml}\n\nREBUILT:\n{rebuilt!.OuterXml}"); + } + + /// + /// Reverse direction: JSON text → Variant → WriteVariantToXml → XML → Variant → + /// re-serialize to JSON. Compare the two JSON representations as Newtonsoft JTokens + /// (order-insensitive for non-struct JObjects; the converter preserves struct key + /// order so JObject equality works there). + /// + [Fact] + public void JsonText_ToXmlText_AndBack_PreservesVariantValue() + { + // Canonical JSON for an Int32 scalar. + var jsonIn = "{ \"UaType\": 6, \"Value\": 12345 }"; + var v1 = Newtonsoft.Json.JsonConvert.DeserializeObject(jsonIn); + Assert.NotNull(v1); + + var xml = VariantConverter.WriteVariantToXml(v1, Ctx()); + Assert.NotNull(xml); + + var v2 = VariantConverter.ReadVariantFromXml(xml, Ctx()); + Assert.NotNull(v2); + + var jsonOut = Newtonsoft.Json.JsonConvert.SerializeObject(v2); + var reparsed = Newtonsoft.Json.JsonConvert.DeserializeObject(jsonOut); + Assert.Equal(v1!.UaType, reparsed!.UaType); + Assert.Equal(Convert.ToInt32(v1.Value), Convert.ToInt32(reparsed.Value)); + } + + /// + /// JSON text → Variant → XML → Variant → JSON, for a structured ExtensionObject body. + /// Verifies field order is preserved through the round-trip (Newtonsoft's JObject + /// keeps insertion order). + /// + [Fact] + public void JsonText_ExtensionObject_ToXmlText_AndBack_PreservesFieldOrder() + { + var jsonIn = @"{ + ""UaType"": 22, + ""Value"": { + ""TypeId"": ""i=5001"", + ""Body"": { + ""$typeName"": ""TestScalarStructure"", + ""A"": true, + ""B"": -5, + ""N"": ""hi"" + } + } + }"; + var v1 = Newtonsoft.Json.JsonConvert.DeserializeObject(jsonIn); + Assert.NotNull(v1); + + var xml = VariantConverter.WriteVariantToXml(v1, Ctx()); + Assert.NotNull(xml); + + // Field element order inside the struct body must match JObject key order. + var structEl = xml! + .ChildNodes.OfType() + .First(e => e.LocalName == "Body") + .ChildNodes.OfType() + .First(); + var fieldNames = structEl.ChildNodes.OfType().Select(c => c.LocalName).ToArray(); + Assert.Equal(new[] { "A", "B", "N" }, fieldNames); + + // Round-trip back through the reader. + var v2 = VariantConverter.ReadVariantFromXml(xml, Ctx()); + var eoOut = (ExtensionObject)v2!.Value!; + var bodyOut = (JObject)eoOut.Body!; + var outNames = bodyOut.Properties().Select(p => p.Name).Where(n => !n.StartsWith("$")).ToArray(); + Assert.Equal(new[] { "A", "B", "N" }, outNames); + } + + #endregion + + #region XML DOM structural comparer + + /// + /// Structural XML equality check. Ignores whitespace-only text differences, attribute + /// ordering, namespace-prefix choices (only NamespaceURI+LocalName matter), and the + /// JSON DOM sidecar attributes my converter writes for schema-free round-trips. + /// + private static bool XmlDomEquals(XmlElement a, XmlElement b, out string diff) + { + diff = ""; + if (a.LocalName != b.LocalName) + { + diff = $"LocalName: {a.LocalName} vs {b.LocalName}"; + return false; + } + if (a.NamespaceURI != b.NamespaceURI) + { + diff = $"NamespaceURI at <{a.LocalName}>: {a.NamespaceURI} vs {b.NamespaceURI}"; + return false; + } + + // Attributes (ignore xmlns:* bindings and xsi:nil — prefix choice doesn't matter). + var aAttrs = a.Attributes.OfType() + .Where(at => at.Prefix != "xmlns" && at.Name != "xmlns") + .OrderBy(at => at.NamespaceURI).ThenBy(at => at.LocalName).ToList(); + var bAttrs = b.Attributes.OfType() + .Where(at => at.Prefix != "xmlns" && at.Name != "xmlns") + .OrderBy(at => at.NamespaceURI).ThenBy(at => at.LocalName).ToList(); + if (aAttrs.Count != bAttrs.Count) + { + diff = $"Attribute count at <{a.LocalName}>: {aAttrs.Count} vs {bAttrs.Count}"; + return false; + } + for (int i = 0; i < aAttrs.Count; i++) + { + if (aAttrs[i].LocalName != bAttrs[i].LocalName || aAttrs[i].Value != bAttrs[i].Value) + { + diff = $"Attribute at <{a.LocalName}>[{i}]: {aAttrs[i].Name}={aAttrs[i].Value} vs {bAttrs[i].Name}={bAttrs[i].Value}"; + return false; + } + } + + // Child elements (order-sensitive — structure sequence matters in xsd). Whitespace- + // only text nodes are ignored. Plain text leaves are compared trimmed. + var aChildren = a.ChildNodes.OfType().ToList(); + var bChildren = b.ChildNodes.OfType().ToList(); + if (aChildren.Count != bChildren.Count) + { + diff = $"Child count at <{a.LocalName}>: {aChildren.Count} vs {bChildren.Count}"; + return false; + } + if (aChildren.Count == 0) + { + var at = (a.InnerText ?? "").Trim(); + var bt = (b.InnerText ?? "").Trim(); + if (at != bt) + { + diff = $"Text at <{a.LocalName}>: '{at}' vs '{bt}'"; + return false; + } + return true; + } + for (int i = 0; i < aChildren.Count; i++) + { + if (!XmlDomEquals(aChildren[i], bChildren[i], out diff)) + return false; + } + return true; + } + + #endregion + + #region Pass 2 — ResolveVariants canonicalization + + /// + /// Build a minimal containing a DataType with a known + /// StructureDefinition, populate a variable whose DOM has keys in reverse order, + /// call , and assert the keys are rewritten + /// in definition order. + /// + [Fact] + public void ResolveVariants_CanonicalizesStructureKeyOrder() + { + var space = new AddressSpace(); + space.AddModel(new ModelDefinition + { + ModelUri = TestModelUri, + XmlSchemaUri = TestModelXmlNs, + }); + + var dtId = $"nsu={TestModelUri};i=5001"; + var dt = new UADataType + { + NodeId = dtId, + NodeClass = NodeClass.UADataType, + BrowseName = $"nsu={TestModelUri};TestScalarStructure", + Definition = new DataTypeDefinition + { + Name = "TestScalarStructure", + Fields = new List + { + new() { Name = "A", DataType = "i=1" }, // Boolean + new() { Name = "B", DataType = "i=2" }, // SByte + new() { Name = "C", DataType = "i=3" }, // Byte + new() { Name = "D", DataType = "i=4" }, // Int16 + } + } + }; + space.AddNode(dt); + + // Build a DOM whose keys are in REVERSE order (D,C,B,A). + var body = new JObject + { + ["$typeName"] = "TestScalarStructure", + ["D"] = -7, + ["C"] = 3, + ["B"] = -1, + ["A"] = true, + }; + var eo = new ExtensionObject { TypeId = dtId, Body = body }; + + var varId = $"nsu={TestModelUri};i=6001"; + space.AddNode(new UAVariable + { + NodeId = varId, + NodeClass = NodeClass.UAVariable, + BrowseName = $"nsu={TestModelUri};v", + DataType = dtId, + Value = new JsonVariant((int)BuiltInType.ExtensionObject, eo), + }); + + space.ResolveVariants(); + + var vNode = (UAVariable)space.Read(varId)!; + var eoAfter = Assert.IsType(vNode.Value!.Value); + var bodyAfter = Assert.IsType(eoAfter.Body); + var orderedKeys = bodyAfter.Properties() + .Select(p => p.Name) + .Where(n => !n.StartsWith("$")) + .ToArray(); + Assert.Equal(new[] { "A", "B", "C", "D" }, orderedKeys); + } + + /// + /// Running twice must produce an + /// identical JSON serialization of every canonicalized DOM. + /// + [Fact] + public void ResolveVariants_IsIdempotent() + { + var space = new AddressSpace(); + space.AddModel(new ModelDefinition { ModelUri = TestModelUri, XmlSchemaUri = TestModelXmlNs }); + + var dtId = $"nsu={TestModelUri};i=5002"; + space.AddNode(new UADataType + { + NodeId = dtId, + NodeClass = NodeClass.UADataType, + BrowseName = $"nsu={TestModelUri};TinyStruct", + Definition = new DataTypeDefinition + { + Name = "TinyStruct", + Fields = new List + { + new() { Name = "X", DataType = "i=6" }, + new() { Name = "Y", DataType = "i=6" }, + } + } + }); + + var body = new JObject { ["Y"] = 2, ["X"] = 1 }; + space.AddNode(new UAVariable + { + NodeId = $"nsu={TestModelUri};i=6002", + NodeClass = NodeClass.UAVariable, + BrowseName = $"nsu={TestModelUri};v2", + DataType = dtId, + Value = new JsonVariant((int)BuiltInType.ExtensionObject, + new ExtensionObject { TypeId = dtId, Body = body }), + }); + + space.ResolveVariants(); + var v = (UAVariable)space.Read($"nsu={TestModelUri};i=6002")!; + var firstJson = ((JObject)((ExtensionObject)v.Value!.Value!).Body!).ToString(); + + space.ResolveVariants(); + var secondJson = ((JObject)((ExtensionObject)v.Value.Value!).Body!).ToString(); + + Assert.Equal(firstJson, secondJson); + } + + /// + /// When no DataTypeDefinition is registered, the DOM is left untouched (and no + /// exception is thrown). Validates the "unresolved type" fallback path. + /// + [Fact] + public void ResolveVariants_UnresolvedType_LeavesDomIntact() + { + var space = new AddressSpace(); + space.AddModel(new ModelDefinition { ModelUri = TestModelUri, XmlSchemaUri = TestModelXmlNs }); + + var body = new JObject { ["Z"] = 99, ["Y"] = 2, ["X"] = 1 }; + space.AddNode(new UAVariable + { + NodeId = $"nsu={TestModelUri};i=7001", + NodeClass = NodeClass.UAVariable, + BrowseName = $"nsu={TestModelUri};unresolved", + DataType = $"nsu={TestModelUri};i=9999", // no such DataType node + Value = new JsonVariant((int)BuiltInType.ExtensionObject, + new ExtensionObject + { + TypeId = $"nsu={TestModelUri};i=9999", + Body = body + }), + }); + + space.ResolveVariants(); + + var v = (UAVariable)space.Read($"nsu={TestModelUri};i=7001")!; + var eo = Assert.IsType(v.Value!.Value); + var bodyAfter = Assert.IsType(eo.Body); + // Keys unchanged — insertion order Z,Y,X preserved. + Assert.Equal(new[] { "Z", "Y", "X" }, + bodyAfter.Properties().Select(p => p.Name).ToArray()); + } + + /// + /// removes encoding objects with + /// BrowseName "Default JSON" while leaving Default XML untouched. + /// + [Fact] + public void StripDefaultJsonEncodings_RemovesOnlyJsonEncoding() + { + var space = new AddressSpace(); + space.AddModel(new ModelDefinition { ModelUri = TestModelUri, XmlSchemaUri = TestModelXmlNs }); + + var dtId = $"nsu={TestModelUri};i=5003"; + var xmlEncId = $"nsu={TestModelUri};i=5103"; + var jsonEncId = $"nsu={TestModelUri};i=5104"; + + space.AddNode(new UADataType + { + NodeId = dtId, + NodeClass = NodeClass.UADataType, + BrowseName = $"nsu={TestModelUri};T", + References = new List + { + new() { ReferenceTypeId = "i=38", TargetId = xmlEncId, IsForward = true }, + new() { ReferenceTypeId = "i=38", TargetId = jsonEncId, IsForward = true }, + } + }); + space.AddNode(new UAObject + { + NodeId = xmlEncId, + NodeClass = NodeClass.UAObject, + BrowseName = "Default XML", + }); + space.AddNode(new UAObject + { + NodeId = jsonEncId, + NodeClass = NodeClass.UAObject, + BrowseName = "Default JSON", + }); + + var removed = space.StripDefaultJsonEncodings(); + Assert.Equal(1, removed); + Assert.Null(space.Read(jsonEncId)); + Assert.NotNull(space.Read(xmlEncId)); + } + + /// + /// When an ExtensionObject's TypeId points at a "Default XML" encoding NodeId on + /// the way in, canonicalization rewrites it to the underlying DataType NodeId. + /// + [Fact] + public void ResolveVariants_NormalizesXmlEncodingTypeIdToDataTypeNodeId() + { + var space = new AddressSpace(); + space.AddModel(new ModelDefinition { ModelUri = TestModelUri, XmlSchemaUri = TestModelXmlNs }); + + var dtId = $"nsu={TestModelUri};i=5004"; + var xmlEncId = $"nsu={TestModelUri};i=5105"; + + space.AddNode(new UADataType + { + NodeId = dtId, + NodeClass = NodeClass.UADataType, + BrowseName = $"nsu={TestModelUri};Canonical", + Definition = new DataTypeDefinition + { + Name = "Canonical", + Fields = new List + { + new() { Name = "A", DataType = "i=1" }, + } + }, + References = new List + { + new() { ReferenceTypeId = "i=38", TargetId = xmlEncId, IsForward = true }, + } + }); + space.AddNode(new UAObject + { + NodeId = xmlEncId, + NodeClass = NodeClass.UAObject, + BrowseName = "Default XML", + }); + + var body = new JObject { ["A"] = true }; + var varId = $"nsu={TestModelUri};i=6004"; + space.AddNode(new UAVariable + { + NodeId = varId, + NodeClass = NodeClass.UAVariable, + BrowseName = $"nsu={TestModelUri};v4", + DataType = dtId, + // TypeId points at the encoding NodeId (as it would when freshly read from XML). + Value = new JsonVariant((int)BuiltInType.ExtensionObject, + new ExtensionObject { TypeId = xmlEncId, Body = body }), + }); + + space.ResolveVariants(); + + var v = (UAVariable)space.Read(varId)!; + var eo = (ExtensionObject)v.Value!.Value!; + Assert.Equal(dtId, eo.TypeId); // normalized + } + + #endregion + + #region Comprehensive TestScalarStructure / TestArrayStructure fixtures + + // Arbitrary placeholder NodeIds for the TestModel DataTypes. The converter carries + // them end-to-end without validating against any schema, so exact values don't + // matter — just that they round-trip unchanged. + private const string TestScalarStructureTypeId = "i=5001"; + private const string TestConcreteStructureTypeId = "i=5002"; + private const string CurrencyUnitTypeTypeId = "i=5010"; + + /// + /// XML text mirroring the strongly-typed CreateTestScalarStructure helper. + /// An ExtensionObject wrapping a TestScalarStructure with one value + /// per builtin type plus nested structures, a union, an optional-fields struct, + /// an enum, an option-set, a Variant and an ExtensionObject. + /// + private static string TestScalarStructureXml() => $@" + {TestScalarStructureTypeId} + + + true + -128 + 255 + 32767 + 65535 + 2147483647 + 4294967295 + 9223372036854775807 + 18446744073709551615 + 3.4028235E+38 + 1.7976931348623157E+308 + 12345678-1234-1234-1234-123456789012 + 2024-01-15T12:34:56Z + Test + SGVsbG8gV29ybGQ= + s=Hello + s=World + 0Hello + deHello + 2153250816 + + + {CurrencyUnitTypeTypeId} + + + 840 + 2 + USD + Dollar + + + + + + + {TestConcreteStructureTypeId} + + + 1 + 2.234 + apple + 3 + 3.14 + banana + + + + + + + 3 + 4.234 + orange + 4 + 5.14 + green + + + Blue_6 + 3 + + + 2 + 3.1415 + + + + + 6 + 2.4567 + Orange + + + + +"; + + /// + /// XML text mirroring the strongly-typed CreateTestArrayStructure helper: + /// a TestArrayStructure containing ListOf elements for several builtin + /// types plus a nested list of structures and enumerations. + /// + private static string TestArrayStructureXml() => $@" + i=5008 + + + + truefalsetrue + + + -1280127 + + + 0127255 + + + -32768032767 + + + -214748364802147483647 + + + RedYellowGreen + + + + 12.234apple33.14banana + + + 34.234orange45.14green + + + + Red_2 + Green_4 + Blue_6 + + + +"; + + #endregion + + #region Comprehensive round-trip tests + + /// + /// XML → Variant → JSON text → Variant → XML → DOM compare for the full + /// TestScalarStructure fixture. + /// + [Fact] + public void TestScalarStructure_XmlToJsonToXml_PreservesDom() + { + var doc = new XmlDocument(); + doc.LoadXml(TestScalarStructureXml()); + var originalXml = doc.DocumentElement!; + + var v = VariantConverter.ReadVariantFromXml(originalXml, Ctx()); + Assert.NotNull(v); + Assert.Equal((int)BuiltInType.ExtensionObject, v!.UaType); + + var json = Newtonsoft.Json.JsonConvert.SerializeObject(v); + var vReloaded = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + Assert.NotNull(vReloaded); + + var rebuilt = VariantConverter.WriteVariantToXml(vReloaded, Ctx()); + Assert.NotNull(rebuilt); + + Assert.True( + XmlDomEquals(originalXml, rebuilt!, out var diff), + $"TestScalarStructure DOM mismatch: {diff}\n\nORIGINAL:\n{originalXml.OuterXml}\n\nREBUILT:\n{rebuilt!.OuterXml}"); + } + + /// + /// Same round-trip for TestArrayStructure. + /// + [Fact] + public void TestArrayStructure_XmlToJsonToXml_PreservesDom() + { + var doc = new XmlDocument(); + doc.LoadXml(TestArrayStructureXml()); + var originalXml = doc.DocumentElement!; + + var v = VariantConverter.ReadVariantFromXml(originalXml, Ctx()); + Assert.NotNull(v); + + var json = Newtonsoft.Json.JsonConvert.SerializeObject(v); + var vReloaded = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + + var rebuilt = VariantConverter.WriteVariantToXml(vReloaded, Ctx()); + Assert.NotNull(rebuilt); + + Assert.True( + XmlDomEquals(originalXml, rebuilt!, out var diff), + $"TestArrayStructure DOM mismatch: {diff}\n\nORIGINAL:\n{originalXml.OuterXml}\n\nREBUILT:\n{rebuilt!.OuterXml}"); + } + + /// + /// Field order in the rebuilt XML must match the source order — JObject insertion + /// order has to survive JSON serialize/deserialize. + /// + [Fact] + public void TestScalarStructure_FieldOrderPreserved_ThroughJsonRoundTrip() + { + var doc = new XmlDocument(); + doc.LoadXml(TestScalarStructureXml()); + var originalXml = doc.DocumentElement!; + + var v = VariantConverter.ReadVariantFromXml(originalXml, Ctx()); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(v); + var vReloaded = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + var rebuilt = VariantConverter.WriteVariantToXml(vReloaded, Ctx()); + + var originalBody = originalXml + .ChildNodes.OfType().First(e => e.LocalName == "Body") + .ChildNodes.OfType().First(); + var rebuiltBody = rebuilt! + .ChildNodes.OfType().First(e => e.LocalName == "Body") + .ChildNodes.OfType().First(); + + var originalFieldOrder = originalBody.ChildNodes.OfType() + .Select(e => e.LocalName).ToArray(); + var rebuiltFieldOrder = rebuiltBody.ChildNodes.OfType() + .Select(e => e.LocalName).ToArray(); + + Assert.Equal(originalFieldOrder, rebuiltFieldOrder); + } + + /// + /// Nested ExtensionObject fields (V and W) must carry their TypeId through the + /// round-trip unchanged. + /// + [Fact] + public void TestScalarStructure_NestedExtensionObject_TypeIdsPreserved() + { + var doc = new XmlDocument(); + doc.LoadXml(TestScalarStructureXml()); + var originalXml = doc.DocumentElement!; + + var v = VariantConverter.ReadVariantFromXml(originalXml, Ctx()); + var eo = Assert.IsType(v!.Value); + var body = Assert.IsType(eo.Body); + + // Field V: a Variant-typed slot holding a CurrencyUnitType ExtensionObject. + // The converter's reader wraps it as a JObject containing TypeId+Body. + // Its JObject form is stored as a child of the field wrapper, which the + // converter renders as the field element's child. Pull the first child. + var vField = body["V"]; + Assert.NotNull(vField); + + // Field W: an ExtensionObject-typed slot wrapping TestConcreteStructure. + var wField = body["W"]; + Assert.NotNull(wField); + + // Both serialize back to JSON and re-read without losing the TypeId — + // surfacing as a substring match is sufficient here. + var json = Newtonsoft.Json.JsonConvert.SerializeObject(v); + Assert.Contains(CurrencyUnitTypeTypeId, json); + Assert.Contains(TestConcreteStructureTypeId, json); + } + + /// + /// Observability check: after parsing the TestScalarStructure fixture and + /// re-serializing, every top-level field name from the XML body appears in the + /// JSON text. Catches silent drop of fields by future converter changes. + /// + [Fact] + public void TestScalarStructure_JsonContainsAllFields() + { + var doc = new XmlDocument(); + doc.LoadXml(TestScalarStructureXml()); + var originalXml = doc.DocumentElement!; + + var v = VariantConverter.ReadVariantFromXml(originalXml, Ctx()); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(v, Newtonsoft.Json.Formatting.Indented); + + foreach (var field in new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", + "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "V", "W", "X", "Y", "Z", "A1", "B1" }) + { + Assert.Contains($"\"{field}\"", json); + } + Assert.Contains(TestScalarStructureTypeId, json); + Assert.Contains(CurrencyUnitTypeTypeId, json); + Assert.Contains(TestConcreteStructureTypeId, json); + } + + #endregion +} diff --git a/Opc.Ua.NodeSetTool/Opc.Ua.NodeSetTool.csproj b/Opc.Ua.NodeSetTool/Opc.Ua.NodeSetTool.csproj index eca5e88..269d28d 100644 --- a/Opc.Ua.NodeSetTool/Opc.Ua.NodeSetTool.csproj +++ b/Opc.Ua.NodeSetTool/Opc.Ua.NodeSetTool.csproj @@ -8,8 +8,6 @@ Opc.Ua.NodeSetTool NodeSetTool OPC UA NodeSet Tool - $(DefaultItemExcludes);build\** - $(SolutionDir)build\obj\$(MSBuildProjectName)\$(Configuration)\ @@ -34,16 +32,6 @@ - - $(SolutionDir)build\bin\$(MSBuildProjectName)\$(Configuration)\ - $(DefineConstants); - - - - $(SolutionDir)build\bin\$(MSBuildProjectName)\$(Configuration)\ - $(DefineConstants); - -