Merge branch 'main' of github.com:Textualize/textual into datatable-events

This commit is contained in:
Darren Burns
2023-01-16 11:00:10 +00:00
243 changed files with 12764 additions and 1680 deletions

File diff suppressed because one or more lines are too long

View File

@@ -103,7 +103,7 @@ def test_header_render(snap_compare):
def test_list_view(snap_compare):
assert snap_compare(
WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up"]
WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up", "_"]
)
@@ -137,6 +137,7 @@ 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)

View File

@@ -94,8 +94,9 @@ def test_arrange_dock_bottom():
assert widgets == {child, header}
assert spacing == Spacing(0, 0, 1, 0)
def test_arrange_dock_badly():
child = Widget(id="child")
child.styles.dock = "nowhere"
with pytest.raises(AssertionError):
_ = arrange( Widget(), [child], Size(80, 24), Size(80, 24))
_ = arrange(Widget(), [child], Size(80, 24), Size(80, 24))

53
tests/test_concurrency.py Normal file
View File

@@ -0,0 +1,53 @@
import pytest
from threading import Thread
from textual.app import App, ComposeResult
from textual.widgets import TextLog
def test_call_from_thread_app_not_running():
app = App()
# Should fail if app is not running
with pytest.raises(RuntimeError):
app.call_from_thread(print)
def test_call_from_thread():
"""Test the call_from_thread method."""
class BackgroundThread(Thread):
"""A background thread which will modify app in some way."""
def __init__(self, app: App) -> None:
self.app = app
super().__init__()
def run(self) -> None:
def write_stuff(text: str) -> None:
"""Write stuff to a widget."""
self.app.query_one(TextLog).write(text)
self.app.call_from_thread(write_stuff, "Hello")
# Exit the app with a code we can assert
self.app.call_from_thread(self.app.exit, 123)
class ThreadTestApp(App):
"""Trivial app with a single widget."""
def compose(self) -> ComposeResult:
yield TextLog()
def on_ready(self) -> None:
"""Launch a thread which will modify the app."""
try:
self.call_from_thread(print)
except RuntimeError as error:
# Calling this from the same thread as the app is an error
self._runtime_error = error
BackgroundThread(self).start()
app = ThreadTestApp()
result = app.run(headless=True, size=(80, 24))
assert isinstance(app._runtime_error, RuntimeError)
assert result == 123

View File

@@ -47,6 +47,7 @@ def test_validate():
def test_inherited_bindings():
"""Test if binding merging is done correctly when (not) inheriting bindings."""
class A(DOMNode):
BINDINGS = [("a", "a", "a")]
@@ -78,29 +79,34 @@ def test_inherited_bindings():
assert list(e._bindings.keys.keys()) == ["e"]
def test_get_default_css():
def test__get_default_css():
class A(DOMNode):
pass
class B(A):
pass
class C(B):
DEFAULT_CSS = "C"
class D(C):
pass
class E(D):
DEFAULT_CSS = "E"
node = DOMNode()
node_css = node.get_default_css()
node_css = node._get_default_css()
a = A()
a_css = a.get_default_css()
a_css = a._get_default_css()
b = B()
b_css = b.get_default_css()
b_css = b._get_default_css()
c = C()
c_css = c.get_default_css()
c_css = c._get_default_css()
d = D()
d_css = d.get_default_css()
d_css = d._get_default_css()
e = E()
e_css = e.get_default_css()
e_css = e._get_default_css()
# Descendants that don't assign to DEFAULT_CSS don't add new CSS to the stack.
assert len(node_css) == len(a_css) == len(b_css) == 0
@@ -115,6 +121,51 @@ def test_get_default_css():
assert e_css[1][1:] == ("C", -2)
def test_component_classes_inheritance():
"""Test if component classes are inherited properly."""
class A(DOMNode):
COMPONENT_CLASSES = {"a-1", "a-2"}
class B(A, inherit_component_classes=False):
COMPONENT_CLASSES = {"b-1"}
class C(B):
COMPONENT_CLASSES = {"c-1", "c-2"}
class D(C):
pass
class E(D):
COMPONENT_CLASSES = {"e-1"}
class F(E, inherit_component_classes=False):
COMPONENT_CLASSES = {"f-1"}
node = DOMNode()
node_cc = node._get_component_classes()
a = A()
a_cc = a._get_component_classes()
b = B()
b_cc = b._get_component_classes()
c = C()
c_cc = c._get_component_classes()
d = D()
d_cc = d._get_component_classes()
e = E()
e_cc = e._get_component_classes()
f = F()
f_cc = f._get_component_classes()
assert node_cc == set()
assert a_cc == {"a-1", "a-2"}
assert b_cc == {"b-1"}
assert c_cc == {"b-1", "c-1", "c-2"}
assert d_cc == c_cc
assert e_cc == {"b-1", "c-1", "c-2", "e-1"}
assert f_cc == {"f-1"}
@pytest.fixture
def search():
"""

View File

@@ -10,6 +10,7 @@ class Focusable(Widget, can_focus=True):
class NonFocusable(Widget, can_focus=False, can_focus_children=False):
pass
class ChildrenFocusableOnly(Widget, can_focus=False, can_focus_children=True):
pass

View File

@@ -24,7 +24,7 @@ def test_non_empty_immutable_sequence() -> None:
def test_no_assign_to_immutable_sequence() -> None:
"""It should not be possible to assign into an immutable sequence."""
tester = wrap([1,2,3,4,5])
tester = wrap([1, 2, 3, 4, 5])
with pytest.raises(TypeError):
tester[0] = 23
with pytest.raises(TypeError):
@@ -33,7 +33,7 @@ def test_no_assign_to_immutable_sequence() -> None:
def test_no_del_from_iummutable_sequence() -> None:
"""It should not be possible delete an item from an immutable sequence."""
tester = wrap([1,2,3,4,5])
tester = wrap([1, 2, 3, 4, 5])
with pytest.raises(TypeError):
del tester[0]
@@ -46,23 +46,23 @@ def test_get_item_from_immutable_sequence() -> None:
def test_get_slice_from_immutable_sequence() -> None:
"""It should be possible to get a slice from an immutable sequence."""
assert list(wrap(range(10))[0:2]) == [0,1]
assert list(wrap(range(10))[0:-1]) == [0,1,2,3,4,5,6,7,8]
assert list(wrap(range(10))[0:2]) == [0, 1]
assert list(wrap(range(10))[0:-1]) == [0, 1, 2, 3, 4, 5, 6, 7, 8]
def test_immutable_sequence_contains() -> None:
"""It should be possible to see if an immutable sequence contains a value."""
tester = wrap([1,2,3,4,5])
tester = wrap([1, 2, 3, 4, 5])
assert 1 in tester
assert 11 not in tester
def test_immutable_sequence_index() -> None:
tester = wrap([1,2,3,4,5])
tester = wrap([1, 2, 3, 4, 5])
assert tester.index(1) == 0
with pytest.raises(ValueError):
_ = tester.index(11)
def test_reverse_immutable_sequence() -> None:
assert list(reversed(wrap([1,2]))) == [2,1]
assert list(reversed(wrap([1, 2]))) == [2, 1]

99
tests/test_input.py Normal file
View File

@@ -0,0 +1,99 @@
from rich.console import Console
from textual.app import App
from textual.widgets import Input
async def test_input_value_visible_on_instantiation():
"""Check if the full input value is rendered if the input is instantiated with it."""
class MyApp(App):
def compose(self):
yield Input(value="value")
app = MyApp()
async with app.run_test():
console = Console(width=5)
with console.capture() as capture:
console.print(app.query_one(Input).render())
assert capture.get() == "value"
async def test_input_value_visible_after_value_assignment():
"""Check if the full input value is rendered if the value is assigned to programmatically."""
class MyApp(App):
def compose(self):
yield Input()
def on_mount(self):
self.query_one(Input).value = "value"
app = MyApp()
async with app.run_test():
console = Console(width=5)
with console.capture() as capture:
console.print(app.query_one(Input).render())
assert capture.get() == "value"
async def test_input_value_visible_if_mounted_later():
"""Check if full input value is rendered if the widget is mounted later."""
class MyApp(App):
BINDINGS = [("a", "add_input", "add_input")]
async def action_add_input(self):
await self.mount(Input(value="value"))
app = MyApp()
async with app.run_test() as pilot:
await pilot.press("a")
console = Console(width=5)
with console.capture() as capture:
console.print(app.query_one(Input).render())
assert capture.get() == "value"
async def test_input_value_visible_if_mounted_later_and_focused():
"""Check if full input value is rendered if the widget is mounted later and immediately focused."""
class MyApp(App):
BINDINGS = [("a", "add_input", "add_input")]
async def action_add_input(self):
inp = Input(value="value")
await self.mount(inp)
inp.focus()
app = MyApp()
async with app.run_test() as pilot:
await pilot.press("a")
console = Console(width=5)
with console.capture() as capture:
console.print(app.query_one(Input).render())
assert capture.get() == "value"
async def test_input_value_visible_if_mounted_later_and_assigned_after():
"""Check if full value rendered if the widget is mounted later and the value is then assigned to."""
class MyApp(App):
BINDINGS = [
("a", "add_input", "add_input"),
("v", "set_value", "set_value"),
]
async def action_add_input(self):
await self.mount(Input())
def action_set_value(self):
self.query_one(Input).value = "value"
app = MyApp()
async with app.run_test() as pilot:
await pilot.press("a")
await pilot.press("v")
console = Console(width=5)
with console.capture() as capture:
console.print(app.query_one(Input).render())
assert capture.get() == "value"

View File

@@ -3,15 +3,18 @@ import pytest
from textual.widget import Widget
from textual._node_list import NodeList
def test_empty_list():
"""Does an empty node list report as being empty?"""
assert len(NodeList())==0
assert len(NodeList()) == 0
def test_add_one():
"""Does adding a node to the node list report as having one item?"""
nodes = NodeList()
nodes._append(Widget())
assert len(nodes)==1
assert len(nodes) == 1
def test_repeat_add_one():
"""Does adding the same item to the node list ignore the additional adds?"""
@@ -19,7 +22,8 @@ def test_repeat_add_one():
widget = Widget()
for _ in range(1000):
nodes._append(widget)
assert len(nodes)==1
assert len(nodes) == 1
def test_insert():
nodes = NodeList()
@@ -28,8 +32,9 @@ def test_insert():
widget3 = Widget()
nodes._append(widget1)
nodes._append(widget3)
nodes._insert(1,widget2)
assert list(nodes) == [widget1,widget2,widget3]
nodes._insert(1, widget2)
assert list(nodes) == [widget1, widget2, widget3]
def test_truthy():
"""Does a node list act as a truthy object?"""
@@ -38,6 +43,7 @@ def test_truthy():
nodes._append(Widget())
assert bool(nodes)
def test_contains():
"""Can we check if a widget is (not) within the list?"""
widget = Widget()
@@ -47,6 +53,7 @@ def test_contains():
assert widget in nodes
assert Widget() not in nodes
def test_index():
"""Can we get the index of a widget in the list?"""
widget = Widget()
@@ -56,6 +63,7 @@ def test_index():
nodes._append(widget)
assert nodes.index(widget) == 0
def test_remove():
"""Can we remove a widget we've added?"""
widget = Widget()
@@ -65,29 +73,31 @@ def test_remove():
nodes._remove(widget)
assert widget not in nodes
def test_clear():
"""Can we clear the list?"""
nodes = NodeList()
assert len(nodes)==0
assert len(nodes) == 0
widgets = [Widget() for _ in range(1000)]
for widget in widgets:
nodes._append(widget)
assert len(nodes)==1000
assert len(nodes) == 1000
for widget in widgets:
assert widget in nodes
nodes._clear()
assert len(nodes)==0
assert len(nodes) == 0
for widget in widgets:
assert widget not in nodes
def test_listy():
nodes = NodeList()
widget1 = Widget()
widget2 = Widget()
nodes._append(widget1)
nodes._append(widget2)
assert list(nodes)==[widget1, widget2]
assert list(reversed(nodes))==[widget2, widget1]
assert nodes[0]==widget1
assert nodes[1]==widget2
assert nodes[0:2]==[widget1, widget2]
assert list(nodes) == [widget1, widget2]
assert list(reversed(nodes)) == [widget2, widget1]
assert nodes[0] == widget1
assert nodes[1] == widget2
assert nodes[0:2] == [widget1, widget2]

View File

@@ -27,12 +27,15 @@ class ListPathApp(App[None]):
CSS_PATH = ["test.css", Path("/another/path.css")]
@pytest.mark.parametrize("app,expected_css_path_attribute", [
(RelativePathObjectApp(), [APP_DIR / "test.css"]),
(RelativePathStrApp(), [APP_DIR / "test.css"]),
(AbsolutePathObjectApp(), [Path("/tmp/test.css")]),
(AbsolutePathStrApp(), [Path("/tmp/test.css")]),
(ListPathApp(), [APP_DIR / "test.css", Path("/another/path.css")]),
])
@pytest.mark.parametrize(
"app,expected_css_path_attribute",
[
(RelativePathObjectApp(), [APP_DIR / "test.css"]),
(RelativePathStrApp(), [APP_DIR / "test.css"]),
(AbsolutePathObjectApp(), [Path("/tmp/test.css")]),
(AbsolutePathStrApp(), [Path("/tmp/test.css")]),
(ListPathApp(), [APP_DIR / "test.css", Path("/another/path.css")]),
],
)
def test_css_paths_of_various_types(app, expected_css_path_attribute):
assert app.css_path == [path.absolute() for path in expected_css_path_attribute]

View File

@@ -2,8 +2,9 @@ import asyncio
import pytest
from textual.app import App
from textual.reactive import reactive, var
from textual.app import App, ComposeResult
from textual.reactive import Reactive, reactive, var
from textual.widget import Widget
OLD_VALUE = 5_000
NEW_VALUE = 1_000_000
@@ -81,7 +82,8 @@ async def test_watch_async_init_true():
await asyncio.wait_for(app.watcher_called_event.wait(), timeout=0.05)
except TimeoutError:
pytest.fail(
"Async watcher wasn't called within timeout when reactive init = True")
"Async watcher wasn't called within timeout when reactive init = True"
)
assert app.count == OLD_VALUE
assert app.watcher_old_value == OLD_VALUE
@@ -155,15 +157,15 @@ async def test_reactive_always_update():
async def test_reactive_with_callable_default():
"""A callable can be supplied as the default value for a reactive.
Textual will call it in order to retrieve the default value."""
called_with_app = None
called_with_app = False
def set_called(app: App) -> int:
def set_called() -> int:
nonlocal called_with_app
called_with_app = app
called_with_app = True
return OLD_VALUE
class ReactiveCallable(App):
value = reactive(set_called)
value = reactive(lambda: 123)
watcher_called_with = None
def watch_value(self, new_value):
@@ -171,9 +173,9 @@ async def test_reactive_with_callable_default():
app = ReactiveCallable()
async with app.run_test():
assert app.value == OLD_VALUE # The value should be set to the return val of the callable
assert called_with_app is app # Ensure the App is passed into the reactive default callable
assert app.watcher_called_with == OLD_VALUE
assert (
app.value == 123
) # The value should be set to the return val of the callable
async def test_validate_init_true():
@@ -216,8 +218,6 @@ async def test_validate_init_true_set_before_dom_ready():
assert validator_call_count == 1
@pytest.mark.xfail(reason="Compute methods not called when init=True [issue#1227]")
async def test_reactive_compute_first_time_set():
class ReactiveComputeFirstTimeSet(App):
number = reactive(1)
@@ -228,15 +228,14 @@ async def test_reactive_compute_first_time_set():
app = ReactiveComputeFirstTimeSet()
async with app.run_test():
await asyncio.sleep(.2) # TODO: We sleep here while issue#1218 is open
await asyncio.sleep(0.2) # TODO: We sleep here while issue#1218 is open
assert app.double_number == 2
@pytest.mark.xfail(reason="Compute methods not called immediately [issue#1218]")
async def test_reactive_method_call_order():
class CallOrder(App):
count = reactive(OLD_VALUE, init=False)
count_times_ten = reactive(OLD_VALUE * 10)
count_times_ten = reactive(OLD_VALUE * 10, init=False)
calls = []
def validate_count(self, value: int) -> int:
@@ -266,3 +265,106 @@ async def test_reactive_method_call_order():
]
assert app.count == NEW_VALUE + 1
assert app.count_times_ten == (NEW_VALUE + 1) * 10
async def test_premature_reactive_call():
watcher_called = False
class BrokenWidget(Widget):
foo = reactive(1)
def __init__(self) -> None:
super().__init__()
self.foo = "bar"
async def watch_foo(self) -> None:
nonlocal watcher_called
watcher_called = True
class PrematureApp(App):
def compose(self) -> ComposeResult:
yield BrokenWidget()
app = PrematureApp()
async with app.run_test() as pilot:
assert watcher_called
app.exit()
async def test_reactive_inheritance():
"""Check that inheritance works as expected for reactives."""
class Primary(App):
foo = reactive(1)
bar = reactive("bar")
class Secondary(Primary):
foo = reactive(2)
egg = reactive("egg")
class Tertiary(Secondary):
baz = reactive("baz")
from rich import print
primary = Primary()
secondary = Secondary()
tertiary = Tertiary()
primary_reactive_count = len(primary._reactives)
# Secondary adds one new reactive
assert len(secondary._reactives) == primary_reactive_count + 1
Reactive._initialize_object(primary)
Reactive._initialize_object(secondary)
Reactive._initialize_object(tertiary)
# Primary doesn't have egg
with pytest.raises(AttributeError):
assert primary.egg
# primary has foo of 1
assert primary.foo == 1
# secondary has different reactive
assert secondary.foo == 2
# foo is accessible through tertiary
assert tertiary.foo == 2
with pytest.raises(AttributeError):
secondary.baz
assert tertiary.baz == "baz"
async def test_watch_compute():
"""Check that watching a computed attribute works."""
watch_called: list[bool] = []
class Calculator(App):
numbers = var("0")
show_ac = var(True)
value = var("")
def compute_show_ac(self) -> bool:
return self.value in ("", "0") and self.numbers == "0"
def watch_show_ac(self, show_ac: bool) -> None:
"""Called when show_ac changes."""
watch_called.append(show_ac)
app = Calculator()
async with app.run_test() as pilot:
assert app.show_ac is True
app.value = "1"
assert app.show_ac is False
app.value = "0"
assert app.show_ac is True
app.numbers = "123"
assert app.show_ac is False
assert watch_called == [True, False, True, False]

View File

@@ -1,6 +1,8 @@
import pytest
from rich.segment import Segment
from rich.style import Style
from textual._segment_tools import NoCellPositionForIndex
from textual.strip import Strip
from textual._filter import Monochrome
@@ -62,9 +64,7 @@ def test_eq():
def test_adjust_cell_length():
for repeat in range(3):
assert Strip([]).adjust_cell_length(3) == Strip([Segment(" ")])
assert Strip([Segment("f")]).adjust_cell_length(3) == Strip(
[Segment("f"), Segment(" ")]
@@ -119,9 +119,7 @@ def test_style_links():
def test_crop():
for repeat in range(3):
assert Strip([Segment("foo")]).crop(0, 3) == Strip([Segment("foo")])
assert Strip([Segment("foo")]).crop(0, 2) == Strip([Segment("fo")])
assert Strip([Segment("foo")]).crop(0, 1) == Strip([Segment("f")])
@@ -136,10 +134,42 @@ def test_crop():
def test_divide():
for repeat in range(3):
assert Strip([Segment("foo")]).divide([1, 2]) == [
Strip([Segment("f")]),
Strip([Segment("o")]),
]
@pytest.mark.parametrize(
"index,cell_position",
[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 6),
(6, 8),
(7, 10),
(8, 11),
(9, 12),
(10, 13),
(11, 14),
],
)
def test_index_to_cell_position(index, cell_position):
strip = Strip([Segment("ab"), Segment("cd日本語ef"), Segment("gh")])
assert cell_position == strip.index_to_cell_position(index)
def test_index_cell_position_no_segments():
strip = Strip([])
with pytest.raises(NoCellPositionForIndex):
strip.index_to_cell_position(2)
def test_index_cell_position_index_too_large():
strip = Strip([Segment("abcdef"), Segment("ghi")])
with pytest.raises(NoCellPositionForIndex):
strip.index_to_cell_position(100)

View File

@@ -12,7 +12,9 @@ async def test_unmount():
class UnmountWidget(Container):
def on_unmount(self, event: events.Unmount):
unmount_ids.append(f"{self.__class__.__name__}#{self.id}-{self.parent is not None}-{len(self.children)}")
unmount_ids.append(
f"{self.__class__.__name__}#{self.id}-{self.parent is not None}-{len(self.children)}"
)
class MyScreen(Screen):
def compose(self) -> ComposeResult:

View File

@@ -3,6 +3,7 @@ import pytest
from textual.app import App
from textual.widget import Widget, WidgetError
async def test_widget_move_child() -> None:
"""Test moving a widget in a child list."""
@@ -35,29 +36,24 @@ async def test_widget_move_child() -> None:
pilot.app.screen.move_child(child, before=Widget())
# Make a background set of widgets.
widgets = [Widget(id=f"widget-{n}") for n in range( 10 )]
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
# Test attempting to move past the end of the child list.
async with App().run_test() as pilot:
container = Widget(*widgets)
await pilot.app.mount(container)
with pytest.raises(WidgetError):
container.move_child(widgets[0], before=len(widgets)+10)
container.move_child(widgets[0], before=len(widgets) + 10)
# Test attempting to move before the end of the child list.
async with App().run_test() as pilot:
container = Widget(*widgets)
await pilot.app.mount(container)
with pytest.raises(WidgetError):
container.move_child(widgets[0], before=-(len(widgets)+10))
container.move_child(widgets[0], before=-(len(widgets) + 10))
# Test the different permutations of moving one widget before another.
perms = (
( 1, 0 ),
( widgets[1], 0 ),
( 1, widgets[ 0 ] ),
( widgets[ 1 ], widgets[ 0 ])
)
perms = ((1, 0), (widgets[1], 0), (1, widgets[0]), (widgets[1], widgets[0]))
for child, target in perms:
async with App().run_test() as pilot:
container = Widget(*widgets)
@@ -68,12 +64,7 @@ async def test_widget_move_child() -> None:
assert container.children[2].id == "widget-2"
# Test the different permutations of moving one widget after another.
perms = (
( 0, 1 ),
( widgets[0], 1 ),
( 0, widgets[ 1 ] ),
( widgets[ 0 ], widgets[ 1 ])
)
perms = ((0, 1), (widgets[0], 1), (0, widgets[1]), (widgets[0], widgets[1]))
for child, target in perms:
async with App().run_test() as pilot:
container = Widget(*widgets)

View File

@@ -5,16 +5,19 @@ from textual.widget import Widget, WidgetError, MountError
from textual.widgets import Static
from textual.css.query import TooManyMatches
class SelfOwn(Widget):
"""Test a widget that tries to own itself."""
def __init__(self) -> None:
super().__init__(self)
async def test_mount_via_app() -> None:
"""Perform mount tests via the app."""
# Make a background set of widgets.
widgets = [Static(id=f"starter-{n}") for n in range( 10 )]
widgets = [Static(id=f"starter-{n}") for n in range(10)]
async with App().run_test() as pilot:
with pytest.raises(WidgetError):

View File

@@ -4,6 +4,7 @@ from textual.widget import Widget
from textual.widgets import Static, Button
from textual.containers import Container
async def test_remove_single_widget():
"""It should be possible to the only widget on a screen."""
async with App().run_test() as pilot:
@@ -12,6 +13,7 @@ async def test_remove_single_widget():
await pilot.app.query_one(Static).remove()
assert len(pilot.app.screen.children) == 0
async def test_many_remove_all_widgets():
"""It should be possible to remove all widgets on a multi-widget screen."""
async with App().run_test() as pilot:
@@ -20,6 +22,7 @@ async def test_many_remove_all_widgets():
await pilot.app.query(Static).remove()
assert len(pilot.app.screen.children) == 0
async def test_many_remove_some_widgets():
"""It should be possible to remove some widgets on a multi-widget screen."""
async with App().run_test() as pilot:
@@ -28,79 +31,42 @@ async def test_many_remove_some_widgets():
await pilot.app.query(".is-0").remove()
assert len(pilot.app.screen.children) == 5
async def test_remove_branch():
"""It should be possible to remove a whole branch in the DOM."""
async with App().run_test() as pilot:
await pilot.app.mount(
Container(
Container(
Container(
Container(
Container(
Static()
)
)
)
)
),
Container(Container(Container(Container(Container(Static()))))),
Static(),
Container(
Container(
Container(
Container(
Container(
Static()
)
)
)
)
),
Container(Container(Container(Container(Container(Static()))))),
)
assert len(pilot.app.screen.walk_children(with_self=False)) == 13
await pilot.app.screen.children[0].remove()
assert len(pilot.app.screen.walk_children(with_self=False)) == 7
async def test_remove_overlap():
"""It should be possible to remove an overlapping collection of widgets."""
async with App().run_test() as pilot:
await pilot.app.mount(
Container(
Container(
Container(
Container(
Container(
Static()
)
)
)
)
),
Container(Container(Container(Container(Container(Static()))))),
Static(),
Container(
Container(
Container(
Container(
Container(
Static()
)
)
)
)
),
Container(Container(Container(Container(Container(Static()))))),
)
assert len(pilot.app.screen.walk_children(with_self=False)) == 13
await pilot.app.query(Container).remove()
assert len(pilot.app.screen.walk_children(with_self=False)) == 1
async def test_remove_move_focus():
"""Removing a focused widget should settle focus elsewhere."""
async with App().run_test() as pilot:
buttons = [ Button(str(n)) for n in range(10)]
buttons = [Button(str(n)) for n in range(10)]
await pilot.app.mount(Container(*buttons[:5]), Container(*buttons[5:]))
assert len(pilot.app.screen.children) == 2
assert len(pilot.app.screen.walk_children(with_self=False)) == 12
assert pilot.app.focused is None
await pilot.press( "tab" )
await pilot.press("tab")
assert pilot.app.focused is not None
assert pilot.app.focused == buttons[0]
await pilot.app.screen.children[0].remove()
@@ -109,13 +75,14 @@ async def test_remove_move_focus():
assert pilot.app.focused is not None
assert pilot.app.focused == buttons[9]
async def test_widget_remove_order():
"""A Widget.remove of a top-level widget should cause bottom-first removal."""
removals: list[str] = []
class Removable(Container):
def on_unmount( self, _ ):
def on_unmount(self, _):
removals.append(self.id if self.id is not None else "unknown")
async with App().run_test() as pilot:
@@ -127,13 +94,14 @@ async def test_widget_remove_order():
assert len(pilot.app.screen.walk_children(with_self=False)) == 0
assert removals == ["grandchild", "child", "parent"]
async def test_query_remove_order():
"""A DOMQuery.remove of a top-level widget should cause bottom-first removal."""
removals: list[str] = []
class Removable(Container):
def on_unmount( self, _ ):
def on_unmount(self, _):
removals.append(self.id if self.id is not None else "unknown")
async with App().run_test() as pilot:

View File

@@ -0,0 +1,16 @@
import pytest
from typing import cast
from textual.widgets import Tree
from textual.widgets._tree import NodeID
def test_get_tree_node_by_id() -> None:
"""It should be possible to get a TreeNode by its ID."""
tree = Tree[None]("Anakin")
child = tree.root.add("Leia")
grandchild = child.add("Ben")
assert tree.get_node_by_id(tree.root.id).id == tree.root.id
assert tree.get_node_by_id(child.id).id == child.id
assert tree.get_node_by_id(grandchild.id).id == grandchild.id
with pytest.raises(Tree.UnknownNodeID):
tree.get_node_by_id(cast(NodeID, grandchild.id + 1000))

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
from textual.app import App, ComposeResult
from textual.widgets import Tree
from textual.message import Message
class MyTree(Tree[None]):
pass
class TreeApp(App[None]):
"""Test tree app."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.messages: list[str] = []
def compose(self) -> ComposeResult:
"""Compose the child widgets."""
yield MyTree("Root")
def on_mount(self) -> None:
self.query_one(MyTree).root.add("Child")
self.query_one(MyTree).focus()
def record(self, event: Message) -> None:
self.messages.append(event.__class__.__name__)
def on_tree_node_selected(self, event: Tree.NodeSelected[None]) -> None:
self.record(event)
def on_tree_node_expanded(self, event: Tree.NodeExpanded[None]) -> None:
self.record(event)
def on_tree_node_collapsed(self, event: Tree.NodeCollapsed[None]) -> None:
self.record(event)
def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[None]) -> None:
self.record(event)
async def test_tree_node_selected_message() -> None:
"""Selecting a node should result in a selected message being emitted."""
async with TreeApp().run_test() as pilot:
await pilot.press("enter")
await pilot.pause(2 / 100)
assert pilot.app.messages == ["NodeExpanded", "NodeSelected"]
async def test_tree_node_expanded_message() -> None:
"""Expanding a node should result in an expanded message being emitted."""
async with TreeApp().run_test() as pilot:
await pilot.press("enter")
await pilot.pause(2 / 100)
assert pilot.app.messages == ["NodeExpanded", "NodeSelected"]
async def test_tree_node_collapsed_message() -> None:
"""Collapsing a node should result in a collapsed message being emitted."""
async with TreeApp().run_test() as pilot:
await pilot.press("enter", "enter")
await pilot.pause(2 / 100)
assert pilot.app.messages == [
"NodeExpanded",
"NodeSelected",
"NodeCollapsed",
"NodeSelected",
]
async def test_tree_node_highlighted_message() -> None:
"""Highlighting a node should result in a highlighted message being emitted."""
async with TreeApp().run_test() as pilot:
await pilot.press("enter", "down")
await pilot.pause(2 / 100)
assert pilot.app.messages == ["NodeExpanded", "NodeSelected", "NodeHighlighted"]

View File

@@ -1,6 +1,7 @@
import pytest
from textual.widgets import Tree, TreeNode
def label_of(node: TreeNode[None]):
"""Get the label of a node as a string"""
return str(node.label)
@@ -8,17 +9,21 @@ def label_of(node: TreeNode[None]):
def test_tree_node_children() -> None:
"""A node's children property should act like an immutable list."""
CHILDREN=23
CHILDREN = 23
tree = Tree[None]("Root")
for child in range(CHILDREN):
tree.root.add(str(child))
assert len(tree.root.children)==CHILDREN
assert len(tree.root.children) == CHILDREN
for child in range(CHILDREN):
assert label_of(tree.root.children[child]) == str(child)
assert label_of(tree.root.children[0]) == "0"
assert label_of(tree.root.children[-1]) == str(CHILDREN-1)
assert [label_of(node) for node in tree.root.children] == [str(n) for n in range(CHILDREN)]
assert [label_of(node) for node in tree.root.children[:2]] == [str(n) for n in range(2)]
assert label_of(tree.root.children[-1]) == str(CHILDREN - 1)
assert [label_of(node) for node in tree.root.children] == [
str(n) for n in range(CHILDREN)
]
assert [label_of(node) for node in tree.root.children[:2]] == [
str(n) for n in range(2)
]
with pytest.raises(TypeError):
tree.root.children[0] = tree.root.children[1]
with pytest.raises(TypeError):

View File

@@ -1,6 +1,7 @@
from textual.widgets import Tree, TreeNode
from rich.text import Text
def test_tree_node_label() -> None:
"""It should be possible to modify a TreeNode's label."""
node = TreeNode(Tree[None]("Xenomorph Lifecycle"), None, 0, "Facehugger")
@@ -8,6 +9,7 @@ def test_tree_node_label() -> None:
node.label = "Chestbuster"
assert node.label == Text("Chestbuster")
def test_tree_node_label_via_tree() -> None:
"""It should be possible to modify a TreeNode's label when created via a Tree."""
tree = Tree[None]("Xenomorph Lifecycle")

View File

@@ -1,5 +1,6 @@
from textual.widgets import TreeNode, Tree
def test_tree_node_parent() -> None:
"""It should be possible to access a TreeNode's parent."""
tree = Tree[None]("Anakin")