Merge branch 'main' into input-auto-completion

This commit is contained in:
Rodrigo Girão Serrão
2023-05-25 17:42:37 +01:00
58 changed files with 5718 additions and 1801 deletions

View 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()

View File

@@ -0,0 +1,100 @@
"""Core selection list unit tests, aimed at testing basic list creation.
Note that the vast majority of the API *isn't* tested in here as
`SelectionList` inherits from `OptionList` and so that would be duplicated
effort. Instead these tests aim to just test the things that have been
changed or wrapped in some way.
"""
from __future__ import annotations
import pytest
from rich.text import Text
from textual.app import App, ComposeResult
from textual.widgets import SelectionList
from textual.widgets.option_list import Option
from textual.widgets.selection_list import Selection, SelectionError
class SelectionListApp(App[None]):
"""Test selection list application."""
def compose(self) -> ComposeResult:
yield SelectionList[int](
("0", 0),
("1", 1, False),
("2", 2, True),
Selection("3", 3, id="3"),
Selection("4", 4, True, id="4"),
)
async def test_all_parameters_become_selctions() -> None:
"""All input parameters to a list should become selections."""
async with SelectionListApp().run_test() as pilot:
selections = pilot.app.query_one(SelectionList)
assert selections.option_count == 5
for n in range(5):
assert isinstance(selections.get_option_at_index(n), Selection)
async def test_get_selection_by_index() -> None:
"""It should be possible to get a selection by index."""
async with SelectionListApp().run_test() as pilot:
option_list = pilot.app.query_one(SelectionList)
for n in range(5):
assert option_list.get_option_at_index(n).prompt == Text(str(n))
assert option_list.get_option_at_index(-1).prompt == Text("4")
async def test_get_selection_by_id() -> None:
"""It should be possible to get a selection by ID."""
async with SelectionListApp().run_test() as pilot:
option_list = pilot.app.query_one(SelectionList)
assert option_list.get_option("3").prompt == Text("3")
assert option_list.get_option("4").prompt == Text("4")
async def test_add_later() -> None:
"""It should be possible to add more items to a selection list."""
async with SelectionListApp().run_test() as pilot:
selections = pilot.app.query_one(SelectionList)
assert selections.option_count == 5
selections.add_option(("5", 5))
assert selections.option_count == 6
selections.add_option(Selection("6", 6))
assert selections.option_count == 7
selections.add_options(
[Selection("7", 7), Selection("8", 8, True), ("9", 9), ("10", 10, True)]
)
assert selections.option_count == 11
selections.add_options([])
assert selections.option_count == 11
async def test_add_later_selcted_state() -> None:
"""When adding selections later the selected collection should get updated."""
async with SelectionListApp().run_test() as pilot:
selections = pilot.app.query_one(SelectionList)
assert selections.selected == [2, 4]
selections.add_option(("5", 5, True))
assert selections.selected == [2, 4, 5]
selections.add_option(Selection("6", 6, True))
assert selections.selected == [2, 4, 5, 6]
async def test_add_non_selections() -> None:
"""Adding options that aren't selections should result in errors."""
async with SelectionListApp().run_test() as pilot:
selections = pilot.app.query_one(SelectionList)
with pytest.raises(SelectionError):
selections.add_option(None)
with pytest.raises(SelectionError):
selections.add_option(Option("Nope"))
with pytest.raises(SelectionError):
selections.add_option("Nope")
with pytest.raises(SelectionError):
selections.add_option(("Nope",))
with pytest.raises(SelectionError):
selections.add_option(("Nope", 0, False, 23))

View File

@@ -0,0 +1,210 @@
"""Unit tests aimed at testing the selection list messages.
Note that these tests only cover a subset of the public API of this widget.
The bulk of the API is inherited from OptionList, and as such there are
comprehensive tests for that. These tests simply cover the parts of the API
that have been modified by the child class.
"""
from __future__ import annotations
from textual import on
from textual.app import App, ComposeResult
from textual.messages import Message
from textual.widgets import OptionList, SelectionList
class SelectionListApp(App[None]):
"""Test selection list application."""
def __init__(self) -> None:
super().__init__()
self.messages: list[tuple[str, int | None]] = []
def compose(self) -> ComposeResult:
yield SelectionList[int](*[(str(n), n) for n in range(10)])
@on(OptionList.OptionHighlighted)
@on(OptionList.OptionSelected)
@on(SelectionList.SelectionHighlighted)
@on(SelectionList.SelectionToggled)
@on(SelectionList.SelectedChanged)
def _record(self, event: Message) -> None:
assert event.control == self.query_one(SelectionList)
self.messages.append(
(
event.__class__.__name__,
event.selection_index
if isinstance(event, SelectionList.SelectionMessage)
else None,
)
)
async def test_messages_on_startup() -> None:
"""There should be a highlighted message when a non-empty selection list first starts up."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
assert pilot.app.messages == [("SelectionHighlighted", 0)]
async def test_new_highlight() -> None:
"""Setting the highlight to a new option should result in a message."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).highlighted = 2
await pilot.pause()
assert pilot.app.messages[1:] == [("SelectionHighlighted", 2)]
async def test_toggle() -> None:
"""Toggling an option should result in messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).toggle(0)
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_toggle_via_user() -> None:
"""Toggling via the user should result in the correct messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.press("space")
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
("SelectionToggled", 0),
]
async def test_toggle_all() -> None:
"""Toggling all options should result in messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).toggle_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_select() -> None:
"""Selecting all an option should result in a message."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select(1)
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_select_selected() -> None:
"""Selecting an option that is already selected should emit no extra message.."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select(0)
await pilot.pause()
pilot.app.query_one(SelectionList).select(0)
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_select_all() -> None:
"""Selecting all options should result in messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_select_all_selected() -> None:
"""Selecting all when all are selected should result in no extra messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select_all()
await pilot.pause()
pilot.app.query_one(SelectionList).select_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_deselect() -> None:
"""Deselecting an option should result in a message."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select(1)
await pilot.pause()
pilot.app.query_one(SelectionList).deselect(1)
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
("SelectedChanged", None),
]
async def test_deselect_deselected() -> None:
"""Deselecting a deselected option should result in no extra messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).deselect(0)
await pilot.pause()
assert pilot.app.messages == [("SelectionHighlighted", 0)]
async def test_deselect_all() -> None:
"""Deselecting all deselected options should result in no additional messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).deselect_all()
await pilot.pause()
assert pilot.app.messages == [("SelectionHighlighted", 0)]
async def test_select_then_deselect_all() -> None:
"""Selecting and then deselecting all options should result in messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
pilot.app.query_one(SelectionList).deselect_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
("SelectedChanged", None),
]

View File

@@ -0,0 +1,82 @@
"""Unit tests dealing with the tracking of selection list values."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import SelectionList
class SelectionListApp(App[None]):
def __init__(self, default_state: bool = False) -> None:
super().__init__()
self._default_state = default_state
def compose(self) -> ComposeResult:
yield SelectionList[int](*[(str(n), n, self._default_state) for n in range(50)])
async def test_empty_selected() -> None:
"""Selected should be empty when nothing is selected."""
async with SelectionListApp().run_test() as pilot:
assert pilot.app.query_one(SelectionList).selected == []
async def test_programatic_select() -> None:
"""Selected should contain a selected value."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.select(0)
assert pilot.app.query_one(SelectionList).selected == [0]
async def test_programatic_select_all() -> None:
"""Selected should contain all selected values."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.select_all()
assert pilot.app.query_one(SelectionList).selected == list(range(50))
async def test_programatic_deselect() -> None:
"""Selected should not contain a deselected value."""
async with SelectionListApp(True).run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.deselect(0)
assert pilot.app.query_one(SelectionList).selected == list(range(50)[1:])
async def test_programatic_deselect_all() -> None:
"""Selected should not contain anything after deselecting all values."""
async with SelectionListApp(True).run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.deselect_all()
assert pilot.app.query_one(SelectionList).selected == []
async def test_programatic_toggle() -> None:
"""Selected should reflect a toggle."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
for n in range(25, 50):
selection.select(n)
for n in range(50):
selection.toggle(n)
assert pilot.app.query_one(SelectionList).selected == list(range(50)[:25])
async def test_programatic_toggle_all() -> None:
"""Selected should contain all values after toggling all on."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.toggle_all()
assert pilot.app.query_one(SelectionList).selected == list(range(50))
async def test_removal_of_selected_item() -> None:
"""Removing a selected selection should remove its value from the selected set."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.toggle(0)
assert pilot.app.query_one(SelectionList).selected == [0]
selection.remove_option_at_index(0)
assert pilot.app.query_one(SelectionList).selected == []

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
from textual.app import App, ComposeResult
from textual.widgets import Input
class BlurApp(App):
BINDINGS = [("f3", "disable")]
def compose(self) -> ComposeResult:
yield Input()
def on_ready(self) -> None:
self.query_one(Input).focus()
def action_disable(self) -> None:
self.query_one(Input).disabled = True
if __name__ == "__main__":
app = BlurApp()
app.run()

View 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()

View File

@@ -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_input_suggestions(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[])
@@ -95,7 +108,6 @@ def test_buttons_render(snap_compare):
def test_placeholder_render(snap_compare):
# Testing the rendering of the multiple placeholder variants and labels.
Placeholder.reset_color_cycle()
assert snap_compare(WIDGET_EXAMPLES_DIR / "placeholder.py")
@@ -238,6 +250,18 @@ def test_select(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")
def test_selection_list_selected(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py")
def test_selection_list_selections(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py")
def test_selection_list_tuples(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py")
def test_select_expanded(snap_compare):
assert snap_compare(
WIDGET_EXAMPLES_DIR / "select_widget.py", press=["tab", "enter"]
@@ -265,7 +289,6 @@ PATHS = [
@pytest.mark.parametrize("file_name", PATHS)
def test_css_property(file_name, snap_compare):
path_to_app = STYLES_EXAMPLES_DIR / file_name
Placeholder.reset_color_cycle()
assert snap_compare(path_to_app)
@@ -524,3 +547,11 @@ def test_select_rebuild(snap_compare):
SNAPSHOT_APPS_DIR / "select_rebuild.py",
press=["space", "escape", "tab", "enter", "tab", "space"],
)
def test_blur_on_disabled(snap_compare):
# https://github.com/Textualize/textual/issues/2641
assert snap_compare(
SNAPSHOT_APPS_DIR / "blur_on_disabled.py",
press=[*"foo", "f3", *"this should not appear"],
)

View File

@@ -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

28
tests/test_footer.py Normal file
View File

@@ -0,0 +1,28 @@
from textual.app import App, ComposeResult
from textual.geometry import Offset
from textual.screen import ModalScreen
from textual.widgets import Footer, Label
async def test_footer_highlight_when_pushing_modal():
"""Regression test for https://github.com/Textualize/textual/issues/2606"""
class MyModalScreen(ModalScreen):
def compose(self) -> ComposeResult:
yield Label("apple")
class MyApp(App[None]):
BINDINGS = [("a", "p", "push")]
def compose(self) -> ComposeResult:
yield Footer()
def action_p(self):
self.push_screen(MyModalScreen())
app = MyApp()
async with app.run_test(size=(80, 2)) as pilot:
await pilot.hover(None, Offset(0, 1))
await pilot.click(None, Offset(0, 1))
assert isinstance(app.screen, MyModalScreen)
assert app.screen_stack[0].query_one(Footer).highlight_key is None

View File

@@ -413,3 +413,51 @@ async def test_public_and_private_watch() -> None:
pilot.app.counter += 1
assert calls["private"] is True
assert calls["public"] is True
@pytest.mark.xfail(reason="https://github.com/Textualize/textual/issues/2539")
async def test_public_and_private_validate() -> None:
"""If a reactive/var has public and private validate both should get called."""
calls: dict[str, bool] = {"private": False, "public": False}
class PrivateValidateTest(App):
counter = var(0, init=False)
def validate_counter(self, _: int) -> None:
calls["public"] = True
def _validate_counter(self, _: int) -> None:
calls["private"] = True
async with PrivateValidateTest().run_test() as pilot:
assert calls["private"] is False
assert calls["public"] is False
pilot.app.counter += 1
assert calls["private"] is True
assert calls["public"] is True
@pytest.mark.xfail(reason="https://github.com/Textualize/textual/issues/2539")
async def test_public_and_private_compute() -> None:
"""If a reactive/var has public and private compute both should get called."""
calls: dict[str, bool] = {"private": False, "public": False}
class PrivateComputeTest(App):
counter = var(0, init=False)
def compute_counter(self) -> int:
calls["public"] = True
return 23
def _compute_counter(self) -> int:
calls["private"] = True
return 42
async with PrivateComputeTest().run_test() as pilot:
assert calls["private"] is False
assert calls["public"] is False
_ = pilot.app.counter
assert calls["private"] is True
assert calls["public"] is True

277
tests/test_screen_modes.py Normal file
View File

@@ -0,0 +1,277 @@
from functools import partial
from itertools import cycle
from typing import Type
import pytest
from textual.app import (
ActiveModeError,
App,
ComposeResult,
InvalidModeError,
UnknownModeError,
)
from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, TextLog
FRUITS = cycle("apple mango strawberry banana peach pear melon watermelon".split())
class ScreenBindingsMixin(Screen[None]):
BINDINGS = [
("1", "one", "Mode 1"),
("2", "two", "Mode 2"),
("p", "push", "Push rnd scrn"),
("o", "pop_screen", "Pop"),
("r", "remove", "Remove mode 1"),
]
def action_one(self) -> None:
self.app.switch_mode("one")
def action_two(self) -> None:
self.app.switch_mode("two")
def action_fruits(self) -> None:
self.app.switch_mode("fruits")
def action_push(self) -> None:
self.app.push_screen(FruitModal())
class BaseScreen(ScreenBindingsMixin):
def __init__(self, label):
super().__init__()
self.label = label
def compose(self) -> ComposeResult:
yield Header()
yield Label(self.label)
yield Footer()
def action_remove(self) -> None:
self.app.remove_mode("one")
class FruitModal(ModalScreen[str], ScreenBindingsMixin):
BINDINGS = [("d", "dismiss_fruit", "Dismiss")]
def compose(self) -> ComposeResult:
yield Label(next(FRUITS))
class FruitsScreen(ScreenBindingsMixin):
def compose(self) -> ComposeResult:
yield TextLog()
@pytest.fixture
def ModesApp():
class ModesApp(App[None]):
MODES = {
"one": lambda: BaseScreen("one"),
"two": "screen_two",
}
SCREENS = {
"screen_two": lambda: BaseScreen("two"),
}
def on_mount(self):
self.switch_mode("one")
return ModesApp
async def test_mode_setup(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
assert isinstance(app.screen, BaseScreen)
assert str(app.screen.query_one(Label).renderable) == "one"
async def test_switch_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
await pilot.press("2")
assert str(app.screen.query_one(Label).renderable) == "two"
await pilot.press("1")
assert str(app.screen.query_one(Label).renderable) == "one"
async def test_switch_same_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
await pilot.press("1")
assert str(app.screen.query_one(Label).renderable) == "one"
await pilot.press("1")
assert str(app.screen.query_one(Label).renderable) == "one"
async def test_switch_unknown_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
with pytest.raises(UnknownModeError):
app.switch_mode("unknown mode here")
async def test_remove_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
app.switch_mode("two")
await pilot.pause()
assert str(app.screen.query_one(Label).renderable) == "two"
app.remove_mode("one")
assert "one" not in app.MODES
async def test_remove_active_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
with pytest.raises(ActiveModeError):
app.remove_mode("one")
async def test_add_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
app.add_mode("three", BaseScreen("three"))
app.switch_mode("three")
await pilot.pause()
assert str(app.screen.query_one(Label).renderable) == "three"
async def test_add_mode_duplicated(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
with pytest.raises(InvalidModeError):
app.add_mode("one", BaseScreen("one"))
async def test_screen_stack_preserved(ModesApp: Type[App]):
fruits = []
N = 5
app = ModesApp()
async with app.run_test() as pilot:
# Build the stack up.
for _ in range(N):
await pilot.press("p")
fruits.append(str(app.query_one(Label).renderable))
assert len(app.screen_stack) == N + 1
# Switch out and back.
await pilot.press("2")
assert len(app.screen_stack) == 1
await pilot.press("1")
# Check the stack.
assert len(app.screen_stack) == N + 1
for _ in range(N):
assert str(app.query_one(Label).renderable) == fruits.pop()
await pilot.press("o")
async def test_inactive_stack_is_alive():
"""This tests that timers in screens outside the active stack keep going."""
pings = []
class FastCounter(Screen[None]):
def compose(self) -> ComposeResult:
yield Label("fast")
def on_mount(self) -> None:
self.set_interval(0.01, self.ping)
def ping(self) -> None:
pings.append(str(self.app.query_one(Label).renderable))
def key_s(self):
self.app.switch_mode("smile")
class SmileScreen(Screen[None]):
def compose(self) -> ComposeResult:
yield Label(":)")
def key_s(self):
self.app.switch_mode("fast")
class ModesApp(App[None]):
MODES = {
"fast": FastCounter,
"smile": SmileScreen,
}
def on_mount(self) -> None:
self.switch_mode("fast")
app = ModesApp()
async with app.run_test() as pilot:
await pilot.press("s")
assert str(app.query_one(Label).renderable) == ":)"
await pilot.press("s")
assert ":)" in pings
async def test_multiple_mode_callbacks():
written = []
class LogScreen(Screen[None]):
def __init__(self, value):
super().__init__()
self.value = value
def key_p(self) -> None:
self.app.push_screen(ResultScreen(self.value), written.append)
class ResultScreen(Screen[str]):
def __init__(self, value):
super().__init__()
self.value = value
def key_p(self) -> None:
self.dismiss(self.value)
def key_f(self) -> None:
self.app.switch_mode("first")
def key_o(self) -> None:
self.app.switch_mode("other")
class ModesApp(App[None]):
MODES = {
"first": lambda: LogScreen("first"),
"other": lambda: LogScreen("other"),
}
def on_mount(self) -> None:
self.switch_mode("first")
def key_f(self) -> None:
self.switch_mode("first")
def key_o(self) -> None:
self.switch_mode("other")
app = ModesApp()
async with app.run_test() as pilot:
# Push and dismiss ResultScreen("first")
await pilot.press("p")
await pilot.press("p")
assert written == ["first"]
# Push ResultScreen("first")
await pilot.press("p")
# Switch to LogScreen("other")
await pilot.press("o")
# Push and dismiss ResultScreen("other")
await pilot.press("p")
await pilot.press("p")
assert written == ["first", "other"]
# Go back to ResultScreen("first")
await pilot.press("f")
# Dismiss ResultScreen("first")
await pilot.press("p")
assert written == ["first", "other", "first"]

View File

@@ -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
View 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