pycascadeui 3.12.2__tar.gz → 3.12.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycascadeui-3.12.2/pycascadeui.egg-info → pycascadeui-3.12.3}/PKG-INFO +3 -3
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/__init__.py +1 -1
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/manager.py +8 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/_navigation.py +2 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/base.py +67 -17
- {pycascadeui-3.12.2 → pycascadeui-3.12.3/pycascadeui.egg-info}/PKG-INFO +3 -3
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pycascadeui.egg-info/requires.txt +2 -2
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pyproject.toml +3 -3
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_layout_view.py +109 -1
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_navigation.py +6 -2
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_persistence.py +142 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/LICENSE +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/README.md +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/__main__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/base.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/buttons.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/inputs.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/patterns/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/patterns/v1.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/patterns/v2.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/selects.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/types.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/v1_composition.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/wrappers.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/devtools.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/exceptions.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/backends/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/backends/memory.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/backends/postgres.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/backends/sqlite.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/config.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/migrations.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/protocols.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/schema.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/schema_postgres.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/py.typed +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/setup.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/_batching.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/actions.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/computed.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/middleware/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/middleware/logging.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/middleware/persistence.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/middleware/undo.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/reducers.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/singleton.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/slots.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/store.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/types.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/testing.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/theming/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/theming/context.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/theming/core.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/theming/themes.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/tracing.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/coercion.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/decorators.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/errors.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/fetch.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/guards.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/hooks.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/logging.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/responses.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/strings.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/tasks.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/validation.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/_interaction.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/_placement.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/layout.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/__init__.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/form.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/leaderboard.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/menu.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/paginated.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/roles.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/tabs.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/types.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/wizard.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/persistent.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/view.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pycascadeui.egg-info/SOURCES.txt +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pycascadeui.egg-info/dependency_links.txt +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pycascadeui.egg-info/top_level.txt +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/setup.cfg +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_auto_defer.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_backends.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_backends_postgres.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_backends_raw_sql.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_batching.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_button_grid.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_components.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_computed.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_devtools.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_dynamic_persistent_button.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_emoji_grid.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_ephemeral_refresh.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_examples.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_fetch.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_five_pillars.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_form_patterns.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_hooks.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_input_coercion.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_inputs.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_instance_limit.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_invariants.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_layout_pagination.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_layout_patterns.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_layout_persistent.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_leaderboard_patterns.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_logging_middleware.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_menu_patterns.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_message_cleanup.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_middleware.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_nav_version.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_navigation_artifact_restore.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_navigation_matrix.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_owner_only.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_paginated_patterns.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_pagination.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_persistence_middleware.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_persistent_views.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_pillar_isolation.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_placement.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_public_api.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_rebuild_callback.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_reducers.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_roles_patterns.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_scoping.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_selects.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_state_slots.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_state_store.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_tab_patterns.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_task_manager.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_theming.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_undo.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_update_coalescing.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_v2_helpers.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_validation.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_view_init.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_wizard_patterns.py +0 -0
- {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_wrappers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycascadeui
|
|
3
|
-
Version: 3.12.
|
|
3
|
+
Version: 3.12.3
|
|
4
4
|
Summary: Redux-inspired UI framework for discord.py
|
|
5
5
|
Author-email: HollowTheSilver <hollow@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -28,8 +28,8 @@ Requires-Dist: discord.py>=2.7
|
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
30
30
|
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
31
|
-
Requires-Dist: black
|
|
32
|
-
Requires-Dist: isort
|
|
31
|
+
Requires-Dist: black<27.0,>=26.3; extra == "dev"
|
|
32
|
+
Requires-Dist: isort<9.0,>=8.0; extra == "dev"
|
|
33
33
|
Requires-Dist: testcontainers[postgres]>=4.0; extra == "dev"
|
|
34
34
|
Provides-Extra: docs
|
|
35
35
|
Requires-Dist: mkdocs>=1.5; extra == "docs"
|
|
@@ -184,7 +184,7 @@ if "PostgresBackend" in _persistence_all:
|
|
|
184
184
|
# // ========================================( Script )======================================== // #
|
|
185
185
|
|
|
186
186
|
|
|
187
|
-
__version__ = "3.12.
|
|
187
|
+
__version__ = "3.12.3"
|
|
188
188
|
|
|
189
189
|
# Export public API
|
|
190
190
|
__all__ = [
|
|
@@ -1396,6 +1396,14 @@ class PersistenceManager:
|
|
|
1396
1396
|
# runs.
|
|
1397
1397
|
view._message = message
|
|
1398
1398
|
|
|
1399
|
+
# Fingerprint the tree the fresh __init__ just produced, before
|
|
1400
|
+
# on_restore has had any chance to run. A teardown before that
|
|
1401
|
+
# render reads this against the CURRENT tree: unchanged means
|
|
1402
|
+
# nothing has touched it and there is nothing on screen to
|
|
1403
|
+
# correct, changed means a teardown override composed real
|
|
1404
|
+
# content (a farewell card) that has to ship regardless.
|
|
1405
|
+
view._reattach_baseline_digest = view._compute_tree_digest()
|
|
1406
|
+
|
|
1399
1407
|
# ``is not None`` (not truthy) so a stored ``user_id=0`` still
|
|
1400
1408
|
# restores -- Discord doesn't mint zero snowflakes, but tests
|
|
1401
1409
|
# and edge-case fixtures do, and the cost of the explicit form
|
|
@@ -623,6 +623,7 @@ class _NavigationMixin:
|
|
|
623
623
|
await self._ack_bounded(
|
|
624
624
|
current_interaction.response.edit_message(view=new_view, **direct_kwargs)
|
|
625
625
|
)
|
|
626
|
+
new_view._has_rendered = True
|
|
626
627
|
new_view._redrive_dynamic_items()
|
|
627
628
|
return True
|
|
628
629
|
except asyncio.TimeoutError:
|
|
@@ -661,6 +662,7 @@ class _NavigationMixin:
|
|
|
661
662
|
msg = await self._bounded(
|
|
662
663
|
current_interaction.edit_original_response(view=new_view, **direct_kwargs)
|
|
663
664
|
)
|
|
665
|
+
new_view._has_rendered = True
|
|
664
666
|
# Preserve the parent's plain Message ref. The edit response
|
|
665
667
|
# is an InteractionMessage / WebhookMessage bound to the
|
|
666
668
|
# 15-minute interaction token; subsequent edits need the
|
|
@@ -1143,6 +1143,23 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1143
1143
|
# ``None`` means no baseline has been recorded yet, so the
|
|
1144
1144
|
# next refresh always runs through to the REST call.
|
|
1145
1145
|
self._last_tree_digest: Optional[int] = None
|
|
1146
|
+
# Distinct from the digest above: this is never reset back to False
|
|
1147
|
+
# once a tree has landed on Discord, even when a stall or a send
|
|
1148
|
+
# failure clears the digest to force the next refresh through
|
|
1149
|
+
# unconditionally. The teardown freeze-edit gate reads this rather
|
|
1150
|
+
# than "digest is not None", since that comparison would otherwise
|
|
1151
|
+
# misread every one of those deliberate baseline-drops as "never
|
|
1152
|
+
# rendered" and skip an edit that has real content to correct.
|
|
1153
|
+
self._has_rendered: bool = False
|
|
1154
|
+
# Stamped once, by the persistence reattach path only, with the
|
|
1155
|
+
# digest of the tree a fresh __init__ produced -- before on_restore
|
|
1156
|
+
# has had any chance to run. Lets the teardown gate tell "restored,
|
|
1157
|
+
# never rendered, and nothing has touched the tree since" (matches
|
|
1158
|
+
# this baseline, safe to skip) apart from "restored, never rendered,
|
|
1159
|
+
# but a teardown override composed real content before delegating"
|
|
1160
|
+
# (differs from this baseline, has to ship). Left None for every
|
|
1161
|
+
# view that was never reattached, where _has_rendered alone governs.
|
|
1162
|
+
self._reattach_baseline_digest: Optional[int] = None
|
|
1146
1163
|
# Set by ``refresh`` when it swallowed a transport failure, so a
|
|
1147
1164
|
# caller that needs the edit to have LANDED can tell that apart from
|
|
1148
1165
|
# a refresh that returned normally. A repaint is content to retry on
|
|
@@ -1857,6 +1874,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1857
1874
|
# embed content is outside the digest, which is fine -- the
|
|
1858
1875
|
# digest only certifies the component tree, and refresh() only
|
|
1859
1876
|
# short-circuits when the caller passes no kwargs.
|
|
1877
|
+
self._has_rendered = True
|
|
1860
1878
|
self._last_tree_digest = self._compute_tree_digest()
|
|
1861
1879
|
|
|
1862
1880
|
await self._update_message_state(self._message)
|
|
@@ -2496,6 +2514,48 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
2496
2514
|
frozen += 1
|
|
2497
2515
|
return frozen
|
|
2498
2516
|
|
|
2517
|
+
def _freeze_edit_needed(self, froze: bool) -> bool:
|
|
2518
|
+
"""Whether a teardown freeze edit has something new to ship.
|
|
2519
|
+
|
|
2520
|
+
``froze`` alone answers "did disabling components change anything",
|
|
2521
|
+
which misses a caller that rebuilt the tree (a farewell card) before
|
|
2522
|
+
tearing down -- the digest comparison is what catches that case.
|
|
2523
|
+
|
|
2524
|
+
The comparison is gated on ``_has_rendered`` rather than on
|
|
2525
|
+
``_last_tree_digest is not None``, because the digest is ``None``
|
|
2526
|
+
for two different reasons and only one of them means "nothing on
|
|
2527
|
+
screen": a view that has never landed a tree (restored from
|
|
2528
|
+
persistence, its post-ready render still pending: ``self.children``
|
|
2529
|
+
is whatever ``__init__`` produced, typically empty for a pattern
|
|
2530
|
+
that builds its tree in ``on_load``, which Discord rejects with
|
|
2531
|
+
error 50006), and a view that HAS rendered but had its digest
|
|
2532
|
+
deliberately dropped so the next refresh ships unconditionally (a
|
|
2533
|
+
stalled edit, a swallowed transport failure, a navigation landing
|
|
2534
|
+
that never stamps a digest at all). Gating on the digest alone
|
|
2535
|
+
would read the second case as "never measured" and skip an edit
|
|
2536
|
+
that has real, possibly stale content to correct, silently
|
|
2537
|
+
dropping a farewell card composed just before teardown -- the
|
|
2538
|
+
exact defect this guard exists to catch.
|
|
2539
|
+
|
|
2540
|
+
A never-rendered view can still have something to ship: a teardown
|
|
2541
|
+
override (``exit()``, ``on_timeout()``) that composes new content
|
|
2542
|
+
before delegating to ``super()`` runs whether or not ``on_restore``
|
|
2543
|
+
ever rendered first, and its farewell card is not the empty tree
|
|
2544
|
+
this guard exists to withhold. ``_reattach_baseline_digest``, stamped
|
|
2545
|
+
once at reattach with the digest of whatever a fresh ``__init__``
|
|
2546
|
+
produced, tells the two apart: a tree that still matches it has
|
|
2547
|
+
nothing on screen to correct (the original defect this method
|
|
2548
|
+
fixes), a tree that no longer matches it was composed by something
|
|
2549
|
+
real and has to ship.
|
|
2550
|
+
"""
|
|
2551
|
+
if froze:
|
|
2552
|
+
return True
|
|
2553
|
+
if self._has_rendered:
|
|
2554
|
+
return self._compute_tree_digest() != self._last_tree_digest
|
|
2555
|
+
if self._reattach_baseline_digest is None:
|
|
2556
|
+
return False
|
|
2557
|
+
return self._compute_tree_digest() != self._reattach_baseline_digest
|
|
2558
|
+
|
|
2499
2559
|
def _dispatch_timeout(self):
|
|
2500
2560
|
"""Repair the dynamic-item registry at the actual discord.py choke point.
|
|
2501
2561
|
|
|
@@ -2534,14 +2594,8 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
2534
2594
|
self.state_store._undo_enabled_views.pop(self.id, None)
|
|
2535
2595
|
await self.state_store._destroy_view(self.id, source_id=self.id)
|
|
2536
2596
|
|
|
2537
|
-
#
|
|
2538
|
-
|
|
2539
|
-
# otherwise send a no-op PATCH on every timeout. The tree is checked
|
|
2540
|
-
# alongside the freeze because an override that rebuilds before
|
|
2541
|
-
# delegating here has something to say even though nothing froze.
|
|
2542
|
-
if self._message and (
|
|
2543
|
-
self._freeze_components() or self._compute_tree_digest() != self._last_tree_digest
|
|
2544
|
-
):
|
|
2597
|
+
# See _freeze_edit_needed for what "nothing to ship" covers here.
|
|
2598
|
+
if self._message and self._freeze_edit_needed(self._freeze_components()):
|
|
2545
2599
|
try:
|
|
2546
2600
|
await self._bounded(self._message.edit(**self._freeze_edit_kwargs()))
|
|
2547
2601
|
except discord.NotFound:
|
|
@@ -3419,6 +3473,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
3419
3473
|
interaction.response.edit_message(view=self, **kwargs),
|
|
3420
3474
|
timeout=fast_path_timeout,
|
|
3421
3475
|
)
|
|
3476
|
+
self._has_rendered = True
|
|
3422
3477
|
self._last_tree_digest = self._compute_tree_digest()
|
|
3423
3478
|
if perf_on:
|
|
3424
3479
|
store._record_edit()
|
|
@@ -3477,6 +3532,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
3477
3532
|
if ("embed" in kwargs or "embeds" in kwargs) and self._webhook_message:
|
|
3478
3533
|
try:
|
|
3479
3534
|
await self._bounded(self._webhook_message.edit(view=self, **kwargs))
|
|
3535
|
+
self._has_rendered = True
|
|
3480
3536
|
self._last_tree_digest = self._compute_tree_digest()
|
|
3481
3537
|
if perf_on:
|
|
3482
3538
|
store._record_edit()
|
|
@@ -3510,6 +3566,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
3510
3566
|
|
|
3511
3567
|
try:
|
|
3512
3568
|
await self._bounded(self._message.edit(view=self, **kwargs))
|
|
3569
|
+
self._has_rendered = True
|
|
3513
3570
|
self._last_tree_digest = self._compute_tree_digest()
|
|
3514
3571
|
if perf_on:
|
|
3515
3572
|
store._record_edit()
|
|
@@ -4790,15 +4847,8 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
4790
4847
|
elif self._is_layout():
|
|
4791
4848
|
# V2 messages ARE their components -- edit(view=None) would
|
|
4792
4849
|
# produce an empty message (error 50006). Freeze instead.
|
|
4793
|
-
#
|
|
4794
|
-
|
|
4795
|
-
# matches the last render. Testing the freeze alone read
|
|
4796
|
-
# a caller who rebuilt the tree before exiting as having
|
|
4797
|
-
# nothing to say, so a farewell card composed in
|
|
4798
|
-
# on_timeout was dropped and the expired prompt kept its
|
|
4799
|
-
# live-looking buttons until someone pressed one.
|
|
4800
|
-
froze = self._freeze_components()
|
|
4801
|
-
if froze or self._compute_tree_digest() != self._last_tree_digest:
|
|
4850
|
+
# See _freeze_edit_needed for what "nothing to ship" covers.
|
|
4851
|
+
if self._freeze_edit_needed(self._freeze_components()):
|
|
4802
4852
|
await self._bounded(self._message.edit(**self._freeze_edit_kwargs()))
|
|
4803
4853
|
else:
|
|
4804
4854
|
await self._bounded(self._message.edit(view=None))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycascadeui
|
|
3
|
-
Version: 3.12.
|
|
3
|
+
Version: 3.12.3
|
|
4
4
|
Summary: Redux-inspired UI framework for discord.py
|
|
5
5
|
Author-email: HollowTheSilver <hollow@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -28,8 +28,8 @@ Requires-Dist: discord.py>=2.7
|
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
30
30
|
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
31
|
-
Requires-Dist: black
|
|
32
|
-
Requires-Dist: isort
|
|
31
|
+
Requires-Dist: black<27.0,>=26.3; extra == "dev"
|
|
32
|
+
Requires-Dist: isort<9.0,>=8.0; extra == "dev"
|
|
33
33
|
Requires-Dist: testcontainers[postgres]>=4.0; extra == "dev"
|
|
34
34
|
Provides-Extra: docs
|
|
35
35
|
Requires-Dist: mkdocs>=1.5; extra == "docs"
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "pycascadeui"
|
|
10
|
-
version = "3.12.
|
|
10
|
+
version = "3.12.3"
|
|
11
11
|
description = "Redux-inspired UI framework for discord.py"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
license = "MIT"
|
|
@@ -38,8 +38,8 @@ dependencies = [
|
|
|
38
38
|
dev = [
|
|
39
39
|
"pytest>=7.0",
|
|
40
40
|
"pytest-asyncio>=0.21",
|
|
41
|
-
"black>=26.3",
|
|
42
|
-
"isort>=8.0",
|
|
41
|
+
"black>=26.3,<27.0",
|
|
42
|
+
"isort>=8.0,<9.0",
|
|
43
43
|
"testcontainers[postgres]>=4.0",
|
|
44
44
|
]
|
|
45
45
|
docs = [
|
|
@@ -5,7 +5,7 @@ import inspect
|
|
|
5
5
|
import io
|
|
6
6
|
import logging
|
|
7
7
|
import time
|
|
8
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
8
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
9
9
|
|
|
10
10
|
import aiohttp
|
|
11
11
|
import discord
|
|
@@ -5678,6 +5678,114 @@ class TestRebuiltTreeShipsOnTeardown:
|
|
|
5678
5678
|
|
|
5679
5679
|
message.edit.assert_awaited_once()
|
|
5680
5680
|
|
|
5681
|
+
async def test_exit_skips_the_edit_for_a_view_that_never_rendered(self):
|
|
5682
|
+
"""A view reattached from persistence but not yet rendered has no
|
|
5683
|
+
baseline on screen for this edit to correct.
|
|
5684
|
+
|
|
5685
|
+
No ``send()`` and no ``refresh()`` runs here, so ``_has_rendered``
|
|
5686
|
+
stays at its ``__init__`` default of ``False`` -- matching a view
|
|
5687
|
+
between reattach and its deferred post-ready render.
|
|
5688
|
+
"""
|
|
5689
|
+
view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
|
|
5690
|
+
message = self._wire(view)
|
|
5691
|
+
assert view._has_rendered is False
|
|
5692
|
+
|
|
5693
|
+
await view.exit(delete_message=False)
|
|
5694
|
+
|
|
5695
|
+
message.edit.assert_not_called()
|
|
5696
|
+
|
|
5697
|
+
async def test_on_timeout_skips_the_edit_for_a_view_that_never_rendered(self):
|
|
5698
|
+
view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
|
|
5699
|
+
message = self._wire(view)
|
|
5700
|
+
assert view._has_rendered is False
|
|
5701
|
+
|
|
5702
|
+
await view.on_timeout()
|
|
5703
|
+
|
|
5704
|
+
message.edit.assert_not_called()
|
|
5705
|
+
|
|
5706
|
+
async def test_exit_still_ships_a_rebuild_when_the_digest_was_never_stamped(self):
|
|
5707
|
+
"""``_last_tree_digest`` is ``None`` for more than one reason, and
|
|
5708
|
+
only "never rendered" means "skip".
|
|
5709
|
+
|
|
5710
|
+
A push/pop navigation landing never stamps the destination's digest,
|
|
5711
|
+
so a pushed view carries ``_has_rendered = True`` (content is
|
|
5712
|
+
genuinely on screen) alongside ``_last_tree_digest = None`` for its
|
|
5713
|
+
whole life until some later refresh happens to land. Gating the
|
|
5714
|
+
teardown skip on the digest alone would misread that combination as
|
|
5715
|
+
"never rendered" and drop a farewell card composed just before
|
|
5716
|
+
teardown -- the exact defect the digest comparison exists to catch.
|
|
5717
|
+
"""
|
|
5718
|
+
view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
|
|
5719
|
+
view.add_item(ActionRow(StatefulButton(label="Accept")))
|
|
5720
|
+
await view.send()
|
|
5721
|
+
message = self._wire(view)
|
|
5722
|
+
|
|
5723
|
+
# Simulate the state a navigation landing leaves behind: rendered,
|
|
5724
|
+
# but with no stamped digest.
|
|
5725
|
+
view._last_tree_digest = None
|
|
5726
|
+
view.clear_items()
|
|
5727
|
+
view.add_item(card("Challenge expired."))
|
|
5728
|
+
|
|
5729
|
+
await view.exit(delete_message=False)
|
|
5730
|
+
|
|
5731
|
+
message.edit.assert_awaited_once()
|
|
5732
|
+
|
|
5733
|
+
async def test_on_timeout_still_ships_a_rebuild_when_the_digest_was_never_stamped(self):
|
|
5734
|
+
view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
|
|
5735
|
+
view.add_item(ActionRow(StatefulButton(label="Accept")))
|
|
5736
|
+
await view.send()
|
|
5737
|
+
message = self._wire(view)
|
|
5738
|
+
|
|
5739
|
+
view._last_tree_digest = None
|
|
5740
|
+
view.clear_items()
|
|
5741
|
+
view.add_item(card("Challenge expired."))
|
|
5742
|
+
|
|
5743
|
+
await view.on_timeout()
|
|
5744
|
+
|
|
5745
|
+
message.edit.assert_awaited_once()
|
|
5746
|
+
|
|
5747
|
+
async def test_has_rendered_survives_a_raising_digest_after_send(self):
|
|
5748
|
+
"""``_has_rendered`` means "the send landed", stamped before the
|
|
5749
|
+
digest computation that follows it.
|
|
5750
|
+
|
|
5751
|
+
A user subclass's tree can raise inside ``_compute_tree_digest``
|
|
5752
|
+
(a property read on a live item) after the send already reached
|
|
5753
|
+
Discord. ``_send_pipeline`` swallows that raise and drops the
|
|
5754
|
+
digest to ``None``, but the flag must not depend on the digest
|
|
5755
|
+
computation succeeding -- otherwise a view Discord is genuinely
|
|
5756
|
+
showing would read as never rendered at its next teardown.
|
|
5757
|
+
"""
|
|
5758
|
+
view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
|
|
5759
|
+
|
|
5760
|
+
with patch.object(type(view), "_compute_tree_digest", side_effect=RuntimeError("boom")):
|
|
5761
|
+
await view.send()
|
|
5762
|
+
|
|
5763
|
+
assert view._has_rendered is True
|
|
5764
|
+
assert view._last_tree_digest is None
|
|
5765
|
+
|
|
5766
|
+
message = self._wire(view)
|
|
5767
|
+
view.clear_items()
|
|
5768
|
+
view.add_item(card("Challenge expired."))
|
|
5769
|
+
|
|
5770
|
+
await view.exit(delete_message=False)
|
|
5771
|
+
|
|
5772
|
+
message.edit.assert_awaited_once()
|
|
5773
|
+
|
|
5774
|
+
async def test_refresh_marks_the_view_as_rendered_without_a_prior_send(self):
|
|
5775
|
+
"""A view can land its first tree through ``refresh()`` rather than
|
|
5776
|
+
``send()`` -- a persistence-restored view's ``on_restore()`` calling
|
|
5777
|
+
``reload()``/``refresh()`` is the production shape this covers. The
|
|
5778
|
+
flag has to flip there too, or a teardown right after would read a
|
|
5779
|
+
genuinely rendered view as never rendered.
|
|
5780
|
+
"""
|
|
5781
|
+
view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
|
|
5782
|
+
message = self._wire(view)
|
|
5783
|
+
assert view._has_rendered is False
|
|
5784
|
+
|
|
5785
|
+
await view.refresh()
|
|
5786
|
+
|
|
5787
|
+
assert view._has_rendered is True
|
|
5788
|
+
|
|
5681
5789
|
|
|
5682
5790
|
class TestTimeoutWindowStillRenders:
|
|
5683
5791
|
"""A view inside its timeout window is intact, so its render has to ship.
|
|
@@ -1097,11 +1097,14 @@ class TestNavigationFastPath:
|
|
|
1097
1097
|
await root.send()
|
|
1098
1098
|
|
|
1099
1099
|
nav = _make_interaction(user_id=1, guild_id=100, is_done=False)
|
|
1100
|
-
await root.push(_Sub, interaction=nav)
|
|
1100
|
+
dest = await root.push(_Sub, interaction=nav)
|
|
1101
1101
|
|
|
1102
1102
|
# One round-trip: edit + ack together, no separate defer.
|
|
1103
1103
|
nav.response.edit_message.assert_awaited_once()
|
|
1104
1104
|
nav.response.defer.assert_not_called()
|
|
1105
|
+
# The fast-path landing is a real render: a teardown right after
|
|
1106
|
+
# must not read it as "never rendered" (see _freeze_edit_needed).
|
|
1107
|
+
assert dest._has_rendered is True
|
|
1105
1108
|
|
|
1106
1109
|
async def test_push_falls_back_to_deferred_edit_when_acked(self):
|
|
1107
1110
|
class _Root(StatefulView):
|
|
@@ -1119,10 +1122,11 @@ class TestNavigationFastPath:
|
|
|
1119
1122
|
nav.edit_original_response = AsyncMock(
|
|
1120
1123
|
return_value=MagicMock(id=777, channel=MagicMock(id=666))
|
|
1121
1124
|
)
|
|
1122
|
-
await root.push(_Sub, interaction=nav)
|
|
1125
|
+
dest = await root.push(_Sub, interaction=nav)
|
|
1123
1126
|
|
|
1124
1127
|
nav.response.edit_message.assert_not_called()
|
|
1125
1128
|
nav.edit_original_response.assert_awaited_once()
|
|
1129
|
+
assert dest._has_rendered is True
|
|
1126
1130
|
|
|
1127
1131
|
async def test_push_non_component_interaction_skips_fast_path(self):
|
|
1128
1132
|
"""A non-component interaction (e.g. a slash command) is ineligible for
|
|
@@ -1248,6 +1248,148 @@ class TestReattachOnBind:
|
|
|
1248
1248
|
assert captured["view"].db == "POOL"
|
|
1249
1249
|
|
|
1250
1250
|
|
|
1251
|
+
class TestReattachBaselineDigest:
|
|
1252
|
+
"""``_reattach_one`` stamps ``_reattach_baseline_digest`` with the tree a
|
|
1253
|
+
fresh ``__init__`` produced, so a teardown before ``on_restore`` ever
|
|
1254
|
+
renders can tell "untouched since reattach" apart from "a teardown
|
|
1255
|
+
override composed real content" -- the second case has to ship even
|
|
1256
|
+
though the view itself was never rendered.
|
|
1257
|
+
"""
|
|
1258
|
+
|
|
1259
|
+
def _stub_seams(self, cls):
|
|
1260
|
+
async def _fake_register(self):
|
|
1261
|
+
pass
|
|
1262
|
+
|
|
1263
|
+
async def _fake_update(self, message):
|
|
1264
|
+
pass
|
|
1265
|
+
|
|
1266
|
+
cls._register_state = _fake_register
|
|
1267
|
+
cls._update_message_state = _fake_update
|
|
1268
|
+
cls._validate_custom_ids = lambda self: None
|
|
1269
|
+
|
|
1270
|
+
async def _row(self, key, cls):
|
|
1271
|
+
be = InMemoryBackend()
|
|
1272
|
+
await be.initialize()
|
|
1273
|
+
await be.row_upsert(
|
|
1274
|
+
TABLE_PERSISTENT_VIEWS,
|
|
1275
|
+
{
|
|
1276
|
+
"persistence_key": key,
|
|
1277
|
+
"view_class": cls.__qualname__,
|
|
1278
|
+
"custom_id": None,
|
|
1279
|
+
"message_id": 1,
|
|
1280
|
+
"channel_id": 2,
|
|
1281
|
+
"guild_id": None,
|
|
1282
|
+
"user_id": None,
|
|
1283
|
+
"session_id": None,
|
|
1284
|
+
"init_kwargs": json.dumps({"persistence_key": key}),
|
|
1285
|
+
"kwargs_schema_version": 1,
|
|
1286
|
+
"schema_version": 1,
|
|
1287
|
+
"created_at": 1,
|
|
1288
|
+
"updated_at": 1,
|
|
1289
|
+
},
|
|
1290
|
+
["persistence_key"],
|
|
1291
|
+
)
|
|
1292
|
+
mgr = PersistenceManager(store=get_store(), registry=RegistryPersistence(backend=be))
|
|
1293
|
+
await mgr.rehydrate()
|
|
1294
|
+
return mgr
|
|
1295
|
+
|
|
1296
|
+
async def _reattach(self, cls, key):
|
|
1297
|
+
from cascadeui.views.persistent import PersistentLayoutView # noqa: F401
|
|
1298
|
+
|
|
1299
|
+
self._stub_seams(cls)
|
|
1300
|
+
mgr = await self._row(key, cls)
|
|
1301
|
+
|
|
1302
|
+
class _Msg:
|
|
1303
|
+
id = 1
|
|
1304
|
+
|
|
1305
|
+
async def edit(self, **kwargs):
|
|
1306
|
+
self.edited_with = kwargs
|
|
1307
|
+
|
|
1308
|
+
async def delete(self):
|
|
1309
|
+
self.deleted = True
|
|
1310
|
+
|
|
1311
|
+
message = _Msg()
|
|
1312
|
+
|
|
1313
|
+
class _FakeBot:
|
|
1314
|
+
def add_view(self, view, message_id):
|
|
1315
|
+
pass
|
|
1316
|
+
|
|
1317
|
+
mgr._bot = _FakeBot()
|
|
1318
|
+
restored_views = []
|
|
1319
|
+
outcome = await mgr._reattach_one(
|
|
1320
|
+
row=mgr._registry_rows[0],
|
|
1321
|
+
view_cls=cls,
|
|
1322
|
+
init_kwargs={"persistence_key": key},
|
|
1323
|
+
message=message,
|
|
1324
|
+
class_name=cls.__qualname__,
|
|
1325
|
+
restored_views=restored_views,
|
|
1326
|
+
)
|
|
1327
|
+
assert outcome == "restored"
|
|
1328
|
+
return restored_views[0], message
|
|
1329
|
+
|
|
1330
|
+
async def test_baseline_matches_a_fresh_non_empty_tree(self):
|
|
1331
|
+
"""The stamp is a fingerprint, not a sentinel: confirm it equals
|
|
1332
|
+
the tree __init__ actually built, on the exact view instance
|
|
1333
|
+
reattach constructed, and differs across two views whose trees
|
|
1334
|
+
differ. Every empty V2 tree hashes to the same constant, so an
|
|
1335
|
+
empty fixture here cannot tell a real computation from a
|
|
1336
|
+
hardcoded one. __init__ has to build something.
|
|
1337
|
+
"""
|
|
1338
|
+
from cascadeui.views.persistent import PersistentLayoutView
|
|
1339
|
+
|
|
1340
|
+
class _EmptyPanel(PersistentLayoutView):
|
|
1341
|
+
pass
|
|
1342
|
+
|
|
1343
|
+
class _BuiltPanel(PersistentLayoutView):
|
|
1344
|
+
def __init__(self, *args, **kwargs):
|
|
1345
|
+
super().__init__(*args, **kwargs)
|
|
1346
|
+
self.add_item(discord.ui.TextDisplay("Loading..."))
|
|
1347
|
+
|
|
1348
|
+
empty_view, _ = await self._reattach(_EmptyPanel, "baseline:empty")
|
|
1349
|
+
built_view, _ = await self._reattach(_BuiltPanel, "baseline:built")
|
|
1350
|
+
|
|
1351
|
+
assert built_view._reattach_baseline_digest is not None
|
|
1352
|
+
assert built_view._reattach_baseline_digest == built_view._compute_tree_digest()
|
|
1353
|
+
assert built_view._reattach_baseline_digest != empty_view._reattach_baseline_digest
|
|
1354
|
+
assert built_view._has_rendered is False
|
|
1355
|
+
|
|
1356
|
+
async def test_untouched_restored_view_skips_the_teardown_edit(self):
|
|
1357
|
+
"""Nothing has changed the tree since reattach, so there is
|
|
1358
|
+
nothing on screen for the teardown edit to correct -- the
|
|
1359
|
+
original defect this stamp exists to keep fixed."""
|
|
1360
|
+
from cascadeui.views.persistent import PersistentLayoutView
|
|
1361
|
+
|
|
1362
|
+
class _EmptyPanel(PersistentLayoutView):
|
|
1363
|
+
pass
|
|
1364
|
+
|
|
1365
|
+
view, message = await self._reattach(_EmptyPanel, "baseline:untouched")
|
|
1366
|
+
|
|
1367
|
+
await view.exit(delete_message=False)
|
|
1368
|
+
|
|
1369
|
+
assert not hasattr(message, "edited_with")
|
|
1370
|
+
|
|
1371
|
+
async def test_farewell_card_composed_before_teardown_still_ships(self):
|
|
1372
|
+
"""A teardown override that composes new content before delegating
|
|
1373
|
+
to ``super()`` runs whether or not ``on_restore`` ever rendered --
|
|
1374
|
+
its farewell card is not the empty tree the skip exists for, and
|
|
1375
|
+
has to reach the message even though ``_has_rendered`` is still
|
|
1376
|
+
False."""
|
|
1377
|
+
from cascadeui.components.patterns.v2 import card
|
|
1378
|
+
from cascadeui.views.persistent import PersistentLayoutView
|
|
1379
|
+
|
|
1380
|
+
class _FarewellPanel(PersistentLayoutView):
|
|
1381
|
+
async def exit(self, delete_message=None):
|
|
1382
|
+
self.clear_items()
|
|
1383
|
+
self.add_item(card("Session expired."))
|
|
1384
|
+
return await super().exit(delete_message=delete_message)
|
|
1385
|
+
|
|
1386
|
+
view, message = await self._reattach(_FarewellPanel, "baseline:farewell")
|
|
1387
|
+
|
|
1388
|
+
await view.exit(delete_message=False)
|
|
1389
|
+
|
|
1390
|
+
assert hasattr(message, "edited_with")
|
|
1391
|
+
|
|
1392
|
+
|
|
1251
1393
|
class TestSendTimeBind:
|
|
1252
1394
|
"""``_bind_from_context`` (the send-time seam) forwards ``on_bind`` when
|
|
1253
1395
|
the construction context resolves a bot, and ``send()`` invokes it."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|