diff --git a/.gitignore b/.gitignore index 2393f2b..8c72c89 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ _ReSharper.Caches/ *.DotSettings.user *.sln.DotSettings.user riderModule.iml +.junie # User-specific/legacy *.user diff --git a/QsNet.Tests/DecodeOptionsTests.cs b/QsNet.Tests/DecodeOptionsTests.cs index 3d43c45..e7c0f13 100644 --- a/QsNet.Tests/DecodeOptionsTests.cs +++ b/QsNet.Tests/DecodeOptionsTests.cs @@ -217,4 +217,52 @@ public void CopyWith_PreservesAndOverrides_Decoders() copy3.DecodeValue("v", Encoding.UTF8).Should().Be("K2:Value:v"); copy3.DecodeKey("k", Encoding.UTF8).Should().Be("K2:Key:k"); } + + [Fact] + public void AllowDots_IsImpliedTrue_When_DecodeDotInKeys_True_And_NotExplicit() + { + var opts = new DecodeOptions { DecodeDotInKeys = true }; + opts.AllowDots.Should().BeTrue(); + } + + [Fact] + public void AllowDots_ExplicitFalse_Wins_Even_When_DecodeDotInKeys_True() + { + var opts = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true }; + opts.AllowDots.Should().BeFalse(); + } + + [Fact] + public void DecodeKey_Throws_When_CustomKindAwareDecoder_Returns_NonString() + { + var opts = new DecodeOptions + { + DecoderWithKind = (_, _, kind) => kind == DecodeKind.Key ? 123 : "ok" + }; + + Action act = () => opts.DecodeKey("abc", Encoding.UTF8); + act.Should().Throw() + .WithMessage("*Key decoder must return a string or null*Int32*"); + } + + [Fact] + public void CopyWith_Overrides_StrictDepth_ThrowOnLimitExceeded_AllowSparseLists() + { + var original = new DecodeOptions + { + StrictDepth = false, + ThrowOnLimitExceeded = false, + AllowSparseLists = false + }; + + var copy = original.CopyWith(strictDepth: true, throwOnLimitExceeded: true, allowSparseLists: true); + + copy.StrictDepth.Should().BeTrue(); + copy.ThrowOnLimitExceeded.Should().BeTrue(); + copy.AllowSparseLists.Should().BeTrue(); + + // Unchanged properties remain the same by default + copy.AllowEmptyLists.Should().Be(original.AllowEmptyLists); + copy.ListLimit.Should().Be(original.ListLimit); + } } \ No newline at end of file diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index e9f4b8f..861ef04 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4168,6 +4168,24 @@ public void Decode_BracketSingle_CommaSplit_YieldsNestedList() inner.Select(x => x?.ToString()).Should().Equal("1", "2", "3"); } + #region Nested brackets + + [Fact] + public void NestedBrackets_AreParsedCorrectly() + { + Qs.Decode("a[b[]]=c", new DecodeOptions()) + .Should().BeEquivalentTo( + new Dictionary + { + ["a"] = new Dictionary + { + ["b[]"] = "c" + } + }); + } + + #endregion + #region Encoded dot behavior in keys (%2E / %2e) [Fact] @@ -4765,22 +4783,4 @@ public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn() } #endregion - - #region Nested brackets - - [Fact] - public void NestedBrackets_AreParsedCorrectly() - { - Qs.Decode("a[b[]]=c", new DecodeOptions()) - .Should().BeEquivalentTo( - new Dictionary - { - ["a"] = new Dictionary - { - ["b[]"] = "c" - } - }); - } - - #endregion } \ No newline at end of file diff --git a/QsNet.Tests/EncodeOptionsTests.cs b/QsNet.Tests/EncodeOptionsTests.cs index 7ddecd3..7e9ff59 100644 --- a/QsNet.Tests/EncodeOptionsTests.cs +++ b/QsNet.Tests/EncodeOptionsTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text; using FluentAssertions; @@ -112,4 +113,139 @@ public void CopyWith_WithModifications_ShouldReturnModifiedOptions() newOptions.Filter.Should().NotBeNull(); newOptions.Filter.Should().BeOfType(); } -} \ No newline at end of file + + [Fact] + public void AllowDots_IsImpliedTrue_When_EncodeDotInKeys_True_And_NotExplicit() + { + var opts = new EncodeOptions { EncodeDotInKeys = true }; + opts.AllowDots.Should().BeTrue(); + } + + [Fact] + public void AllowDots_ExplicitFalse_Wins_Even_When_EncodeDotInKeys_True() + { + var opts = new EncodeOptions { AllowDots = false, EncodeDotInKeys = true }; + opts.AllowDots.Should().BeFalse(); + } + +#pragma warning disable CS0618 + [Fact] + public void ListFormat_Fallback_And_Override_Priority() + { + // Default: Indices when neither ListFormat nor Indices is set + var def = new EncodeOptions(); + def.ListFormat.Should().Be(ListFormat.Indices); + + // Indices=false => Repeat (fallback path) + var optsFalse = new EncodeOptions { Indices = false }; + optsFalse.ListFormat.Should().Be(ListFormat.Repeat); + + // Indices=true => Indices + var optsTrue = new EncodeOptions { Indices = true }; + optsTrue.ListFormat.Should().Be(ListFormat.Indices); + + // Explicit ListFormat overrides Indices + var optsOverride = new EncodeOptions { Indices = false, ListFormat = ListFormat.Brackets }; + optsOverride.ListFormat.Should().Be(ListFormat.Brackets); + } +#pragma warning restore CS0618 + + [Fact] + public void GetEncoder_Uses_Custom_When_Present_And_Passes_Encoding_And_Format() + { + Encoding? seenEnc = null; + Format? seenFmt = null; + object? seenVal = null; + + var opts = new EncodeOptions + { + Encoder = (v, e, f) => + { + seenVal = v; + seenEnc = e; + seenFmt = f; + return "X"; + }, + Charset = Encoding.Latin1, + Format = Format.Rfc3986 + }; + + var result = opts.GetEncoder("a b", Encoding.UTF8, Format.Rfc1738); + result.Should().Be("X"); + seenVal.Should().Be("a b"); + seenEnc.Should().Be(Encoding.UTF8); // override provided encoding is passed + seenFmt.Should().Be(Format.Rfc1738); // override provided format is passed + + // When null overrides supplied, it should pass options.Charset/Format + opts.GetEncoder("y z"); + seenEnc.Should().Be(Encoding.Latin1); + seenFmt.Should().Be(Format.Rfc3986); + } + + [Fact] + public void GetEncoder_Falls_Back_To_Default_When_No_Custom() + { + var opts = new EncodeOptions { Format = Format.Rfc1738 }; + // Utils.Encode returns %20; plus substitution happens later via Formatter, not in GetEncoder + opts.GetEncoder("a b", Encoding.UTF8).Should().Be("a%20b"); + } + + [Fact] + public void GetDateSerializer_Default_And_Custom() + { + var date = new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc); + + var optsDefault = new EncodeOptions(); + optsDefault.GetDateSerializer(date).Should().Be(date.ToString("O")); + + var optsCustom = new EncodeOptions + { + DateSerializer = d => d.ToString("yyyyMMddHHmmss") + }; + optsCustom.GetDateSerializer(date).Should().Be("20200102030405"); + } + +#pragma warning disable CS0618 + [Fact] + public void CopyWith_Indices_Sort_Encoder_DateSerializer_Mapping() + { + var baseOpts = new EncodeOptions + { + Indices = true, + Sort = (_, _) => 0, + Encoder = (_, _, _) => "base", + DateSerializer = _ => "base" + }; + + var enc2Called = false; + var ds2Called = false; + var copy = baseOpts.CopyWith( + indices: false, + sort: (_, _) => 1, + encoder: (_, _, _) => + { + enc2Called = true; + return "x"; + }, + dateSerializer: _ => + { + ds2Called = true; + return "y"; + } + ); + + // Because CopyWith resolves ListFormat from the source before setting Indices, it remains Indices here + copy.ListFormat.Should().Be(ListFormat.Indices); + + // Ensure functions are the new ones + copy.GetEncoder("val").Should().Be("x"); + enc2Called.Should().BeTrue(); + + copy.GetDateSerializer(DateTime.UtcNow).Should().Be("y"); + ds2Called.Should().BeTrue(); + + // Sort exists (can't easily trigger usage here, but mapping should hold) + copy.Sort.Should().NotBeNull(); + } +} +#pragma warning restore CS0618 \ No newline at end of file diff --git a/QsNet.Tests/EncodeTests.cs b/QsNet.Tests/EncodeTests.cs index b08fd5f..4fa17d6 100644 --- a/QsNet.Tests/EncodeTests.cs +++ b/QsNet.Tests/EncodeTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Numerics; @@ -4148,6 +4149,477 @@ public void Encode_StringifiesObjectsInsideArrays() .Should() .Be("a%5B%5D%5Bb%5D%5Bc%5D%5B%5D=1"); } + + #region Additional Encoder tests + + [Fact] + public void CyclicObject_Throws_InvalidOperation() + { + var dict = new Dictionary(); + dict["self"] = dict; // cycle + + Action act = () => Qs.Encode(dict, new EncodeOptions()); + act.Should().Throw().WithMessage("*Cyclic object value*"); + } + + [Fact] + public void AllowEmptyLists_Produces_EmptyBrackets() + { + var data = new Dictionary + { + ["a"] = new List() + }; + + var qs = Qs.Encode(data, new EncodeOptions + { + AllowEmptyLists = true, + // Keep default indices list format + Encode = false // easier assertion without percent-encoding + }); + + qs.Should().Be("a[]"); + } + + [Fact] + public void EncodeDotInKeys_TopLevelDotNotEncoded_When_AllowDots_False_PrimitivePath() + { + var data = new Dictionary + { + ["a.b"] = "x" + }; + + var qs = Qs.Encode(data, new EncodeOptions + { + EncodeDotInKeys = true, + AllowDots = false + }); + + // Top-level primitive path does not apply encodeDotInKeys to the keyPrefix + qs.Should().Be("a.b=x"); + } + + [Fact] + public void EncodeDotInKeys_With_AllowDots_Encodes_Child_Key_Dots() + { + var inner = new Dictionary { ["b.c"] = "x" }; + var data = new Dictionary { ["a"] = inner }; + + var qs = Qs.Encode(data, new EncodeOptions + { + AllowDots = true, + EncodeDotInKeys = true + }); + + // When keys are percent-encoded, the "%" in "%2E" is itself encoded to "%25" + qs.Should().Be("a.b%252Ec=x"); + } + + [Fact] + public void Comma_List_With_EncodeValuesOnly_Sets_ChildEncoder_Null_Path() + { + var data = new Dictionary + { + ["letters"] = new[] { "a", "b" } + }; + + var qs = Qs.Encode(data, new EncodeOptions + { + ListFormat = ListFormat.Comma, + EncodeValuesOnly = true, + // Supply a benign encoder to exercise the (isCommaGen && encodeValuesOnly) path + Encoder = (v, _, _) => v?.ToString() ?? string.Empty, + Encode = true + }); + + // Expect simple join with comma under the key + qs.Should().Be("letters=a,b"); + } + + [Fact] + public void IterableFilter_With_DictionaryObjectKeys_Skips_Missing_Keys() + { + var data = new Dictionary { ["x"] = 1 }; + + var qs = Qs.Encode(data, new EncodeOptions + { + Filter = new IterableFilter(new object?[] { "x", "y" }), + Encode = false // easier assertion + }); + + // Only "x" exists; "y" is missing -> treated as undefined and omitted + qs.Should().Be("x=1"); + } + + [Fact] + public void ByteArray_Is_Treated_As_Primitive_And_Encoded_With_Default_Encoder() + { + var data = new Dictionary + { + ["b"] = Encoding.UTF8.GetBytes("hi") + }; + + var qs = Qs.Encode(data, new EncodeOptions()); + qs.Should().Be("b=hi"); + } + + [Fact] + public void IEnumerable_Indexing_With_IterableFilter_Uses_String_Indices_And_Skips_OutOfRange() + { + var data = new List { "x", "y" }; + + // Ask for indices "1" and "2" (string form): 1 exists -> y, 2 is OOR -> omitted + var qs = Qs.Encode(data, new EncodeOptions + { + Filter = new IterableFilter(new object?[] { "1", "2" }), + Encode = false + }); + + qs.Should().Be("1=y"); + } + + private static string[] Parts(object encoded) + { + if (encoded is IEnumerable en and not string) + return en.Cast().Where(p => p is string { Length: > 0 }).Select(p => p!.ToString()!).ToArray(); + return encoded is string { Length: > 0 } s ? [s] : []; + } + + [Fact] + public void StrictNullHandling_Returns_BareKey_When_NoCustomEncoder() + { + var data = new Dictionary { ["a"] = null }; + + var qs = Qs.Encode(data, new EncodeOptions + { + StrictNullHandling = true, + Encode = false // easier to assert bare key + }); + + qs.Should().Be("a"); + } + + [Fact] + public void StrictNullHandling_With_CustomEncoder_Encodes_KeyPrefix() + { + var data = new Dictionary { ["a"] = null }; + + var qs = Qs.Encode(data, new EncodeOptions + { + StrictNullHandling = true, + EncodeValuesOnly = false, + Encoder = (v, _, _) => v?.ToString() == "a" ? "KEY" : v?.ToString() ?? string.Empty, + // Keep encoding enabled so our custom encoder is used + Encode = true + }); + + // The branch should return the encoded key only, without '=' + qs.Should().Be("KEY"); + } + + [Fact] + public void SkipNulls_Skips_Null_Values_And_Keeps_Others() + { + var data = new Dictionary + { + ["a"] = null, + ["b"] = "x" + }; + + var qs = Qs.Encode(data, new EncodeOptions { SkipNulls = true }); + qs.Should().Be("b=x"); + } + + [Fact] + public void AllowDots_Nested_Object_Uses_Dots_Vs_Brackets() + { + var inner = new Dictionary { ["b"] = 1 }; + var data = new Dictionary { ["a"] = inner }; + + var withDots = Qs.Encode(data, new EncodeOptions { AllowDots = true }); + withDots.Should().Be("a.b=1"); + + var withoutDots = Qs.Encode(data, new EncodeOptions { AllowDots = false }); + withoutDots.Should().Be("a%5Bb%5D=1"); + } + + [Fact] + public void DateTimeOffset_Normalized_In_Comma_List() + { + var dto = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); + var data = new Dictionary { ["d"] = new[] { dto } }; + + var qs = Qs.Encode(data, new EncodeOptions + { + ListFormat = ListFormat.Comma, + Encode = false // to see ISO text directly + }); + + qs.Should().Be("d=2020-01-01T00:00:00.0000000+00:00"); + } + + [Fact] + public void FunctionFilter_Can_Replace_Inner_Value() + { + var inner = new Dictionary { ["inner"] = 5 }; + var data = new Dictionary { ["a"] = inner }; + + var qs = Qs.Encode(data, new EncodeOptions + { + // Replace value when we hit the inner key prefix + Filter = new FunctionFilter((key, value) => + { + // for bracket style, this will be "a[inner]"; for dot style, "a.inner" + if (key.EndsWith("[inner]") || key.EndsWith(".inner")) + return 7; + return value; + }) + }); + + // default is bracket style since AllowDots=false by default + qs.Should().Be("a%5Binner%5D=7"); + } + + [Fact] + public void IDictionary_Object_Generic_FastPath_With_IterableFilter() + { + var obj = new Dictionary { ["x"] = 1 }; + var res = Encoder.Encode( + obj, + false, + new SideChannelFrame(), + "a", + ListFormat.Indices.GetGenerator(), + false, + false, + false, + false, + false, + null, + null, + null, + new IterableFilter(new object?[] { "x", "y" }), + false, + Format.Rfc3986, + s => s, + false, + Encoding.UTF8 + ); + + Parts(res).Should().Equal("a[x]=1"); + } + + [Fact] + public void IDictionary_String_Generic_FastPath_Missing_Key_Omitted() + { + var obj = new Dictionary { ["x"] = 2 }; + var res = Encoder.Encode( + obj, + false, + new SideChannelFrame(), + "a", + ListFormat.Indices.GetGenerator(), + false, + false, + false, + false, + false, + null, + null, + null, + new IterableFilter(new object?[] { "x", "z" }), + false, + Format.Rfc3986, + s => s, + false, + Encoding.UTF8 + ); + + Parts(res).Should().Equal("a[x]=2"); + } + + [Fact] + public void IDictionary_NonGeneric_DefaultContainsPath_With_Missing() + { + IDictionary map = new Hashtable { ["x"] = 3 }; + var res = Encoder.Encode( + map, + false, + new SideChannelFrame(), + "a", + ListFormat.Indices.GetGenerator(), + false, + false, + false, + false, + false, + null, + null, + null, + new IterableFilter(new object?[] { "x", "missing" }), + false, + Format.Rfc3986, + s => s, + false, + Encoding.UTF8 + ); + + Parts(res).Should().Equal("a[x]=3"); + } + + [Fact] + public void Array_IndexOutOfRange_Omitted() + { + var arr = new object?[] { "v" }; + var res = Encoder.Encode( + arr, + false, + new SideChannelFrame(), + "a", + ListFormat.Indices.GetGenerator(), + false, + false, + false, + false, + false, + null, + null, + null, + new IterableFilter(new object?[] { 0, 1 }), + false, + Format.Rfc3986, + s => s, + false, + Encoding.UTF8 + ); + + Parts(res).Should().Equal("a[0]=v"); + } + + [Fact] + public void IList_StringIndexParsing_And_OutOfRange() + { + var list = new List { "x", "y" }; + var res = Encoder.Encode( + list, + false, + new SideChannelFrame(), + "a", + ListFormat.Indices.GetGenerator(), + false, + false, + false, + false, + false, + null, + null, + null, + new IterableFilter(new object?[] { "01", "2" }), + false, + Format.Rfc3986, + s => s, + false, + Encoding.UTF8 + ); + + Parts(res).Should().Equal("a[01]=y"); + } + + [Fact] + public void IEnumerable_NonList_Indexing_With_OutOfRange() + { + var en = new YieldEnumerable(); + var res = Encoder.Encode( + en, + false, + new SideChannelFrame(), + "a", + ListFormat.Indices.GetGenerator(), + false, + false, + false, + false, + false, + null, + null, + null, + new IterableFilter(new object?[] { 1, 5 }), + false, + Format.Rfc3986, + s => s, + false, + Encoding.UTF8 + ); + + Parts(res).Should().Equal("a[1]=n"); + } + + [Fact] + public void AddQueryPrefix_IsUsed_When_No_Prefix_And_StrictNullHandling() + { + var res = Encoder.Encode( + null, + false, + new SideChannelFrame(), + null, + ListFormat.Indices.GetGenerator(), + false, + false, + true, + false, + false, + null, + null, + null, + null, + false, + Format.Rfc3986, + s => s, + false, + Encoding.UTF8, + true + ); + + Parts(res).Should().Equal("?"); + } + + [Fact] + public void Primitive_With_EncodeValuesOnly_Uses_RawKey_And_EncodedValue() + { + var res = Encoder.Encode( + "val", + false, + new SideChannelFrame(), + "k", + ListFormat.Indices.GetGenerator(), + false, + false, + false, + false, + false, + (v, _, _) => v?.ToString()?.ToUpperInvariant() ?? string.Empty, + null, + null, + null, + false, + Format.Rfc3986, + s => s, + true, + Encoding.UTF8 + ); + + Parts(res).Should().Equal("k=VAL"); + } + + private sealed class YieldEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return "m"; + yield return "n"; + } + } + + #endregion } // Custom object class for testing diff --git a/QsNet.Tests/FormatExtensionsTests.cs b/QsNet.Tests/FormatExtensionsTests.cs new file mode 100644 index 0000000..49feec8 --- /dev/null +++ b/QsNet.Tests/FormatExtensionsTests.cs @@ -0,0 +1,36 @@ +using System; +using FluentAssertions; +using QsNet.Enums; +using Xunit; + +namespace QsNet.Tests; + +public class FormatExtensionsTests +{ + [Fact] + public void GetFormatter_Rfc3986_IsIdentity() + { + var formatter = Format.Rfc3986.GetFormatter(); + + formatter("abc%20def").Should().Be("abc%20def"); // unchanged + formatter(string.Empty).Should().Be(string.Empty); + } + + [Fact] + public void GetFormatter_Rfc1738_ReplacesPercent20WithPlus() + { + var formatter = Format.Rfc1738.GetFormatter(); + + formatter("a%20b%20c").Should().Be("a+b+c"); + // Ensure it only replaces "%20" and leaves other percents untouched + formatter("%2F%20%3F").Should().Be("%2F+%3F"); + } + + [Fact] + public void GetFormatter_Throws_ForInvalidEnum() + { + const Format invalid = (Format)999; + Action act = () => invalid.GetFormatter(); + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/QsNet.Tests/ListFormatExtensionsTests.cs b/QsNet.Tests/ListFormatExtensionsTests.cs new file mode 100644 index 0000000..6731618 --- /dev/null +++ b/QsNet.Tests/ListFormatExtensionsTests.cs @@ -0,0 +1,49 @@ +using System; +using FluentAssertions; +using QsNet.Enums; +using Xunit; + +namespace QsNet.Tests; + +public class ListFormatExtensionsTests +{ + [Fact] + public void GetGenerator_Brackets_AppendsEmptyBrackets() + { + var gen = ListFormat.Brackets.GetGenerator(); + gen("foo", null).Should().Be("foo[]"); + gen("x", "ignored").Should().Be("x[]"); + } + + [Fact] + public void GetGenerator_Comma_ReturnsPrefixUnchanged() + { + var gen = ListFormat.Comma.GetGenerator(); + gen("foo", null).Should().Be("foo"); + gen("bar", "1").Should().Be("bar"); + } + + [Fact] + public void GetGenerator_Repeat_ReturnsPrefixUnchanged() + { + var gen = ListFormat.Repeat.GetGenerator(); + gen("foo", null).Should().Be("foo"); + gen("bar", "1").Should().Be("bar"); + } + + [Fact] + public void GetGenerator_Indices_AppendsIndexInBrackets() + { + var gen = ListFormat.Indices.GetGenerator(); + gen("foo", "0").Should().Be("foo[0]"); + gen("bar", "123").Should().Be("bar[123]"); + } + + [Fact] + public void GetGenerator_Throws_ForInvalidEnum() + { + const ListFormat invalid = (ListFormat)999; + Action act = () => invalid.GetGenerator(); + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/QsNet.Tests/QsNet.Tests.csproj b/QsNet.Tests/QsNet.Tests.csproj index 684435c..28766bf 100644 --- a/QsNet.Tests/QsNet.Tests.csproj +++ b/QsNet.Tests/QsNet.Tests.csproj @@ -5,21 +5,21 @@ false - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - runtime; build; native; contentfiles; analyzers; buildtransitive - all + runtime; build; native; contentfiles; analyzers; buildtransitive + all - + diff --git a/QsNet.Tests/SentinelExtensionsTests.cs b/QsNet.Tests/SentinelExtensionsTests.cs new file mode 100644 index 0000000..faf9b73 --- /dev/null +++ b/QsNet.Tests/SentinelExtensionsTests.cs @@ -0,0 +1,55 @@ +using System; +using FluentAssertions; +using QsNet.Enums; +using Xunit; + +namespace QsNet.Tests; + +public class SentinelExtensionsTests +{ + [Fact] + public void GetValue_ReturnsExpected_ForIsoAndCharset() + { + Sentinel.Iso.GetValue().Should().Be("✓"); + Sentinel.Charset.GetValue().Should().Be("✓"); + } + + [Fact] + public void GetEncoded_ReturnsExpected_ForIsoAndCharset() + { + Sentinel.Iso.GetEncoded().Should().Be("utf8=%26%2310003%3B"); + Sentinel.Charset.GetEncoded().Should().Be("utf8=%E2%9C%93"); + } + + [Fact] + public void ToString_Extension_ReturnsEncoded_ForIsoAndCharset() + { + // Note: must call the extension explicitly via the static class to avoid Enum.ToString() + SentinelExtensions.ToString(Sentinel.Iso).Should().Be("utf8=%26%2310003%3B"); + SentinelExtensions.ToString(Sentinel.Charset).Should().Be("utf8=%E2%9C%93"); + } + + [Fact] + public void GetValue_Throws_ForInvalidEnum() + { + var invalid = (Sentinel)999; + Action act = () => invalid.GetValue(); + act.Should().Throw(); + } + + [Fact] + public void GetEncoded_Throws_ForInvalidEnum() + { + const Sentinel invalid = (Sentinel)999; + Action act = () => invalid.GetEncoded(); + act.Should().Throw(); + } + + [Fact] + public void ToString_Extension_Throws_ForInvalidEnum() + { + const Sentinel invalid = (Sentinel)999; + Action act = () => SentinelExtensions.ToString(invalid); + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/QsNet.Tests/UndefinedTests.cs b/QsNet.Tests/UndefinedTests.cs index b4c1351..fce0ac2 100644 --- a/QsNet.Tests/UndefinedTests.cs +++ b/QsNet.Tests/UndefinedTests.cs @@ -7,35 +7,28 @@ namespace QsNet.Tests; public class UndefinedTests { [Fact] - public void Undefined_CreatesEquivalentInstances() + public void Instance_IsSingleton_And_ToString_IsExpected() { - // Arrange & Act - var undefined1 = Undefined.Create(); - var undefined2 = Undefined.Create(); + var inst1 = Undefined.Instance; + var inst2 = Undefined.Instance; - // Assert - undefined2.Should().Be(undefined1); + inst1.Should().NotBeNull(); + ReferenceEquals(inst1, inst2).Should().BeTrue("Undefined.Instance should be a singleton"); + inst1.ToString().Should().Be("Undefined"); } [Fact] - public void Undefined_StaticInstanceIsEquivalent() + public void Create_Returns_Same_Instance_As_Instance_Property() { - // Arrange & Act - var undefined1 = Undefined.Instance; - var undefined2 = Undefined.Create(); - - // Assert - undefined2.Should().Be(undefined1); + var created = Undefined.Create(); + ReferenceEquals(created, Undefined.Instance).Should().BeTrue(); } [Fact] - public void Undefined_CreateMethodReturnsEquivalentInstance() + public void Multiple_Calls_To_Create_Return_Same_Singleton() { - // Arrange & Act - var undefined1 = Undefined.Create(); - var undefined2 = Undefined.Create(); - - // Assert - undefined2.Should().Be(undefined1); + var a = Undefined.Create(); + var b = Undefined.Create(); + ReferenceEquals(a, b).Should().BeTrue(); } } \ No newline at end of file diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 3aba058..cc4d12d 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -1627,9 +1627,13 @@ public void ToStringKeyDeepNonRecursive_Converts_Nested_Lists_And_Dicts() [Fact] public void EnsureAstralCharactersAtSegmentLimitMinus1OrSegmentLimitEncodeAs4ByteSequences() { - const int SegmentLimit = 1024; + var segField = typeof(Utils).GetField("SegmentLimit", + BindingFlags.NonPublic | BindingFlags.Static); + var segmentLimit = segField is null + ? 1024 // fallback to current default to avoid breaking if refactoring hides the field + : (int)(segField.IsLiteral ? segField.GetRawConstantValue()! : segField.GetValue(null)!); // Ensure astral characters at SegmentLimit-1/SegmentLimit encode as 4-byte sequences - var s = new string('a', SegmentLimit - 1) + "\U0001F600" + "b"; + var s = new string('a', segmentLimit - 1) + "\U0001F600" + "b"; var encoded = Utils.Encode(s, Encoding.UTF8, Format.Rfc3986); Assert.Contains("%F0%9F%98%80", encoded); } @@ -1637,10 +1641,419 @@ public void EnsureAstralCharactersAtSegmentLimitMinus1OrSegmentLimitEncodeAs4Byt [Fact] public void EnsureAstralCharactersAtSegmentLimitEncodeAs4ByteSequences() { - const int SegmentLimit = 1024; + var segField = typeof(Utils).GetField("SegmentLimit", + BindingFlags.NonPublic | BindingFlags.Static); + var segmentLimit = segField is null + ? 1024 + : (int)(segField.IsLiteral ? segField.GetRawConstantValue()! : segField.GetValue(null)!); // Astral character starts exactly at the chunk boundary (index == SegmentLimit) - var s = new string('a', SegmentLimit) + "\U0001F600" + "b"; + var s = new string('a', segmentLimit) + "\U0001F600" + "b"; var encoded = Utils.Encode(s, Encoding.UTF8, Format.Rfc3986); Assert.Contains("%F0%9F%98%80", encoded); } + + #region To Dictionary tests + + [Fact] + public void ToDictionary_Converts_From_NonGeneric_IDictionary_To_ObjectKeyed_Copy() + { + IDictionary src = new Hashtable { ["a"] = 1, [2] = "b" }; + + var method = typeof(Utils).GetMethod("ToDictionary", BindingFlags.NonPublic | BindingFlags.Static)!; + var result = method.Invoke(null, [src]); + + result.Should().BeOfType>(); + var dict = (Dictionary)result; + dict.Should().Contain(new KeyValuePair("a", 1)); + dict.Should().Contain(new KeyValuePair(2, "b")); + ReferenceEquals(result, src).Should().BeFalse(); + } + + #endregion + + #region Branch tests + + [Fact] + public void IsEmpty_ReturnsTrueForNullAndUndefined_AndHandlesStrings() + { + Utils.IsEmpty(null).Should().BeTrue(); + Utils.IsEmpty(Undefined.Instance).Should().BeTrue(); + Utils.IsEmpty(string.Empty).Should().BeTrue(); + Utils.IsEmpty("x").Should().BeFalse(); + } + + [Fact] + public void IsEmpty_EnumerableWithNonDisposableEnumerator_CoversHasAnyBothOutcomes() + { + // Enumerator does not implement IDisposable: covers the HasAny branch where cast to IDisposable is null + Utils.IsEmpty(new NonDisposableEmptyEnumerable()).Should().BeTrue(); + Utils.IsEmpty(new NonDisposableSingleEnumerable()).Should().BeFalse(); + + // Regular enumerable (List) enumerator implements IDisposable: covers the other HasAny branch + Utils.IsEmpty(new List()).Should().BeTrue(); + Utils.IsEmpty(new List { 1 }).Should().BeFalse(); + } + + [Fact] + public void Apply_DefaultBranch_WhenTypeMismatch_ReturnsOriginalValue() + { + // T is int, value is string => should hit the default branch and return the original string + object input = "abc"; + var result = Utils.Apply(input, x => x * 2); + result.Should().BeSameAs(input); + } + + [Fact] + public void IsNonNullishPrimitive_DefaultBranch_ReturnsTrueForCustomType() + { + // Not string/number/bool/enum/DateTime/Uri/IEnumerable/IDictionary/Undefined/null + // Should match the default case and return true + Utils.IsNonNullishPrimitive(new CustomType()).Should().BeTrue(); + } + + private sealed class NonDisposableEmptyEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() + { + return new NonDisposableEmptyEnumerator(); + } + + private sealed class NonDisposableEmptyEnumerator : IEnumerator + { + public bool MoveNext() + { + return false; + // empty + } + + public void Reset() + { + } + + public object Current => null!; + } + } + + private sealed class NonDisposableSingleEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() + { + return new NonDisposableSingleEnumerator(); + } + + private sealed class NonDisposableSingleEnumerator : IEnumerator + { + private int _state; + + public bool MoveNext() + { + if (_state != 0) return false; + _state = 1; + return true; // one item + } + + public void Reset() + { + _state = 0; + } + + public object Current => 123; // any value + } + } + + private sealed class CustomType + { + } + + #endregion + + #region Compact tests + + [Fact] + public void Compact_Removes_Undefined_From_ObjectKeyed_Dictionary() + { + var root = new Dictionary + { + ["a"] = Undefined.Instance, + ["b"] = 1 + }; + + var res = Utils.Compact(root); + + res.Should().ContainKey("b").And.NotContainKey("a"); + res["b"].Should().Be(1); + } + + [Fact] + public void Compact_Walks_Mixed_StringKeyed_And_ObjectKeyed_Maps() + { + var innerStringMap = new Dictionary + { + ["x"] = Undefined.Instance, + ["y"] = 2 + }; + var root = new Dictionary + { + ["m"] = innerStringMap, + ["k"] = 5 + }; + + var res = Utils.Compact(root); + + // inner undefined key removed + ((Dictionary)res["m"]!).Should().NotContainKey("x"); + ((Dictionary)res["m"]!)["y"].Should().Be(2); + res["k"].Should().Be(5); + } + + [Fact] + public void Compact_Converts_NonGeneric_IDictionary_Inside_Dictionary_And_List() + { + // Two distinct non-generic maps to ensure both conversion branches execute + IDictionary nonGenericForMap = new Hashtable + { + ["drop"] = Undefined.Instance, + ["keep"] = 10 + }; + IDictionary nonGenericForList = new Hashtable + { + ["drop"] = Undefined.Instance, + ["keep"] = 20 + }; + + var list = new List + { + Undefined.Instance, + nonGenericForList + }; + + var root = new Dictionary + { + ["list"] = list, + ["map"] = nonGenericForMap + }; + + // allowSparseLists: false -> first Undefined removed from list + var compacted = Utils.Compact(root); + + // map was converted to object-keyed dictionary and undefined removed + var convertedMap = (Dictionary)compacted["map"]!; + convertedMap.Should().NotContainKey("drop"); + convertedMap["keep"].Should().Be(10); + + var compactedList = (List)compacted["list"]!; + compactedList.Should().HaveCount(1); + var convertedInList = (Dictionary)compactedList[0]!; + convertedInList.Should().ContainKey("keep").And.NotContainKey("drop"); + convertedInList["keep"].Should().Be(20); + + // Now with allowSparseLists: true -> Undefined becomes null slot + var nonGeneric2 = new Hashtable { ["drop"] = Undefined.Instance, ["keep"] = 1 }; + var list2 = new List { Undefined.Instance, nonGeneric2 }; + var root2 = new Dictionary { ["list"] = list2 }; + var compacted2 = Utils.Compact(root2, true); + var list2Result = (List)compacted2["list"]!; + list2Result.Should().HaveCount(2); + list2Result[0].Should().BeNull(); // sparse preserved as null + var converted2 = (Dictionary)list2Result[1]!; + converted2.Should().ContainKey("keep").And.NotContainKey("drop"); + } + + [Fact] + public void Compact_Respects_Visited_Set_To_Avoid_Cycles() + { + var a = new Dictionary(); + var b = new Dictionary(); + a["b"] = b; + b["a"] = a; + a["u"] = Undefined.Instance; + b["u"] = Undefined.Instance; + + var res = Utils.Compact(a); + + // Undefined keys removed + res.Should().NotContainKey("u"); + var bRes = (Dictionary)res["b"]!; + bRes.Should().NotContainKey("u"); + // cycle preserved without infinite recursion + ((Dictionary)bRes["a"]!).Should().BeSameAs(res); + } + + #endregion + + #region Deep Conversion Identity tests + + [Fact] + public void ToStringKeyDeepNonRecursive_Handles_Cycle_And_Preserves_Identity() + { + // root -> child (IDictionary) -> back to root + IDictionary root = new Hashtable(); + IDictionary child = new Hashtable(); + root["child"] = child; + child["parent"] = root; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + + result.Should().ContainKey("child"); + var childOut = result["child"] as Dictionary; + childOut.Should().NotBeNull(); + // The child's "parent" should reference the top result dictionary + ReferenceEquals(childOut["parent"], result).Should().BeTrue(); + } + + [Fact] + public void ConvertNestedDictionary_Keeps_StringKeyed_Children_In_List_AsIs() + { + var stringKeyedChild = new Dictionary { ["a"] = 1 }; + IList list = new ArrayList + { + stringKeyedChild, + new Hashtable { ["b"] = 2 } + }; + IDictionary src = new Hashtable { ["lst"] = list }; + + var method = typeof(Utils) + .GetMethod( + "ConvertNestedDictionary", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(IDictionary), typeof(ISet)], + null + )!; + var converted = method.Invoke(null, [src, new HashSet()]) as Dictionary; + + converted.Should().NotBeNull(); + var outListObj = converted["lst"]; + outListObj.Should().BeAssignableTo(); + var outList = (IList)outListObj; + // The original IList instance is preserved + ReferenceEquals(outList, list).Should().BeTrue(); + // First element should be the exact same instance + ReferenceEquals(outList[0], stringKeyedChild).Should().BeTrue(); + // Second element should be converted to a string-keyed dictionary + outList[1].Should().BeOfType>(); + ((Dictionary)outList[1]!).Should().ContainKey("b"); + } + + #endregion + + #region Normalize tests + + [Fact] + public void Merge_NullTarget_With_SelfReferencing_NonGenericMap_Returns_Same_Instance() + { + IDictionary map = new Hashtable(); + map["self"] = map; // self-reference + + var result = Utils.Merge(null, map); + + // NormalizeForTarget should detect self-reference and return the same instance + ReferenceEquals(result, map).Should().BeTrue(); + } + + [Fact] + public void ConvertNestedValues_Sequence_Enumerable_Is_Materialized_To_List_And_Children_Converted() + { + var seq = new YieldingEnumerable(); + var obj = new Dictionary { ["seq"] = seq }; + + // ConvertNestedValues returns an object-keyed IDictionary for dictionaries + var converted = Utils.ConvertNestedValues(obj) as IDictionary; + converted.Should().NotBeNull(); + var list = converted["seq"] as List; + list.Should().NotBeNull(); + list.Count.Should().Be(2); + // First element (a Hashtable) is normalized to an object-keyed IDictionary + list[0].Should().BeAssignableTo(); + var firstMap = (IDictionary)list[0]!; + firstMap.Contains("k").Should().BeTrue(); + list[1].Should().Be(2); + } + + private sealed class YieldingEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return new Hashtable { ["k"] = 1 }; + yield return 2; + } + } + + #endregion + + #region Normalize and Decode Null tests + + [Fact] + public void Merge_NullTarget_With_NonGenericMap_Returns_ObjectKeyed_Copy() + { + IDictionary src = new Hashtable { ["a"] = 1, ["b"] = 2 }; + + var result = Utils.Merge(null, src); + + result.Should().BeOfType>(); + var dict = (Dictionary)result; + dict.Should().Contain(new KeyValuePair("a", 1)); + dict.Should().Contain(new KeyValuePair("b", 2)); + } + + [Fact] + public void Merge_NullTarget_With_ObjectKeyedMap_Returns_Same_Instance() + { + var src = new Dictionary { ["x"] = 1 }; + + var result = Utils.Merge(null, src); + + // NormalizeForTarget returns the same instance when already object-keyed + ReferenceEquals(result, src).Should().BeTrue(); + } + + [Fact] + public void Decode_Returns_Null_For_Null_Input() + { + Utils.Decode(null).Should().BeNull(); + } + + #endregion + + #region String Key Preservation tests + + [Fact] + public void ToStringKeyDeepNonRecursive_Preserves_StringKeyed_Child_Map_Identity() + { + var child = new Dictionary { ["a"] = 1 }; + IDictionary root = new Hashtable { ["child"] = child }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + + result.Should().ContainKey("child"); + ReferenceEquals(result["child"], child).Should().BeTrue(); + } + + [Fact] + public void ToStringKeyDeepNonRecursive_Preserves_StringKeyed_Map_Inside_List() + { + var child = new Dictionary { ["a"] = 1 }; + IList list = new ArrayList { child }; + IDictionary root = new Hashtable { ["lst"] = list }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + var outList = result["lst"] as List; + outList.Should().NotBeNull(); + ReferenceEquals(outList[0], child).Should().BeTrue(); + } + + [Fact] + public void ConvertDictionaryToStringKeyed_Converts_NonGeneric_Keys_To_Strings() + { + IDictionary src = new Hashtable + { + [1] = "x", + ["y"] = 2 + }; + + var res = Utils.ConvertDictionaryToStringKeyed(src); + res.Should().Equal(new Dictionary { ["1"] = "x", ["y"] = 2 }); + } + + #endregion } \ No newline at end of file