"""
Validation tests for the MockScreen / TestApplication test harness.
"""

import curses
import time
import pytest

from pyos.testing import MockScreen, HarnessApplication
from pyos.Activity import Activity
from pyos.EventTypes import KeyStroke, ScrollChange, TextBoxSubmit, TextBoxChange
from pyos.input_handlers import handle_scroll_list_input, handle_text_box_input
from pyos.printers.TopBar import TopBar
from pyos.printers.BottomBar import BottomBar
from pyos.printers.ScrollList import ScrollList
from pyos.printers.TextInput import TextInput


# ---------------------------------------------------------------------------
# Test subject: a minimal Activity with TopBar + ScrollList + BottomBar
# ---------------------------------------------------------------------------

class SimpleListActivity(Activity):
    def __init__(self, items=None):
        super().__init__()
        self.items = items or ["Alpha", "Bravo", "Charlie", "Delta", "Echo"]

    def on_start(self):
        self.display_state = {
            "top_bar": TopBar.display_state(items={"title": "Test App"}),
            "scroll_list": ScrollList.display_state(
                screen=self.screen,
                items=self.items,
                selected_index=0,
                focused=True,
            ),
            "bottom_bar": BottomBar.display_state(items={"status": "Ready"}),
        }
        self.application.subscribe(KeyStroke, self, self.on_key)

    def on_key(self, event):
        if event.key == 27:  # ESC
            self.application.pop_activity()
            return
        handle_scroll_list_input("scroll_list", self.display_state["scroll_list"], event, self.event_queue)
        self.refresh_screen()


class SimpleTextInputActivity(Activity):
    def __init__(self):
        super().__init__()

    def on_start(self):
        self.display_state = {
            "text_input": TextInput.display_state(label="Name", text="", focused=True),
        }
        self.application.subscribe(KeyStroke, self, self.on_key)

    def on_key(self, event):
        handle_text_box_input("text_input", self.display_state["text_input"], event, self.event_queue)
        self.refresh_screen()


# ===========================================================================
# MockScreen tests
# ===========================================================================

class TestMockScreen:
    def test_addstr_writes_to_buffer(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "Hello", curses.A_BOLD)
        assert screen.get_row_text(0) == "Hello"

    def test_addstr_with_offset(self):
        screen = MockScreen(10, 40)
        screen.addstr(2, 5, "World")
        assert "World" in screen.get_row_text(2)
        # Characters before offset remain spaces
        assert screen.buffer[2][4] == " "

    def test_addstr_truncates_at_edge(self):
        screen = MockScreen(5, 10)
        screen.addstr(0, 7, "ABCDEF")
        row = "".join(screen.buffer[0])
        assert row == "       ABC"

    def test_clear_resets_buffer(self):
        screen = MockScreen(5, 10)
        screen.addstr(0, 0, "data")
        screen.clear()
        assert screen.get_row_text(0) == ""

    def test_find_text(self):
        screen = MockScreen(10, 40)
        screen.addstr(3, 0, "needle in a haystack")
        assert screen.find_text("needle")
        assert not screen.find_text("missing")

    def test_attr_buffer(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "Bold", curses.A_BOLD)
        assert screen.get_attr_at(0, 0) == curses.A_BOLD
        assert screen.get_attr_at(0, 3) == curses.A_BOLD
        # Unwritten cells have attr 0
        assert screen.get_attr_at(0, 10) == 0

    def test_clrtoeol(self):
        screen = MockScreen(5, 20)
        screen.addstr(0, 0, "Hello World")
        screen.move(0, 5)
        screen.clrtoeol()
        assert screen.get_row_text(0) == "Hello"

    def test_refresh_counter(self):
        screen = MockScreen()
        assert screen.refresh_count == 0
        screen.refresh()
        screen.refresh()
        assert screen.refresh_count == 2

    def test_assert_text_on_screen(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "expected text")
        screen.assert_text_on_screen("expected")
        with pytest.raises(AssertionError):
            screen.assert_text_on_screen("not here")

    def test_assert_text_not_on_screen(self):
        screen = MockScreen(10, 40)
        screen.assert_text_not_on_screen("ghost")
        screen.addstr(0, 0, "ghost")
        with pytest.raises(AssertionError):
            screen.assert_text_not_on_screen("ghost")


# ===========================================================================
# MockScreen compound helper tests
# ===========================================================================

class TestMockScreenCompound:
    def test_find_text_at_returns_position(self):
        screen = MockScreen(10, 40)
        screen.addstr(3, 5, "target")
        assert screen.find_text_at("target") == (3, 5)

    def test_find_text_at_returns_none_when_missing(self):
        screen = MockScreen(10, 40)
        assert screen.find_text_at("missing") is None

    def test_find_text_at_returns_first_occurrence(self):
        screen = MockScreen(10, 40)
        screen.addstr(1, 0, "dup")
        screen.addstr(5, 0, "dup")
        assert screen.find_text_at("dup") == (1, 0)

    def test_assert_text_has_attr_passes(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "Bold", curses.A_BOLD)
        screen.assert_text_has_attr("Bold", curses.A_BOLD)

    def test_assert_text_has_attr_fails_missing_text(self):
        screen = MockScreen(10, 40)
        with pytest.raises(AssertionError, match="Expected to find"):
            screen.assert_text_has_attr("missing", curses.A_BOLD)

    def test_assert_text_has_attr_fails_wrong_attr(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "Normal", 0)
        with pytest.raises(AssertionError, match="has attr"):
            screen.assert_text_has_attr("Normal", curses.A_BOLD)

    def test_assert_text_not_has_attr_passes(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "Normal", 0)
        screen.assert_text_not_has_attr("Normal", curses.A_BOLD)

    def test_assert_text_not_has_attr_fails(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "Bold", curses.A_BOLD)
        with pytest.raises(AssertionError, match="NOT to have"):
            screen.assert_text_not_has_attr("Bold", curses.A_BOLD)

    def test_get_text_with_attr(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "normal", 0)
        screen.addstr(1, 0, "bold1", curses.A_BOLD)
        screen.addstr(1, 10, "bold2", curses.A_BOLD)
        screen.addstr(2, 0, "also normal", 0)
        segments = screen.get_text_with_attr(curses.A_BOLD)
        assert segments == ["bold1", "bold2"]

    def test_get_text_with_attr_empty(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "normal", 0)
        assert screen.get_text_with_attr(curses.A_BOLD) == []


# ===========================================================================
# Activity rendering tests
# ===========================================================================

class TestActivityRendering:
    def test_simple_activity_renders(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)

        mock_screen.assert_text_on_screen("Test App")
        mock_screen.assert_text_on_screen("Alpha")
        mock_screen.assert_text_on_screen("Ready")

    def test_scroll_down_changes_selection(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)

        # Initially "Alpha" is selected (highlighted)
        mock_screen.assert_text_has_attr("Alpha", curses.A_REVERSE)

        # Press KEY_DOWN — selection moves to "Bravo"
        app.send_key(curses.KEY_DOWN)

        mock_screen.assert_text_has_attr("Bravo", curses.A_REVERSE)
        mock_screen.assert_text_not_has_attr("Alpha", curses.A_REVERSE)

    def test_multiple_scrolls(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)

        app.send_keys(curses.KEY_DOWN, curses.KEY_DOWN, curses.KEY_DOWN)

        mock_screen.assert_text_has_attr("Delta", curses.A_REVERSE)

    def test_esc_pops_activity(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)
        assert app.current_activity() is activity

        app.send_key(27)  # ESC
        app.drain()

        assert app.current_activity() is None

    def test_stacked_activities(self, app, mock_screen):
        first = SimpleListActivity(items=["First"])
        second = SimpleListActivity(items=["Second"])

        app.start_activity(first)
        mock_screen.assert_text_on_screen("First")

        app.start_activity(second)
        mock_screen.assert_text_on_screen("Second")

        assert app.current_activity() is second

        # Pop second — first resumes
        app.send_key(27)  # ESC
        app.drain()

        assert app.current_activity() is first
        mock_screen.assert_text_on_screen("First")


# ===========================================================================
# TextInput tests
# ===========================================================================

class TestTextInput:
    def test_type_text(self, app, mock_screen):
        activity = SimpleTextInputActivity()
        app.start_activity(activity)

        mock_screen.assert_text_on_screen("Name")

        app.send_text("hello")
        mock_screen.assert_text_on_screen("hello")
        assert app.input_text("text_input") == "hello"

    def test_backspace(self, app, mock_screen):
        activity = SimpleTextInputActivity()
        app.start_activity(activity)

        app.send_text("abc")
        mock_screen.assert_text_on_screen("abc")

        app.send_key(127)  # backspace
        mock_screen.assert_text_not_on_screen("abc")
        mock_screen.assert_text_on_screen("ab")
        assert app.input_text("text_input") == "ab"

    def test_text_state_updated(self, app, mock_screen):
        activity = SimpleTextInputActivity()
        app.start_activity(activity)

        app.send_text("test")
        assert app.input_text("text_input") == "test"


# ===========================================================================
# Display-state shortcut tests
# ===========================================================================

class TestDisplayStateShortcuts:
    def test_state_returns_context(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)

        ctx = app.state("scroll_list")
        assert "items" in ctx
        assert "selected_index" in ctx

    def test_selected_item(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)

        assert app.selected_item("scroll_list") == "Alpha"
        app.send_key(curses.KEY_DOWN)
        assert app.selected_item("scroll_list") == "Bravo"

    def test_input_text_shortcut(self, app, mock_screen):
        activity = SimpleTextInputActivity()
        app.start_activity(activity)

        assert app.input_text("text_input") == ""
        app.send_text("hi")
        assert app.input_text("text_input") == "hi"


# ===========================================================================
# wait_for tests
# ===========================================================================

class TestWaitFor:
    def test_wait_for_text_immediate(self, app, mock_screen):
        """Text already on screen — returns immediately."""
        activity = SimpleListActivity()
        app.start_activity(activity)
        app.wait_for_text("Alpha", timeout=1.0)

    def test_wait_for_text_after_delay(self, app, mock_screen):
        """Background thread posts update after a short delay."""
        activity = SimpleTextInputActivity()
        app.start_activity(activity)

        def delayed_type():
            time.sleep(0.1)
            app.main_thread.submit_async(lambda: _type_and_refresh())

        def _type_and_refresh():
            ctx = activity.display_state["text_input"]
            ctx["text"] = "delayed"
            ctx["cursor_position"] = len("delayed")
            activity.refresh_screen()

        app.background_thread.submit_async(delayed_type)
        app.wait_for_text("delayed", timeout=2.0)

    def test_wait_for_text_timeout(self, app, mock_screen):
        """Times out when text never appears."""
        activity = SimpleListActivity()
        app.start_activity(activity)
        with pytest.raises(TimeoutError, match="never_here"):
            app.wait_for_text("never_here", timeout=0.15)

    def test_wait_for_predicate(self, app, mock_screen):
        """Generic wait_for with a predicate."""
        activity = SimpleListActivity()
        app.start_activity(activity)

        def delayed_scroll():
            time.sleep(0.1)
            app.main_thread.submit_async(lambda: _scroll())

        def _scroll():
            ctx = activity.display_state["scroll_list"]
            ctx["selected_index"] = 2
            activity.refresh_screen()

        app.background_thread.submit_async(delayed_scroll)
        app.wait_for(lambda: app.selected_item("scroll_list") == "Charlie", timeout=2.0)

    def test_wait_for_timeout(self, app, mock_screen):
        """Generic wait_for times out when predicate stays falsy."""
        activity = SimpleListActivity()
        app.start_activity(activity)
        with pytest.raises(TimeoutError):
            app.wait_for(lambda: False, timeout=0.15)


# ===========================================================================
# Bitmask attr comparison tests
# ===========================================================================

class TestMockScreenBitmask:
    def test_row_has_attr_combined(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "combo", curses.A_BOLD | curses.A_REVERSE)
        assert screen.row_has_attr(0, curses.A_BOLD)
        assert screen.row_has_attr(0, curses.A_REVERSE)
        assert screen.row_has_attr(0, curses.A_BOLD | curses.A_REVERSE)

    def test_row_has_attr_missing(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "bold", curses.A_BOLD)
        assert not screen.row_has_attr(0, curses.A_REVERSE)

    def test_assert_text_has_attr_combined(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "combo", curses.A_BOLD | curses.A_REVERSE)
        screen.assert_text_has_attr("combo", curses.A_BOLD)
        screen.assert_text_has_attr("combo", curses.A_REVERSE)
        screen.assert_text_has_attr("combo", curses.A_BOLD | curses.A_REVERSE)

    def test_assert_text_has_attr_combined_fails(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "bold", curses.A_BOLD)
        with pytest.raises(AssertionError):
            screen.assert_text_has_attr("bold", curses.A_BOLD | curses.A_REVERSE)

    def test_assert_text_not_has_attr_combined(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "bold", curses.A_BOLD)
        screen.assert_text_not_has_attr("bold", curses.A_REVERSE)

    def test_assert_text_not_has_attr_combined_fails(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "combo", curses.A_BOLD | curses.A_REVERSE)
        with pytest.raises(AssertionError):
            screen.assert_text_not_has_attr("combo", curses.A_BOLD)

    def test_get_text_with_attr_combined(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "both", curses.A_BOLD | curses.A_REVERSE)
        screen.addstr(1, 0, "just_bold", curses.A_BOLD)
        # Searching for A_BOLD matches both lines
        segments = screen.get_text_with_attr(curses.A_BOLD)
        assert "both" in segments
        assert "just_bold" in segments

    def test_get_text_with_attr_combined_exact(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "both", curses.A_BOLD | curses.A_REVERSE)
        screen.addstr(1, 0, "just_bold", curses.A_BOLD)
        # Searching for combined only matches the combined line
        segments = screen.get_text_with_attr(curses.A_BOLD | curses.A_REVERSE)
        assert "both" in segments
        assert "just_bold" not in segments


# ===========================================================================
# count_text tests
# ===========================================================================

class TestMockScreenCountText:
    def test_single_occurrence(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "hello world")
        assert screen.count_text("hello") == 1

    def test_multiple_occurrences_different_rows(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "item")
        screen.addstr(1, 0, "item")
        screen.addstr(2, 0, "item")
        assert screen.count_text("item") == 3

    def test_multiple_occurrences_same_row(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "ab ab ab")
        assert screen.count_text("ab") == 3

    def test_zero_occurrences(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "hello")
        assert screen.count_text("missing") == 0

    def test_empty_screen(self):
        screen = MockScreen(5, 10)
        assert screen.count_text("anything") == 0


# ===========================================================================
# resize tests
# ===========================================================================

class TestMockScreenResize:
    def test_grow(self):
        screen = MockScreen(5, 10)
        screen.addstr(0, 0, "hello")
        screen.resize(10, 20)
        assert screen.rows == 10
        assert screen.cols == 20
        assert screen.getmaxyx() == (10, 20)
        assert screen.get_row_text(0) == "hello"

    def test_shrink(self):
        screen = MockScreen(10, 40)
        screen.addstr(0, 0, "long text here")
        screen.resize(5, 8)
        assert screen.rows == 5
        assert screen.cols == 8
        assert screen.get_row_text(0) == "long tex"

    def test_cursor_clamped(self):
        screen = MockScreen(10, 40)
        screen.move(8, 35)
        screen.resize(5, 10)
        assert screen.cursor_y == 4
        assert screen.cursor_x == 9

    def test_cursor_preserved_when_fits(self):
        screen = MockScreen(10, 40)
        screen.move(3, 5)
        screen.resize(20, 80)
        assert screen.cursor_y == 3
        assert screen.cursor_x == 5

    def test_attr_preserved(self):
        screen = MockScreen(5, 10)
        screen.addstr(0, 0, "bold", curses.A_BOLD)
        screen.resize(10, 20)
        assert screen.get_attr_at(0, 0) == curses.A_BOLD
        assert screen.get_attr_at(0, 3) == curses.A_BOLD


# ===========================================================================
# HarnessApplication convenience method tests
# ===========================================================================

class TestHarnessConvenience:
    def test_assert_text_on_screen_proxy(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)
        app.assert_text_on_screen("Alpha")
        with pytest.raises(AssertionError):
            app.assert_text_on_screen("missing")

    def test_assert_text_not_on_screen_proxy(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)
        app.assert_text_not_on_screen("missing")
        with pytest.raises(AssertionError):
            app.assert_text_not_on_screen("Alpha")

    def test_activity_stack_depth(self, app, mock_screen):
        assert app.activity_stack_depth() == 0
        first = SimpleListActivity()
        app.start_activity(first)
        assert app.activity_stack_depth() == 1
        second = SimpleListActivity()
        app.start_activity(second)
        assert app.activity_stack_depth() == 2

    def test_activity_stack_types(self, app, mock_screen):
        app.start_activity(SimpleListActivity())
        app.start_activity(SimpleTextInputActivity())
        assert app.activity_stack_types() == ["SimpleListActivity", "SimpleTextInputActivity"]

    def test_focused_element_none_when_empty(self, app, mock_screen):
        assert app.focused_element() is None

    def test_focused_element_returns_focus(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)
        # SimpleListActivity doesn't set self.focus, so it should be None
        assert app.focused_element() is None

    def test_replace_activity(self, app, mock_screen):
        first = SimpleListActivity(items=["First"])
        app.start_activity(first)
        assert app.activity_stack_depth() == 1

        second = SimpleListActivity(items=["Second"])
        app.replace_activity(second)
        assert app.activity_stack_depth() == 1
        assert app.current_activity() is second
        mock_screen.assert_text_on_screen("Second")

    def test_resize(self, app, mock_screen):
        activity = SimpleListActivity()
        app.start_activity(activity)
        app.resize(40, 120)
        assert mock_screen.rows == 40
        assert mock_screen.cols == 120
        # Activity still renders after resize
        mock_screen.assert_text_on_screen("Alpha")

    def test_teardown_error_none_on_success(self, mock_screen):
        application = HarnessApplication(mock_screen)
        application.setup()
        application.teardown()
        assert application.teardown_error is None


# ===========================================================================
# make_app factory fixture tests
# ===========================================================================

class TestMakeAppFixture:
    def test_default_dimensions(self, make_app):
        app, screen = make_app()
        assert screen.rows == 24
        assert screen.cols == 80

    def test_custom_dimensions(self, make_app):
        app, screen = make_app(rows=40, cols=120)
        assert screen.rows == 40
        assert screen.cols == 120

    def test_app_is_functional(self, make_app):
        app, screen = make_app(rows=30, cols=100)
        activity = SimpleListActivity()
        app.start_activity(activity)
        screen.assert_text_on_screen("Alpha")

    def test_multiple_apps(self, make_app):
        app1, screen1 = make_app(rows=10, cols=40)
        app2, screen2 = make_app(rows=20, cols=60)
        assert screen1.rows == 10
        assert screen2.rows == 20


# ===========================================================================
# shutdown_signal tests
# ===========================================================================

class TestShutdownSignal:
    def test_shutdown_signal_exists(self, app, mock_screen):
        """setup() creates a shutdown_signal that is not done."""
        assert app.shutdown_signal is not None
        assert not app.shutdown_signal.done()

    def test_shutdown_signal_accessible_from_activity(self, app, mock_screen):
        """Activities can safely poll shutdown_signal.done()."""
        activity = SimpleListActivity()
        app.start_activity(activity)
        assert not activity.application.shutdown_signal.done()


# ===========================================================================
# StopApplication / flush_stop_events tests
# ===========================================================================

class TestStopEventHandling:
    def test_pop_last_activity_does_not_crash(self, app, mock_screen):
        """Popping the last activity puts StopApplication in event_queue.
        _process_event_queue should handle it gracefully."""
        activity = SimpleListActivity()
        app.start_activity(activity)
        app.send_key(27)  # ESC pops the only activity
        app.drain()
        # Should not raise — StopApplication is silently consumed
        assert app.current_activity() is None

    def test_flush_stop_events_returns_stop(self, app, mock_screen):
        """flush_stop_events() returns StopApplication events."""
        activity = SimpleListActivity()
        app.start_activity(activity)

        # Manually pop so StopApplication is queued but _process_event_queue
        # hasn't run yet
        app._pop_activity()
        app.drain()

        stops = app.flush_stop_events()
        assert len(stops) == 1

    def test_flush_stop_events_empty_when_none(self, app, mock_screen):
        """flush_stop_events() returns empty list when no stop events."""
        activity = SimpleListActivity()
        app.start_activity(activity)
        assert app.flush_stop_events() == []

    def test_flush_stop_events_preserves_other_events(self, app, mock_screen):
        """flush_stop_events() keeps non-StopApplication events in the queue."""
        from pyos.EventTypes import StopApplication
        activity = SimpleTextInputActivity()
        app.start_activity(activity)

        # Manually inject a TextBoxChange and a StopApplication
        app.event_queue.put(TextBoxChange(ui_element="text_input"))
        app.event_queue.put(StopApplication())

        stops = app.flush_stop_events()
        assert len(stops) == 1
        # The TextBoxChange should still be in the queue
        remaining = app.event_queue.get_nowait()
        assert isinstance(remaining, TextBoxChange)


# ===========================================================================
# TextBoxChange event tests
# ===========================================================================

class TextBoxChangeTracker(Activity):
    """Activity that tracks TextBoxChange events."""

    def __init__(self):
        super().__init__()
        self.change_events = []

    def on_start(self):
        self.display_state = {
            "text_input": TextInput.display_state(label="Name", text="", focused=True),
        }
        self.application.subscribe(KeyStroke, self, self.on_key)
        self.application.subscribe(TextBoxChange, self, self.on_change)

    def on_key(self, event):
        handle_text_box_input("text_input", self.display_state["text_input"], event, self.event_queue)
        self.refresh_screen()

    def on_change(self, event):
        self.change_events.append(event)


class TestTextBoxChange:
    def test_typing_fires_change_events(self, app, mock_screen):
        """Each typed character should fire a TextBoxChange."""
        activity = TextBoxChangeTracker()
        app.start_activity(activity)

        app.send_text("abc")
        assert len(activity.change_events) == 3
        assert all(isinstance(e, TextBoxChange) for e in activity.change_events)

    def test_change_event_has_ui_element(self, app, mock_screen):
        """TextBoxChange events carry the ui_element name."""
        activity = TextBoxChangeTracker()
        app.start_activity(activity)

        app.send_text("x")
        assert activity.change_events[0].ui_element == "text_input"

    def test_backspace_fires_change_event(self, app, mock_screen):
        """Backspace should also fire a TextBoxChange."""
        activity = TextBoxChangeTracker()
        app.start_activity(activity)

        app.send_text("ab")
        activity.change_events.clear()

        app.send_key(127)  # backspace
        assert len(activity.change_events) == 1
        assert activity.change_events[0].ui_element == "text_input"

    def test_backspace_on_empty_no_change(self, app, mock_screen):
        """Backspace on empty text should not fire TextBoxChange."""
        activity = TextBoxChangeTracker()
        app.start_activity(activity)

        app.send_key(127)  # backspace on empty
        assert len(activity.change_events) == 0
