diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index 861ef04..37a3b4a 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -16,7 +16,7 @@ namespace QsNet.Tests; -public class DecodeTest +public partial class DecodeTest { [Fact] public void Decode_ThrowsArgumentException_WhenParameterLimitIsNotPositive() @@ -27,6 +27,45 @@ public void Decode_ThrowsArgumentException_WhenParameterLimitIsNotPositive() exception.Message.Should().Contain("Parameter limit must be a positive integer."); } + [Fact] + public void Decode_StrictDepthOverflowThrows() + { + var options = new DecodeOptions { Depth = 1, StrictDepth = true }; + Action act = () => Qs.Decode("a[b][c]=1", options); + act.Should() + .Throw() + .WithMessage("Input depth exceeded depth option of 1 and strictDepth is true"); + } + + [Fact] + public void SplitKeyIntoSegments_StrictDepthThrowsOnTrailingText() + { + Action act = () => InternalDecoder.SplitKeyIntoSegments("a[b]c", false, 1, true); + act.Should() + .Throw() + .WithMessage("Input depth exceeded depth option of 1 and strictDepth is true"); + } + + [Fact] + public void SplitKeyIntoSegments_AppendsTrailingSegmentWhenNotStrict() + { + var segments = InternalDecoder.SplitKeyIntoSegments("a[b]c", false, 2, false); + segments.Should().Contain("[c]"); + } + + [Fact] + public void ParseKeys_ConvertsHashtableLeafToObjectKeyedDictionary() + { + var hashtable = new Hashtable { ["inner"] = "value" }; + var result = InternalDecoder.ParseKeys("root", hashtable, new DecodeOptions(), false); + + result.Should().BeOfType>(); + var dict = (Dictionary)result; + dict.Should().ContainKey("root"); + dict["root"].Should().BeOfType>() + .Which.Should().ContainKey("inner").WhoseValue.Should().Be("value"); + } + [Fact] public void Decode_NestedListHandling_InParseObjectMethod() { @@ -62,7 +101,7 @@ public void Decode_NestedListHandling_InParseObjectMethod() // Try a more complex approach that should trigger the specific code path // First, create a query string that will create a list with a nested list - var queryString3 = "a[0][]=first&a[0][]=second"; + const string queryString3 = "a[0][]=first&a[0][]=second"; // Now decode it, which should create a list with a nested list var result3 = Qs.Decode(queryString3); @@ -78,7 +117,7 @@ public void Decode_NestedListHandling_InParseObjectMethod() result3.Should().BeEquivalentTo(expected3); // Now try to add to the existing list - var queryString4 = "a[0][2]=third"; + const string queryString4 = "a[0][2]=third"; // Decode it with the existing result as the input var result4 = Qs.Decode(queryString4); @@ -1395,8 +1434,7 @@ public void Decode_ParsesBuffersCorrectly() [Fact] public void Decode_ParsesJqueryParamStrings() { - var encoded = - "filter%5B0%5D%5B%5D=int1&filter%5B0%5D%5B%5D=%3D&filter%5B0%5D%5B%5D=77&filter%5B%5D=and&filter%5B2%5D%5B%5D=int2&filter%5B2%5D%5B%5D=%3D&filter%5B2%5D%5B%5D=8"; + const string encoded = "filter%5B0%5D%5B%5D=int1&filter%5B0%5D%5B%5D=%3D&filter%5B0%5D%5B%5D=77&filter%5B%5D=and&filter%5B2%5D%5B%5D=int2&filter%5B2%5D%5B%5D=%3D&filter%5B2%5D%5B%5D=8"; var expected = new Dictionary { ["filter"] = new List @@ -1451,7 +1489,7 @@ public void Decode_ParsesStringWithAlternativeStringDelimiter() [Fact] public void Decode_ParsesStringWithAlternativeRegexDelimiter() { - Qs.Decode("a=b; c=d", new DecodeOptions { Delimiter = new RegexDelimiter(@"[;,] *") }) + Qs.Decode("a=b; c=d", new DecodeOptions { Delimiter = new RegexDelimiter("[;,] *") }) .Should() .BeEquivalentTo(new Dictionary { ["a"] = "b", ["c"] = "d" }); } @@ -1919,7 +1957,7 @@ public void Decode_ParsesDatesCorrectly() [Fact] public void Decode_ParsesRegularExpressionsCorrectly() { - var re = new Regex("^test$"); + var re = MyRegex(); Qs.Decode(new Dictionary { ["a"] = re }) .Should() .BeEquivalentTo(new Dictionary { ["a"] = re }); @@ -1995,13 +2033,14 @@ public void Decode_CanParseWithCustomEncoding() { var expected = new Dictionary { ["県"] = "大阪府" }; + var options = new DecodeOptions { Decoder = CustomDecoder }; + Qs.Decode("%8c%a7=%91%e5%8d%e3%95%7b", options).Should().BeEquivalentTo(expected); + return; + string? CustomDecoder(string? str, Encoding? charset) { return str?.Replace("%8c%a7", "県").Replace("%91%e5%8d%e3%95%7b", "大阪府"); } - - var options = new DecodeOptions { Decoder = CustomDecoder }; - Qs.Decode("%8c%a7=%91%e5%8d%e3%95%7b", options).Should().BeEquivalentTo(expected); } [Fact] @@ -2135,11 +2174,6 @@ public void Decode_HandlesCustomDecoderReturningNullInIso88591CharsetWhenInterpr { const string urlEncodedNumSmiley = "%26%239786%3B"; - string? CustomDecoder(string? str, Encoding? charset) - { - return !string.IsNullOrEmpty(str) ? Utils.Decode(str, charset) : null; - } - var options = new DecodeOptions { Charset = Encoding.Latin1, @@ -2150,6 +2184,12 @@ public void Decode_HandlesCustomDecoderReturningNullInIso88591CharsetWhenInterpr Qs.Decode($"foo=&bar={urlEncodedNumSmiley}", options) .Should() .BeEquivalentTo(new Dictionary { ["foo"] = null, ["bar"] = "☺" }); + return; + + string? CustomDecoder(string? str, Encoding? charset) + { + return !string.IsNullOrEmpty(str) ? Utils.Decode(str, charset) : null; + } } [Fact] @@ -3817,12 +3857,7 @@ public void ShouldUseNumberDecoder() { var options = new DecodeOptions { - Decoder = (value, _) => - { - if (int.TryParse(value, out var intValue)) - return $"[{intValue}]"; - return value; - } + Decoder = (value, _) => int.TryParse(value, out var intValue) ? $"[{intValue}]" : value }; Qs.Decode("foo=1", options) @@ -4782,5 +4817,8 @@ public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn() act.Should().Throw(); } + [GeneratedRegex("^test$")] + private static partial Regex MyRegex(); + #endregion -} \ No newline at end of file +} diff --git a/QsNet.Tests/EncodeTests.cs b/QsNet.Tests/EncodeTests.cs index 4fa17d6..5420fde 100644 --- a/QsNet.Tests/EncodeTests.cs +++ b/QsNet.Tests/EncodeTests.cs @@ -18,6 +18,12 @@ namespace QsNet.Tests; [TestSubject(typeof(Encoder))] public class EncodeTests { + private static readonly string[] Value = ["b", "c"]; + private static readonly string[] ValueArray = ["b", "c"]; + private static readonly string[] ValueArray0 = ["b"]; + private static readonly string[] Iterable = ["a"]; + private static readonly string[] Value1 = ["b"]; + [Fact] public void Encode_DefaultParameterInitializationsInEncodeMethod() { @@ -36,7 +42,7 @@ public void Encode_DefaultParameterInitializationsInEncodeMethod() // Try another approach with a list to trigger the generateArrayPrefix default var result2 = Qs.Encode( - new Dictionary { { "a", new[] { "b", "c" } } }, + new Dictionary { { "a", Value } }, new EncodeOptions { // Force the code to use the default initializations @@ -48,7 +54,7 @@ public void Encode_DefaultParameterInitializationsInEncodeMethod() // Try with comma format to trigger the commaRoundTrip default var result3 = Qs.Encode( - new Dictionary { { "a", new[] { "b", "c" } } }, + new Dictionary { { "a", ValueArray } }, new EncodeOptions { ListFormat = ListFormat.Comma, CommaRoundTrip = null } ); result3.Should().Be("a=b%2Cc"); @@ -159,7 +165,7 @@ public void Encode_WithDefaultParameterValues() var customOptions = new EncodeOptions { ListFormat = ListFormat.Comma, Encode = false }; // This should use the default commaRoundTrip value (false) - Qs.Encode(new Dictionary { { "a", new[] { "b" } } }, customOptions) + Qs.Encode(new Dictionary { { "a", ValueArray0 } }, customOptions) .Should() .Be("a=b"); @@ -173,7 +179,7 @@ public void Encode_WithDefaultParameterValues() // This should append [] to single-item lists Qs.Encode( - new Dictionary { { "a", new[] { "b" } } }, + new Dictionary { { "a", Value1 } }, customOptionsWithCommaRoundTrip ) .Should() @@ -200,15 +206,119 @@ public void Encode_EncodesFalsyValues() } [Fact] - public void Encode_EncodesLongs() + public void Encode_PrimitiveBoolWithoutEncoder_UsesLiteralValues() { - var three = 3L; + var options = new EncodeOptions { Encode = false }; + var result = Qs.Encode(new Dictionary { { "flag", true } }, options); + result.Should().Be("flag=true"); + } - string EncodeWithN(object? value, Encoding? encoding, Format? format) + [Fact] + public void Encode_NonListEnumerableMaterializesIndices() + { + var queue = new Queue(["a", "b"]); + var result = Qs.Encode(new Dictionary { { "queue", queue } }, new EncodeOptions { Encode = false }); + result.Should().Be("queue[0]=a&queue[1]=b"); + } + + [Fact] + public void Encode_IterableFilterAllowsConvertibleIndices() + { + var list = new List { "zero", "one", "two" }; + var encoded = Encoder.Encode( + list, + undefined: false, + sideChannel: new SideChannelFrame(), + prefix: "items", + filter: new IterableFilter(new object[] { "1", "missing" }) + ); + + encoded.Should().BeOfType>(); + var parts = ((List)encoded).Select(v => v?.ToString()).Where(s => !string.IsNullOrEmpty(s)).ToList(); + parts.Should().Contain("items[1]=one"); + parts.Should().HaveCount(1); + } + + [Fact] + public void Encode_AcceptsNonGenericDictionary() + { + var map = new Hashtable { { "a", "b" }, { 1, "one" } }; + var result = Qs.Encode(map, new EncodeOptions { Encode = false }); + result.Split('&').Should().BeEquivalentTo("a=b", "1=one"); + } + + [Fact] + public void Encode_CopiesGenericInterfaceDictionary() + { + IDictionary sorted = new SortedList { ["b"] = 2, ["a"] = 1 }; + + var encoded = Qs.Encode(sorted, new EncodeOptions { Encode = false }); + + encoded.Should().Be("a=1&b=2"); + } + + [Fact] + public void Encode_FilterExceptionsAreIgnored() + { + var data = new Dictionary { ["a"] = 1 }; + var options = new EncodeOptions { - var result = Utils.Encode(value, format: format); - return value is long ? $"{result}n" : result; - } + Encode = false, + Filter = new FunctionFilter((key, value) => key.Length == 0 ? throw new InvalidOperationException() : value) + }; + + Qs.Encode(data, options).Should().Be("a=1"); + } + + [Fact] + public void Encode_SkipNullsSkipsMissingFilteredKeys() + { + var data = new Dictionary { ["present"] = "value" }; + var options = new EncodeOptions + { + Encode = false, + SkipNulls = true, + Filter = new IterableFilter(new object[] { "present", "missing" }) + }; + + Qs.Encode(data, options).Should().Be("present=value"); + } + + [Fact] + public void Encode_FilterReturningHashtableIsConverted() + { + var data = new Dictionary { ["ignored"] = "value" }; + var options = new EncodeOptions + { + Encode = false, + Filter = new FunctionFilter((key, value) => key.Length == 0 ? new Hashtable { ["x"] = "y" } : value) + }; + + Qs.Encode(data, options).Should().Be("x=y"); + } + + [Fact] + public void Encoder_TreatsOutOfRangeIterableIndicesAsUndefined() + { + var list = new List { "zero", "one" }; + var encoded = Encoder.Encode( + list, + undefined: false, + sideChannel: new SideChannelFrame(), + prefix: "items", + filter: new IterableFilter(new object[] { "0", "5" }) + ); + + encoded.Should().BeOfType>(); + var parts = ((List)encoded).Select(x => x?.ToString()).ToList(); + parts.Should().Contain("items[0]=zero"); + parts.Should().NotContain(s => s != null && s.Contains("items[5]")); + } + + [Fact] + public void Encode_EncodesLongs() + { + const long three = 3L; Qs.Encode(three).Should().Be(""); Qs.Encode(new List { three }).Should().Be("0=3"); @@ -251,6 +361,13 @@ string EncodeWithN(object? value, Encoding? encoding, Format? format) ) .Should() .Be("a[]=3n"); + return; + + string EncodeWithN(object? value, Encoding? encoding, Format? format) + { + var result = Utils.Encode(value, format: format); + return value is long ? $"{result}n" : result; + } } [Fact] @@ -1755,7 +1872,7 @@ public void Encode_EncodesBooleanValues() [Fact] public void Encode_EncodesBufferValues() { - Qs.Encode(new Dictionary { { "a", Encoding.UTF8.GetBytes("test") } }) + Qs.Encode(new Dictionary { { "a", "test"u8.ToArray() } }) .Should() .Be("a=test"); @@ -1764,7 +1881,7 @@ public void Encode_EncodesBufferValues() { { "a", - new Dictionary { { "b", Encoding.UTF8.GetBytes("test") } } + new Dictionary { { "b", "test"u8.ToArray() } } } } ) @@ -1874,7 +1991,7 @@ public void Encode_SelectsPropertiesWhenFilterIsIterableFilter() { Qs.Encode( new Dictionary { { "a", "b" } }, - new EncodeOptions { Filter = new IterableFilter(new[] { "a" }) } + new EncodeOptions { Filter = new IterableFilter(Iterable) } ) .Should() .Be("a=b"); @@ -2036,11 +2153,6 @@ public void Encode_CanDisableUriEncoding() [Fact] public void Encode_CanSortTheKeys() { - int Sort(object? a, object? b) - { - return string.Compare(a?.ToString(), b?.ToString(), StringComparison.Ordinal); - } - Qs.Encode( new Dictionary { @@ -2067,16 +2179,17 @@ int Sort(object? a, object? b) ) .Should() .Be("a=c&b=f&z%5Bi%5D=b&z%5Bj%5D=a"); - } + return; - [Fact] - public void Encode_CanSortTheKeysAtDepth3OrMoreToo() - { int Sort(object? a, object? b) { return string.Compare(a?.ToString(), b?.ToString(), StringComparison.Ordinal); } + } + [Fact] + public void Encode_CanSortTheKeysAtDepth3OrMoreToo() + { Qs.Encode( new Dictionary { @@ -2142,6 +2255,12 @@ int Sort(object? a, object? b) ) .Should() .Be("a=a&z[zj][zjb]=zjb&z[zj][zja]=zja&z[zi][zib]=zib&z[zi][zia]=zia&b=b"); + return; + + int Sort(object? a, object? b) + { + return string.Compare(a?.ToString(), b?.ToString(), StringComparison.Ordinal); + } } [Fact] @@ -2210,7 +2329,7 @@ public void Encode_CanUseCustomEncoderForBufferMap() .Should() .Be("a=b"); - var bufferWithText = Encoding.UTF8.GetBytes("a b"); + var bufferWithText = "a b"u8.ToArray(); Qs.Encode( new Dictionary { { "a", bufferWithText } }, @@ -2319,7 +2438,7 @@ public void Encode_Rfc1738Serialization() .Be("a+b=c+d"); Qs.Encode( - new Dictionary { { "a b", Encoding.UTF8.GetBytes("a b") } }, + new Dictionary { { "a b", "a b"u8.ToArray() } }, new EncodeOptions { Format = Format.Rfc1738 } ) .Should() @@ -2351,7 +2470,7 @@ public void Encode_Rfc3986SpacesSerialization() .Be("a%20b=c%20d"); Qs.Encode( - new Dictionary { { "a b", Encoding.UTF8.GetBytes("a b") } }, + new Dictionary { { "a b", "a b"u8.ToArray() } }, new EncodeOptions { Format = Format.Rfc3986 } ) .Should() @@ -2363,7 +2482,7 @@ public void Encode_BackwardCompatibilityToRfc3986() { Qs.Encode(new Dictionary { { "a", "b c" } }).Should().Be("a=b%20c"); - Qs.Encode(new Dictionary { { "a b", Encoding.UTF8.GetBytes("a b") } }) + Qs.Encode(new Dictionary { { "a b", "a b"u8.ToArray() } }) .Should() .Be("a%20b=a%20b"); } @@ -3142,7 +3261,7 @@ public void Encode_EncodesNumberValue() [Fact] public void Encode_EncodesBufferValue() { - Qs.Encode(new Dictionary { { "a", Encoding.UTF8.GetBytes("test") } }) + Qs.Encode(new Dictionary { { "a", "test"u8.ToArray() } }) .Should() .Be("a=test"); } @@ -3463,7 +3582,7 @@ public void Encode_CommaListWithSingleElementAndRoundTripAddsArrayBrackets() [Fact] public void Encode_CommaListWithSingleElementAndRoundTripDisabledOmitsArrayBrackets() { - var only = "v"; + const string only = "v"; var result = Qs.Encode( new Dictionary @@ -4255,7 +4374,7 @@ public void ByteArray_Is_Treated_As_Primitive_And_Encoded_With_Default_Encoder() { var data = new Dictionary { - ["b"] = Encoding.UTF8.GetBytes("hi") + ["b"] = "hi"u8.ToArray() }; var qs = Qs.Encode(data, new EncodeOptions()); @@ -4627,4 +4746,4 @@ public class CustomObject(string value) { public string this[string key] => key == "prop" ? value : throw new KeyNotFoundException($"Key '{key}' not found"); -} \ No newline at end of file +} diff --git a/QsNet.Tests/SentinelExtensionsTests.cs b/QsNet.Tests/SentinelExtensionsTests.cs index faf9b73..f6946f5 100644 --- a/QsNet.Tests/SentinelExtensionsTests.cs +++ b/QsNet.Tests/SentinelExtensionsTests.cs @@ -32,7 +32,7 @@ public void ToString_Extension_ReturnsEncoded_ForIsoAndCharset() [Fact] public void GetValue_Throws_ForInvalidEnum() { - var invalid = (Sentinel)999; + const Sentinel invalid = (Sentinel)999; Action act = () => invalid.GetValue(); act.Should().Throw(); } diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index cc4d12d..2a5e94d 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -348,7 +348,7 @@ public void Escape_HandlesStringWithVariousPunctuation() [Fact] public void Escape_HandlesNullCharacter() { - Utils.Escape("\u0000").Should().Be("%00"); + Utils.Escape("\0").Should().Be("%00"); } [Fact] @@ -1196,9 +1196,9 @@ public void Combine_BothLists() [Fact] public void Combine_OneListOneNonList() { - var aN = 1; + const int aN = 1; var a = new List { aN }; - var bN = 2; + const int bN = 2; var b = new List { bN }; var combinedAnB = Utils.Combine(aN, b); @@ -1213,8 +1213,8 @@ public void Combine_OneListOneNonList() [Fact] public void Combine_NeitherIsList() { - var a = 1; - var b = 2; + const int a = 1; + const int b = 2; var combined = Utils.Combine(a, b); combined.Should().BeEquivalentTo(new List { 1, 2 }); @@ -1250,8 +1250,8 @@ public void InterpretNumericEntities_DecodesASingleDecimalEntity() [Fact] public void InterpretNumericEntities_DecodesMultipleEntitiesInASentence() { - var input = "Hello World!"; - var expected = "Hello World!"; + const string input = "Hello World!"; + const string expected = "Hello World!"; Utils.InterpretNumericEntities(input).Should().Be(expected); } @@ -1761,9 +1761,7 @@ public void Reset() } } - private sealed class CustomType - { - } + private sealed class CustomType; #endregion @@ -2055,5 +2053,276 @@ public void ConvertDictionaryToStringKeyed_Converts_NonGeneric_Keys_To_Strings() res.Should().Equal(new Dictionary { ["1"] = "x", ["y"] = 2 }); } + [Fact] + public void Merge_ReturnsTargetWhenSourceIsNull() + { + var target = new Dictionary { ["a"] = "b" }; + var result = Utils.Merge(target, null); + result.Should().BeSameAs(target); + } + + [Fact] + public void Merge_RemovesUndefinedEntriesWhenListsAreDisabled() + { + var undefined = Undefined.Create(); + var target = new List { undefined, "keep" }; + var options = new DecodeOptions { ParseLists = false }; + + var result = Utils.Merge(target, Undefined.Create(), options); + + result.Should().BeEquivalentTo(new List { "keep" }); + } + + [Fact] + public void Encode_UnpairedSurrogateFallsBackToThreeByteSequence() + { + const string highSurrogate = "\uD83D"; // lone high surrogate, invalid pair + var encoded = Utils.Encode(highSurrogate); + encoded.Should().Be("%ED%A0%BD"); + } + + [Fact] + public void Compact_ConvertsNestedNonGenericDictionariesInsideStringMaps() + { + var inner = new Hashtable { ["x"] = 1 }; + var stringKeyed = new Dictionary { ["inner"] = inner, ["skip"] = Undefined.Create() }; + var root = new Dictionary { ["root"] = stringKeyed }; + + var compacted = Utils.Compact(root); + + compacted.Should().ContainKey("root"); + var converted = compacted["root"].Should().BeOfType>().Which; + converted.Should().NotContainKey("skip"); + converted["inner"].Should().BeOfType>(); + } + + [Fact] + public void ConvertNestedDictionary_HandlesCyclicReferencesWithoutReentry() + { + IDictionary first = new Hashtable(); + IDictionary second = new Hashtable(); + first["next"] = second; + second["back"] = first; + + var converted = Utils.ConvertNestedDictionary(first); + + converted.Should().ContainKey("next"); + var next = converted["next"].Should().BeOfType>().Which; + next.Should().ContainKey("back"); + var back = next["back"].Should().BeOfType>().Which; + back.Should().ContainKey("next"); + back["next"].Should().BeAssignableTo(); + } + + [Fact] + public void Decode_InvalidPercentEncodingFallsBackToOriginal() + { + const string original = "%E0%A4"; + var strictEncoding = Encoding.GetEncoding( + "utf-8", + new EncoderExceptionFallback(), + new DecoderExceptionFallback() + ); + + Utils.Decode(original, strictEncoding).Should().Be(original); + } + + [Fact] + public void InterpretNumericEntities_InvalidDigitsLeaveSequenceUntouched() + { + const string input = "&#xyz;"; + Utils.InterpretNumericEntities(input).Should().Be(input); + } + + [Fact] + public void InterpretNumericEntities_OverflowDigitsAreLeftAlone() + { + const string input = "�"; + Utils.InterpretNumericEntities(input).Should().Be(input); + } + + private sealed class ThrowingEncoding : Encoding + { + private sealed class ThrowingDecoder : System.Text.Decoder + { + public override int GetCharCount(byte[] bytes, int index, int count) => + throw new InvalidOperationException("decoder failure"); + + public override int GetChars(byte[] bytes, int byteIndex, int byteCount, char[] chars, int charIndex) => + throw new InvalidOperationException("decoder failure"); + } + + public override string EncodingName => "ThrowingEncoding"; + public override int GetByteCount(char[] chars, int index, int count) => throw new NotSupportedException(); + public override int GetBytes(char[] chars, int charIndex, int charCount, byte[] bytes, int byteIndex) => + throw new NotSupportedException(); + public override int GetCharCount(byte[] bytes, int index, int count) => throw new InvalidOperationException(); + public override int GetChars(byte[] bytes, int byteIndex, int byteCount, char[] chars, int charIndex) => + throw new InvalidOperationException(); + public override int GetMaxByteCount(int charCount) => charCount * 2; + public override int GetMaxCharCount(int byteCount) => byteCount; + public override System.Text.Decoder GetDecoder() => new ThrowingDecoder(); + public override byte[] GetPreamble() => []; + } + + [Fact] + public void Decode_ReturnsOriginalWhenUrlDecodeThrows() + { + const string encoded = "%41"; + Utils.Decode(encoded, new ThrowingEncoding()).Should().Be(encoded); + } + + [Fact] + public void ToStringKeyDeepNonRecursive_ThrowsForNonDictionaryRoot() + { + Action act = () => Utils.ToStringKeyDeepNonRecursive(new object()); + act.Should().Throw().WithMessage("*Root must be an IDictionary*"); + } + + [Fact] + public void ConvertNestedDictionary_PreservesSelfReferencesAndStringKeyedMaps() + { + IDictionary parent = new Hashtable(); + parent["self"] = parent; + var stringChild = new Dictionary { ["x"] = 1 }; + parent["string"] = stringChild; + + var converted = Utils.ConvertNestedDictionary(parent); + + converted["self"].Should().BeSameAs(parent); + converted["string"].Should().BeSameAs(stringChild); + } + + [Fact] + public void Compact_VisitsStringKeyedDictionaries() + { + var child = new Dictionary { ["value"] = 1 }; + var root = new Dictionary { ["child"] = child }; + + var result = Utils.Compact(root); + + result.Should().ContainKey("child"); + result["child"].Should().BeOfType>().Which.Should().ContainKey("value"); + } + + [Fact] + public void Compact_ConvertsNestedNonGenericDictionaryWithinStringDictionary() + { + var inner = new Hashtable { ["x"] = 1 }; + var map = new Dictionary { ["inner"] = inner }; + var root = new Dictionary { ["outer"] = map }; + + var compacted = Utils.Compact(root); + + var converted = compacted["outer"].Should().BeOfType>().Which; + converted["inner"].Should().BeOfType>(); + } + + [Fact] + public void Compact_TrimsUndefinedAcrossMixedStructures() + { + var nestedObjectDict = new Dictionary(); + var nestedStringDict = new Dictionary { ["keep"] = "value" }; + var nestedList = new List { "item" }; + var oddMap = new Hashtable { ["k"] = "v" }; + + var stringDict = new Dictionary + { + ["undef"] = Undefined.Create(), + ["objectDict"] = nestedObjectDict, + ["stringDict"] = nestedStringDict, + ["list"] = nestedList, + ["odd"] = oddMap + }; + + var list = new List + { + Undefined.Create(), + nestedObjectDict, + nestedStringDict, + nestedList, + new Hashtable { ["z"] = "w" } + }; + + var root = new Dictionary + { + ["map"] = stringDict, + ["list"] = list + }; + + var compacted = Utils.Compact(root); + + var compactedDict = compacted["map"].Should().BeOfType>().Which; + compactedDict.Should().NotContainKey("undef"); + compactedDict["objectDict"].Should().BeOfType>().Which.Should().BeEmpty(); + compactedDict["stringDict"].Should().BeSameAs(nestedStringDict); + compactedDict["list"].Should().BeSameAs(nestedList); + compactedDict["odd"].Should().BeOfType>(); + + var compactedList = compacted["list"].Should().BeOfType>().Which; + compactedList.Should().HaveCount(4); + compactedList[0].Should().BeSameAs(nestedObjectDict); + compactedList[1].Should().BeSameAs(nestedStringDict); + compactedList[2].Should().BeSameAs(nestedList); + compactedList[3].Should().BeOfType>(); + } + + [Fact] + public void ToStringKeyDeepNonRecursive_ReusesVisitedNodesInLists() + { + IDictionary shared = new Hashtable { ["v"] = 1 }; + IList list = new ArrayList { shared, shared }; + IDictionary root = new Hashtable { ["list"] = list }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + var convertedList = result["list"].Should().BeOfType>().Which; + convertedList[0].Should().BeSameAs(convertedList[1]); + } + + [Fact] + public void ToStringKeyDeepNonRecursive_ReusesListsReferencedMultipleTimes() + { + IList shared = new ArrayList { 1 }; + IDictionary root = new Hashtable { ["a"] = shared, ["b"] = shared }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + var first = result["a"].Should().BeOfType>().Which; + var second = result["b"].Should().BeOfType>().Which; + second.Should().BeSameAs(first); + } + + [Fact] + public void ToStringKeyDeepNonRecursive_SupportsSelfReferentialLists() + { + var inner = new ArrayList(); + inner.Add(inner); + IDictionary root = new Hashtable { ["loop"] = inner }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + var convertedList = result["loop"].Should().BeOfType>().Which; + convertedList[0].Should().BeSameAs(convertedList); + } + + [Fact] + public void ConvertNestedDictionary_ReturnsExistingStringKeyedInstanceWhenVisited() + { + var method = typeof(Utils) + .GetMethod( + "ConvertNestedDictionary", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(IDictionary), typeof(ISet)], + null + ); + method.Should().NotBeNull(); + + var dictionary = new Dictionary { ["x"] = 1 }; + IDictionary raw = dictionary; + var visited = new HashSet(Internal.ReferenceEqualityComparer.Instance) { raw }; + + var result = (Dictionary)method.Invoke(null, [raw, visited])!; + result.Should().BeSameAs(dictionary); + } + #endregion -} \ No newline at end of file +}