Skip to content

Commit e968ca0

Browse files
authored
Various tests for reactivity (#1223)
1 parent 9419189 commit e968ca0

File tree

1 file changed

+226
-12
lines changed

1 file changed

+226
-12
lines changed

tests/test_reactive.py

Lines changed: 226 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,240 @@
1+
import asyncio
2+
3+
import pytest
4+
15
from textual.app import App, ComposeResult
2-
from textual.reactive import reactive
6+
from textual.reactive import reactive, var
7+
from textual.widget import Widget
38

9+
OLD_VALUE = 5_000
10+
NEW_VALUE = 1_000_000
411

5-
class WatchApp(App):
612

7-
count = reactive(0, init=False)
13+
async def test_watch():
14+
"""Test that changes to a watched reactive attribute happen immediately."""
815

9-
test_count = 0
16+
class WatchApp(App):
17+
count = reactive(0, init=False)
1018

11-
def watch_count(self, value: int) -> None:
12-
self.test_count = value
19+
watcher_call_count = 0
1320

21+
def watch_count(self, value: int) -> None:
22+
self.watcher_call_count = value
1423

15-
async def test_watch():
16-
"""Test that changes to a watched reactive attribute happen immediately."""
1724
app = WatchApp()
1825
async with app.run_test():
1926
app.count += 1
20-
assert app.test_count == 1
27+
assert app.watcher_call_count == 1
2128
app.count += 1
22-
assert app.test_count == 2
29+
assert app.watcher_call_count == 2
2330
app.count -= 1
24-
assert app.test_count == 1
31+
assert app.watcher_call_count == 1
2532
app.count -= 1
26-
assert app.test_count == 0
33+
assert app.watcher_call_count == 0
34+
35+
36+
async def test_watch_async_init_false():
37+
"""Ensure that async watchers are called eventually when set by user code"""
38+
39+
class WatchAsyncApp(App):
40+
count = reactive(OLD_VALUE, init=False)
41+
watcher_old_value = None
42+
watcher_new_value = None
43+
watcher_called_event = asyncio.Event()
44+
45+
async def watch_count(self, old_value: int, new_value: int) -> None:
46+
self.watcher_old_value = old_value
47+
self.watcher_new_value = new_value
48+
self.watcher_called_event.set()
49+
50+
app = WatchAsyncApp()
51+
async with app.run_test():
52+
app.count = NEW_VALUE
53+
assert app.count == NEW_VALUE # Value is set immediately
54+
try:
55+
await asyncio.wait_for(app.watcher_called_event.wait(), timeout=0.05)
56+
except TimeoutError:
57+
pytest.fail("Async watch method (watch_count) wasn't called within timeout")
58+
59+
assert app.count == NEW_VALUE # Sanity check
60+
assert app.watcher_old_value == OLD_VALUE # old_value passed to watch method
61+
assert app.watcher_new_value == NEW_VALUE # new_value passed to watch method
62+
63+
64+
async def test_watch_async_init_true():
65+
"""Ensure that when init is True in a reactive, its async watcher gets called
66+
by Textual eventually, even when the user does not set the value themselves."""
67+
68+
class WatchAsyncApp(App):
69+
count = reactive(OLD_VALUE, init=True)
70+
watcher_called_event = asyncio.Event()
71+
watcher_old_value = None
72+
watcher_new_value = None
73+
74+
async def watch_count(self, old_value: int, new_value: int) -> None:
75+
self.watcher_old_value = old_value
76+
self.watcher_new_value = new_value
77+
self.watcher_called_event.set()
78+
79+
app = WatchAsyncApp()
80+
async with app.run_test():
81+
try:
82+
await asyncio.wait_for(app.watcher_called_event.wait(), timeout=0.05)
83+
except TimeoutError:
84+
pytest.fail("Async watcher wasn't called within timeout when reactive init = True")
85+
86+
assert app.count == OLD_VALUE
87+
assert app.watcher_old_value == OLD_VALUE
88+
assert app.watcher_new_value == OLD_VALUE # The value wasn't changed
89+
90+
91+
@pytest.mark.xfail(reason="Reactive watcher is incorrectly always called the first time it is set, even if value is same [issue#1230]")
92+
async def test_watch_init_false_always_update_false():
93+
class WatcherInitFalse(App):
94+
count = reactive(0, init=False)
95+
watcher_call_count = 0
96+
97+
def watch_count(self, new_value: int) -> None:
98+
self.watcher_call_count += 1
99+
100+
app = WatcherInitFalse()
101+
async with app.run_test():
102+
app.count = 0 # Value hasn't changed, and always_update=False, so watch_count shouldn't run
103+
assert app.watcher_call_count == 0
104+
105+
106+
async def test_watch_init_true():
107+
class WatcherInitTrue(App):
108+
count = var(OLD_VALUE)
109+
watcher_call_count = 0
110+
111+
def watch_count(self, new_value: int) -> None:
112+
self.watcher_call_count += 1
113+
114+
app = WatcherInitTrue()
115+
async with app.run_test():
116+
assert app.count == OLD_VALUE
117+
assert app.watcher_call_count == 1 # Watcher called on init
118+
app.count = NEW_VALUE # User sets the value...
119+
assert app.watcher_call_count == 2 # ...resulting in 2nd call
120+
app.count = NEW_VALUE # Setting to the SAME value
121+
assert app.watcher_call_count == 2 # Watcher is NOT called again
122+
123+
124+
async def test_reactive_always_update():
125+
calls = []
126+
127+
class AlwaysUpdate(App):
128+
first_name = reactive("Darren", init=False, always_update=True)
129+
last_name = reactive("Burns", init=False)
130+
131+
def watch_first_name(self, value):
132+
calls.append(f"first_name {value}")
133+
134+
def watch_last_name(self, value):
135+
calls.append(f"last_name {value}")
136+
137+
app = AlwaysUpdate()
138+
async with app.run_test():
139+
# Value is the same, but always_update=True, so watcher called...
140+
app.first_name = "Darren"
141+
assert calls == ["first_name Darren"]
142+
# TODO: Commented out below due to issue#1230, should work after issue fixed
143+
# Value is the same, and always_update=False, so watcher NOT called...
144+
# app.last_name = "Burns"
145+
# assert calls == ["first_name Darren"]
146+
# Values changed, watch method always called regardless of always_update
147+
app.first_name = "abc"
148+
app.last_name = "def"
149+
assert calls == ["first_name Darren", "first_name abc", "last_name def"]
150+
151+
152+
async def test_reactive_with_callable_default():
153+
"""A callable can be supplied as the default value for a reactive.
154+
Textual will call it in order to retrieve the default value."""
155+
called_with_app = None
156+
157+
def set_called(app: App) -> int:
158+
nonlocal called_with_app
159+
called_with_app = app
160+
return OLD_VALUE
161+
162+
class ReactiveCallable(App):
163+
value = reactive(set_called)
164+
watcher_called_with = None
165+
166+
def watch_value(self, new_value):
167+
self.watcher_called_with = new_value
168+
169+
app = ReactiveCallable()
170+
async with app.run_test():
171+
assert app.value == OLD_VALUE # The value should be set to the return val of the callable
172+
assert called_with_app is app # Ensure the App is passed into the reactive default callable
173+
assert app.watcher_called_with == OLD_VALUE
174+
175+
176+
@pytest.mark.xfail(reason="Validator methods not running when init=True [issue#1220]")
177+
async def test_validate_init_true():
178+
"""When init is True for a reactive attribute, Textual should call the validator
179+
AND the watch method when the app starts."""
180+
181+
class ValidatorInitTrue(App):
182+
count = var(5, init=True)
183+
184+
def validate_count(self, value: int) -> int:
185+
return value + 1
186+
187+
app = ValidatorInitTrue()
188+
async with app.run_test():
189+
assert app.count == 6 # Validator should run, so value should be 5+1=6
190+
191+
192+
@pytest.mark.xfail(reason="Compute methods not called when init=True [issue#1227]")
193+
async def test_reactive_compute_first_time_set():
194+
class ReactiveComputeFirstTimeSet(App):
195+
number = reactive(1)
196+
double_number = reactive(None)
197+
198+
def compute_double_number(self):
199+
return self.number * 2
200+
201+
app = ReactiveComputeFirstTimeSet()
202+
async with app.run_test():
203+
await asyncio.sleep(.2) # TODO: We sleep here while issue#1218 is open
204+
assert app.double_number == 2
205+
206+
207+
@pytest.mark.xfail(reason="Compute methods not called immediately [issue#1218]")
208+
async def test_reactive_method_call_order():
209+
class CallOrder(App):
210+
count = reactive(OLD_VALUE, init=False)
211+
count_times_ten = reactive(OLD_VALUE * 10)
212+
calls = []
213+
214+
def validate_count(self, value: int) -> int:
215+
self.calls.append(f"validate {value}")
216+
return value + 1
217+
218+
def watch_count(self, value: int) -> None:
219+
self.calls.append(f"watch {value}")
220+
221+
def compute_count_times_ten(self) -> int:
222+
self.calls.append(f"compute {self.count}")
223+
return self.count * 10
224+
225+
app = CallOrder()
226+
async with app.run_test():
227+
app.count = NEW_VALUE
228+
assert app.calls == [
229+
# The validator receives NEW_VALUE, since that's what the user
230+
# set the reactive attribute to...
231+
f"validate {NEW_VALUE}",
232+
# The validator adds 1 to the new value, and this is what should
233+
# be passed into the watcher...
234+
f"watch {NEW_VALUE + 1}",
235+
# The compute method accesses the reactive value directly, which
236+
# should have been updated by the validator to NEW_VALUE + 1.
237+
f"compute {NEW_VALUE + 1}",
238+
]
239+
assert app.count == NEW_VALUE + 1
240+
assert app.count_times_ten == (NEW_VALUE + 1) * 10

0 commit comments

Comments
 (0)