Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/textual/widgets/_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ def press(self) -> Self:
self._start_active_affect()
# ...and let other components know that we've just been clicked:
if self.action is None:
self.post_message(Button.Pressed(self))
self.post_message(self.Pressed(self))
else:
self.call_later(
self.app.run_action, self.action, default_namespace=self._parent
Expand Down
145 changes: 145 additions & 0 deletions tests/test_event_handler_dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Regression tests for event handler dispatch with specialized message types.

Verifies the three acceptance criteria:
1. @on(SpecializedButton.Pressed) fires only when a SpecializedButton is pressed.
2. @on(Button.Pressed) fires for both regular and specialized buttons (polymorphism).
3. Specialized-button handlers do NOT fire when a plain Button.Pressed is dispatched.
"""
from __future__ import annotations

from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Button


class SpecializedButton(Button):
"""A button subclass that defines its own Pressed message type."""

class Pressed(Button.Pressed):
"""Pressed message for SpecializedButton."""


class _ButtonApp(App):
"""Base app with both button types for dispatch tests."""

def compose(self) -> ComposeResult:
yield Button("Plain", id="plain")
yield SpecializedButton("Special", id="special")


# ---------------------------------------------------------------------------
# AC-1 & AC-3: decorator-based handlers
# ---------------------------------------------------------------------------


async def test_specialized_handler_fires_only_for_specialized_button() -> None:
"""@on(SpecializedButton.Pressed) must not fire when a plain Button is pressed."""
fired: list[str] = []

class MyApp(_ButtonApp):
@on(SpecializedButton.Pressed)
def on_specialized(self, event: SpecializedButton.Pressed) -> None:
fired.append(f"specialized:{type(event).__name__}")

async with MyApp().run_test() as pilot:
await pilot.click("#plain")
await pilot.pause()

assert fired == [], f"Expected no fires but got: {fired}"


async def test_specialized_handler_fires_for_specialized_button() -> None:
"""@on(SpecializedButton.Pressed) must fire when a SpecializedButton is pressed."""
fired: list[str] = []

class MyApp(_ButtonApp):
@on(SpecializedButton.Pressed)
def on_specialized(self, event: SpecializedButton.Pressed) -> None:
fired.append(f"specialized:{type(event).__name__}")

async with MyApp().run_test() as pilot:
await pilot.click("#special")
await pilot.pause()

assert fired == ["specialized:Pressed"]


# ---------------------------------------------------------------------------
# AC-2: @on(Button.Pressed) fires for BOTH button types
# ---------------------------------------------------------------------------


async def test_parent_handler_fires_for_both_buttons() -> None:
"""@on(Button.Pressed) must fire for both plain and specialized buttons."""
fired: list[str] = []

class MyApp(_ButtonApp):
@on(Button.Pressed)
def on_any_button(self, event: Button.Pressed) -> None:
fired.append(event.button.id or "?")

async with MyApp().run_test() as pilot:
await pilot.click("#plain")
await pilot.click("#special")
await pilot.pause()

assert fired == ["plain", "special"]


# ---------------------------------------------------------------------------
# AC-1 & AC-2 combined: both handler types present simultaneously
# ---------------------------------------------------------------------------


async def test_both_handlers_with_specialized_button() -> None:
"""When both handlers exist, only Button.Pressed fires for the plain button,
and both fire for the specialized button."""
fired: list[str] = []

class MyApp(_ButtonApp):
@on(Button.Pressed)
def on_any_button(self, event: Button.Pressed) -> None:
fired.append(f"parent:{event.button.id}")

@on(SpecializedButton.Pressed)
def on_specialized(self, event: SpecializedButton.Pressed) -> None:
fired.append(f"specialized:{event.button.id}")

async with MyApp().run_test() as pilot:
await pilot.click("#plain")
await pilot.pause()
plain_fires = list(fired)
fired.clear()

await pilot.click("#special")
await pilot.pause()
special_fires = list(fired)

# Plain button: only parent handler fires
assert plain_fires == ["parent:plain"], f"plain: {plain_fires}"

# Specialized button: both parent and specialized handler fire
assert "parent:special" in special_fires, f"special: {special_fires}"
assert "specialized:special" in special_fires, f"special: {special_fires}"


# ---------------------------------------------------------------------------
# AC-3 regression: naming-convention specialized handler is exact-type only
# ---------------------------------------------------------------------------


async def test_naming_convention_specialized_handler_not_for_plain() -> None:
"""on_specialized_button_pressed must not fire when a plain Button is pressed."""
fired: list[str] = []

class MyApp(_ButtonApp):
def on_specialized_button_pressed(
self, event: SpecializedButton.Pressed
) -> None:
fired.append("specialized")

async with MyApp().run_test() as pilot:
await pilot.click("#plain")
await pilot.pause()

assert fired == []