/
/
/
1"""Tests for the JSON helpers."""
2
3from __future__ import annotations
4
5import datetime
6import enum
7import uuid
8from typing import Any
9
10import pytest
11
12from music_assistant.helpers.json import (
13 get_serializable_value,
14 json_dumps,
15 serialize_to_json,
16 strip_code_fence,
17)
18
19
20@pytest.mark.parametrize(
21 ("text", "expected"),
22 [
23 ('```json\n{"a": 1}\n```', '{"a": 1}'),
24 ('```JSON\n{"a": 1}\n```', '{"a": 1}'),
25 ('```\n{"a": 1}\n```', '{"a": 1}'),
26 ('````json\n{"a": 1}\n````', '{"a": 1}'),
27 (' ```json\r\n{"a": 1}\r\n``` ', '{"a": 1}'),
28 ('```json\n\n{"a": 1}\n\n```', '{"a": 1}'),
29 ],
30)
31def test_strip_code_fence_unwraps_a_single_fenced_block(text: str, expected: str) -> None:
32 """Return the content of one code fence wrapping the complete text."""
33 assert strip_code_fence(text) == expected
34
35
36@pytest.mark.parametrize(
37 "text",
38 [
39 '{"a": 1}',
40 "not fenced at all",
41 # A fence around only part of the text must not be unwrapped.
42 'Here you go:\n```json\n{"a": 1}\n```',
43 '```json\n{"a": 1}\n```\nHope that helps!',
44 '```json\nnot json\n```\n```json\n{"a": 1}\n```',
45 # An unterminated or mismatched fence is not a fenced block.
46 '```json\n{"a": 1}',
47 '````json\n{"a": 1}\n```',
48 '```json\n{"a": 1}\n````',
49 "```",
50 "``````",
51 # The opening line must carry nothing but a language tag.
52 '```json {"a": 1}\n```',
53 '```{"a": 1}\n```',
54 ],
55)
56def test_strip_code_fence_only_trims_whitespace_of_other_text(text: str) -> None:
57 """Leave text that is not exactly one fenced block untouched apart from whitespace."""
58 assert strip_code_fence(f" {text} ") == text
59
60
61def test_strip_code_fence_handles_long_backtick_runs() -> None:
62 """
63 Return long runs of backticks unchanged instead of scanning them for a fence body.
64
65 Matching a fence with a backtracking pattern took seconds on these, which would block
66 the event loop of a caller parsing an AI response.
67 """
68 assert strip_code_fence("`" * 4096) == "`" * 4096
69 assert strip_code_fence("`" * 4087 + "jsonjson") == "`" * 4087 + "jsonjson"
70
71
72class _WithToDict:
73 def to_dict(self) -> dict[str, Any]:
74 return {"x": 1}
75
76
77class _Unhandled:
78 """Stands in for a raw third party SDK object without a to_dict method."""
79
80
81class _Colour(enum.StrEnum):
82 RED = "red"
83
84
85@pytest.mark.parametrize(
86 ("value", "expected"),
87 [
88 ("hello", '"hello"'),
89 ({}, "{}"),
90 (["a", "b"], '["a","b"]'),
91 ({"a": [1, 2], "b": {"c": None}}, '{"a":[1,2],"b":{"c":null}}'),
92 ([1, True, 1.5], "[1,true,1.5]"),
93 ((1, 2), "[1,2]"),
94 (b"\x00\x01", '"AAE="'),
95 (datetime.date(2026, 1, 1), '"2026-01-01"'),
96 (uuid.UUID(int=0), '"00000000-0000-0000-0000-000000000000"'),
97 (_Colour.RED, '"red"'),
98 ([datetime.date(2026, 1, 1)], '["2026-01-01"]'),
99 ],
100)
101def test_serialize_to_json_passes_through_supported_values(value: Any, expected: str) -> None:
102 """Values that orjson handles itself are not rejected by the unhandled type check."""
103 assert serialize_to_json(value) == expected
104
105
106def test_serialize_to_json_uses_to_dict() -> None:
107 """Objects exposing to_dict are converted through it."""
108 assert serialize_to_json(_WithToDict()) == '{"x":1}'
109 assert serialize_to_json([_WithToDict()]) == '[{"x":1}]'
110
111
112def test_dict_value_iterator_is_serialized_as_list() -> None:
113 """A dict value iterator is consumed into a list rather than left unhandled."""
114 assert serialize_to_json(iter({"a": 1}.values())) == "[1]"
115
116
117def test_unhandled_type_reports_the_offending_type() -> None:
118 """An object nothing can convert raises an error naming the type, not a recursion error."""
119 with pytest.raises(TypeError) as excinfo:
120 json_dumps({"tracks": [_Unhandled()]})
121
122 assert "recursion" not in str(excinfo.value).lower()
123 cause = excinfo.value.__cause__
124 assert cause is not None
125 # the module path matters: several providers ship classes named like our own models
126 assert f"{__name__}._Unhandled" in str(cause)
127
128
129def test_get_serializable_value_still_returns_unhandled_values() -> None:
130 """The direct helper stays lenient, only the orjson hook raises."""
131 obj = _Unhandled()
132 assert get_serializable_value(obj) is obj
133