Skip to content

Commit 397ff7b

Browse files
committed
test(encode): add regression coverage for edge cases and internals
Expand tests for key-path nodes, generator fallbacks, strict-null formatting, and callable-filter mutation safety.
1 parent a862a88 commit 397ff7b

4 files changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import typing as t
2+
3+
from qs_codec.encode import _identity_key, _next_path_for_sequence
4+
from qs_codec.models.key_path_node import KeyPathNode
5+
from qs_codec.models.weak_wrapper import WeakWrapper
6+
7+
8+
def test_identity_key_returns_int_unchanged() -> None:
9+
assert _identity_key(42) == 42
10+
11+
12+
def test_identity_key_handles_collected_weak_wrapper() -> None:
13+
wrapper = WeakWrapper({"a": "b"})
14+
object.__setattr__(wrapper, "_wref", lambda: None)
15+
assert _identity_key(wrapper) == id(wrapper)
16+
17+
18+
def test_identity_key_returns_object_id_for_non_wrapper_values() -> None:
19+
value = {"a": "b"}
20+
assert _identity_key(value) == id(value)
21+
22+
23+
def test_next_path_for_sequence_uses_custom_suffix_when_child_starts_with_parent() -> None:
24+
root = KeyPathNode.from_materialized("root")
25+
26+
def custom_generator(prefix: str, key: t.Optional[str]) -> str:
27+
return f"{prefix}<{key}>"
28+
29+
next_path = _next_path_for_sequence(root, custom_generator, "item")
30+
assert next_path.materialize() == "root<item>"
31+
32+
33+
def test_next_path_for_sequence_rebuilds_when_child_is_not_prefixed() -> None:
34+
root = KeyPathNode.from_materialized("root")
35+
36+
def custom_generator(prefix: str, key: t.Optional[str]) -> str: # pylint: disable=W0613 # noqa: ARG001
37+
return f"other[{key}]"
38+
39+
next_path = _next_path_for_sequence(root, custom_generator, "item")
40+
assert next_path.materialize() == "other[item]"

tests/unit/encode_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,12 @@ def test_encodes_a_complicated_map(self) -> None:
650650
[
651651
pytest.param({"a": ""}, None, "a=", id="empty-string"),
652652
pytest.param({"a": None}, EncodeOptions(strict_null_handling=True), "a", id="none-strict-null"),
653+
pytest.param(
654+
{"a b": None},
655+
EncodeOptions(strict_null_handling=True, format=Format.RFC1738),
656+
"a+b",
657+
id="none-strict-null-rfc1738-space",
658+
),
653659
pytest.param({"a": "", "b": ""}, None, "a=&b=", id="multiple-empty"),
654660
pytest.param(
655661
{"a": None, "b": ""},
@@ -967,6 +973,28 @@ def filter_func(prefix: str, value: t.Any) -> t.Any:
967973
assert encode(obj, options=EncodeOptions(filter=filter_func)) == "a=b&c=&e%5Bf%5D=1257894000"
968974
assert calls == 5
969975

976+
def test_callable_filter_does_not_mutate_root_list_elements(self) -> None:
977+
data: t.List[t.Dict[str, str]] = [{"a": "b"}]
978+
979+
def filter_func(prefix: str, value: t.Any) -> t.Any:
980+
if prefix == "":
981+
value["0"]["a"] = "x"
982+
return value
983+
984+
assert encode(data, options=EncodeOptions(filter=filter_func)) == "0%5Ba%5D=x"
985+
assert data == [{"a": "b"}]
986+
987+
def test_callable_filter_does_not_mutate_root_tuple_elements(self) -> None:
988+
data: t.Tuple[t.Dict[str, str], ...] = ({"a": "b"},)
989+
990+
def filter_func(prefix: str, value: t.Any) -> t.Any:
991+
if prefix == "":
992+
value["0"]["a"] = "x"
993+
return value
994+
995+
assert encode(data, options=EncodeOptions(filter=filter_func)) == "0%5Ba%5D=x"
996+
assert data[0]["a"] == "b"
997+
970998
def test_encode_handles_mapping_get_exception(self) -> None:
971999
class ExplodingMapping(t.Mapping):
9721000
def __iter__(self):

tests/unit/key_path_node_test.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import pytest
2+
3+
from qs_codec.models.key_path_node import KeyPathNode
4+
5+
6+
class TestKeyPathNode:
7+
def test_append_empty_segment_returns_same_node(self) -> None:
8+
root = KeyPathNode.from_materialized("root")
9+
assert root.append("") is root
10+
11+
def test_materialize_builds_nested_paths(self) -> None:
12+
path = KeyPathNode.from_materialized("a").append("[b]").append("[c]")
13+
assert path.materialize() == "a[b][c]"
14+
15+
def test_materialize_uses_cached_value(self) -> None:
16+
path = KeyPathNode.from_materialized("a").append("[b]").append("[c]")
17+
first = path.materialize()
18+
second = path.materialize()
19+
assert first == second == "a[b][c]"
20+
21+
def test_as_dot_encoded_replaces_literal_dots(self) -> None:
22+
path = KeyPathNode.from_materialized("a.b").append("[c.d]")
23+
encoded = path.as_dot_encoded()
24+
assert encoded.materialize() == "a%2Eb[c%2Ed]"
25+
26+
def test_as_dot_encoded_reuses_cached_node(self) -> None:
27+
path = KeyPathNode.from_materialized("a.b").append("[c]")
28+
first = path.as_dot_encoded()
29+
second = path.as_dot_encoded()
30+
assert first is second
31+
32+
def test_as_dot_encoded_returns_self_when_no_segments_need_encoding(self) -> None:
33+
path = KeyPathNode.from_materialized("a").append("[c]")
34+
assert path.as_dot_encoded() is path
35+
36+
def test_as_dot_encoded_handles_deep_paths_without_recursion_error(self) -> None:
37+
path = KeyPathNode.from_materialized("root")
38+
for i in range(12_000):
39+
path = path.append(f".k{i}")
40+
41+
encoded = path.as_dot_encoded().materialize()
42+
assert encoded.startswith("root%2Ek0%2Ek1")
43+
44+
@pytest.mark.parametrize(
45+
"path, expected",
46+
[
47+
(KeyPathNode.from_materialized("a"), "a"),
48+
(KeyPathNode.from_materialized("a").append(".b"), "a.b"),
49+
(KeyPathNode.from_materialized("a").append("[0]").append("[b]"), "a[0][b]"),
50+
],
51+
)
52+
def test_materialize_path_variants(self, path: KeyPathNode, expected: str) -> None:
53+
assert path.materialize() == expected

tests/unit/list_format_test.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import typing as t
2+
3+
import pytest
4+
5+
from qs_codec.enums.list_format import ListFormatGenerator
6+
7+
8+
@pytest.mark.parametrize(
9+
"generator, prefix, key, expected",
10+
[
11+
(ListFormatGenerator.brackets, "a", None, "a[]"),
12+
(ListFormatGenerator.comma, "a", "0", "a"),
13+
(ListFormatGenerator.indices, "a", "0", "a[0]"),
14+
(ListFormatGenerator.repeat, "a", "0", "a"),
15+
],
16+
)
17+
def test_list_format_generators(
18+
generator: t.Callable[[str, t.Optional[str]], str], prefix: str, key: t.Optional[str], expected: str
19+
) -> None:
20+
assert generator(prefix, key) == expected

0 commit comments

Comments
 (0)