mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' of github.com:Textualize/textual into datatable-events
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
53
tests/test_concurrency.py
Normal 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
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
99
tests/test_input.py
Normal 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"
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
16
tests/tree/test_tree_get_node_by_id.py
Normal file
16
tests/tree/test_tree_get_node_by_id.py
Normal 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))
|
||||
78
tests/tree/test_tree_messages.py
Normal file
78
tests/tree/test_tree_messages.py
Normal 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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user