diff --git a/src/Opc.Ua.Types/BuiltIn/Variant.cs b/src/Opc.Ua.Types/BuiltIn/Variant.cs index 16c75c0045..67579df72e 100644 --- a/src/Opc.Ua.Types/BuiltIn/Variant.cs +++ b/src/Opc.Ua.Types/BuiltIn/Variant.cs @@ -1146,7 +1146,7 @@ namespace Opc.Ua { return 0; } - if (!ValueIsValueType && ValueIsDefaultOrNull) + if (IsAbsentPayload) { // A typed null payload (null array, matrix, byte string, // qualified name, ...) equals Variant.Null and must @@ -7906,7 +7906,7 @@ namespace Opc.Ua // letting it compare equal collapses both into one bucket of // every Dictionary and HashSet keyed on Variant. Variant typed = IsNull ? other : this; - return !typed.ValueIsValueType && typed.ValueIsDefaultOrNull; + return typed.IsAbsentPayload; } TypeInfo ourTypeInfo = m_typeInfo; @@ -7918,6 +7918,15 @@ namespace Opc.Ua { return false; } + if (IsAbsentPayload && other.IsAbsentPayload) + { + // Both hold the typed null of the same (or a convertible) type, + // e.g. a null array field. Each already equals Variant.Null and + // hashes like it, so they must equal each other as well; the + // accessors below cannot read an absent payload and made such a + // variant unequal even to itself. + return true; + } if (ourTypeInfo.ValueRank == 0 || otherTypeInfo.ValueRank == 0) { // A null MatrixOf carries no dimensions, so its type info @@ -8413,6 +8422,14 @@ namespace Opc.Ua } } + /// + /// True for a typed variant whose reference payload is absent (a null + /// array, matrix, string, byte string, ...). Such a variant equals + /// and hashes like it. A numeric, boolean or status + /// code zero is a value, not an absent one. + /// + private bool IsAbsentPayload => !ValueIsValueType && ValueIsDefaultOrNull; + /// /// Returns true if the variant holds an array or matrix without /// elements. A null array counts as empty because OPC 10000-6 diff --git a/tests/Opc.Ua.Types.Tests/BuiltIn/VariantAuditRegressionTests.cs b/tests/Opc.Ua.Types.Tests/BuiltIn/VariantAuditRegressionTests.cs index c4fa9201ec..3cc734c673 100644 --- a/tests/Opc.Ua.Types.Tests/BuiltIn/VariantAuditRegressionTests.cs +++ b/tests/Opc.Ua.Types.Tests/BuiltIn/VariantAuditRegressionTests.cs @@ -629,6 +629,82 @@ namespace Opc.Ua.Types.Tests.BuiltIn }); } + [Test] + public void VariantWithTypedNullArrayEqualsAnIdenticalTypedNull() + { + // A typed null array equalled Variant.Null but not itself: the + // array accessors cannot read an absent payload. Equality must be + // reflexive and agree with the hash code (OPC 10000-6 5.1.11). + TypeInfo[] types = + [ + TypeInfo.Arrays.Int32, + TypeInfo.Arrays.String, + TypeInfo.Arrays.Variant, + TypeInfo.Arrays.ExtensionObject, + TypeInfo.Create(BuiltInType.Double, ValueRanks.TwoDimensions) + ]; + + Assert.Multiple(() => + { + foreach (TypeInfo type in types) + { + Variant a = Variant.CreateDefault(type); + Variant b = Variant.CreateDefault(type); + Assert.That(IsEqual(a, a), Is.True, type.ToString()); + Assert.That(IsEqual(a, b), Is.True, type.ToString()); + Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode()), type.ToString()); + } + + // the boxed null ArrayOf spelling equals the payload-less one + Variant nullInt32s = Variant.CreateDefault(TypeInfo.Arrays.Int32); + Assert.That(IsEqual(Variant.From(ArrayOf.Null), nullInt32s), Is.True); + + // a typed null still differs from a value and from another type + Assert.That(IsEqual(nullInt32s, Variant.From(s_oneTwoThree.ToArrayOf())), Is.False); + Variant nullBooleans = Variant.CreateDefault(TypeInfo.Arrays.Boolean); + Variant nullStrings = Variant.CreateDefault(TypeInfo.Arrays.String); + Assert.That(IsEqual(nullBooleans, nullStrings), Is.False); + }); + } + + [Test] + public void StructureWithNullArrayFieldsEqualsAnIdenticalStructure() + { + var definition = new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + new StructureField + { + Name = "A", + DataType = DataTypeIds.Int32, + ValueRank = ValueRanks.OneDimension + }, + new StructureField + { + Name = "V", + DataType = DataTypeIds.BaseDataType, + ValueRank = ValueRanks.OneDimension + } + ] + }; + var types = new Dictionary + { + ["A"] = BuiltInType.Int32, + ["V"] = BuiltInType.Variant + }; + var a = new global::Opc.Ua.Encoders.Structure( + new System.Xml.XmlQualifiedName("S", Namespaces.OpcUaXsd), + new ExpandedNodeId(1u), new ExpandedNodeId(2u), new ExpandedNodeId(3u), + definition, + types); + var b = (global::Opc.Ua.Encoders.Structure)a.Clone(); + + Assert.That(a.IsEqual(b), Is.True); + } + [Test] public void LocalizedTextWithEmptyLocaleAndNullTextHashesLikeNull() { diff --git a/tests/Opc.Ua.Types.Tests/Encoders/CodecReviewRegressionTests.cs b/tests/Opc.Ua.Types.Tests/Encoders/CodecReviewRegressionTests.cs index 50be6b73e3..00d14e0e36 100644 --- a/tests/Opc.Ua.Types.Tests/Encoders/CodecReviewRegressionTests.cs +++ b/tests/Opc.Ua.Types.Tests/Encoders/CodecReviewRegressionTests.cs @@ -650,12 +650,7 @@ namespace Opc.Ua.Types.Tests.Encoders foreach (string field in new[] { "N", "I", "U", "M" }) { Assert.That(parsed[field].TypeInfo, Is.EqualTo(output[field].TypeInfo), field); - // Variant equality never matches a typed null array, so - // null or empty arrays are compared by their emptiness. - if (!output[field].IsEmptyArray || !parsed[field].IsEmptyArray) - { - Assert.That(parsed[field], Is.EqualTo(output[field]), field); - } + Assert.That(parsed[field], Is.EqualTo(output[field]), field); } break; default: