From 11df79357c042cea0fa106316b3e1679bd953815 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 5 Jul 2026 23:28:27 +1000 Subject: [PATCH 1/2] fix correctness bugs across core, JsonPath, Xml and FSharp fix correctness bugs across core, JsonPath, Xml and FSharp Add regression tests (src/ArgonTests/BugFixes/, NullTokenTests.fs) and fix: core writer/reader - JsonConvert.ToString(TimeSpan) escaped the whole stack buffer, embedding NULs - JsonWriter.WriteValue(StringBuilder) wrote each chunk as a separate JSON string - JsonTextWriter.WriteValue(char) ignored QuoteValue/QuoteChar - JsonTextReader.Close() double-returned charBuffer to ArrayPool on a second call - JsonReader.SetUndefinedToken set Null instead of Undefined serializer - JsonConverter.ReadJson returned null on container tokens without consuming them - OrderByKey packed the comparer into the sort key, making ordering culture-sensitive - JsonSerializerProxy did not forward ResolveContract or Converters to converters - CreateProperties used unstable List.Sort, scrambling order for >16 props with Order - removed duplicate EncodingConverter registration utilities - GetDefaultValue(char) returned boxed int 0 so DefaultValueHandling.Ignore missed chars - BoxedPrimitives.Get(decimal) dropped the sign of negative zero - ISO parser accepted out-of-range zone offsets, throwing from the DateTimeOffset ctor linq - CompareBigInteger inverted comparisons against negative fractions - GetHashCode/GetDeepHashCode were inconsistent with Equals across numeric types - TryAddInternal advanced the index when a child insert was skipped - implemented JObject IDictionary.Values (was NotImplementedException) jsonpath - FieldMultipleFilter threw ErrorWhenNoMatch even when the property existed - numeric filter literals were not terminated by && or || - $[-1] crashed instead of returning null fsharp - FSharpList/FSharpMap converters crashed on a JSON null xml - single-element nested arrays lost json:Array for prefixed/encoded names - multiple sibling comments serialized to "#comment": [], dropping the text - CreateXmlDocumentType discarded the DTD internal subset - #cdata-section/#text with a null value crashed for XDocument --- .../Argon.FSharp.Tests.fsproj | 1 + src/Argon.FSharp.Tests/NullTokenTests.fs | 31 ++++ src/Argon.FSharp/FSharpListConverter.cs | 5 + src/Argon.FSharp/FSharpMapConverter.cs | 5 + src/Argon.JsonPath/FieldMultipleFilter.cs | 3 +- src/Argon.JsonPath/JPath.cs | 2 +- src/Argon.JsonPath/PathFilter.cs | 2 +- src/Argon.Xml/XmlDocumentWrapper.cs | 2 +- src/Argon.Xml/XmlNodeConverter.cs | 29 +++- src/Argon/JsonConvert.cs | 4 +- src/Argon/JsonConverter.cs | 2 +- src/Argon/JsonReader.cs | 2 +- src/Argon/JsonSerializer.cs | 7 +- src/Argon/JsonTextReader.cs | 6 + src/Argon/JsonTextWriter.cs | 2 +- src/Argon/JsonWriter.cs | 5 +- src/Argon/Linq/JContainer.cs | 6 +- src/Argon/Linq/JObject.cs | 5 +- src/Argon/Linq/JValue.cs | 42 +++-- .../Serialization/DefaultContractResolver.cs | 5 +- .../JsonSerializerInternalWriter.cs | 2 +- .../Serialization/JsonSerializerProxy.cs | 2 + src/Argon/Utilities/BoxedPrimitives.cs | 23 ++- src/Argon/Utilities/DateTimeUtils.cs | 8 + src/Argon/Utilities/ReflectionUtils.cs | 1 + .../BugFixes/ContractResolverBugFixes.cs | 65 ++++++++ src/ArgonTests/BugFixes/JsonPathBugFixes.cs | 73 +++++++++ src/ArgonTests/BugFixes/LinqDomBugFixes.cs | 99 +++++++++++ src/ArgonTests/BugFixes/ReaderBugFixes.cs | 63 +++++++ .../BugFixes/SerializerEngineBugFixes.cs | 155 ++++++++++++++++++ src/ArgonTests/BugFixes/UtilitiesBugFixes.cs | 85 ++++++++++ src/ArgonTests/BugFixes/WriterBugFixes.cs | 103 ++++++++++++ src/ArgonTests/BugFixes/XmlBugFixes.cs | 83 ++++++++++ 33 files changed, 877 insertions(+), 51 deletions(-) create mode 100644 src/Argon.FSharp.Tests/NullTokenTests.fs create mode 100644 src/ArgonTests/BugFixes/ContractResolverBugFixes.cs create mode 100644 src/ArgonTests/BugFixes/JsonPathBugFixes.cs create mode 100644 src/ArgonTests/BugFixes/LinqDomBugFixes.cs create mode 100644 src/ArgonTests/BugFixes/ReaderBugFixes.cs create mode 100644 src/ArgonTests/BugFixes/SerializerEngineBugFixes.cs create mode 100644 src/ArgonTests/BugFixes/UtilitiesBugFixes.cs create mode 100644 src/ArgonTests/BugFixes/WriterBugFixes.cs create mode 100644 src/ArgonTests/BugFixes/XmlBugFixes.cs diff --git a/src/Argon.FSharp.Tests/Argon.FSharp.Tests.fsproj b/src/Argon.FSharp.Tests/Argon.FSharp.Tests.fsproj index e9d381dde..7b531aa20 100644 --- a/src/Argon.FSharp.Tests/Argon.FSharp.Tests.fsproj +++ b/src/Argon.FSharp.Tests/Argon.FSharp.Tests.fsproj @@ -9,6 +9,7 @@ + diff --git a/src/Argon.FSharp.Tests/NullTokenTests.fs b/src/Argon.FSharp.Tests/NullTokenTests.fs new file mode 100644 index 000000000..2b39adb28 --- /dev/null +++ b/src/Argon.FSharp.Tests/NullTokenTests.fs @@ -0,0 +1,31 @@ +module NullTokenTests + +open Argon +open Xunit + +type ListHolder() = + member val List: int list = [] with get, set + member val After: int = 0 with get, set + +type MapHolder() = + member val Map: Map = Map.empty with get, set + member val After: int = 0 with get, set + +// Bug: FSharpListConverter.ReadJson had no Null handling, so a JSON null crashed and +// desynced the reader instead of yielding a null list. +[] +let ``Deserialize null into FSharp list property`` () = + let result = + JsonConvert.DeserializeObject("""{"List": null, "After": 7}""", FSharpListConverter()) + + Assert.Null(box result.List) + Assert.Equal(7, result.After) + +// Bug: FSharpMapConverter.ReadJson had the same missing Null check. +[] +let ``Deserialize null into FSharp map property`` () = + let result = + JsonConvert.DeserializeObject("""{"Map": null, "After": 7}""", FSharpMapConverter()) + + Assert.Null(box result.Map) + Assert.Equal(7, result.After) diff --git a/src/Argon.FSharp/FSharpListConverter.cs b/src/Argon.FSharp/FSharpListConverter.cs index 4fb2162d0..01fcf847e 100644 --- a/src/Argon.FSharp/FSharpListConverter.cs +++ b/src/Argon.FSharp/FSharpListConverter.cs @@ -21,6 +21,11 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s public override object? ReadJson(JsonReader reader, Type type, object? existingValue, JsonSerializer serializer) { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + var genericArgument = type.GetGenericArguments()[0]; return readList.MakeGenericMethod(genericArgument) .Invoke( diff --git a/src/Argon.FSharp/FSharpMapConverter.cs b/src/Argon.FSharp/FSharpMapConverter.cs index 6fb1d55f9..1b0a14132 100644 --- a/src/Argon.FSharp/FSharpMapConverter.cs +++ b/src/Argon.FSharp/FSharpMapConverter.cs @@ -24,6 +24,11 @@ public static void WriteMap(JsonWriter writer, FSharpMap value, Json public override object? ReadJson(JsonReader reader, Type type, object? existingValue, JsonSerializer serializer) { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + var arguments = type.GetGenericArguments(); return readMap.MakeGenericMethod(arguments[0], arguments[1]) .Invoke( diff --git a/src/Argon.JsonPath/FieldMultipleFilter.cs b/src/Argon.JsonPath/FieldMultipleFilter.cs index ce1fd3dd1..ca979ed07 100644 --- a/src/Argon.JsonPath/FieldMultipleFilter.cs +++ b/src/Argon.JsonPath/FieldMultipleFilter.cs @@ -17,8 +17,7 @@ public override IEnumerable ExecuteFilter(JToken root, IEnumerable - new XmlDocumentTypeWrapper(document.CreateDocumentType(name, publicId, systemId, null)); + new XmlDocumentTypeWrapper(document.CreateDocumentType(name, publicId, systemId, internalSubset)); public IXmlNode CreateProcessingInstruction(string target, string data) => new XmlNodeWrapper(document.CreateProcessingInstruction(target, data)); diff --git a/src/Argon.Xml/XmlNodeConverter.cs b/src/Argon.Xml/XmlNodeConverter.cs index 294cc58e9..c8a9b7712 100644 --- a/src/Argon.Xml/XmlNodeConverter.cs +++ b/src/Argon.Xml/XmlNodeConverter.cs @@ -295,6 +295,19 @@ void WriteGroupedNodes(JsonWriter writer, XmlNamespaceManager manager, bool writ if (writeArray) { + // Comments share the name "#comment" and cannot be array values. Write each as an + // inline JSON comment (which round-trips back to XML comments) instead of an empty + // array that silently drops the comment text. + if (groupedNodes[0].NodeType == XmlNodeType.Comment) + { + foreach (var node in groupedNodes) + { + writer.WriteComment(node.Value); + } + + return; + } + if (writePropertyName) { writer.WritePropertyName(elementNames); @@ -614,16 +627,16 @@ void DeserializeValue(JsonReader reader, IXmlDocument document, XmlNamespaceMana switch (propertyName) { case textName: - currentNode.AppendChild(document.CreateTextNode(ConvertTokenToXmlValue(reader)!)); + currentNode.AppendChild(document.CreateTextNode(ConvertTokenToXmlValue(reader) ?? "")); return; case cDataName: - currentNode.AppendChild(document.CreateCDataSection(ConvertTokenToXmlValue(reader)!)); + currentNode.AppendChild(document.CreateCDataSection(ConvertTokenToXmlValue(reader) ?? "")); return; case whitespaceName: - currentNode.AppendChild(document.CreateWhitespace(ConvertTokenToXmlValue(reader)!)); + currentNode.AppendChild(document.CreateWhitespace(ConvertTokenToXmlValue(reader) ?? "")); return; case significantWhitespaceName: - currentNode.AppendChild(document.CreateSignificantWhitespace(ConvertTokenToXmlValue(reader)!)); + currentNode.AppendChild(document.CreateSignificantWhitespace(ConvertTokenToXmlValue(reader) ?? "")); return; default: // processing instructions and the xml declaration start with ? @@ -874,9 +887,15 @@ void ReadArrayElements(JsonReader reader, IXmlDocument document, string property if (count == 1 && WriteArrayAttribute) { + // Match on the decoded local name and namespace (not the raw JSON property name), + // otherwise prefixed or XML-encoded names never match and a single-element nested + // array loses its json:Array marker on round-trip. + XmlUtils.GetQualifiedNameParts(propertyName, out var qualifiedPrefix, out var localName); + var ns = qualifiedPrefix.IsNullOrEmpty() ? manager.DefaultNamespace : manager.LookupNamespace(qualifiedPrefix); + foreach (var childNode in nestedArrayElement.ChildNodes) { - if (childNode is IXmlElement element && element.LocalName == propertyName) + if (childNode is IXmlElement element && element.LocalName == localName && element.NamespaceUri == ns) { AddJsonArrayAttribute(element, document); break; diff --git a/src/Argon/JsonConvert.cs b/src/Argon/JsonConvert.cs index f52b0e022..d3640caa3 100644 --- a/src/Argon/JsonConvert.cs +++ b/src/Argon/JsonConvert.cs @@ -245,8 +245,8 @@ public static string ToString(Guid value) public static string ToString(TimeSpan value) { Span destination = stackalloc char[26]; - value.TryFormat(destination, out _, ['c']); - return ToString(destination, '"'); + value.TryFormat(destination, out var charsWritten, ['c']); + return ToString(destination[..charsWritten], '"'); } /// diff --git a/src/Argon/JsonConverter.cs b/src/Argon/JsonConverter.cs index d889eeae1..ef966c7ba 100644 --- a/src/Argon/JsonConverter.cs +++ b/src/Argon/JsonConverter.cs @@ -65,7 +65,7 @@ public sealed override void WriteJson(JsonWriter writer, object value, JsonSeria throw new JsonSerializationException($"Converter cannot read JSON with the specified existing value. {typeof(T)} is required."); } - if (reader.Value is null) + if (reader.TokenType is JsonToken.Null or JsonToken.Undefined) { return null; } diff --git a/src/Argon/JsonReader.cs b/src/Argon/JsonReader.cs index 4571c9917..972b17972 100644 --- a/src/Argon/JsonReader.cs +++ b/src/Argon/JsonReader.cs @@ -1028,7 +1028,7 @@ protected void SetNoneToken() /// protected void SetUndefinedToken() { - tokenType = JsonToken.Null; + tokenType = JsonToken.Undefined; value = null; SetPostValueState(false); } diff --git a/src/Argon/JsonSerializer.cs b/src/Argon/JsonSerializer.cs index 6c8beaf78..2c6cd41b6 100644 --- a/src/Argon/JsonSerializer.cs +++ b/src/Argon/JsonSerializer.cs @@ -129,12 +129,15 @@ public virtual IContractResolver? ContractResolver public JsonContract ResolveContract(Type type) { - if (contractResolver == null) + // Read through the virtual ContractResolver property so that subclasses + // (e.g. JsonSerializerProxy handed to converters) can redirect resolution. + var resolver = ContractResolver; + if (resolver == null) { return DefaultContractResolver.Instance.ResolveContract(type); } - return contractResolver.ResolveContract(type); + return resolver.ResolveContract(type); } /// diff --git a/src/Argon/JsonTextReader.cs b/src/Argon/JsonTextReader.cs index e3375185b..2844cea8b 100644 --- a/src/Argon/JsonTextReader.cs +++ b/src/Argon/JsonTextReader.cs @@ -2171,9 +2171,15 @@ object ParseNumberNaN(ReadType readType, bool matched) /// public override void Close() { + if (CurrentState == State.Closed) + { + return; + } + base.Close(); BufferUtils.ReturnBuffer(charBuffer); + charBuffer = []; if (CloseInput) { diff --git a/src/Argon/JsonTextWriter.cs b/src/Argon/JsonTextWriter.cs index 2d4de301a..d982c0bc0 100644 --- a/src/Argon/JsonTextWriter.cs +++ b/src/Argon/JsonTextWriter.cs @@ -459,7 +459,7 @@ public override void WriteValue(ushort value) public override void WriteValue(char value) { InternalWriteValue(JsonToken.String); - WriteValueInternal(JsonConvert.ToString(value)); + WriteEscapedString([value], QuoteValue); } /// diff --git a/src/Argon/JsonWriter.cs b/src/Argon/JsonWriter.cs index fba3dd26c..5a9428d02 100644 --- a/src/Argon/JsonWriter.cs +++ b/src/Argon/JsonWriter.cs @@ -796,10 +796,7 @@ public virtual void WriteValue(StringBuilder? value) return; } - foreach (var chunk in value.GetChunks()) - { - WriteValue(chunk.Span); - } + WriteValue(value.ToString()); } /// diff --git a/src/Argon/Linq/JContainer.cs b/src/Argon/Linq/JContainer.cs index 62b3fd274..51ef10559 100644 --- a/src/Argon/Linq/JContainer.cs +++ b/src/Argon/Linq/JContainer.cs @@ -402,8 +402,10 @@ internal bool TryAddInternal(int index, object? content, bool skipParentCheck) var multiIndex = index; foreach (var c in enumerable) { - TryAddInternal(multiIndex, c, skipParentCheck); - multiIndex++; + if (TryAddInternal(multiIndex, c, skipParentCheck)) + { + multiIndex++; + } } return true; diff --git a/src/Argon/Linq/JObject.cs b/src/Argon/Linq/JObject.cs index ac11f7bec..b8e105244 100644 --- a/src/Argon/Linq/JObject.cs +++ b/src/Argon/Linq/JObject.cs @@ -458,7 +458,10 @@ public bool TryGetValue(string propertyName, [NotNullWhen(true)] out JToken? val return true; } - ICollection IDictionary.Values => throw new NotImplementedException(); + ICollection IDictionary.Values => + Properties() + .Select(_ => (JToken?) _.Value) + .ToList(); #endregion diff --git a/src/Argon/Linq/JValue.cs b/src/Argon/Linq/JValue.cs index a2d13c414..0c51c004e 100644 --- a/src/Argon/Linq/JValue.cs +++ b/src/Argon/Linq/JValue.cs @@ -176,13 +176,13 @@ static int CompareBigInteger(BigInteger i1, object i2) // check for fraction if result is two numbers are equal if (i2 is decimal d1) { - return 0m.CompareTo(Math.Abs(d1 - Math.Truncate(d1))); + return 0m.CompareTo(d1 - Math.Truncate(d1)); } if (i2 is double or float) { var d = Convert.ToDouble(i2, InvariantCulture); - return 0d.CompareTo(Math.Abs(d - Math.Truncate(d))); + return 0d.CompareTo(d - Math.Truncate(d)); } return result; @@ -759,14 +759,33 @@ public override void WriteTo(JsonWriter writer, params IList conv throw MiscellaneousUtils.CreateArgumentOutOfRangeException(nameof(Type), valueType, "Unexpected token type."); } - internal override int GetDeepHashCode() + // Numeric JValues of the same JTokenType are equal by numeric value regardless of the + // backing CLR type (int vs long, decimal vs double, BigInteger). Hash a canonical double + // so GetHashCode stays consistent with Equals for exact numeric values. Values that are + // only approximately equal cannot share a hash - that is inherent to the approximate float + // comparison used by Equals. + int GetValueHashCode() { - var valueHashCode = value?.GetHashCode() ?? 0; + if (value == null) + { + return 0; + } - // GetHashCode on an enum boxes so cast to int - return ((int) valueType).GetHashCode() ^ valueHashCode; + if (valueType is JTokenType.Integer or JTokenType.Float) + { + var d = value is BigInteger bigInteger + ? (double) bigInteger + : Convert.ToDouble(value, InvariantCulture); + return d.GetHashCode(); + } + + return value.GetHashCode(); } + internal override int GetDeepHashCode() => + // GetHashCode on an enum boxes so cast to int + ((int) valueType).GetHashCode() ^ GetValueHashCode(); + static bool ValuesEquals(JValue v1, JValue v2) => v1 == v2 || (v1.valueType == v2.valueType && Compare(v1.valueType, v1.value, v2.value) == 0); @@ -804,15 +823,8 @@ public override bool Equals(object? obj) /// /// A hash code for the current . /// - public override int GetHashCode() - { - if (value == null) - { - return 0; - } - - return value.GetHashCode(); - } + public override int GetHashCode() => + GetValueHashCode(); /// /// Returns a that represents this instance. diff --git a/src/Argon/Serialization/DefaultContractResolver.cs b/src/Argon/Serialization/DefaultContractResolver.cs index 628ea938d..1cdb359ed 100644 --- a/src/Argon/Serialization/DefaultContractResolver.cs +++ b/src/Argon/Serialization/DefaultContractResolver.cs @@ -37,7 +37,6 @@ public class DefaultContractResolver : IContractResolver new EncodingConverter(), new PathInfoConverter(), new RegexConverter(), - new EncodingConverter(), new TimeZoneInfoConverter(), new VersionConverter(), new StringWriterConverter() @@ -690,7 +689,9 @@ protected virtual IList CreateProperties(Type type, MemberSerializ if (needsSort) { - list.Sort(static (a, b) => (a.Order ?? -1).CompareTo(b.Order ?? -1)); + // OrderBy is a stable sort, so properties without an explicit Order keep + // their declaration order. List.Sort (introsort) is unstable above 16 items. + return list.OrderBy(_ => _.Order ?? -1).ToList(); } return list; diff --git a/src/Argon/Serialization/JsonSerializerInternalWriter.cs b/src/Argon/Serialization/JsonSerializerInternalWriter.cs index e11604e9f..f5aef7f79 100644 --- a/src/Argon/Serialization/JsonSerializerInternalWriter.cs +++ b/src/Argon/Serialization/JsonSerializerInternalWriter.cs @@ -912,7 +912,7 @@ static IEnumerable Items(IDictionary values) { if (contract.DictionaryKeyType == typeof(string)) { - foreach (var entry in Items(values).OrderBy(_ => ((string) _.Key, StringComparer.OrdinalIgnoreCase))) + foreach (var entry in Items(values).OrderBy(_ => (string) _.Key, StringComparer.OrdinalIgnoreCase)) { SerializeDictionaryItem(writer, contract, member, entry.Key, entry.Value, keyContract, underlying); } diff --git a/src/Argon/Serialization/JsonSerializerProxy.cs b/src/Argon/Serialization/JsonSerializerProxy.cs index 4f0396462..d4b88959b 100644 --- a/src/Argon/Serialization/JsonSerializerProxy.cs +++ b/src/Argon/Serialization/JsonSerializerProxy.cs @@ -67,6 +67,8 @@ public override IContractResolver? ContractResolver set => serializer.ContractResolver = value; } + public override List Converters => serializer.Converters; + public override MissingMemberHandling? MissingMemberHandling { get => serializer.MissingMemberHandling; diff --git a/src/Argon/Utilities/BoxedPrimitives.cs b/src/Argon/Utilities/BoxedPrimitives.cs index 77b53df4f..9958d787c 100644 --- a/src/Argon/Utilities/BoxedPrimitives.cs +++ b/src/Argon/Utilities/BoxedPrimitives.cs @@ -78,16 +78,21 @@ internal static object Get(decimal value) { Span bits = stackalloc int[4]; decimal.GetBits(value, bits); - var scale = (byte) (bits[3] >> ScaleShift); - // Only use cached boxed value if value is zero and there is zero or one trailing zeros. - if (scale == 0) + // Preserve the sign of negative zero (-0.0m). The cached boxes are positive zero, + // so only use them for positive zero - consistent across scales and with Get(double). + if ((bits[3] & int.MinValue) == 0) { - return DecimalZero; - } - - if (scale == 1) - { - return DecimalZeroWithTrailingZero; + var scale = (byte) (bits[3] >> ScaleShift); + // Only use cached boxed value if value is zero and there is zero or one trailing zeros. + if (scale == 0) + { + return DecimalZero; + } + + if (scale == 1) + { + return DecimalZeroWithTrailingZero; + } } } #endif diff --git a/src/Argon/Utilities/DateTimeUtils.cs b/src/Argon/Utilities/DateTimeUtils.cs index 74bb9e9a9..85bda8ef8 100644 --- a/src/Argon/Utilities/DateTimeUtils.cs +++ b/src/Argon/Utilities/DateTimeUtils.cs @@ -52,6 +52,14 @@ internal static bool TryParseDateTimeOffsetIso(StringReference text, out DateTim break; } + // DateTimeOffset only permits offsets within +/- 14 hours. The ISO parser accepts + // larger zone hour/minute values, so reject them here instead of throwing from the ctor. + if (Math.Abs(offset.Ticks) > 14 * TimeSpan.TicksPerHour) + { + dt = default; + return false; + } + var ticks = d.Ticks - offset.Ticks; if (ticks is < 0 or > 3155378975999999999) { diff --git a/src/Argon/Utilities/ReflectionUtils.cs b/src/Argon/Utilities/ReflectionUtils.cs index 4b5d1a4fa..24cb766b7 100644 --- a/src/Argon/Utilities/ReflectionUtils.cs +++ b/src/Argon/Utilities/ReflectionUtils.cs @@ -712,6 +712,7 @@ public static bool IsMethodOverridden( case PrimitiveTypeCode.Boolean: return false; case PrimitiveTypeCode.Char: + return '\0'; case PrimitiveTypeCode.SByte: case PrimitiveTypeCode.Byte: case PrimitiveTypeCode.Int16: diff --git a/src/ArgonTests/BugFixes/ContractResolverBugFixes.cs b/src/ArgonTests/BugFixes/ContractResolverBugFixes.cs new file mode 100644 index 000000000..7f55ebedf --- /dev/null +++ b/src/ArgonTests/BugFixes/ContractResolverBugFixes.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +public class ContractResolverBugFixes : TestFixtureBase +{ + // Bug: CreateProperties used List.Sort (unstable introsort) so properties without an + // explicit Order lost their declaration order once the type had more than 16 members + // and any property carried an Order. + + [Fact] + public void Property_order_is_stable_when_a_property_has_order() + { + var json = JsonConvert.SerializeObject(new ManyProperties()); + var names = JObject.Parse(json) + .Properties() + .Select(_ => _.Name) + .ToList(); + + var expected = new List(); + for (var i = 1; i <= 18; i++) + { + expected.Add($"P{i:D2}"); + } + + // The ordered property sorts last (Order 1 > the implicit -1 of the rest), + // and the unordered properties keep declaration order. + expected.Add("Ordered"); + + Assert.Equal(expected, names); + } + + [Fact] + public void Default_converters_contains_a_single_encoding_converter() + { + var count = DefaultContractResolver.Converters.Count(_ => _.GetType().Name == "EncodingConverter"); + + Assert.Equal(1, count); + } + + public class ManyProperties + { + public int P01 { get; set; } + public int P02 { get; set; } + public int P03 { get; set; } + public int P04 { get; set; } + public int P05 { get; set; } + public int P06 { get; set; } + public int P07 { get; set; } + public int P08 { get; set; } + public int P09 { get; set; } + public int P10 { get; set; } + public int P11 { get; set; } + public int P12 { get; set; } + public int P13 { get; set; } + public int P14 { get; set; } + public int P15 { get; set; } + public int P16 { get; set; } + public int P17 { get; set; } + public int P18 { get; set; } + + [JsonProperty(Order = 1)] + public int Ordered { get; set; } + } +} diff --git a/src/ArgonTests/BugFixes/JsonPathBugFixes.cs b/src/ArgonTests/BugFixes/JsonPathBugFixes.cs new file mode 100644 index 000000000..23d138939 --- /dev/null +++ b/src/ArgonTests/BugFixes/JsonPathBugFixes.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +public class JsonPathBugFixes : TestFixtureBase +{ + // Bug: FieldMultipleFilter threw for ErrorWhenNoMatch unconditionally (missing else), so + // $['a','b'] errored even when the properties existed. + + [Fact] + public void SelectTokens_multiple_existing_fields_does_not_error() + { + var o = JObject.Parse("""{"a":1,"b":2}"""); + + var values = o.SelectTokens("$['a','b']", errorWhenNoMatch: true) + .Select(_ => (int) _) + .ToList(); + + Assert.Equal(new[] {1, 2}, values); + } + + [Fact] + public void SelectTokens_multiple_with_missing_field_still_errors() + { + var o = JObject.Parse("""{"a":1}"""); + + Assert.Throws(() => + o.SelectTokens("$['a','missing']", errorWhenNoMatch: true).ToList()); + } + + // Bug: filter numeric literals were only terminated by space or ')', so && / || directly + // after a number failed to parse. + + [Fact] + public void Filter_with_no_spaces_around_logical_and() + { + var a = JArray.Parse("""[{"a":1,"b":2},{"a":1,"b":9}]"""); + + var matches = a.SelectTokens("$[?(@.a==1&&@.b==2)]").ToList(); + + Assert.Single(matches); + Assert.Equal(2, (int) matches[0]["b"]); + } + + [Fact] + public void Filter_with_no_spaces_around_logical_or() + { + var a = JArray.Parse("""[{"a":1},{"a":5},{"a":9}]"""); + + var matches = a.SelectTokens("$[?(@.a==1||@.a==5)]").ToList(); + + Assert.Equal(2, matches.Count); + } + + // Bug: GetTokenIndex had no negative-index guard, so $[-1] crashed with + // ArgumentOutOfRangeException instead of returning null / throwing JsonException. + + [Fact] + public void Negative_array_index_returns_null() + { + var a = new JArray(1, 2, 3); + + Assert.Null(a.SelectToken("$[-1]")); + } + + [Fact] + public void Negative_array_index_with_error_throws_jsonexception() + { + var a = new JArray(1, 2, 3); + + Assert.Throws(() => a.SelectToken("$[-1]", errorWhenNoMatch: true)); + } +} diff --git a/src/ArgonTests/BugFixes/LinqDomBugFixes.cs b/src/ArgonTests/BugFixes/LinqDomBugFixes.cs new file mode 100644 index 000000000..6977c910b --- /dev/null +++ b/src/ArgonTests/BugFixes/LinqDomBugFixes.cs @@ -0,0 +1,99 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +public class LinqDomBugFixes : TestFixtureBase +{ + // Bug: CompareBigInteger used Math.Abs on the fractional part, discarding its sign and + // inverting comparisons when the other operand had a negative fraction. + + [Fact] + public void BigInteger_compares_correctly_against_negative_fraction() + { + // -5 > -5.5 + Assert.True(new JValue(BigInteger.Parse("-5")).CompareTo(new JValue(-5.5)) > 0); + Assert.True(new JValue(-5.5).CompareTo(new JValue(BigInteger.Parse("-5"))) < 0); + + // 5 < 5.5 (positive fraction still correct) + Assert.True(new JValue(BigInteger.Parse("5")).CompareTo(new JValue(5.5)) < 0); + Assert.True(new JValue(5.5).CompareTo(new JValue(BigInteger.Parse("5"))) > 0); + + // equal integers + Assert.Equal(0, new JValue(BigInteger.Parse("5")).CompareTo(new JValue(5.0))); + } + + // Bug: GetHashCode/GetDeepHashCode hashed the boxed CLR value, so numerically-equal + // JValues with different backing types produced different hashes, breaking hash-based + // lookups through JToken.EqualityComparer. + + [Fact] + public void Equal_numeric_values_have_equal_hashcodes() + { + var intBacked = (JValue) JToken.FromObject(-1); + var longBacked = new JValue(-1L); + Assert.True(intBacked.Equals(longBacked)); + Assert.Equal(intBacked.GetHashCode(), longBacked.GetHashCode()); + + var decimalBacked = new JValue(1.5m); + var doubleBacked = new JValue(1.5d); + Assert.True(decimalBacked.Equals(doubleBacked)); + Assert.Equal(decimalBacked.GetHashCode(), doubleBacked.GetHashCode()); + } + + [Fact] + public void EqualityComparer_matches_across_numeric_representations() + { + var comparer = JToken.EqualityComparer; + var intBacked = JToken.FromObject(-1); + var longBacked = new JValue(-1L); + + Assert.True(comparer.Equals(intBacked, longBacked)); + Assert.Equal(comparer.GetHashCode(intBacked), comparer.GetHashCode(longBacked)); + } + + // Bug: TryAddInternal incremented the insert index even when the child insert was skipped + // (e.g. a comment token in a JObject), so the next insert ran past the end and threw. + + [Fact] + public void Add_multicontent_with_comment_does_not_throw() + { + var o = new JObject(); + object[] content = + [ + new JProperty("p1", 1), + JValue.CreateComment("c"), + new JProperty("p2", 2) + ]; + o.Add(content); + + Assert.Equal(1, (int) o["p1"]); + Assert.Equal(2, (int) o["p2"]); + } + + [Fact] + public void JObject_ctor_with_leading_comment_does_not_throw() + { + var o = new JObject(JValue.CreateComment("c"), new JProperty("a", 1)); + + Assert.Equal(1, (int) o["a"]); + } + + // Bug: JObject's explicit IDictionary.Values threw NotImplementedException. + + [Fact] + public void IDictionary_Values_returns_property_values() + { + IDictionary dictionary = new JObject + { + ["a"] = 1, + ["b"] = 2 + }; + + var values = dictionary.Values + .Select(_ => (int) _) + .OrderBy(_ => _) + .ToList(); + + Assert.Equal(new[] {1, 2}, values); + } +} diff --git a/src/ArgonTests/BugFixes/ReaderBugFixes.cs b/src/ArgonTests/BugFixes/ReaderBugFixes.cs new file mode 100644 index 000000000..a4072cfd6 --- /dev/null +++ b/src/ArgonTests/BugFixes/ReaderBugFixes.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +using System.Reflection; + +public class ReaderBugFixes : TestFixtureBase +{ + [Fact] + public void Close_releases_buffer_and_is_idempotent() + { + var reader = new JsonTextReader(new StringReader("123")); + reader.Read(); + reader.Close(); + + // After close the pooled buffer must be released so a second close cannot + // return the same array to ArrayPool.Shared a second time. + var field = typeof(JsonTextReader).GetField("charBuffer", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); + Assert.Empty((char[]) field.GetValue(reader)); + + // second close must be a no-op (no double-return, no throw) + reader.Close(); + Assert.Empty((char[]) field.GetValue(reader)); + } + + [Fact] + public void Dispose_after_explicit_close_does_not_double_return() + { + var reader = new JsonTextReader(new StringReader("123")); + reader.Read(); + reader.Close(); + + // Disposing after an explicit Close must not return the buffer again. + ((IDisposable) reader).Dispose(); + + var field = typeof(JsonTextReader).GetField("charBuffer", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.Empty((char[]) field.GetValue(reader)); + } + + [Fact] + public void ReadAsInt32_number_error_sets_Undefined_token() + { + var reader = new JsonTextReader(new StringReader("[123456789012345]")); + reader.Read(); // StartArray + + Assert.Throws(() => reader.ReadAsInt32()); + + // The error-recovery token must be Undefined, distinguishable from a real JSON null. + Assert.Equal(JsonToken.Undefined, reader.TokenType); + } + + [Fact] + public void ReadAsInt32_invalid_integer_sets_Undefined_token() + { + var reader = new JsonTextReader(new StringReader("[1.5]")); + reader.Read(); // StartArray + + Assert.Throws(() => reader.ReadAsInt32()); + + Assert.Equal(JsonToken.Undefined, reader.TokenType); + } +} diff --git a/src/ArgonTests/BugFixes/SerializerEngineBugFixes.cs b/src/ArgonTests/BugFixes/SerializerEngineBugFixes.cs new file mode 100644 index 000000000..3ed8cb46d --- /dev/null +++ b/src/ArgonTests/BugFixes/SerializerEngineBugFixes.cs @@ -0,0 +1,155 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +public class SerializerEngineBugFixes : TestFixtureBase +{ + // Bug: JsonConverter.ReadJson returned null for any token whose Value is null, + // including StartObject/StartArray, without consuming the token -> reader desync. + + [Fact] + public void GenericConverter_reads_object_token_and_keeps_reader_aligned() + { + var holder = JsonConvert.DeserializeObject("""{"Point":{"X":1,"Y":2},"After":42}"""); + + Assert.NotNull(holder.Point); + Assert.Equal(1, holder.Point.X); + Assert.Equal(2, holder.Point.Y); + Assert.Equal(42, holder.After); + } + + [Fact] + public void GenericConverter_still_returns_null_for_json_null() + { + var holder = JsonConvert.DeserializeObject("""{"Point":null,"After":7}"""); + + Assert.Null(holder.Point); + Assert.Equal(7, holder.After); + } + + public class Holder + { + [JsonConverter(typeof(PointConverter))] + public Point Point { get; set; } + + public int After { get; set; } + } + + public class Point + { + public int X { get; set; } + public int Y { get; set; } + } + + class PointConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Point value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("X"); + writer.WriteValue(value.X); + writer.WritePropertyName("Y"); + writer.WriteValue(value.Y); + writer.WriteEndObject(); + } + + public override Point ReadJson(JsonReader reader, Type type, Point existingValue, bool hasExisting, JsonSerializer serializer) + { + var o = JObject.Load(reader); + return new() + { + X = (int) o["X"], + Y = (int) o["Y"] + }; + } + } + + // Bug: OrderByKey packed StringComparer into the sort-key tuple, so ordering fell + // back to the culture-sensitive default comparer. The fix uses OrdinalIgnoreCase, + // making ordering deterministic and culture-independent. + + [Fact] + public void OrderByKey_sorts_ordinal_ignore_case() + { + var target = new Dictionary + { + {"_x", 5}, + {"ab", 4}, + {"@", 2}, + {"a-c", 3}, + {"#", 1} + }; + + var settings = new JsonSerializerSettings + { + ContractResolver = new OrderByKeyContractResolver() + }; + + var json = JsonConvert.SerializeObject(target, settings); + + Assert.Equal("""{"#":1,"@":2,"a-c":3,"ab":4,"_x":5}""", json); + } + + class OrderByKeyContractResolver : DefaultContractResolver + { + protected override JsonDictionaryContract CreateDictionaryContract(Type type) + { + var contract = base.CreateDictionaryContract(type); + contract.OrderByKey = true; + return contract; + } + } + + // Bug: JsonSerializerProxy (handed to converters) did not proxy ResolveContract + // or Converters, so converters saw the default resolver and an empty converter list. + + [Fact] + public void Converter_sees_registered_converters_and_configured_resolver() + { + CaptureConverter.Reset(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + } + }; + settings.Converters.Add(new CaptureConverter()); + + JsonConvert.SerializeObject(new CaptureHolder(), settings); + + Assert.True(CaptureConverter.ConverterCount >= 1); + Assert.Equal("someValue", CaptureConverter.ResolvedPropertyName); + } + + public class CaptureHolder; + + public class Inner + { + public int SomeValue { get; set; } + } + + class CaptureConverter : JsonConverter + { + public static int ConverterCount; + public static string ResolvedPropertyName; + + public static void Reset() + { + ConverterCount = 0; + ResolvedPropertyName = null; + } + + public override void WriteJson(JsonWriter writer, CaptureHolder value, JsonSerializer serializer) + { + ConverterCount = serializer.Converters.Count; + var contract = (JsonObjectContract) serializer.ResolveContract(typeof(Inner)); + ResolvedPropertyName = contract.Properties[0].PropertyName; + writer.WriteNull(); + } + + public override CaptureHolder ReadJson(JsonReader reader, Type type, CaptureHolder existingValue, bool hasExisting, JsonSerializer serializer) => + null; + } +} diff --git a/src/ArgonTests/BugFixes/UtilitiesBugFixes.cs b/src/ArgonTests/BugFixes/UtilitiesBugFixes.cs new file mode 100644 index 000000000..31f9ab217 --- /dev/null +++ b/src/ArgonTests/BugFixes/UtilitiesBugFixes.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +public class UtilitiesBugFixes : TestFixtureBase +{ + // Bug: ReflectionUtils.GetDefaultValue(typeof(char)) returned a boxed int 0 instead of + // '\0', so DefaultValueHandling.Ignore never omitted a default char member. + + [Fact] + public void DefaultValueHandling_Ignore_omits_default_char() + { + var json = JsonConvert.SerializeObject( + new HasChar(), + new JsonSerializerSettings {DefaultValueHandling = DefaultValueHandling.Ignore}); + + Assert.Equal("{}", json); + } + + [Fact] + public void DefaultValueHandling_Ignore_keeps_non_default_char() + { + var json = JsonConvert.SerializeObject( + new HasChar {C = 'a'}, + new JsonSerializerSettings {DefaultValueHandling = DefaultValueHandling.Ignore}); + + Assert.Equal("""{"C":"a"}""", json); + } + + public class HasChar + { + public char C { get; set; } + } + + // Bug: BoxedPrimitives.Get(decimal) returned a cached positive-zero box for -0.0m, so + // the negative-zero sign was dropped at scale 0/1 but kept at higher scales. + + [Fact] + public void Negative_zero_decimal_preserves_sign_across_scales() + { + Assert.True(HasNegativeSignBit(ReadDecimal("-0.0"))); + Assert.True(HasNegativeSignBit(ReadDecimal("-0.00"))); + Assert.False(HasNegativeSignBit(ReadDecimal("0.0"))); + + // ToString is unaffected (decimal never renders a negative-zero sign). + Assert.Equal("0.0", ReadDecimal("-0.0").ToString(InvariantCulture)); + } + + static bool HasNegativeSignBit(decimal value) => + (decimal.GetBits(value)[3] & int.MinValue) != 0; + + static decimal ReadDecimal(string json) + { + var reader = new JsonTextReader(new StringReader(json)) + { + FloatParseHandling = FloatParseHandling.Decimal + }; + while (reader.Read()) + { + if (reader.TokenType == JsonToken.Float) + { + return (decimal) reader.Value; + } + } + + throw new InvalidOperationException("no float token"); + } + + // Bug: the ISO date parser accepts zone offsets up to +/-99:99, then the DateTimeOffset + // constructor threw instead of the parse returning false. + + [Fact] + public void TryParseDateTimeOffsetIso_rejects_out_of_range_offset() + { + Assert.False(DateTimeUtils.TryParseDateTimeOffsetIso(Ref("2000-01-01T00:00:00+15:00"), out _)); + Assert.False(DateTimeUtils.TryParseDateTimeOffsetIso(Ref("2000-01-01T00:00:00-15:00"), out _)); + Assert.False(DateTimeUtils.TryParseDateTimeOffsetIso(Ref("2000-01-01T00:00:00+13:99"), out _)); + + // +/-14:00 is the maximum offset DateTimeOffset allows. + Assert.True(DateTimeUtils.TryParseDateTimeOffsetIso(Ref("2000-01-01T00:00:00+14:00"), out _)); + } + + static StringReference Ref(string s) => + new(s.ToCharArray(), 0, s.Length); +} diff --git a/src/ArgonTests/BugFixes/WriterBugFixes.cs b/src/ArgonTests/BugFixes/WriterBugFixes.cs new file mode 100644 index 000000000..b8d2a1257 --- /dev/null +++ b/src/ArgonTests/BugFixes/WriterBugFixes.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +using System.Text; + +public class WriterBugFixes : TestFixtureBase +{ + [Fact] + public void ToString_TimeSpan_does_not_pad_with_nulls() + { + Assert.Equal("\"01:00:00\"", JsonConvert.ToString(TimeSpan.FromHours(1))); + Assert.Equal("\"1.02:03:04\"", JsonConvert.ToString(new TimeSpan(1, 2, 3, 4))); + } + + [Fact] + public void ToString_object_TimeSpan_does_not_pad_with_nulls() => + Assert.Equal("\"01:00:00\"", JsonConvert.ToString((object) TimeSpan.FromHours(1))); + + [Fact] + public void WriteValue_StringBuilder_multichunk_writes_single_string_in_array() + { + var builder = new StringBuilder(); + builder.Append('a', 10); + builder.Append('b', 9000); + + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartArray(); + writer.WriteValue(builder); + writer.WriteEndArray(); + } + + Assert.Equal($"[\"{builder}\"]", stringWriter.ToString()); + } + + [Fact] + public void WriteValue_StringBuilder_multichunk_writes_single_string_as_property() + { + var builder = new StringBuilder(); + builder.Append('x', 10000); + + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartObject(); + writer.WritePropertyName("p"); + writer.WriteValue(builder); + writer.WriteEndObject(); + } + + Assert.Equal($"{{\"p\":\"{builder}\"}}", stringWriter.ToString()); + } + + [Fact] + public void WriteValue_StringBuilder_null_writes_null() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteValue((StringBuilder) null); + } + + Assert.Equal("null", stringWriter.ToString()); + } + + [Fact] + public void WriteValue_char_honors_QuoteChar() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter) {QuoteChar = '\''}) + { + writer.WriteValue('c'); + } + + Assert.Equal("'c'", stringWriter.ToString()); + } + + [Fact] + public void WriteValue_char_honors_QuoteValue() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter) {QuoteValue = false}) + { + writer.WriteValue('c'); + } + + Assert.Equal("c", stringWriter.ToString()); + } + + [Fact] + public void WriteValue_char_default_is_double_quoted() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteValue('c'); + } + + Assert.Equal("\"c\"", stringWriter.ToString()); + } +} diff --git a/src/ArgonTests/BugFixes/XmlBugFixes.cs b/src/ArgonTests/BugFixes/XmlBugFixes.cs new file mode 100644 index 000000000..a5b0de6a9 --- /dev/null +++ b/src/ArgonTests/BugFixes/XmlBugFixes.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +using System.Xml; + +public class XmlBugFixes : TestFixtureBase +{ + // Bug: a single-element nested array lost its json:Array marker when the property name was + // prefixed or XML-encoded, because the raw JSON name was compared against the decoded + // element LocalName. Result: the inner array nesting was dropped on round-trip. + + [Fact] + public void Nested_single_element_array_with_prefix_round_trips() + { + var json = """{"root":{"@xmlns:ns":"http://x","ns:items":[["a"]]}}"""; + + var doc = JsonXmlConvert.DeserializeXmlNode(json, null, true); + var roundTripped = JObject.Parse(JsonXmlConvert.SerializeXmlNode(doc)); + + var items = roundTripped["root"]["ns:items"]; + Assert.Equal(JTokenType.Array, items.Type); + Assert.Equal(JTokenType.Array, items[0].Type); + Assert.Equal("a", (string) items[0][0]); + } + + // Bug: two or more sibling comments serialized to "#comment": [] - all comment text dropped + // plus a bogus property that cannot round-trip. + + [Fact] + public void Multiple_sibling_comments_round_trip() + { + var doc = new XmlDocument(); + doc.LoadXml(""); + + var json = JsonXmlConvert.SerializeXmlNode(doc); + Assert.DoesNotContain("#comment", json); + + var roundTripped = JsonXmlConvert.DeserializeXmlNode(json); + var comments = roundTripped.DocumentElement + .ChildNodes + .Cast() + .Where(_ => _.NodeType == XmlNodeType.Comment) + .Select(_ => _.Value) + .ToList(); + + Assert.Equal(new[] {"a", "b"}, comments); + } + + // Bug: XmlDocumentWrapper.CreateXmlDocumentType discarded the internalSubset argument, so + // JSON -> XmlDocument silently lost DTD internal subsets. + + [Fact] + public void DocType_internal_subset_is_preserved() + { + var json = """{"!DOCTYPE":{"@name":"root","@internalSubset":""},"root":"x"}"""; + + var doc = JsonXmlConvert.DeserializeXmlNode(json); + + Assert.NotNull(doc.DocumentType); + Assert.Contains("ENTITY foo", doc.DocumentType.InternalSubset); + } + + // Bug: #cdata-section / #text with a JSON null crashed with ArgumentNullException for + // XDocument targets (while XmlDocument produced an empty node). + + [Fact] + public void CData_null_does_not_crash_xdocument() + { + var xdoc = JsonXmlConvert.DeserializeXNode("""{"root":{"#cdata-section":null}}"""); + + Assert.NotNull(xdoc); + Assert.Contains("CDATA", xdoc.ToString()); + } + + [Fact] + public void Text_null_does_not_crash_xdocument() + { + var xdoc = JsonXmlConvert.DeserializeXNode("""{"root":{"#text":null}}"""); + + Assert.NotNull(xdoc); + } +} From 4b7ccd8ff7c437d09ddb26bea8e2b6e8affdac09 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 6 Jul 2026 19:35:39 +1000 Subject: [PATCH 2/2] Update ArgonTests.csproj --- src/ArgonTests/ArgonTests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ArgonTests/ArgonTests.csproj b/src/ArgonTests/ArgonTests.csproj index ec05ea36b..5294a9778 100644 --- a/src/ArgonTests/ArgonTests.csproj +++ b/src/ArgonTests/ArgonTests.csproj @@ -43,6 +43,7 @@ +