textual/tests/test_reactive.py
Will McGugan 52e522b2e8 test
2023-02-19 22:26:07 +00:00

388 lines
12 KiB
Python

import asyncio
import pytest
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
async def test_watch():
"""Test that changes to a watched reactive attribute happen immediately."""
class WatchApp(App):
count = reactive(0, init=False)
watcher_call_count = 0
def watch_count(self, value: int) -> None:
self.watcher_call_count = value
app = WatchApp()
async with app.run_test():
app.count += 1
assert app.watcher_call_count == 1
app.count += 1
assert app.watcher_call_count == 2
app.count -= 1
assert app.watcher_call_count == 1
app.count -= 1
assert app.watcher_call_count == 0
async def test_watch_async_init_false():
"""Ensure that async watchers are called eventually when set by user code"""
class WatchAsyncApp(App):
count = reactive(OLD_VALUE, init=False)
watcher_old_value = None
watcher_new_value = None
watcher_called_event = asyncio.Event()
async def watch_count(self, old_value: int, new_value: int) -> None:
self.watcher_old_value = old_value
self.watcher_new_value = new_value
self.watcher_called_event.set()
app = WatchAsyncApp()
async with app.run_test():
app.count = NEW_VALUE
assert app.count == NEW_VALUE # Value is set immediately
try:
await asyncio.wait_for(app.watcher_called_event.wait(), timeout=0.05)
except TimeoutError:
pytest.fail("Async watch method (watch_count) wasn't called within timeout")
assert app.count == NEW_VALUE # Sanity check
assert app.watcher_old_value == OLD_VALUE # old_value passed to watch method
assert app.watcher_new_value == NEW_VALUE # new_value passed to watch method
async def test_watch_async_init_true():
"""Ensure that when init is True in a reactive, its async watcher gets called
by Textual eventually, even when the user does not set the value themselves."""
class WatchAsyncApp(App):
count = reactive(OLD_VALUE, init=True)
watcher_called_event = asyncio.Event()
watcher_old_value = None
watcher_new_value = None
async def watch_count(self, old_value: int, new_value: int) -> None:
self.watcher_old_value = old_value
self.watcher_new_value = new_value
self.watcher_called_event.set()
app = WatchAsyncApp()
async with app.run_test():
try:
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"
)
assert app.count == OLD_VALUE
assert app.watcher_old_value == OLD_VALUE
assert app.watcher_new_value == OLD_VALUE # The value wasn't changed
async def test_watch_init_false_always_update_false():
class WatcherInitFalse(App):
count = reactive(0, init=False)
watcher_call_count = 0
def watch_count(self, new_value: int) -> None:
self.watcher_call_count += 1
app = WatcherInitFalse()
async with app.run_test():
app.count = 0 # Value hasn't changed, and always_update=False, so watch_count shouldn't run
assert app.watcher_call_count == 0
app.count = 0
assert app.watcher_call_count == 0
app.count = 1
assert app.watcher_call_count == 1
async def test_watch_init_true():
class WatcherInitTrue(App):
count = var(OLD_VALUE)
watcher_call_count = 0
def watch_count(self, new_value: int) -> None:
self.watcher_call_count += 1
app = WatcherInitTrue()
async with app.run_test():
assert app.count == OLD_VALUE
assert app.watcher_call_count == 1 # Watcher called on init
app.count = NEW_VALUE # User sets the value...
assert app.watcher_call_count == 2 # ...resulting in 2nd call
app.count = NEW_VALUE # Setting to the SAME value
assert app.watcher_call_count == 2 # Watcher is NOT called again
async def test_reactive_always_update():
calls = []
class AlwaysUpdate(App):
first_name = reactive("Darren", init=False, always_update=True)
last_name = reactive("Burns", init=False)
def watch_first_name(self, value):
calls.append(f"first_name {value}")
def watch_last_name(self, value):
calls.append(f"last_name {value}")
app = AlwaysUpdate()
async with app.run_test():
# Value is the same, but always_update=True, so watcher called...
app.first_name = "Darren"
assert calls == ["first_name Darren"]
# Value is the same, and always_update=False, so watcher NOT called...
app.last_name = "Burns"
assert calls == ["first_name Darren"]
# Values changed, watch method always called regardless of always_update
app.first_name = "abc"
app.last_name = "def"
assert calls == ["first_name Darren", "first_name abc", "last_name def"]
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."""
class ReactiveCallable(App):
value = reactive(lambda: 123)
watcher_called_with = None
def watch_value(self, new_value):
self.watcher_called_with = new_value
app = ReactiveCallable()
async with app.run_test():
assert app.value == 123
assert app.watcher_called_with == 123
async def test_validate_init_true():
"""When init is True for a reactive attribute, Textual should call the validator
AND the watch method when the app starts."""
validator_call_count = 0
class ValidatorInitTrue(App):
count = var(5, init=True)
def validate_count(self, value: int) -> int:
nonlocal validator_call_count
validator_call_count += 1
return value + 1
app = ValidatorInitTrue()
async with app.run_test():
app.count = 5
assert app.count == 6 # Validator should run, so value should be 5+1=6
assert validator_call_count == 1
async def test_validate_init_true_set_before_dom_ready():
"""When init is True for a reactive attribute, Textual should call the validator
AND the watch method when the app starts."""
validator_call_count = 0
class ValidatorInitTrue(App):
count = var(5, init=True)
def validate_count(self, value: int) -> int:
nonlocal validator_call_count
validator_call_count += 1
return value + 1
app = ValidatorInitTrue()
app.count = 5
async with app.run_test():
assert app.count == 6 # Validator should run, so value should be 5+1=6
assert validator_call_count == 1
async def test_reactive_compute_first_time_set():
class ReactiveComputeFirstTimeSet(App):
number = reactive(1)
double_number = reactive(None)
def compute_double_number(self):
return self.number * 2
app = ReactiveComputeFirstTimeSet()
async with app.run_test():
assert app.double_number == 2
async def test_reactive_method_call_order():
class CallOrder(App):
count = reactive(OLD_VALUE, init=False)
count_times_ten = reactive(OLD_VALUE * 10, init=False)
calls = []
def validate_count(self, value: int) -> int:
self.calls.append(f"validate {value}")
return value + 1
def watch_count(self, value: int) -> None:
self.calls.append(f"watch {value}")
def compute_count_times_ten(self) -> int:
self.calls.append(f"compute {self.count}")
return self.count * 10
app = CallOrder()
async with app.run_test():
app.count = NEW_VALUE
assert app.calls == [
# The validator receives NEW_VALUE, since that's what the user
# set the reactive attribute to...
f"validate {NEW_VALUE}",
# The validator adds 1 to the new value, and this is what should
# be passed into the watcher...
f"watch {NEW_VALUE + 1}",
# The compute method accesses the reactive value directly, which
# should have been updated by the validator to NEW_VALUE + 1.
f"compute {NEW_VALUE + 1}",
]
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_compute():
"""Check compute method is called."""
class ComputeApp(App):
count = var(0)
count_double = var(0)
def __init__(self) -> None:
self.start = 0
super().__init__()
def compute_count_double(self) -> int:
return self.start + self.count * 2
app = ComputeApp()
async with app.run_test():
assert app.count_double == 0
app.count = 1
assert app.count_double == 2
assert app.count_double == 2
app.count = 2
assert app.count_double == 4
app.start = 10
assert app.count_double == 14
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()
# Referencing the value calls compute
# Setting any reactive values calls compute
async with app.run_test():
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, True, False, False, True, True, False, False]