@@ -64,9 +64,29 @@ def __init__(self):
6464 def select_node (self , node : MockTreeNode ) -> None :
6565 self .selected_node = node
6666
67+ def move_cursor (self , node : MockTreeNode ) -> None :
68+ self .selected_node = node
69+
6770 def focus (self ) -> None :
6871 self .has_focus = True
6972
73+ def is_node_in_tree (self , node : MockTreeNode | None ) -> bool :
74+ """Walk the tree to check if a node reference is still attached.
75+
76+ Mirrors Textual's actual behavior: a cursor reference pointing at a
77+ node that was `child.remove()`d is stale — the widget no longer
78+ renders that node.
79+ """
80+ if node is None :
81+ return False
82+ stack = [self .root ]
83+ while stack :
84+ current = stack .pop ()
85+ if current is node :
86+ return True
87+ stack .extend (current .children )
88+ return False
89+
7090
7191class MockFilterInput :
7292 """Mock TreeFilterInput capturing the last set_filter call."""
@@ -384,3 +404,52 @@ def test_filter_finds_lazy_loaded_tables_in_multi_db_mode(self):
384404 assert "cs_ticket" in matched , (
385405 f"Issue #141: filter must find lazy-loaded tables; got { matched } "
386406 )
407+
408+
409+ class TestCursorPositionAfterFilterAccept :
410+ """Pressing Enter on a filter match should leave the cursor on that
411+ same match in the rebuilt tree — not on a stale node reference (the
412+ pre-snapshot match object has been removed and replaced by a fresh
413+ node when the tree was restored)."""
414+
415+ def _open_filter (self , host : _FilterHost ) -> None :
416+ TreeFilterMixin .action_tree_filter (host ) # type: ignore[arg-type]
417+
418+ def _type (self , host : _FilterHost , text : str ) -> None :
419+ for ch in text :
420+ host ._tree_filter_text += ch
421+ TreeFilterMixin ._update_tree_filter (host ) # type: ignore[arg-type]
422+
423+ def test_cursor_stays_on_matched_node_after_accept (self ):
424+ host = _FilterHost (["alpha" , "test-server" , "production" ])
425+
426+ self ._open_filter (host )
427+ self ._type (host , "test" )
428+
429+ # We have exactly one match: 'test-server'
430+ assert len (host ._tree_filter_matches ) == 1
431+ matched_label = host ._tree_filter_matches [0 ].data .get_label_text ()
432+ assert matched_label == "test-server"
433+
434+ # Accept (this closes the filter and restores the tree from snapshot)
435+ TreeFilterMixin .action_tree_filter_accept (host ) # type: ignore[arg-type]
436+
437+ cursor = host .object_tree .selected_node
438+
439+ # 1. The cursor must point at a node that is still in the tree —
440+ # not at a removed/stale Python object from before the restore.
441+ assert host .object_tree .is_node_in_tree (cursor ), (
442+ "After accept, cursor references a node that's no longer in "
443+ "the tree (stale reference left over from the filter session). "
444+ f"cursor: { cursor !r} "
445+ )
446+
447+ # 2. That node should correspond to the match the user accepted.
448+ assert cursor is not None
449+ assert (
450+ cursor .data is not None
451+ and cursor .data .get_label_text () == "test-server"
452+ ), (
453+ "Cursor ended up on the wrong node after accept. "
454+ f"Expected 'test-server', got: { cursor .data .get_label_text () if cursor .data else None } "
455+ )
0 commit comments