pycascadeui 3.3.3__tar.gz → 3.3.4__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.
Files changed (135) hide show
  1. {pycascadeui-3.3.3/pycascadeui.egg-info → pycascadeui-3.3.4}/PKG-INFO +1 -1
  2. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/__init__.py +1 -1
  3. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/_navigation.py +33 -0
  4. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/base.py +12 -0
  5. {pycascadeui-3.3.3 → pycascadeui-3.3.4/pycascadeui.egg-info}/PKG-INFO +1 -1
  6. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/pyproject.toml +1 -1
  7. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_layout_view.py +70 -1
  8. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_navigation.py +63 -0
  9. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/LICENSE +0 -0
  10. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/README.md +0 -0
  11. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/__main__.py +0 -0
  12. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/__init__.py +0 -0
  13. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/base.py +0 -0
  14. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/buttons.py +0 -0
  15. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/inputs.py +0 -0
  16. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/patterns/__init__.py +0 -0
  17. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/patterns/v1.py +0 -0
  18. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/patterns/v2.py +0 -0
  19. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/selects.py +0 -0
  20. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/types.py +0 -0
  21. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/v1_composition.py +0 -0
  22. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/components/wrappers.py +0 -0
  23. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/devtools.py +0 -0
  24. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/exceptions.py +0 -0
  25. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/__init__.py +0 -0
  26. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/backends/__init__.py +0 -0
  27. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/backends/memory.py +0 -0
  28. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/backends/postgres.py +0 -0
  29. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/backends/sqlite.py +0 -0
  30. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/config.py +0 -0
  31. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/manager.py +0 -0
  32. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/migrations.py +0 -0
  33. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/protocols.py +0 -0
  34. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/schema.py +0 -0
  35. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/persistence/schema_postgres.py +0 -0
  36. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/py.typed +0 -0
  37. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/setup.py +0 -0
  38. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/__init__.py +0 -0
  39. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/actions.py +0 -0
  40. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/computed.py +0 -0
  41. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/middleware/__init__.py +0 -0
  42. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/middleware/logging.py +0 -0
  43. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/middleware/persistence.py +0 -0
  44. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/middleware/undo.py +0 -0
  45. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/reducers.py +0 -0
  46. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/singleton.py +0 -0
  47. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/slots.py +0 -0
  48. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/store.py +0 -0
  49. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/state/types.py +0 -0
  50. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/theming/__init__.py +0 -0
  51. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/theming/context.py +0 -0
  52. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/theming/core.py +0 -0
  53. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/theming/themes.py +0 -0
  54. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/tracing.py +0 -0
  55. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/utils/__init__.py +0 -0
  56. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/utils/coercion.py +0 -0
  57. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/utils/decorators.py +0 -0
  58. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/utils/errors.py +0 -0
  59. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/utils/fetch.py +0 -0
  60. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/utils/logging.py +0 -0
  61. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/utils/strings.py +0 -0
  62. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/utils/tasks.py +0 -0
  63. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/validation.py +0 -0
  64. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/__init__.py +0 -0
  65. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/_interaction.py +0 -0
  66. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/_placement.py +0 -0
  67. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/layout.py +0 -0
  68. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/__init__.py +0 -0
  69. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/form.py +0 -0
  70. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/leaderboard.py +0 -0
  71. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/menu.py +0 -0
  72. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/paginated.py +0 -0
  73. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/roles.py +0 -0
  74. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/tabs.py +0 -0
  75. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/types.py +0 -0
  76. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/patterns/wizard.py +0 -0
  77. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/persistent.py +0 -0
  78. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/cascadeui/views/view.py +0 -0
  79. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/pycascadeui.egg-info/SOURCES.txt +0 -0
  80. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/pycascadeui.egg-info/dependency_links.txt +0 -0
  81. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/pycascadeui.egg-info/requires.txt +0 -0
  82. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/pycascadeui.egg-info/top_level.txt +0 -0
  83. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/setup.cfg +0 -0
  84. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_auto_defer.py +0 -0
  85. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_backends.py +0 -0
  86. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_backends_postgres.py +0 -0
  87. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_backends_raw_sql.py +0 -0
  88. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_batching.py +0 -0
  89. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_button_grid.py +0 -0
  90. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_components.py +0 -0
  91. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_computed.py +0 -0
  92. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_devtools.py +0 -0
  93. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_dynamic_persistent_button.py +0 -0
  94. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_emoji_grid.py +0 -0
  95. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_ephemeral_refresh.py +0 -0
  96. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_fetch.py +0 -0
  97. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_five_pillars.py +0 -0
  98. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_form_patterns.py +0 -0
  99. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_hooks.py +0 -0
  100. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_input_coercion.py +0 -0
  101. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_inputs.py +0 -0
  102. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_instance_limit.py +0 -0
  103. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_layout_pagination.py +0 -0
  104. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_layout_patterns.py +0 -0
  105. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_layout_persistent.py +0 -0
  106. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_leaderboard_patterns.py +0 -0
  107. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_menu_patterns.py +0 -0
  108. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_message_cleanup.py +0 -0
  109. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_middleware.py +0 -0
  110. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_nav_version.py +0 -0
  111. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_navigation_artifact_restore.py +0 -0
  112. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_owner_only.py +0 -0
  113. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_paginated_patterns.py +0 -0
  114. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_pagination.py +0 -0
  115. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_persistence.py +0 -0
  116. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_persistence_middleware.py +0 -0
  117. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_persistent_views.py +0 -0
  118. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_pillar_isolation.py +0 -0
  119. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_placement.py +0 -0
  120. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_rebuild_callback.py +0 -0
  121. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_reducers.py +0 -0
  122. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_roles_patterns.py +0 -0
  123. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_scoping.py +0 -0
  124. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_selects.py +0 -0
  125. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_state_slots.py +0 -0
  126. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_state_store.py +0 -0
  127. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_tab_patterns.py +0 -0
  128. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_theming.py +0 -0
  129. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_undo.py +0 -0
  130. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_update_coalescing.py +0 -0
  131. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_v2_helpers.py +0 -0
  132. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_validation.py +0 -0
  133. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_view_init.py +0 -0
  134. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_wizard_patterns.py +0 -0
  135. {pycascadeui-3.3.3 → pycascadeui-3.3.4}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.3.3
3
+ Version: 3.3.4
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
@@ -147,7 +147,7 @@ if "SQLiteBackend" in _persistence_all:
147
147
  # // ========================================( Script )======================================== // #
148
148
 
149
149
 
150
- __version__ = "3.3.3"
150
+ __version__ = "3.3.4"
151
151
 
152
152
  # Export public API
153
153
  __all__ = [
@@ -395,6 +395,39 @@ class _NavigationMixin:
395
395
  # populates the tree post-init.
396
396
  new_view._check_placement()
397
397
 
398
+ # Push/pop reuse the parent's Discord message, carried onto
399
+ # new_view._message in the navigation batch above. That message --
400
+ # not the acting interaction's -- is the navigation edit target.
401
+ target_message = new_view._message or self._message
402
+
403
+ # The acting interaction normally IS a click on target_message. A
404
+ # with_confirmation wrapper, though, runs the original callback with
405
+ # the confirm-button interaction, whose message is the ephemeral
406
+ # prompt -- a different message. Both interaction-coupled paths below
407
+ # (response.edit_message and edit_original_response) would then render
408
+ # the new view onto the prompt and leave target_message stale. When
409
+ # the interaction carries a message that differs from the view's,
410
+ # edit target_message directly through the channel endpoint instead.
411
+ # Interactions with no message (slash commands) keep the
412
+ # edit_original_response path -- their original response IS the view's
413
+ # message.
414
+ interaction_msg_id = getattr(getattr(current_interaction, "message", None), "id", None)
415
+ if (
416
+ target_message is not None
417
+ and interaction_msg_id is not None
418
+ and interaction_msg_id != target_message.id
419
+ ):
420
+ # Ack the foreign interaction so the click does not error
421
+ # (_safe_defer no-ops when a wrapper already consumed the slot),
422
+ # then edit the view's own message. refresh() carries the
423
+ # cooldown/backoff/digest machinery and its own acting-fast-path
424
+ # gate re-confirms the message match, so it cannot misfire here.
425
+ await self._safe_defer(current_interaction)
426
+ if new_view._message is None:
427
+ new_view._message = target_message
428
+ await new_view.refresh(**edit_kwargs)
429
+ return
430
+
398
431
  # Fast path: a component/modal interaction whose response slot is still
399
432
  # open edits + acks in one request -- no separate defer round-trip, which
400
433
  # was the source of the navigation pause. Bounded at the ack deadline via
@@ -1452,6 +1452,17 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1452
1452
  parts.append(("t", item.content))
1453
1453
  # Buttons and selects: record the wire-visible attributes.
1454
1454
  elif hasattr(item, "custom_id"):
1455
+ # A select's rendered selection lives in opt.default, which
1456
+ # none of the scalar attributes above capture. set_selected()
1457
+ # mutates exactly that field, so a selection-only rebuild
1458
+ # would otherwise hash identical and refresh() would drop the
1459
+ # re-render. Walk options (string selects only -- auto-populated
1460
+ # UserSelect/RoleSelect/etc. expose no .options, so the getattr
1461
+ # returns None for them and for buttons).
1462
+ options = getattr(item, "options", None)
1463
+ option_state = (
1464
+ tuple((opt.value, opt.default) for opt in options) if options else None
1465
+ )
1455
1466
  parts.append(
1456
1467
  (
1457
1468
  "i",
@@ -1463,6 +1474,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1463
1474
  getattr(item, "url", None),
1464
1475
  getattr(item, "placeholder", None),
1465
1476
  str(getattr(item, "emoji", None)) if getattr(item, "emoji", None) else None,
1477
+ option_state,
1466
1478
  )
1467
1479
  )
1468
1480
  # Other layout items (Separator, MediaGallery, Thumbnail) do
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.3.3
3
+ Version: 3.3.4
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pycascadeui"
7
- version = "3.3.3"
7
+ version = "3.3.4"
8
8
  description = "Redux-inspired UI framework for discord.py"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -10,7 +10,7 @@ import pytest
10
10
  from discord.ui import ActionRow, LayoutView, TextDisplay
11
11
  from helpers import make_interaction as _make_interaction
12
12
 
13
- from cascadeui.components.base import StatefulButton
13
+ from cascadeui.components.base import StatefulButton, StatefulSelect
14
14
  from cascadeui.state.singleton import get_store
15
15
  from cascadeui.state.store import _CURRENT_INTERACTION
16
16
  from cascadeui.views.base import _StatefulMixin, _view_class_registry
@@ -764,6 +764,75 @@ class TestRenderHashShortCircuit:
764
764
  after = view._compute_tree_digest()
765
765
  assert before != after
766
766
 
767
+ def test_digest_reflects_select_selection(self):
768
+ """A select's rendered selection lives in opt.default, which the
769
+ scalar wire attributes (placeholder/disabled/...) do not capture.
770
+ A selection-only rebuild must change the digest, or refresh()
771
+ short-circuits and the re-render is silently dropped -- the client
772
+ keeps the stale selection and the next interaction submits stale
773
+ values.
774
+ """
775
+ selected = ["a"]
776
+
777
+ def build(view):
778
+ view.clear_items()
779
+ view.add_item(
780
+ ActionRow(
781
+ StatefulSelect(
782
+ placeholder="Pick",
783
+ options=[
784
+ discord.SelectOption(
785
+ label="A", value="a", default=(selected[0] == "a")
786
+ ),
787
+ discord.SelectOption(
788
+ label="B", value="b", default=(selected[0] == "b")
789
+ ),
790
+ ],
791
+ min_values=0,
792
+ max_values=1,
793
+ )
794
+ )
795
+ )
796
+
797
+ view = self._make_view_with_build(build)
798
+ view.build_ui()
799
+ before = view._compute_tree_digest()
800
+
801
+ selected[0] = "b"
802
+ view.build_ui()
803
+ after = view._compute_tree_digest()
804
+ assert before != after
805
+
806
+ def test_digest_reflects_set_selected(self):
807
+ """set_selected() is the canonical selection-mutation API; it
808
+ rewrites opt.default in place without rebuilding the component.
809
+ The digest must reflect the change so the same select object
810
+ flipped to a new selection re-renders.
811
+ """
812
+ select = StatefulSelect(
813
+ placeholder="Pick",
814
+ options=[
815
+ discord.SelectOption(label="A", value="a"),
816
+ discord.SelectOption(label="B", value="b"),
817
+ ],
818
+ min_values=0,
819
+ max_values=1,
820
+ )
821
+
822
+ def build(view):
823
+ view.clear_items()
824
+ view.add_item(ActionRow(select))
825
+
826
+ view = self._make_view_with_build(build)
827
+ select.set_selected("a")
828
+ view.build_ui()
829
+ before = view._compute_tree_digest()
830
+
831
+ select.set_selected("b")
832
+ view.build_ui()
833
+ after = view._compute_tree_digest()
834
+ assert before != after
835
+
767
836
  async def test_refresh_skips_edit_when_digest_matches(self):
768
837
  def build(view):
769
838
  view.clear_items()
@@ -538,6 +538,69 @@ class TestNavigationFastPath:
538
538
  nav.edit_original_response.assert_awaited_once()
539
539
 
540
540
 
541
+ class TestNavigationForeignInteraction:
542
+ """A with_confirmation wrapper runs the navigation callback with the
543
+ confirm-button interaction, whose message is the ephemeral prompt --
544
+ a different message than the view's dashboard. Navigation must edit the
545
+ view's own message via the channel endpoint, not the foreign prompt."""
546
+
547
+ async def test_push_with_foreign_interaction_edits_view_message(self):
548
+ class _Root(StatefulView):
549
+ pass
550
+
551
+ class _Sub(StatefulView):
552
+ async def on_state_changed(self, state):
553
+ pass
554
+
555
+ root = _Root(interaction=_make_interaction(user_id=1, guild_id=100))
556
+ await root.send()
557
+ # Pin a concrete dashboard message id for the foreign comparison.
558
+ dashboard = MagicMock(id=555, channel=MagicMock(id=2))
559
+ dashboard.edit = AsyncMock()
560
+ root._message = dashboard
561
+
562
+ # Confirm-button click on the ephemeral prompt (different message id),
563
+ # slot already consumed by the prompt edit -- the with_confirmation shape.
564
+ prompt = MagicMock(id=999)
565
+ confirm = _make_interaction(user_id=1, guild_id=100, is_done=True, message=prompt)
566
+ confirm.edit_original_response = AsyncMock()
567
+
568
+ sub = await root.push(_Sub, interaction=confirm)
569
+
570
+ # The view's own message is edited; the foreign prompt is untouched.
571
+ dashboard.edit.assert_awaited()
572
+ confirm.edit_original_response.assert_not_called()
573
+ confirm.response.edit_message.assert_not_called()
574
+ assert sub._message is dashboard
575
+
576
+ async def test_pop_with_foreign_interaction_edits_view_message(self):
577
+ class _Root(StatefulView):
578
+ pass
579
+
580
+ class _Sub(StatefulView):
581
+ async def on_state_changed(self, state):
582
+ pass
583
+
584
+ root = _Root(interaction=_make_interaction(user_id=1, guild_id=100))
585
+ await root.send()
586
+ dashboard = MagicMock(id=555, channel=MagicMock(id=2))
587
+ dashboard.edit = AsyncMock()
588
+ root._message = dashboard
589
+
590
+ sub = await root.push(_Sub)
591
+
592
+ prompt = MagicMock(id=999)
593
+ confirm = _make_interaction(user_id=1, guild_id=100, is_done=True, message=prompt)
594
+ confirm.edit_original_response = AsyncMock()
595
+
596
+ restored = await sub.pop(confirm)
597
+
598
+ assert restored is not None
599
+ dashboard.edit.assert_awaited()
600
+ confirm.edit_original_response.assert_not_called()
601
+ confirm.response.edit_message.assert_not_called()
602
+
603
+
541
604
  class TestNavigationUndoTransfer:
542
605
  """Push/pop forward-transfer the undo/redo stacks to the new view's state
543
606
  row so the undo timeline stays continuous across navigation. The transfer
File without changes
File without changes
File without changes