Skip to content

Commit f2320f4

Browse files
committed
✅ add DecodeOptions tests for dot-in-keys behavior, decoder signatures, and parser state isolation
1 parent 1eef5ba commit f2320f4

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

tests/unit/decode_options_test.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import typing as t
2+
3+
import pytest
4+
5+
from qs_codec import Charset, DecodeOptions
6+
from qs_codec.enums.decode_kind import DecodeKind
7+
from qs_codec.utils.decode_utils import DecodeUtils
8+
9+
10+
class TestDecodeOptionsPostInitDefaults:
11+
def test_defaults_normalize(self) -> None:
12+
opts = DecodeOptions()
13+
assert opts.decode_dot_in_keys is False
14+
assert opts.allow_dots is False
15+
16+
def test_decode_dot_implies_allow_dots(self) -> None:
17+
opts = DecodeOptions(decode_dot_in_keys=True)
18+
assert opts.allow_dots is True
19+
20+
def test_invariant_violation_raises(self) -> None:
21+
with pytest.raises(ValueError):
22+
DecodeOptions(decode_dot_in_keys=True, allow_dots=False)
23+
24+
25+
class TestDecodeOptionsDecoderDefault:
26+
def test_default_decoder_behaves_like_decodeutils(self) -> None:
27+
# The adapter may wrap the default, so compare behavior rather than identity.
28+
opts = DecodeOptions()
29+
s = "a+b%2E"
30+
out_key = opts.decoder(s, Charset.UTF8, kind=DecodeKind.KEY)
31+
out_val = opts.decoder(s, Charset.UTF8, kind=DecodeKind.VALUE)
32+
assert out_key == DecodeUtils.decode(s, charset=Charset.UTF8, kind=DecodeKind.KEY)
33+
assert out_val == DecodeUtils.decode(s, charset=Charset.UTF8, kind=DecodeKind.VALUE)
34+
35+
36+
class TestDecoderAdapterSignatures:
37+
def test_legacy_single_arg(self) -> None:
38+
calls: t.List[t.Tuple[t.Optional[str]]] = []
39+
40+
def dec(s: t.Optional[str]) -> t.Optional[str]:
41+
calls.append((s,))
42+
return None if s is None else s.upper()
43+
44+
opts = DecodeOptions(decoder=dec)
45+
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "X"
46+
assert calls == [("x",)]
47+
48+
def test_two_args_s_charset(self) -> None:
49+
seen: t.List[t.Tuple[t.Optional[str], t.Optional[Charset]]] = []
50+
51+
def dec(s: t.Optional[str], charset: t.Optional[Charset]) -> t.Optional[str]:
52+
seen.append((s, charset))
53+
# Echo string and charset name to prove we passed it
54+
return None if s is None else f"{s}|{charset.name if charset else 'NONE'}"
55+
56+
opts = DecodeOptions(decoder=dec)
57+
assert opts.decoder("hi", Charset.LATIN1, kind=DecodeKind.VALUE) == "hi|LATIN1"
58+
assert seen == [("hi", Charset.LATIN1)]
59+
60+
def test_three_args_kind_enum_annotation(self) -> None:
61+
seen: t.List[t.Any] = []
62+
63+
def dec(s: t.Optional[str], charset: t.Optional[Charset], kind: DecodeKind) -> t.Optional[str]:
64+
seen.append(kind)
65+
# return a marker showing what we received
66+
return None if s is None else f"K:{'E' if isinstance(kind, DecodeKind) else type(kind).__name__}"
67+
68+
opts = DecodeOptions(decoder=dec)
69+
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "K:E"
70+
assert seen and isinstance(seen[0], DecodeKind) and seen[0] is DecodeKind.KEY
71+
72+
def test_three_args_kind_str_annotation(self) -> None:
73+
seen: t.List[t.Any] = []
74+
75+
def dec(s: t.Optional[str], charset: t.Optional[Charset], kind: str) -> t.Optional[str]:
76+
seen.append(kind)
77+
return None if s is None else kind # echo back
78+
79+
opts = DecodeOptions(decoder=dec)
80+
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "key"
81+
assert seen == ["key"]
82+
83+
def test_kwonly_kind_str(self) -> None:
84+
seen: t.List[t.Any] = []
85+
86+
def dec(s: t.Optional[str], charset: t.Optional[Charset], *, kind: str) -> t.Optional[str]:
87+
seen.append(kind)
88+
return None if s is None else kind
89+
90+
opts = DecodeOptions(decoder=dec)
91+
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
92+
assert seen == ["value"]
93+
94+
def test_varargs_kwargs_receives_kind_string(self) -> None:
95+
seen: t.List[t.Any] = []
96+
97+
def dec(s: t.Optional[str], *args, **kwargs) -> t.Optional[str]: # type: ignore[no-untyped-def]
98+
seen.append(kwargs.get("kind"))
99+
return s
100+
101+
opts = DecodeOptions(decoder=dec)
102+
assert opts.decoder("ok", Charset.UTF8, kind=DecodeKind.KEY) == "ok"
103+
assert seen == ["key"]
104+
105+
def test_user_decoder_typeerror_is_not_swallowed(self) -> None:
106+
def dec(s: t.Optional[str]) -> t.Optional[str]:
107+
raise TypeError("boom")
108+
109+
opts = DecodeOptions(decoder=dec)
110+
with pytest.raises(TypeError):
111+
_ = opts.decoder("oops", Charset.UTF8, kind=DecodeKind.KEY)
112+
113+
114+
class TestParserStateIsolation:
115+
def test_parse_lists_toggle_does_not_leak_across_calls(self) -> None:
116+
# Construct a query with many top-level params to trigger the internal optimization
117+
big_query = "&".join(f"k{i}=v{i}" for i in range(25))
118+
opts = DecodeOptions(list_limit=20)
119+
120+
# First call may temporarily disable parse_lists internally
121+
from qs_codec import decode
122+
123+
res1 = decode(big_query, opts)
124+
assert isinstance(res1, dict) and len(res1) == 25
125+
# The option should be restored on the options object
126+
assert opts.parse_lists is True
127+
128+
# Second call should still parse lists as lists
129+
res2 = decode("a[]=1&a[]=2", opts)
130+
assert res2 == {"a": ["1", "2"]}

0 commit comments

Comments
 (0)