mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into multiselect
This commit is contained in:
79
tests/input/test_input_validation.py
Normal file
79
tests/input/test_input_validation.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.validation import Number, ValidationResult
|
||||
from textual.widgets import Input
|
||||
|
||||
|
||||
class InputApp(App):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.messages = []
|
||||
self.validator = Number(minimum=1, maximum=5)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(
|
||||
validators=self.validator,
|
||||
)
|
||||
|
||||
@on(Input.Changed)
|
||||
@on(Input.Submitted)
|
||||
def on_changed_or_submitted(self, event):
|
||||
self.messages.append(event)
|
||||
|
||||
|
||||
async def test_input_changed_message_validation_failure():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.value = "8"
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[0].validation_result == ValidationResult.failure(
|
||||
failures=[
|
||||
Number.NotInRange(
|
||||
value="8",
|
||||
validator=app.validator,
|
||||
description="Must be between 1 and 5.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_input_changed_message_validation_success():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.value = "3"
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[0].validation_result == ValidationResult.success()
|
||||
|
||||
|
||||
async def test_input_submitted_message_validation_failure():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.value = "8"
|
||||
await input.action_submit()
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 2
|
||||
assert app.messages[1].validation_result == ValidationResult.failure(
|
||||
failures=[
|
||||
Number.NotInRange(
|
||||
value="8",
|
||||
validator=app.validator,
|
||||
description="Must be between 1 and 5.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_input_submitted_message_validation_success():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.value = "3"
|
||||
await input.action_submit()
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 2
|
||||
assert app.messages[1].validation_result == ValidationResult.success()
|
||||
File diff suppressed because one or more lines are too long
45
tests/snapshot_tests/snapshot_apps/input_validation.py
Normal file
45
tests/snapshot_tests/snapshot_apps/input_validation.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.validation import Number
|
||||
from textual.widgets import Input
|
||||
|
||||
VALIDATORS = [
|
||||
Number(minimum=1, maximum=5),
|
||||
]
|
||||
|
||||
|
||||
class InputApp(App):
|
||||
CSS = """
|
||||
Input.-valid {
|
||||
border: tall $success 60%;
|
||||
}
|
||||
Input.-valid:focus {
|
||||
border: tall $success;
|
||||
}
|
||||
Input {
|
||||
margin: 1 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(
|
||||
placeholder="Enter a number between 1 and 5",
|
||||
validators=VALIDATORS,
|
||||
)
|
||||
yield Input(
|
||||
placeholder="Enter a number between 1 and 5",
|
||||
validators=VALIDATORS,
|
||||
)
|
||||
yield Input(
|
||||
placeholder="Enter a number between 1 and 5",
|
||||
validators=VALIDATORS,
|
||||
)
|
||||
yield Input(
|
||||
placeholder="Enter a number between 1 and 5",
|
||||
validators=VALIDATORS,
|
||||
)
|
||||
|
||||
|
||||
app = InputApp()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
||||
@@ -84,6 +84,19 @@ def test_input_and_focus(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "input.py", press=press)
|
||||
|
||||
|
||||
def test_input_validation(snap_compare):
|
||||
"""Checking that invalid styling is applied. The snapshot app itself
|
||||
also adds styling for -valid which gives a green border."""
|
||||
press = [
|
||||
*"-2", # -2 is invalid, so -invalid should be applied
|
||||
"tab",
|
||||
"3", # This is valid, so -valid should be applied
|
||||
"tab",
|
||||
*"-2", # -2 is invalid, so -invalid should be applied (and :focus, since we stop here)
|
||||
]
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "input_validation.py", press=press)
|
||||
|
||||
|
||||
def test_buttons_render(snap_compare):
|
||||
# Testing button rendering. We press tab to focus the first button too.
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
|
||||
|
||||
@@ -1009,7 +1009,9 @@ async def test_scrolling_cursor_into_view():
|
||||
table.add_column("n")
|
||||
table.add_rows([(n,) for n in range(300)])
|
||||
|
||||
await pilot.pause()
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
assert table.scroll_y > 100
|
||||
|
||||
|
||||
|
||||
@@ -153,7 +153,9 @@ async def test_screens():
|
||||
await app._shutdown()
|
||||
|
||||
|
||||
async def test_auto_focus():
|
||||
async def test_auto_focus_on_screen_if_app_auto_focus_is_none():
|
||||
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
|
||||
|
||||
class MyScreen(Screen[None]):
|
||||
def compose(self):
|
||||
yield Button()
|
||||
@@ -161,10 +163,11 @@ async def test_auto_focus():
|
||||
yield Input(id="two")
|
||||
|
||||
class MyApp(App[None]):
|
||||
pass
|
||||
AUTO_FOCUS = None
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
MyScreen.AUTO_FOCUS = "*"
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Button)
|
||||
app.pop_screen()
|
||||
@@ -193,6 +196,80 @@ async def test_auto_focus():
|
||||
assert app.focused.id == "two"
|
||||
|
||||
|
||||
async def test_auto_focus_on_screen_if_app_auto_focus_is_disabled():
|
||||
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
|
||||
|
||||
class MyScreen(Screen[None]):
|
||||
def compose(self):
|
||||
yield Button()
|
||||
yield Input(id="one")
|
||||
yield Input(id="two")
|
||||
|
||||
class MyApp(App[None]):
|
||||
AUTO_FOCUS = ""
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
MyScreen.AUTO_FOCUS = "*"
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Button)
|
||||
app.pop_screen()
|
||||
|
||||
MyScreen.AUTO_FOCUS = None
|
||||
await app.push_screen(MyScreen())
|
||||
assert app.focused is None
|
||||
app.pop_screen()
|
||||
|
||||
MyScreen.AUTO_FOCUS = "Input"
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Input)
|
||||
assert app.focused.id == "one"
|
||||
app.pop_screen()
|
||||
|
||||
MyScreen.AUTO_FOCUS = "#two"
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Input)
|
||||
assert app.focused.id == "two"
|
||||
|
||||
# If we push and pop another screen, focus should be preserved for #two.
|
||||
MyScreen.AUTO_FOCUS = None
|
||||
await app.push_screen(MyScreen())
|
||||
assert app.focused is None
|
||||
app.pop_screen()
|
||||
assert app.focused.id == "two"
|
||||
|
||||
|
||||
async def test_auto_focus_inheritance():
|
||||
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
|
||||
|
||||
class MyScreen(Screen[None]):
|
||||
def compose(self):
|
||||
yield Button()
|
||||
yield Input(id="one")
|
||||
yield Input(id="two")
|
||||
|
||||
class MyApp(App[None]):
|
||||
pass
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
MyApp.AUTO_FOCUS = "Input"
|
||||
MyScreen.AUTO_FOCUS = "*"
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Button)
|
||||
app.pop_screen()
|
||||
|
||||
MyScreen.AUTO_FOCUS = None
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Input)
|
||||
app.pop_screen()
|
||||
|
||||
MyScreen.AUTO_FOCUS = ""
|
||||
await app.push_screen(MyScreen())
|
||||
assert app.focused is None
|
||||
app.pop_screen()
|
||||
|
||||
|
||||
async def test_auto_focus_skips_non_focusable_widgets():
|
||||
class MyScreen(Screen[None]):
|
||||
def compose(self):
|
||||
|
||||
216
tests/test_validation.py
Normal file
216
tests/test_validation.py
Normal file
@@ -0,0 +1,216 @@
|
||||
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
|
||||
],
|
||||
)
|
||||
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, True), # valid integer in scientific notation
|
||||
],
|
||||
)
|
||||
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
|
||||
Reference in New Issue
Block a user