test_app.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909
  1. import io
  2. import os.path
  3. import sys
  4. from typing import List, Tuple, Type
  5. if sys.version_info.major >= 3 and sys.version_info.minor > 7:
  6. from unittest.mock import AsyncMock # type: ignore
  7. else:
  8. # python 3.7 doesn't ship with unittest.mock
  9. from asynctest import CoroutineMock as AsyncMock
  10. import pytest
  11. import sqlmodel
  12. from fastapi import UploadFile
  13. from starlette_admin.auth import AuthProvider
  14. from starlette_admin.contrib.sqla.admin import Admin
  15. from starlette_admin.contrib.sqla.view import ModelView
  16. from reflex import AdminDash, constants
  17. from reflex.app import App, DefaultState, process, upload
  18. from reflex.components import Box
  19. from reflex.event import Event, get_hydrate_event
  20. from reflex.middleware import HydrateMiddleware
  21. from reflex.model import Model
  22. from reflex.state import State, StateUpdate
  23. from reflex.style import Style
  24. from reflex.utils import format
  25. from reflex.vars import ComputedVar
  26. @pytest.fixture
  27. def app() -> App:
  28. """A base app.
  29. Returns:
  30. The app.
  31. """
  32. return App()
  33. @pytest.fixture
  34. def index_page():
  35. """An index page.
  36. Returns:
  37. The index page.
  38. """
  39. def index():
  40. return Box.create("Index")
  41. return index
  42. @pytest.fixture
  43. def about_page():
  44. """An about page.
  45. Returns:
  46. The about page.
  47. """
  48. def about():
  49. return Box.create("About")
  50. return about
  51. @pytest.fixture()
  52. def test_state() -> Type[State]:
  53. """A default state.
  54. Returns:
  55. A default state.
  56. """
  57. class TestState(State):
  58. var: int
  59. return TestState
  60. @pytest.fixture()
  61. def test_model() -> Type[Model]:
  62. """A default model.
  63. Returns:
  64. A default model.
  65. """
  66. class TestModel(Model):
  67. pass
  68. return TestModel
  69. @pytest.fixture()
  70. def test_model_auth() -> Type[Model]:
  71. """A default model.
  72. Returns:
  73. A default model.
  74. """
  75. class TestModelAuth(Model):
  76. """A test model with auth."""
  77. pass
  78. return TestModelAuth
  79. @pytest.fixture()
  80. def test_get_engine():
  81. """A default database engine.
  82. Returns:
  83. A default database engine.
  84. """
  85. enable_admin = True
  86. url = "sqlite:///test.db"
  87. return sqlmodel.create_engine(
  88. url,
  89. echo=False,
  90. connect_args={"check_same_thread": False} if enable_admin else {},
  91. )
  92. @pytest.fixture()
  93. def test_custom_auth_admin() -> Type[AuthProvider]:
  94. """A default auth provider.
  95. Returns:
  96. A default default auth provider.
  97. """
  98. class TestAuthProvider(AuthProvider):
  99. """A test auth provider."""
  100. login_path: str = "/login"
  101. logout_path: str = "/logout"
  102. def login(self):
  103. """Login."""
  104. pass
  105. def is_authenticated(self):
  106. """Is authenticated."""
  107. pass
  108. def get_admin_user(self):
  109. """Get admin user."""
  110. pass
  111. def logout(self):
  112. """Logout."""
  113. pass
  114. return TestAuthProvider
  115. def test_default_app(app: App):
  116. """Test creating an app with no args.
  117. Args:
  118. app: The app to test.
  119. """
  120. assert app.state() == DefaultState()
  121. assert app.middleware == [HydrateMiddleware()]
  122. assert app.style == Style()
  123. assert app.admin_dash is None
  124. def test_add_page_default_route(app: App, index_page, about_page):
  125. """Test adding a page to an app.
  126. Args:
  127. app: The app to test.
  128. index_page: The index page.
  129. about_page: The about page.
  130. """
  131. assert app.pages == {}
  132. app.add_page(index_page)
  133. assert set(app.pages.keys()) == {"index"}
  134. app.add_page(about_page)
  135. assert set(app.pages.keys()) == {"index", "about"}
  136. def test_add_page_set_route(app: App, index_page, windows_platform: bool):
  137. """Test adding a page to an app.
  138. Args:
  139. app: The app to test.
  140. index_page: The index page.
  141. windows_platform: Whether the system is windows.
  142. """
  143. route = "test" if windows_platform else "/test"
  144. assert app.pages == {}
  145. app.add_page(index_page, route=route)
  146. assert set(app.pages.keys()) == {"test"}
  147. def test_add_page_set_route_dynamic(app: App, index_page, windows_platform: bool):
  148. """Test adding a page with dynamic route variable to an app.
  149. Args:
  150. app: The app to test.
  151. index_page: The index page.
  152. windows_platform: Whether the system is windows.
  153. """
  154. route = "/test/[dynamic]"
  155. if windows_platform:
  156. route.lstrip("/").replace("/", "\\")
  157. assert app.pages == {}
  158. app.add_page(index_page, route=route)
  159. assert set(app.pages.keys()) == {"test/[dynamic]"}
  160. assert "dynamic" in app.state.computed_vars
  161. assert app.state.computed_vars["dynamic"].deps(objclass=DefaultState) == {
  162. constants.ROUTER_DATA
  163. }
  164. assert constants.ROUTER_DATA in app.state().computed_var_dependencies
  165. def test_add_page_set_route_nested(app: App, index_page, windows_platform: bool):
  166. """Test adding a page to an app.
  167. Args:
  168. app: The app to test.
  169. index_page: The index page.
  170. windows_platform: Whether the system is windows.
  171. """
  172. route = "test\\nested" if windows_platform else "/test/nested"
  173. assert app.pages == {}
  174. app.add_page(index_page, route=route)
  175. assert set(app.pages.keys()) == {route.strip(os.path.sep)}
  176. def test_initialize_with_admin_dashboard(test_model):
  177. """Test setting the admin dashboard of an app.
  178. Args:
  179. test_model: The default model.
  180. """
  181. app = App(admin_dash=AdminDash(models=[test_model]))
  182. assert app.admin_dash is not None
  183. assert len(app.admin_dash.models) > 0
  184. assert app.admin_dash.models[0] == test_model
  185. def test_initialize_with_custom_admin_dashboard(
  186. test_get_engine,
  187. test_custom_auth_admin,
  188. test_model_auth,
  189. ):
  190. """Test setting the custom admin dashboard of an app.
  191. Args:
  192. test_get_engine: The default database engine.
  193. test_model_auth: The default model for an auth admin dashboard.
  194. test_custom_auth_admin: The custom auth provider.
  195. """
  196. custom_admin = Admin(engine=test_get_engine, auth_provider=test_custom_auth_admin)
  197. app = App(admin_dash=AdminDash(models=[test_model_auth], admin=custom_admin))
  198. assert app.admin_dash is not None
  199. assert app.admin_dash.admin is not None
  200. assert len(app.admin_dash.models) > 0
  201. assert app.admin_dash.models[0] == test_model_auth
  202. assert app.admin_dash.admin.auth_provider == test_custom_auth_admin
  203. def test_initialize_admin_dashboard_with_view_overrides(test_model):
  204. """Test setting the admin dashboard of an app with view class overriden.
  205. Args:
  206. test_model: The default model.
  207. """
  208. class TestModelView(ModelView):
  209. pass
  210. app = App(
  211. admin_dash=AdminDash(
  212. models=[test_model], view_overrides={test_model: TestModelView}
  213. )
  214. )
  215. assert app.admin_dash is not None
  216. assert app.admin_dash.models == [test_model]
  217. assert app.admin_dash.view_overrides[test_model] == TestModelView
  218. def test_initialize_with_state(test_state):
  219. """Test setting the state of an app.
  220. Args:
  221. test_state: The default state.
  222. """
  223. app = App(state=test_state)
  224. assert app.state == test_state
  225. # Get a state for a given token.
  226. token = "token"
  227. state = app.state_manager.get_state(token)
  228. assert isinstance(state, test_state)
  229. assert state.var == 0 # type: ignore
  230. def test_set_and_get_state(test_state):
  231. """Test setting and getting the state of an app with different tokens.
  232. Args:
  233. test_state: The default state.
  234. """
  235. app = App(state=test_state)
  236. # Create two tokens.
  237. token1 = "token1"
  238. token2 = "token2"
  239. # Get the default state for each token.
  240. state1 = app.state_manager.get_state(token1)
  241. state2 = app.state_manager.get_state(token2)
  242. assert state1.var == 0 # type: ignore
  243. assert state2.var == 0 # type: ignore
  244. # Set the vars to different values.
  245. state1.var = 1
  246. state2.var = 2
  247. app.state_manager.set_state(token1, state1)
  248. app.state_manager.set_state(token2, state2)
  249. # Get the states again and check the values.
  250. state1 = app.state_manager.get_state(token1)
  251. state2 = app.state_manager.get_state(token2)
  252. assert state1.var == 1 # type: ignore
  253. assert state2.var == 2 # type: ignore
  254. @pytest.mark.asyncio
  255. async def test_dynamic_var_event(test_state):
  256. """Test that the default handler of a dynamic generated var
  257. works as expected.
  258. Args:
  259. test_state: State Fixture.
  260. """
  261. test_state = test_state()
  262. test_state.add_var("int_val", int, 0)
  263. result = await test_state._process(
  264. Event(
  265. token="fake-token",
  266. name="test_state.set_int_val",
  267. router_data={"pathname": "/", "query": {}},
  268. payload={"value": 50},
  269. )
  270. ).__anext__()
  271. assert result.delta == {"test_state": {"int_val": 50}}
  272. @pytest.mark.asyncio
  273. @pytest.mark.parametrize(
  274. "event_tuples",
  275. [
  276. pytest.param(
  277. [
  278. (
  279. "test_state.make_friend",
  280. {"test_state": {"plain_friends": ["Tommy", "another-fd"]}},
  281. ),
  282. (
  283. "test_state.change_first_friend",
  284. {"test_state": {"plain_friends": ["Jenny", "another-fd"]}},
  285. ),
  286. ],
  287. id="append then __setitem__",
  288. ),
  289. pytest.param(
  290. [
  291. (
  292. "test_state.unfriend_first_friend",
  293. {"test_state": {"plain_friends": []}},
  294. ),
  295. (
  296. "test_state.make_friend",
  297. {"test_state": {"plain_friends": ["another-fd"]}},
  298. ),
  299. ],
  300. id="delitem then append",
  301. ),
  302. pytest.param(
  303. [
  304. (
  305. "test_state.make_friends_with_colleagues",
  306. {"test_state": {"plain_friends": ["Tommy", "Peter", "Jimmy"]}},
  307. ),
  308. (
  309. "test_state.remove_tommy",
  310. {"test_state": {"plain_friends": ["Peter", "Jimmy"]}},
  311. ),
  312. (
  313. "test_state.remove_last_friend",
  314. {"test_state": {"plain_friends": ["Peter"]}},
  315. ),
  316. (
  317. "test_state.unfriend_all_friends",
  318. {"test_state": {"plain_friends": []}},
  319. ),
  320. ],
  321. id="extend, remove, pop, clear",
  322. ),
  323. pytest.param(
  324. [
  325. (
  326. "test_state.add_jimmy_to_second_group",
  327. {
  328. "test_state": {
  329. "friends_in_nested_list": [["Tommy"], ["Jenny", "Jimmy"]]
  330. }
  331. },
  332. ),
  333. (
  334. "test_state.remove_first_person_from_first_group",
  335. {
  336. "test_state": {
  337. "friends_in_nested_list": [[], ["Jenny", "Jimmy"]]
  338. }
  339. },
  340. ),
  341. (
  342. "test_state.remove_first_group",
  343. {"test_state": {"friends_in_nested_list": [["Jenny", "Jimmy"]]}},
  344. ),
  345. ],
  346. id="nested list",
  347. ),
  348. pytest.param(
  349. [
  350. (
  351. "test_state.add_jimmy_to_tommy_friends",
  352. {"test_state": {"friends_in_dict": {"Tommy": ["Jenny", "Jimmy"]}}},
  353. ),
  354. (
  355. "test_state.remove_jenny_from_tommy",
  356. {"test_state": {"friends_in_dict": {"Tommy": ["Jimmy"]}}},
  357. ),
  358. (
  359. "test_state.tommy_has_no_fds",
  360. {"test_state": {"friends_in_dict": {"Tommy": []}}},
  361. ),
  362. ],
  363. id="list in dict",
  364. ),
  365. ],
  366. )
  367. async def test_list_mutation_detection__plain_list(
  368. event_tuples: List[Tuple[str, List[str]]], list_mutation_state: State
  369. ):
  370. """Test list mutation detection
  371. when reassignment is not explicitly included in the logic.
  372. Args:
  373. event_tuples: From parametrization.
  374. list_mutation_state: A state with list mutation features.
  375. """
  376. for event_name, expected_delta in event_tuples:
  377. result = await list_mutation_state._process(
  378. Event(
  379. token="fake-token",
  380. name=event_name,
  381. router_data={"pathname": "/", "query": {}},
  382. payload={},
  383. )
  384. ).__anext__()
  385. assert result.delta == expected_delta
  386. @pytest.mark.asyncio
  387. @pytest.mark.parametrize(
  388. "event_tuples",
  389. [
  390. pytest.param(
  391. [
  392. (
  393. "test_state.add_age",
  394. {"test_state": {"details": {"name": "Tommy", "age": 20}}},
  395. ),
  396. (
  397. "test_state.change_name",
  398. {"test_state": {"details": {"name": "Jenny", "age": 20}}},
  399. ),
  400. (
  401. "test_state.remove_last_detail",
  402. {"test_state": {"details": {"name": "Jenny"}}},
  403. ),
  404. ],
  405. id="update then __setitem__",
  406. ),
  407. pytest.param(
  408. [
  409. (
  410. "test_state.clear_details",
  411. {"test_state": {"details": {}}},
  412. ),
  413. (
  414. "test_state.add_age",
  415. {"test_state": {"details": {"age": 20}}},
  416. ),
  417. ],
  418. id="delitem then update",
  419. ),
  420. pytest.param(
  421. [
  422. (
  423. "test_state.add_age",
  424. {"test_state": {"details": {"name": "Tommy", "age": 20}}},
  425. ),
  426. (
  427. "test_state.remove_name",
  428. {"test_state": {"details": {"age": 20}}},
  429. ),
  430. (
  431. "test_state.pop_out_age",
  432. {"test_state": {"details": {}}},
  433. ),
  434. ],
  435. id="add, remove, pop",
  436. ),
  437. pytest.param(
  438. [
  439. (
  440. "test_state.remove_home_address",
  441. {"test_state": {"address": [{}, {"work": "work address"}]}},
  442. ),
  443. (
  444. "test_state.add_street_to_home_address",
  445. {
  446. "test_state": {
  447. "address": [
  448. {"street": "street address"},
  449. {"work": "work address"},
  450. ]
  451. }
  452. },
  453. ),
  454. ],
  455. id="dict in list",
  456. ),
  457. pytest.param(
  458. [
  459. (
  460. "test_state.change_friend_name",
  461. {
  462. "test_state": {
  463. "friend_in_nested_dict": {
  464. "name": "Nikhil",
  465. "friend": {"name": "Tommy"},
  466. }
  467. }
  468. },
  469. ),
  470. (
  471. "test_state.add_friend_age",
  472. {
  473. "test_state": {
  474. "friend_in_nested_dict": {
  475. "name": "Nikhil",
  476. "friend": {"name": "Tommy", "age": 30},
  477. }
  478. }
  479. },
  480. ),
  481. (
  482. "test_state.remove_friend",
  483. {"test_state": {"friend_in_nested_dict": {"name": "Nikhil"}}},
  484. ),
  485. ],
  486. id="nested dict",
  487. ),
  488. ],
  489. )
  490. async def test_dict_mutation_detection__plain_list(
  491. event_tuples: List[Tuple[str, List[str]]], dict_mutation_state: State
  492. ):
  493. """Test dict mutation detection
  494. when reassignment is not explicitly included in the logic.
  495. Args:
  496. event_tuples: From parametrization.
  497. dict_mutation_state: A state with dict mutation features.
  498. """
  499. for event_name, expected_delta in event_tuples:
  500. result = await dict_mutation_state._process(
  501. Event(
  502. token="fake-token",
  503. name=event_name,
  504. router_data={"pathname": "/", "query": {}},
  505. payload={},
  506. )
  507. ).__anext__()
  508. assert result.delta == expected_delta
  509. @pytest.mark.asyncio
  510. @pytest.mark.parametrize(
  511. "fixture, expected",
  512. [
  513. (
  514. "upload_state",
  515. {"file_upload_state": {"img_list": ["image1.jpg", "image2.jpg"]}},
  516. ),
  517. (
  518. "upload_sub_state",
  519. {
  520. "file_state.file_upload_state": {
  521. "img_list": ["image1.jpg", "image2.jpg"]
  522. }
  523. },
  524. ),
  525. (
  526. "upload_grand_sub_state",
  527. {
  528. "base_file_state.file_sub_state.file_upload_state": {
  529. "img_list": ["image1.jpg", "image2.jpg"]
  530. }
  531. },
  532. ),
  533. ],
  534. )
  535. async def test_upload_file(fixture, request, expected):
  536. """Test that file upload works correctly.
  537. Args:
  538. fixture: The state.
  539. request: Fixture request.
  540. expected: Expected delta
  541. """
  542. data = b"This is binary data"
  543. # Create a binary IO object and write data to it
  544. bio = io.BytesIO()
  545. bio.write(data)
  546. app = App(state=request.getfixturevalue(fixture))
  547. file1 = UploadFile(
  548. filename="token:file_upload_state.multi_handle_upload:True:image1.jpg",
  549. file=bio,
  550. )
  551. file2 = UploadFile(
  552. filename="token:file_upload_state.multi_handle_upload:True:image2.jpg",
  553. file=bio,
  554. )
  555. fn = upload(app)
  556. result = await fn([file1, file2]) # type: ignore
  557. assert isinstance(result, StateUpdate)
  558. assert result.delta == expected
  559. @pytest.mark.asyncio
  560. @pytest.mark.parametrize(
  561. "fixture", ["upload_state", "upload_sub_state", "upload_grand_sub_state"]
  562. )
  563. async def test_upload_file_without_annotation(fixture, request):
  564. """Test that an error is thrown when there's no param annotated with rx.UploadFile or List[UploadFile].
  565. Args:
  566. fixture: The state.
  567. request: Fixture request.
  568. """
  569. data = b"This is binary data"
  570. # Create a binary IO object and write data to it
  571. bio = io.BytesIO()
  572. bio.write(data)
  573. app = App(state=request.getfixturevalue(fixture))
  574. file1 = UploadFile(
  575. filename="token:file_upload_state.handle_upload2:True:image1.jpg",
  576. file=bio,
  577. )
  578. file2 = UploadFile(
  579. filename="token:file_upload_state.handle_upload2:True:image2.jpg",
  580. file=bio,
  581. )
  582. fn = upload(app)
  583. with pytest.raises(ValueError) as err:
  584. await fn([file1, file2])
  585. assert (
  586. err.value.args[0]
  587. == "`file_upload_state.handle_upload2` handler should have a parameter annotated as List[rx.UploadFile]"
  588. )
  589. class DynamicState(State):
  590. """State class for testing dynamic route var.
  591. This is defined at module level because event handlers cannot be addressed
  592. correctly when the class is defined as a local.
  593. There are several counters:
  594. * loaded: counts how many times `on_load` was triggered by the hydrate middleware
  595. * counter: counts how many times `on_counter` was triggered by a non-naviagational event
  596. -> these events should NOT trigger reload or recalculation of router_data dependent vars
  597. * side_effect_counter: counts how many times a computed var was
  598. recalculated when the dynamic route var was dirty
  599. """
  600. loaded: int = 0
  601. counter: int = 0
  602. # side_effect_counter: int = 0
  603. def on_load(self):
  604. """Event handler for page on_load, should trigger for all navigation events."""
  605. self.loaded = self.loaded + 1
  606. def on_counter(self):
  607. """Increment the counter var."""
  608. self.counter = self.counter + 1
  609. @ComputedVar
  610. def comp_dynamic(self) -> str:
  611. """A computed var that depends on the dynamic var.
  612. Returns:
  613. same as self.dynamic
  614. """
  615. # self.side_effect_counter = self.side_effect_counter + 1
  616. return self.dynamic
  617. @pytest.mark.asyncio
  618. async def test_dynamic_route_var_route_change_completed_on_load(
  619. index_page,
  620. windows_platform: bool,
  621. ):
  622. """Create app with dynamic route var, and simulate navigation.
  623. on_load should fire, allowing any additional vars to be updated before the
  624. initial page hydrate.
  625. Args:
  626. index_page: The index page.
  627. windows_platform: Whether the system is windows.
  628. """
  629. arg_name = "dynamic"
  630. route = f"/test/[{arg_name}]"
  631. if windows_platform:
  632. route.lstrip("/").replace("/", "\\")
  633. app = App(state=DynamicState)
  634. assert arg_name not in app.state.vars
  635. app.add_page(index_page, route=route, on_load=DynamicState.on_load) # type: ignore
  636. assert arg_name in app.state.vars
  637. assert arg_name in app.state.computed_vars
  638. assert app.state.computed_vars[arg_name].deps(objclass=DynamicState) == {
  639. constants.ROUTER_DATA
  640. }
  641. assert constants.ROUTER_DATA in app.state().computed_var_dependencies
  642. token = "mock_token"
  643. sid = "mock_sid"
  644. client_ip = "127.0.0.1"
  645. state = app.state_manager.get_state(token)
  646. assert state.dynamic == ""
  647. exp_vals = ["foo", "foobar", "baz"]
  648. def _event(name, val, **kwargs):
  649. return Event(
  650. token=kwargs.pop("token", token),
  651. name=name,
  652. router_data=kwargs.pop(
  653. "router_data", {"pathname": route, "query": {arg_name: val}}
  654. ),
  655. payload=kwargs.pop("payload", {}),
  656. **kwargs,
  657. )
  658. def _dynamic_state_event(name, val, **kwargs):
  659. return _event(
  660. name=format.format_event_handler(getattr(DynamicState, name)), # type: ignore
  661. val=val,
  662. **kwargs,
  663. )
  664. for exp_index, exp_val in enumerate(exp_vals):
  665. update = await process(
  666. app,
  667. event=_event(name=get_hydrate_event(state), val=exp_val),
  668. sid=sid,
  669. headers={},
  670. client_ip=client_ip,
  671. ).__anext__()
  672. # route change triggers: [full state dict, call on_load events, call set_is_hydrated(True)]
  673. assert update == StateUpdate(
  674. delta={
  675. state.get_name(): {
  676. arg_name: exp_val,
  677. f"comp_{arg_name}": exp_val,
  678. constants.IS_HYDRATED: False,
  679. "loaded": exp_index,
  680. "counter": exp_index,
  681. # "side_effect_counter": exp_index,
  682. }
  683. },
  684. events=[
  685. _dynamic_state_event(name="on_load", val=exp_val, router_data={}),
  686. _dynamic_state_event(
  687. name="set_is_hydrated",
  688. payload={"value": "true"},
  689. val=exp_val,
  690. router_data={},
  691. ),
  692. ],
  693. )
  694. assert state.dynamic == exp_val
  695. on_load_update = await process(
  696. app,
  697. event=_dynamic_state_event(name="on_load", val=exp_val),
  698. sid=sid,
  699. headers={},
  700. client_ip=client_ip,
  701. ).__anext__()
  702. assert on_load_update == StateUpdate(
  703. delta={
  704. state.get_name(): {
  705. # These computed vars _shouldn't_ be here, because they didn't change
  706. arg_name: exp_val,
  707. f"comp_{arg_name}": exp_val,
  708. "loaded": exp_index + 1,
  709. },
  710. },
  711. events=[],
  712. )
  713. on_set_is_hydrated_update = await process(
  714. app,
  715. event=_dynamic_state_event(
  716. name="set_is_hydrated", payload={"value": True}, val=exp_val
  717. ),
  718. sid=sid,
  719. headers={},
  720. client_ip=client_ip,
  721. ).__anext__()
  722. assert on_set_is_hydrated_update == StateUpdate(
  723. delta={
  724. state.get_name(): {
  725. # These computed vars _shouldn't_ be here, because they didn't change
  726. arg_name: exp_val,
  727. f"comp_{arg_name}": exp_val,
  728. "is_hydrated": True,
  729. },
  730. },
  731. events=[],
  732. )
  733. # a simple state update event should NOT trigger on_load or route var side effects
  734. update = await process(
  735. app,
  736. event=_dynamic_state_event(name="on_counter", val=exp_val),
  737. sid=sid,
  738. headers={},
  739. client_ip=client_ip,
  740. ).__anext__()
  741. assert update == StateUpdate(
  742. delta={
  743. state.get_name(): {
  744. # These computed vars _shouldn't_ be here, because they didn't change
  745. f"comp_{arg_name}": exp_val,
  746. arg_name: exp_val,
  747. "counter": exp_index + 1,
  748. }
  749. },
  750. events=[],
  751. )
  752. assert state.loaded == len(exp_vals)
  753. assert state.counter == len(exp_vals)
  754. # print(f"Expected {exp_vals} rendering side effects, got {state.side_effect_counter}")
  755. # assert state.side_effect_counter == len(exp_vals)
  756. @pytest.mark.asyncio
  757. async def test_process_events(gen_state, mocker):
  758. """Test that an event is processed properly and that it is postprocessed
  759. n+1 times. Also check that the processing flag of the last stateupdate is set to
  760. False.
  761. Args:
  762. gen_state: The state.
  763. mocker: mocker object.
  764. """
  765. router_data = {
  766. "pathname": "/",
  767. "query": {},
  768. "token": "mock_token",
  769. "sid": "mock_sid",
  770. "headers": {},
  771. "ip": "127.0.0.1",
  772. }
  773. app = App(state=gen_state)
  774. mocker.patch.object(app, "postprocess", AsyncMock())
  775. event = Event(
  776. token="token", name="gen_state.go", payload={"c": 5}, router_data=router_data
  777. )
  778. async for _update in process(app, event, "mock_sid", {}, "127.0.0.1"):
  779. pass
  780. assert gen_state.value == 5
  781. assert app.postprocess.call_count == 6