Files
textual/tests/test_validation.py
Charles Merriam 43dfe08117 Fix Number and Float validation errors, allow underscores, add tests. Fix issue #4718 (#4784)
* Update Validation Test cases until not passing

* update Number and Integer validators

* Upgrade Number and Integer type regex to allow underscore, augment tests

* Force CHANGELOG update to prevent merge issues

* Update CHANGELOG.md for this PR

---------

Co-authored-by: Darren Burns <darrenb900@gmail.com>
2024-09-05 08:20:39 +01:00

235 lines
8.7 KiB
Python

from __future__ import annotations
import pytest
from textual.validation import (
URL,
Failure,
Function,
Integer,
Length,
Number,
Regex,
ValidationResult,
Validator,
)
VALIDATOR = Function(lambda value: True)
def test_ValidationResult_merge_successes():
results = [ValidationResult.success(), ValidationResult.success()]
assert ValidationResult.merge(results) == ValidationResult.success()
def test_ValidationResult_merge_failures():
failure_one = Failure(VALIDATOR, "1")
failure_two = Failure(VALIDATOR, "2")
results = [
ValidationResult.failure([failure_one]),
ValidationResult.failure([failure_two]),
ValidationResult.success(),
]
expected_result = ValidationResult.failure([failure_one, failure_two])
assert ValidationResult.merge(results) == expected_result
def test_ValidationResult_failure_descriptions():
result = ValidationResult.failure(
[
Failure(VALIDATOR, description="One"),
Failure(VALIDATOR, description="Two"),
Failure(VALIDATOR, description="Three"),
],
)
assert result.failure_descriptions == ["One", "Two", "Three"]
class ValidatorWithDescribeFailure(Validator):
def validate(self, value: str) -> ValidationResult:
return self.failure()
def describe_failure(self, failure: Failure) -> str | None:
return "describe_failure"
def test_Failure_description_priorities_parameter_only():
number_validator = Number(failure_description="ABC")
non_number_value = "x"
result = number_validator.validate(non_number_value)
# The inline value takes priority over the describe_failure.
assert result.failures[0].description == "ABC"
def test_Failure_description_priorities_parameter_and_describe_failure():
validator = ValidatorWithDescribeFailure(failure_description="ABC")
result = validator.validate("x")
# Even though the validator has a `describe_failure`, we've provided it
# inline and the inline value should take priority.
assert result.failures[0].description == "ABC"
def test_Failure_description_priorities_describe_failure_only():
validator = ValidatorWithDescribeFailure()
result = validator.validate("x")
assert result.failures[0].description == "describe_failure"
class ValidatorWithFailureMessageAndNoDescribe(Validator):
def validate(self, value: str) -> ValidationResult:
return self.failure(description="ABC")
def test_Failure_description_parameter_and_description_inside_validate():
validator = ValidatorWithFailureMessageAndNoDescribe()
result = validator.validate("x")
assert result.failures[0].description == "ABC"
class ValidatorWithFailureMessageAndDescribe(Validator):
def validate(self, value: str) -> ValidationResult:
return self.failure(value=value, description="ABC")
def describe_failure(self, failure: Failure) -> str | None:
return "describe_failure"
def test_Failure_description_describe_and_description_inside_validate():
# This is kind of a weird case - there's no reason to supply both of
# these but lets still make sure we're sensible about how we handle it.
validator = ValidatorWithFailureMessageAndDescribe()
result = validator.validate("x")
assert result.failures == [Failure(validator, "x", "ABC")]
@pytest.mark.parametrize(
"value, minimum, maximum, expected_result",
[
("123", None, None, True), # valid number, no range
("-123", None, None, True), # valid negative number, no range
("123.45", None, None, True), # valid float, no range
("1.23e-4", None, None, True), # valid scientific notation, no range
("abc", None, None, False), # non-numeric string, no range
("123", 100, 200, True), # valid number within range
("99", 100, 200, False), # valid number but not in range
("201", 100, 200, False), # valid number but not in range
("1.23e4", 0, 50000, True), # valid scientific notation within range
("inf", None, None, False), # infinity never valid
("nan", None, None, False), # nan never valid
("-inf", None, None, False), # nan never valid
("-4", 0, 5, False), # valid negative number, out of range with zero
("2", -3, 0, False), # valid number out of range with zero
("-2", -3, 0, True), # negative in range
],
)
def test_Number_validate(value, minimum, maximum, expected_result):
validator = Number(minimum=minimum, maximum=maximum)
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"regex, value, expected_result",
[
(r"\d+", "123", True), # matches regex for one or more digits
(r"\d+", "abc", False), # does not match regex for one or more digits
(r"[a-z]+", "abc", True), # matches regex for one or more lowercase letters
(
r"[a-z]+",
"ABC",
False,
), # does not match regex for one or more lowercase letters
(r"\w+", "abc123", True), # matches regex for one or more word characters
(r"\w+", "!@#", False), # does not match regex for one or more word characters
],
)
def test_Regex_validate(regex, value, expected_result):
validator = Regex(regex)
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"value, minimum, maximum, expected_result",
[
("123", None, None, True), # valid integer, no range
("-123", None, None, True), # valid negative integer, no range
("123.45", None, None, False), # float, not a valid integer
("1.23e-4", None, None, False), # scientific notation, not a valid integer
("abc", None, None, False), # non-numeric string, not a valid integer
("123", 100, 200, True), # valid integer within range
("99", 100, 200, False), # valid integer but not in range
("201", 100, 200, False), # valid integer but not in range
("1.23e4", None, None, False), # valid scientific notation, even resolving to an integer, is not valid
("123.", None, None, False), # periods not valid in integers
("123_456", None, None, True), # underscores are valid python
("_123_456", None, None, False), # leading underscores are not valid python
("-123", -123, -123, True), # valid negative number in minimal range
],
)
def test_Integer_validate(value, minimum, maximum, expected_result):
validator = Integer(minimum=minimum, maximum=maximum)
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"value, min_length, max_length, expected_result",
[
("", None, None, True), # empty string
("test", None, None, True), # any string with no restrictions
("test", 5, None, False), # shorter than minimum length
("test", None, 3, False), # longer than maximum length
("test", 4, 4, True), # exactly matches minimum and maximum length
("test", 2, 6, True), # within length range
],
)
def test_Length_validate(value, min_length, max_length, expected_result):
validator = Length(minimum=min_length, maximum=max_length)
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"value, expected_result",
[
("http://example.com", True), # valid URL
("https://example.com", True), # valid URL with https
("www.example.com", False), # missing scheme
("://example.com", False), # invalid URL (no scheme)
("https:///path", False), # missing netloc
(
"redis://username:pass[word@localhost:6379/0",
False,
), # invalid URL characters
("", False), # empty string
],
)
def test_URL_validate(value, expected_result):
validator = URL()
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"function, failure_description, is_valid",
[
((lambda value: True), None, True),
((lambda value: False), "failure!", False),
],
)
def test_Function_validate(function, failure_description, is_valid):
validator = Function(function, failure_description)
result = validator.validate("x")
assert result.is_valid is is_valid
if result.failure_descriptions:
assert result.failure_descriptions[0] == failure_description
def test_Integer_failure_description_when_NotANumber():
"""Regression test for https://github.com/Textualize/textual/issues/4413"""
validator = Integer()
result = validator.validate("x")
assert result.is_valid is False
assert result.failure_descriptions[0] == "Must be a valid integer."