Skip to content

Commit b12ecd2

Browse files
committed
test: add unit tests for conversions and hashing utilities
1 parent b121665 commit b12ecd2

File tree

2 files changed

+332
-0
lines changed

2 files changed

+332
-0
lines changed

tests/utils/test_conversions.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
from __future__ import annotations
2+
3+
from datetime import date, datetime
4+
5+
import pytest
6+
7+
from sqlmesh.utils.conversions import ensure_bool, make_serializable, try_str_to_bool
8+
9+
10+
class TestTryStrToBool:
11+
"""Tests for the try_str_to_bool function."""
12+
13+
@pytest.mark.parametrize(
14+
"input_val,expected",
15+
[
16+
("true", True),
17+
("True", True),
18+
("TRUE", True),
19+
("TrUe", True),
20+
("false", False),
21+
("False", False),
22+
("FALSE", False),
23+
("FaLsE", False),
24+
],
25+
)
26+
def test_boolean_strings(self, input_val: str, expected: bool) -> None:
27+
"""Strings 'true' and 'false' (case-insensitive) convert to bool."""
28+
assert try_str_to_bool(input_val) is expected
29+
30+
@pytest.mark.parametrize(
31+
"input_val",
32+
[
33+
"yes",
34+
"no",
35+
"1",
36+
"0",
37+
"",
38+
"truthy",
39+
"falsey",
40+
"t",
41+
"f",
42+
"on",
43+
"off",
44+
],
45+
)
46+
def test_non_boolean_strings_pass_through(self, input_val: str) -> None:
47+
"""Non-boolean strings are returned unchanged."""
48+
assert try_str_to_bool(input_val) == input_val
49+
50+
def test_return_type_for_true(self) -> None:
51+
"""Returns actual bool True, not truthy value."""
52+
result = try_str_to_bool("true")
53+
assert result is True
54+
assert isinstance(result, bool)
55+
56+
def test_return_type_for_false(self) -> None:
57+
"""Returns actual bool False, not falsey value."""
58+
result = try_str_to_bool("false")
59+
assert result is False
60+
assert isinstance(result, bool)
61+
62+
63+
class TestEnsureBool:
64+
"""Tests for the ensure_bool function."""
65+
66+
def test_bool_true_passthrough(self) -> None:
67+
"""Boolean True passes through unchanged."""
68+
assert ensure_bool(True) is True
69+
70+
def test_bool_false_passthrough(self) -> None:
71+
"""Boolean False passes through unchanged."""
72+
assert ensure_bool(False) is False
73+
74+
@pytest.mark.parametrize(
75+
"input_val,expected",
76+
[
77+
("true", True),
78+
("True", True),
79+
("false", False),
80+
("False", False),
81+
],
82+
)
83+
def test_boolean_strings(self, input_val: str, expected: bool) -> None:
84+
"""String 'true'/'false' converts to corresponding bool."""
85+
assert ensure_bool(input_val) is expected
86+
87+
@pytest.mark.parametrize(
88+
"input_val,expected",
89+
[
90+
("yes", True), # Non-empty string is truthy
91+
("no", True), # Non-empty string is truthy
92+
("", False), # Empty string is falsey
93+
("0", True), # String "0" is truthy (non-empty)
94+
],
95+
)
96+
def test_other_strings_use_bool_conversion(self, input_val: str, expected: bool) -> None:
97+
"""Non-boolean strings fall back to bool() conversion."""
98+
assert ensure_bool(input_val) is expected
99+
100+
@pytest.mark.parametrize(
101+
"input_val,expected",
102+
[
103+
(1, True),
104+
(0, False),
105+
(-1, True),
106+
(100, True),
107+
],
108+
)
109+
def test_integers(self, input_val: int, expected: bool) -> None:
110+
"""Integers convert via bool() - 0 is False, others True."""
111+
assert ensure_bool(input_val) is expected
112+
113+
@pytest.mark.parametrize(
114+
"input_val,expected",
115+
[
116+
(1.0, True),
117+
(0.0, False),
118+
(-0.5, True),
119+
],
120+
)
121+
def test_floats(self, input_val: float, expected: bool) -> None:
122+
"""Floats convert via bool() - 0.0 is False, others True."""
123+
assert ensure_bool(input_val) is expected
124+
125+
@pytest.mark.parametrize(
126+
"input_val,expected",
127+
[
128+
([], False),
129+
([1], True),
130+
({}, False),
131+
({"a": 1}, True),
132+
(None, False),
133+
],
134+
)
135+
def test_other_types(self, input_val: object, expected: bool) -> None:
136+
"""Other types convert via bool()."""
137+
assert ensure_bool(input_val) is expected
138+
139+
140+
class TestMakeSerializable:
141+
"""Tests for the make_serializable function."""
142+
143+
def test_date_to_isoformat(self) -> None:
144+
"""date objects convert to ISO format string."""
145+
d = date(2024, 1, 15)
146+
assert make_serializable(d) == "2024-01-15"
147+
148+
def test_datetime_to_isoformat(self) -> None:
149+
"""datetime objects convert to ISO format string."""
150+
dt = datetime(2024, 1, 15, 10, 30, 45)
151+
assert make_serializable(dt) == "2024-01-15T10:30:45"
152+
153+
def test_datetime_with_microseconds(self) -> None:
154+
"""datetime with microseconds preserves precision."""
155+
dt = datetime(2024, 1, 15, 10, 30, 45, 123456)
156+
assert make_serializable(dt) == "2024-01-15T10:30:45.123456"
157+
158+
def test_dict_recursive(self) -> None:
159+
"""Dictionaries are processed recursively."""
160+
obj = {"date": date(2024, 1, 15), "name": "test"}
161+
result = make_serializable(obj)
162+
assert result == {"date": "2024-01-15", "name": "test"}
163+
164+
def test_list_recursive(self) -> None:
165+
"""Lists are processed recursively."""
166+
obj = [date(2024, 1, 15), "test", 123]
167+
result = make_serializable(obj)
168+
assert result == ["2024-01-15", "test", 123]
169+
170+
def test_nested_structure(self) -> None:
171+
"""Deeply nested structures are fully processed."""
172+
obj = {
173+
"dates": [date(2024, 1, 1), date(2024, 12, 31)],
174+
"nested": {"inner": {"dt": datetime(2024, 6, 15, 12, 0, 0)}},
175+
}
176+
result = make_serializable(obj)
177+
assert result == {
178+
"dates": ["2024-01-01", "2024-12-31"],
179+
"nested": {"inner": {"dt": "2024-06-15T12:00:00"}},
180+
}
181+
182+
@pytest.mark.parametrize(
183+
"input_val",
184+
[
185+
"string",
186+
123,
187+
45.67,
188+
True,
189+
False,
190+
None,
191+
],
192+
)
193+
def test_primitives_unchanged(self, input_val: object) -> None:
194+
"""Primitive types pass through unchanged."""
195+
assert make_serializable(input_val) == input_val
196+
197+
def test_empty_dict(self) -> None:
198+
"""Empty dict returns empty dict."""
199+
assert make_serializable({}) == {}
200+
201+
def test_empty_list(self) -> None:
202+
"""Empty list returns empty list."""
203+
assert make_serializable([]) == []
204+
205+
def test_dict_keys_unchanged(self) -> None:
206+
"""Dictionary keys are not modified."""
207+
obj = {"key_with_date": date(2024, 1, 1)}
208+
result = make_serializable(obj)
209+
assert "key_with_date" in result

tests/utils/test_hashing.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from sqlmesh.utils.hashing import crc32, hash_data, md5
6+
7+
8+
class TestCrc32:
9+
"""Tests for the crc32 function."""
10+
11+
def test_crc32_single_string(self) -> None:
12+
"""CRC32 of a single string returns consistent hash."""
13+
result = crc32(["hello"])
14+
assert result == str(__import__("zlib").crc32(b"hello"))
15+
16+
def test_crc32_multiple_strings(self) -> None:
17+
"""CRC32 of multiple strings joins with semicolons."""
18+
result = crc32(["a", "b", "c"])
19+
assert result == str(__import__("zlib").crc32(b"a;b;c"))
20+
21+
def test_crc32_empty_iterable(self) -> None:
22+
"""CRC32 of empty iterable returns hash of empty string."""
23+
result = crc32([])
24+
assert result == str(__import__("zlib").crc32(b""))
25+
26+
def test_crc32_with_none_values(self) -> None:
27+
"""CRC32 treats None as empty string."""
28+
result = crc32(["a", None, "c"])
29+
assert result == str(__import__("zlib").crc32(b"a;;c"))
30+
31+
def test_crc32_all_none(self) -> None:
32+
"""CRC32 of all None values returns hash of semicolons."""
33+
result = crc32([None, None])
34+
assert result == str(__import__("zlib").crc32(b";"))
35+
36+
def test_crc32_returns_string(self) -> None:
37+
"""CRC32 always returns a string type."""
38+
result = crc32(["test"])
39+
assert isinstance(result, str)
40+
41+
def test_crc32_deterministic(self) -> None:
42+
"""CRC32 returns same result for same input."""
43+
data = ["hello", "world"]
44+
assert crc32(data) == crc32(data)
45+
46+
47+
class TestMd5:
48+
"""Tests for the md5 function."""
49+
50+
def test_md5_single_string(self) -> None:
51+
"""MD5 accepts a single string directly."""
52+
result = md5("hello")
53+
assert result == __import__("hashlib").md5(b"hello").hexdigest()
54+
55+
def test_md5_iterable(self) -> None:
56+
"""MD5 accepts an iterable of strings."""
57+
result = md5(["a", "b", "c"])
58+
assert result == __import__("hashlib").md5(b"a;b;c").hexdigest()
59+
60+
def test_md5_empty_string(self) -> None:
61+
"""MD5 of empty string returns expected hash."""
62+
result = md5("")
63+
assert result == __import__("hashlib").md5(b"").hexdigest()
64+
65+
def test_md5_empty_iterable(self) -> None:
66+
"""MD5 of empty iterable returns hash of empty string."""
67+
result = md5([])
68+
assert result == __import__("hashlib").md5(b"").hexdigest()
69+
70+
def test_md5_with_none_values(self) -> None:
71+
"""MD5 treats None as empty string in iterable."""
72+
result = md5(["a", None, "c"])
73+
assert result == __import__("hashlib").md5(b"a;;c").hexdigest()
74+
75+
def test_md5_returns_hexdigest(self) -> None:
76+
"""MD5 returns a 32-character hexadecimal string."""
77+
result = md5("test")
78+
assert len(result) == 32
79+
assert all(c in "0123456789abcdef" for c in result)
80+
81+
def test_md5_deterministic(self) -> None:
82+
"""MD5 returns same result for same input."""
83+
assert md5("hello") == md5("hello")
84+
assert md5(["a", "b"]) == md5(["a", "b"])
85+
86+
87+
class TestHashData:
88+
"""Tests for the hash_data function."""
89+
90+
def test_hash_data_delegates_to_crc32(self) -> None:
91+
"""hash_data is an alias for crc32."""
92+
data = ["hello", "world"]
93+
assert hash_data(data) == crc32(data)
94+
95+
def test_hash_data_with_none(self) -> None:
96+
"""hash_data handles None values like crc32."""
97+
data = ["a", None, "b"]
98+
assert hash_data(data) == crc32(data)
99+
100+
def test_hash_data_empty(self) -> None:
101+
"""hash_data handles empty iterable."""
102+
assert hash_data([]) == crc32([])
103+
104+
105+
@pytest.mark.parametrize(
106+
"data,expected_separator_count",
107+
[
108+
(["a"], 0),
109+
(["a", "b"], 1),
110+
(["a", "b", "c"], 2),
111+
([None], 0),
112+
([None, None], 1),
113+
],
114+
)
115+
def test_concatenation_uses_semicolons(
116+
data: list[str | None], expected_separator_count: int
117+
) -> None:
118+
"""Verify that data is concatenated with semicolons."""
119+
# We verify this indirectly by checking that different orderings
120+
# produce different hashes (which wouldn't happen if not concatenated properly)
121+
if len(data) >= 2 and data[0] != data[-1]:
122+
reversed_data = list(reversed(data))
123+
assert crc32(data) != crc32(reversed_data)

0 commit comments

Comments
 (0)