Sage / tests /test_validation.py
vxa8502's picture
Update and add tests to match restructured codebase; remove poorly-designed tests
3c98117
Raw
History Blame Contribute Delete
6.74 kB
"""Tests for shared data validation helpers."""
from __future__ import annotations
import math
import numpy as np
import pytest
from sage.data._validation import (
clean_text,
normalize_whitespace,
optional_bool,
optional_float,
optional_identifier,
optional_int,
optional_object,
optional_str,
parse_unique_csv_strings,
parse_unique_string_list,
require_float,
require_int,
require_nonempty_str,
require_positive_int,
)
def test_text_helpers_normalize_expected_whitespace() -> None:
assert normalize_whitespace(" one\t two\nthree ") == "one two three"
assert clean_text(123) == ""
assert (
clean_text(" one\t two\n", collapse_internal_whitespace=False) == "one\t two"
)
assert (
require_nonempty_str(
" one\t two\n",
"field",
"context",
collapse_internal_whitespace=True,
)
== "one two"
)
assert optional_str(" ", "field", "context") is None
def test_required_string_rejects_missing_and_blank_values() -> None:
with pytest.raises(ValueError, match="'field' must be a string"):
require_nonempty_str(None, "field", "context")
with pytest.raises(ValueError, match="'field' must be non-empty"):
require_nonempty_str(" ", "field", "context")
with pytest.raises(ValueError, match="'field' must be a string or null"):
optional_str(7, "field", "context")
@pytest.mark.parametrize(
"value",
[
None,
True,
np.bool_(True),
"",
" ",
"nan",
"NaN",
"<NA>",
"None",
"null",
"NaT",
float("nan"),
float("inf"),
-float("inf"),
],
)
def test_optional_identifier_treats_null_like_values_as_missing(value: object) -> None:
assert optional_identifier(value) is None
def test_optional_identifier_normalizes_strings_and_numbers() -> None:
assert optional_identifier(" ASIN\t123\n") == "ASIN 123"
assert (
optional_identifier(" ASIN\t123\n", collapse_internal_whitespace=False)
== "ASIN\t123"
)
assert optional_identifier(123) == "123"
assert optional_identifier(np.int64(123)) == "123"
def test_integer_helpers_reject_bool_and_accept_integral_scalars() -> None:
assert require_int(3, "field", "context") == 3
assert require_int(np.int64(4), "field", "context") == 4
assert optional_int(None, "field", "context") is None
assert optional_int(np.int64(5), "field", "context") == 5
with pytest.raises(ValueError, match="'field' must be an int"):
require_int(True, "field", "context")
with pytest.raises(ValueError, match="'field' must be an int"):
require_int(np.bool_(True), "field", "context")
with pytest.raises(ValueError, match="'field' must be >= 1"):
require_positive_int(0, "field", "context")
def test_float_helpers_require_finite_real_numbers() -> None:
assert require_float(3, "field", "context") == 3.0
assert require_float(np.float32(1.25), "field", "context") == pytest.approx(1.25)
assert optional_float(None, "field", "context") is None
assert optional_float(np.float64(2.5), "field", "context") == 2.5
for value in (True, np.bool_(True), "1.0"):
with pytest.raises(ValueError, match="'field' must be numeric"):
require_float(value, "field", "context")
for value in (float("nan"), float("inf"), -float("inf"), np.float64(math.nan)):
with pytest.raises(ValueError, match="'field' must be finite"):
require_float(value, "field", "context")
def test_optional_bool_accepts_bool_scalars_only() -> None:
assert optional_bool(None, "field", "context") is None
assert optional_bool(True, "field", "context") is True
assert optional_bool(np.bool_(False), "field", "context") is False
with pytest.raises(ValueError, match="'field' must be a bool or null"):
optional_bool("true", "field", "context")
def test_optional_object_returns_shallow_copy() -> None:
payload = {"a": 1}
parsed = optional_object(payload, "field", "context")
assert parsed == payload
assert parsed is not payload
with pytest.raises(ValueError, match="'field' must be an object or null"):
optional_object([], "field", "context")
def test_parse_unique_string_list_validates_items_and_duplicates() -> None:
assert parse_unique_string_list(
[" one two ", "THREE"],
field_name="tags",
context="row",
collapse_internal_whitespace=True,
transform=str.casefold,
) == ("one two", "three")
with pytest.raises(ValueError, match="Duplicate value 'a'"):
parse_unique_string_list(
["A", "a"],
field_name="tags",
context="row",
transform=str.casefold,
)
with pytest.raises(ValueError, match="'tags\\[1\\]' must be a non-empty string"):
parse_unique_string_list(["a", " "], field_name="tags", context="row")
with pytest.raises(ValueError, match="transform must return a string"):
parse_unique_string_list(
["a"],
field_name="tags",
context="row",
transform=lambda _value: 1, # type: ignore[return-value]
)
def test_parse_unique_string_list_enforces_none_and_min_item_policy() -> None:
assert parse_unique_string_list(None, field_name="tags", context="row") == ()
with pytest.raises(ValueError, match="'tags' must be a list"):
parse_unique_string_list(
None, field_name="tags", context="row", allow_none=False
)
with pytest.raises(ValueError, match="at least 2 item"):
parse_unique_string_list(["a"], field_name="tags", context="row", min_items=2)
with pytest.raises(ValueError, match="min_items for 'tags' must be >= 0"):
parse_unique_string_list([], field_name="tags", context="row", min_items=-1)
def test_parse_unique_csv_strings_ignores_empty_items_and_duplicate_repeats() -> None:
assert parse_unique_csv_strings(
" alpha, beta, , alpha, gamma ",
field_name="subsets",
context="cli",
) == ("alpha", "beta", "gamma")
with pytest.raises(ValueError, match="'subsets' must be a comma-separated string"):
parse_unique_csv_strings(["alpha"], field_name="subsets", context="cli")
with pytest.raises(ValueError, match="at least 1 item"):
parse_unique_csv_strings(
" , ", field_name="subsets", context="cli", min_items=1
)
with pytest.raises(ValueError, match="min_items for 'subsets' must be >= 0"):
parse_unique_csv_strings("", field_name="subsets", context="cli", min_items=-1)