Skip to content

Commit 86df2b8

Browse files
committed
fix layout tests + add HookCatcher utility
1 parent 00eb5ae commit 86df2b8

File tree

5 files changed

+134
-103
lines changed

5 files changed

+134
-103
lines changed

idom/core/layout.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ async def _render_element(self, element_state: ElementState) -> Dict[str, Any]:
135135
resolved_model = await self._render_model(element_state, raw_model)
136136
element_state.model.clear()
137137
element_state.model.update(resolved_model)
138-
except Exception:
138+
except Exception as error:
139139
logger.exception(f"Failed to render {element_state.element_obj}")
140+
element_state.model.update({"tagName": "div", "__error__": str(error)})
140141

141142
# We need to return the model from the `element_state` so that the model
142143
# between all `ElementState` objects within a `Layout` are shared.
@@ -147,7 +148,9 @@ async def _render_model(
147148
) -> Dict[str, Any]:
148149
model: Dict[str, Any] = dict(model)
149150

150-
model["eventHandlers"] = self._render_model_event_handlers(element_state, model)
151+
event_handlers = self._render_model_event_handlers(element_state, model)
152+
if event_handlers:
153+
model["eventHandlers"] = event_handlers
151154

152155
if "children" in model:
153156
model["children"] = await self._render_model_children(

tests/general_utils.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,57 @@
1+
from functools import wraps
2+
from weakref import ref
3+
4+
5+
import idom
6+
7+
8+
class HookCatcher:
9+
"""Utility for capturing a LifeCycleHook from an element
10+
11+
Example:
12+
.. code-block::
13+
element_hook = HookCatcher()
14+
15+
@idom.element
16+
@element_hook.capture
17+
async def MyElement():
18+
...
19+
20+
After the first render of ``MyElement`` the ``HookCatcher`` will have
21+
captured the element's ``LifeCycleHook``.
22+
"""
23+
24+
current: idom.hooks.LifeCycleHook
25+
26+
def capture(self, render_function):
27+
"""Decorator for capturing a ``LifeCycleHook`` on the first render of an element"""
28+
29+
# The render function holds a reference to `self` and, via the `LifeCycleHook`,
30+
# the element. Some tests check whether elements are garbage collected, thus we
31+
# must use a `ref` here to ensure these checks pass.
32+
self_ref = ref(self)
33+
34+
@wraps(render_function)
35+
async def wrapper(*args, **kwargs):
36+
self_ref().current = idom.hooks.current_hook()
37+
return await render_function(*args, **kwargs)
38+
39+
return wrapper
40+
41+
def schedule_render(self) -> None:
42+
"""Useful alias of ``HookCatcher.current.schedule_render``"""
43+
self.current.schedule_render()
44+
45+
146
def assert_unordered_equal(x, y):
47+
"""Check that two unordered sequences are equal"""
48+
249
list_x = list(x)
350
list_y = list(y)
451

5-
assert len(x) == len(y) and all(
52+
assert len(x) == len(y), f"len({x}) != len({y})"
53+
assert all(
654
# this is not very efficient unfortunately so don't compare anything large
755
list_x.count(value) == list_y.count(value)
856
for value in list_x
9-
)
57+
), f"{x} != {y}"

tests/test_core/test_hooks.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22

33
import idom
44

5-
from tests.general_utils import assert_unordered_equal
6-
7-
from .utils import HookCatcher
5+
from tests.general_utils import assert_unordered_equal, HookCatcher
86

97

108
async def test_must_be_rendering_in_layout_to_use_hooks():
@@ -392,6 +390,14 @@ def effect():
392390
assert effect_run_count.current == 2
393391

394392

393+
def test_error_in_effect_is_gracefully_handled():
394+
assert False
395+
396+
397+
def test_error_in_effect_cleanup_is_gracefully_handled():
398+
assert False
399+
400+
395401
async def test_use_reducer():
396402
saved_count = idom.Ref(None)
397403
saved_dispatch = idom.Ref(None)

tests/test_core/test_layout.py

Lines changed: 70 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1+
import asyncio
12
import gc
23
from weakref import finalize
34

45
import pytest
56

67
import idom
7-
from idom.core.layout import LayoutUpdate
88

9-
from tests.general_utils import assert_unordered_equal
10-
11-
from .utils import HookCatcher
9+
from tests.general_utils import assert_unordered_equal, HookCatcher
1210

1311

1412
def test_layout_repr():
@@ -40,13 +38,7 @@ async def SimpleElement():
4038
path, changes = await layout.render()
4139

4240
assert path == ""
43-
assert_unordered_equal(
44-
changes,
45-
[
46-
{"op": "add", "path": "/eventHandlers", "value": {}},
47-
{"op": "add", "path": "/tagName", "value": "div"},
48-
],
49-
)
41+
assert changes == [{"op": "add", "path": "/tagName", "value": "div"}]
5042

5143
set_state_hook.current("table")
5244
path, changes = await layout.render()
@@ -77,16 +69,12 @@ async def Child():
7769
assert_unordered_equal(
7870
changes,
7971
[
80-
{"op": "add", "path": "/tagName", "value": "div"},
8172
{
8273
"op": "add",
8374
"path": "/children",
84-
"value": [
85-
"0",
86-
{"children": ["0"], "eventHandlers": {}, "tagName": "div"},
87-
],
75+
"value": ["0", {"tagName": "div", "children": ["0"]}],
8876
},
89-
{"op": "add", "path": "/eventHandlers", "value": {}},
77+
{"op": "add", "path": "/tagName", "value": "div"},
9078
],
9179
)
9280

@@ -104,93 +92,67 @@ async def Child():
10492

10593

10694
async def test_layout_render_error_has_partial_update():
107-
history = RenderHistory()
108-
109-
@history.track("main")
11095
@idom.element
11196
async def Main():
11297
return idom.html.div([OkChild(), BadChild(), OkChild()])
11398

114-
@history.track("ok_child")
11599
@idom.element
116100
async def OkChild():
117101
return idom.html.div(["hello"])
118102

119-
@history.track("bad_child")
120103
@idom.element
121104
async def BadChild():
122105
raise ValueError("Something went wrong :(")
123106

124107
async with idom.Layout(Main()) as layout:
125-
126-
src, new, old, errors = await layout.render()
127-
128-
assert src == history.main_1.id
129-
130-
assert new == {
131-
history.main_1.id: {
132-
"tagName": "div",
133-
"children": [
134-
{"type": "ref", "data": history.ok_child_1.id},
135-
{"type": "ref", "data": history.bad_child_1.id},
136-
{"type": "ref", "data": history.ok_child_2.id},
137-
],
138-
},
139-
history.ok_child_1.id: {
140-
"tagName": "div",
141-
"children": [{"type": "str", "data": "hello"}],
142-
},
143-
history.bad_child_1.id: {"tagName": "div"},
144-
history.ok_child_2.id: {
145-
"tagName": "div",
146-
"children": [{"type": "str", "data": "hello"}],
147-
},
148-
}
149-
150-
assert old == []
151-
152-
assert len(errors) == 1
153-
assert isinstance(errors[0], ValueError)
154-
assert str(errors[0]) == "Something went wrong :("
108+
patch = await layout.render()
109+
assert_unordered_equal(
110+
patch.changes,
111+
[
112+
{
113+
"op": "add",
114+
"path": "/children",
115+
"value": [
116+
{"tagName": "div", "children": ["hello"]},
117+
{"tagName": "div", "__error__": "Something went wrong :("},
118+
{"tagName": "div", "children": ["hello"]},
119+
],
120+
},
121+
{"op": "add", "path": "/tagName", "value": "div"},
122+
],
123+
)
155124

156125

157126
async def test_render_raw_vdom_dict_with_single_element_object_as_children():
158-
history = RenderHistory()
159-
160-
@history.track("main")
161127
@idom.element
162128
async def Main():
163129
return {"tagName": "div", "children": Child()}
164130

165-
@history.track("child")
166131
@idom.element
167132
async def Child():
168133
return {"tagName": "div", "children": {"tagName": "h1"}}
169134

170135
async with idom.Layout(Main()) as layout:
171-
render = await layout.render()
172-
173-
assert render == LayoutUpdate(
174-
src=history.main_1.id,
175-
new={
176-
history.child_1.id: {
177-
"tagName": "div",
178-
"children": [{"type": "obj", "data": {"tagName": "h1"}}],
179-
},
180-
history.main_1.id: {
181-
"tagName": "div",
182-
"children": [{"type": "ref", "data": history.child_1.id}],
183-
},
184-
},
185-
old=[],
186-
errors=[],
187-
)
136+
patch = await layout.render()
137+
assert_unordered_equal(
138+
patch.changes,
139+
[
140+
{
141+
"op": "add",
142+
"path": "/children",
143+
"value": [{"tagName": "div", "children": [{"tagName": "h1"}]}],
144+
},
145+
{"op": "add", "path": "/tagName", "value": "div"},
146+
],
147+
)
188148

189149

190150
async def test_elements_are_garbage_collected():
191151
live_elements = set()
152+
outer_element_hook = HookCatcher()
192153

193154
@idom.element
155+
@outer_element_hook.capture
194156
async def Outer():
195157
element = idom.hooks.current_hook().element
196158
live_elements.add(element.id)
@@ -213,22 +175,54 @@ async def Inner():
213175

214176
async with idom.Layout(Outer()) as layout:
215177
await layout.render()
178+
216179
assert len(live_elements) == 2
180+
217181
last_live_elements = live_elements.copy()
218182
# The existing `Outer` element rerenders. A new `Inner` element is created and
219183
# the the old `Inner` element should be deleted. Thus there should be one
220184
# changed element in the set of `live_elements` the old `Inner` deleted and new
221185
# `Inner` added.
222-
await layout.dispatch(idom.core.layout.LayoutEvent("force-update", []))
186+
outer_element_hook.schedule_render()
223187
await layout.render()
188+
224189
assert len(live_elements - last_live_elements) == 1
225190

226191
# The layout still holds a reference to the root so that's
227192
# only deleted once we release a reference to it.
228193
del layout
194+
# the hook also contains a reference to the root element
195+
del outer_element_hook
196+
229197
gc.collect()
230198
assert not live_elements
231199

232200

233-
def test_double_updated_element_is_not_double_rendered():
234-
assert False
201+
async def test_double_updated_element_is_not_double_rendered():
202+
hook = HookCatcher()
203+
run_count = idom.Ref(0)
204+
205+
@idom.element
206+
@hook.capture
207+
async def AnElement():
208+
run_count.current += 1
209+
return idom.html.div()
210+
211+
async with idom.Layout(AnElement()) as layout:
212+
await layout.render()
213+
214+
assert run_count.current == 1
215+
216+
hook.schedule_render()
217+
hook.schedule_render()
218+
219+
await layout.render()
220+
try:
221+
asyncio.wait_for(
222+
[layout.render()],
223+
timeout=0.1, # this should have been plenty of time
224+
)
225+
except asyncio.CancelledError:
226+
pass # the render should still be rendering since we only update once
227+
228+
assert run_count.current == 2

tests/test_core/utils.py

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)