[PATCH RFC 11/11] review-tui: test per-version tracker rows

Christian Brauner <[email protected]> Sat, 18 Jul 2026 00:37:47 +0200
Newsgroups org.kernel.linux.tools
Message-ID <[email protected]>
Cover the expansion affordance and toggling, tracked-revision
marking, child-row selection state, survival of expansion and cursor
position across DB reloads and limit filtering, child-row thread
opening with the right revision, direct child range-diff, expand-all,
details-panel version row, and NULL-count rendering.

Assisted-by: LLM
Signed-off-by: Christian Brauner (Amutable) <[email protected]>
---
 src/tests/test_tui_tracking.py | 534 ++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 533 insertions(+), 1 deletion(-)

diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index e9d646e..0d4be62 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -11,6 +11,7 @@ core user workflows: series listing, navigation, filtering,
 status transitions, and modal interactions.
 """
 
+import contextlib
 import datetime
 import email.message
 import os
@@ -19,7 +20,7 @@ from typing import Any, Dict, List, Optional, Tuple
 from unittest.mock import patch
 
 import pytest
-from textual.widgets import Input, ListView, Static
+from textual.widgets import Input, Label, ListView, Static
 
 import b4
 import b4.review
@@ -30,6 +31,7 @@ from b4 import (
     _worktree_inprogress_op,
     _worktree_merge_in_progress,
 )
+from b4.review_tui._lite_app import LiteThreadScreen
 from b4.review_tui._modals import (
     ActionItem,
     ActionScreen,
@@ -38,10 +40,12 @@ from b4.review_tui._modals import (
     HelpScreen,
     LimitScreen,
     LinkRevisionScreen,
+    RangeDiffScreen,
     SnoozeScreen,
     TargetBranchScreen,
 )
 from b4.review_tui._tracking_app import (
+    TrackedRevisionItem,
     TrackedSeriesItem,
     TrackingApp,
     _resolve_worktree_take_conflict,
@@ -1544,6 +1548,534 @@ class TestTrackingDetailPanel:
             assert 'alpha' in app._selected_series.get('subject', '')
 
 
+# ---------------------------------------------------------------------------
+# Per-version child rows
+# ---------------------------------------------------------------------------
+
+
+def _seed_multiver(
+    identifier: str,
+    change_id: str = 'multi-1',
+    tracked: int = 2,
+    revisions: Optional[List[int]] = None,
+) -> None:
+    """Seed a series tracking v*tracked* with *revisions* in the catalog.
+
+    The tracked revision has message counts (5 total, 2 unseen); the
+    other revisions were never fetched, so their counts stay NULL.
+    """
+    conn = tracking.init_db(identifier)
+    tracking.add_series_to_db(
+        conn,
+        change_id=change_id,
+        revision=tracked,
+        subject=f'[PATCH v{tracked} 0/2] multi: test series',
+        sender_name='Vera Version',
+        sender_email='[email protected]',
+        sent_at='2026-03-10T10:00:00+00:00',
+        message_id=f'{change_id}-v{tracked}@example.com',
+        num_patches=2,
+    )
+    conn.execute(
+        'UPDATE series SET message_count = 5, seen_message_count = 3'
+        ' WHERE change_id = ? AND revision = ?',
+        (change_id, tracked),
+    )
+    conn.commit()
+    for rev in [1, 2, 3] if revisions is None else revisions:
+        tracking.add_revision(
+            conn,
+            change_id,
+            rev,
+            f'{change_id}-v{rev}@example.com',
+            subject=f'[PATCH v{rev} 0/2] multi: test series',
+        )
+    conn.close()
+
+
+def _list_items(app: TrackingApp) -> List[Any]:
+    """Every row currently in the tracking list, parents and children."""
+    return list(app.query_one('#tracking-list', ListView).children)
+
+
+def _row_text(item: Any) -> str:
+    """The rendered text of a single tracking list row."""
+    return _static_text(item.query_one(Label))
+
+
+def _msgs_column(text: str) -> str:
+    """The Msgs column of a rendered row (total + unseen badge).
+
+    Fixed offset: 20 (submitter) + 1 (attestation) + 1 + 7 (A·R·T) = 29,
+    then 5 for the total and 3 for the badge.  Slicing it asserts that
+    parent and child rows agree on the column layout.
+    """
+    return text[29:37].strip()
+
+
+class TestVersionExpansion:
+    """Tests for expanding a series into per-version child rows."""
+
+    @pytest.mark.asyncio
+    async def test_affordance_only_with_multiple_versions(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Only a series with more than one known version gets the marker."""
+        _seed_multiver('expand-affordance')
+        conn = tracking.get_db('expand-affordance')
+        tracking.add_series_to_db(
+            conn,
+            change_id='single-1',
+            revision=1,
+            subject='[PATCH] single: just one version',
+            sender_name='Sam Single',
+            sender_email='[email protected]',
+            sent_at='2026-03-11T10:00:00+00:00',
+            message_id='[email protected]',
+            num_patches=1,
+        )
+        tracking.add_revision(conn, 'single-1', 1, '[email protected]')
+        conn.close()
+
+        app = TrackingApp('expand-affordance')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            rows = {i.series['change_id']: _row_text(i) for i in _list_items(app)}
+            assert '▸' in rows['multi-1']
+            assert '▸' not in rows['single-1']
+
+    @pytest.mark.asyncio
+    async def test_x_expands_into_child_rows(self, tmp_path: pathlib.Path) -> None:
+        """x adds one child row per known version, oldest first."""
+        _seed_multiver('expand-toggle')
+
+        app = TrackingApp('expand-toggle')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert len(_list_items(app)) == 1
+
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            assert len(items) == 4
+            assert isinstance(items[0], TrackedSeriesItem)
+            assert items[0].expanded
+            assert '▾' in _row_text(items[0])
+            children = items[1:]
+            assert all(isinstance(c, TrackedRevisionItem) for c in children)
+            assert [c.rev['revision'] for c in children] == [1, 2, 3]
+
+    @pytest.mark.asyncio
+    async def test_x_collapses_again(self, tmp_path: pathlib.Path) -> None:
+        """A second x hides the child rows and restores the parent cursor."""
+        _seed_multiver('expand-collapse')
+
+        app = TrackingApp('expand-collapse')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            # Collapse from a child row — focus must return to the parent
+            await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            assert len(items) == 1
+            assert '▸' in _row_text(items[0])
+            assert app._selected_revision is None
+            assert app.query_one('#tracking-list', ListView).index == 0
+
+    @pytest.mark.asyncio
+    async def test_tracked_version_is_marked(self, tmp_path: pathlib.Path) -> None:
+        """The child row for the tracked revision carries an asterisk."""
+        _seed_multiver('expand-marker')
+
+        app = TrackingApp('expand-marker')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            children = _list_items(app)[1:]
+            marked = [c for c in children if c.is_tracked]
+            assert len(marked) == 1
+            assert marked[0].rev['revision'] == 2
+            assert 'v2*' in _row_text(marked[0])
+            assert 'v1*' not in _row_text(children[0])
+            assert 'v3*' not in _row_text(children[2])
+
+    @pytest.mark.asyncio
+    async def test_highlighting_child_keeps_parent_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A child row selects its version, but the series stays the parent."""
+        _seed_multiver('expand-select')
+
+        app = TrackingApp('expand-select')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+
+            await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 1
+            assert app._selected_series is not None
+            assert app._selected_series['change_id'] == 'multi-1'
+
+            # Back on the parent row the version selection is cleared
+            await pilot.press('k')
+            await pilot.pause()
+            assert app._selected_revision is None
+            assert app._selected_series is not None
+            assert app._selected_series['change_id'] == 'multi-1'
+
+    @pytest.mark.asyncio
+    async def test_expansion_and_cursor_survive_db_reload(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """An external DB change rebuilds the list without losing the child."""
+        _seed_multiver('expand-reload')
+
+        app = TrackingApp('expand-reload')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 2
+
+            # Bump the mtime the way another b4 process writing the DB would
+            db_path = tracking.get_db_path('expand-reload')
+            stamp = os.path.getmtime(db_path) + 10
+            os.utime(db_path, (stamp, stamp))
+            app._check_db_changed()
+            await pilot.pause()
+
+            items = _list_items(app)
+            assert len(items) == 4
+            assert isinstance(items[2], TrackedRevisionItem)
+            assert app.query_one('#tracking-list', ListView).index == 2
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 2
+
+    @pytest.mark.asyncio
+    async def test_x_is_noop_on_single_version_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A series with only the tracked version has nothing to expand."""
+        _seed_db('expand-single', [SAMPLE_SERIES[1]])
+
+        app = TrackingApp('expand-single')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            assert len(_list_items(app)) == 1
+            assert not app._expanded_change_ids
+
+    @pytest.mark.asyncio
+    async def test_expansion_survives_limit_filter(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Setting and clearing a limit keeps the series expanded."""
+        _seed_multiver('expand-limit')
+
+        app = TrackingApp('expand-limit')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            assert len(_list_items(app)) == 4
+
+            await pilot.press('l')
+            await pilot.pause()
+            app.screen.query_one('#limit-input', Input).value = 'multi'
+            await pilot.press('enter')
+            await pilot.pause()
+            assert len(_list_items(app)) == 4
+
+            await pilot.press('l')
+            await pilot.pause()
+            app.screen.query_one('#limit-input', Input).value = ''
+            await pilot.press('enter')
+            await pilot.pause()
+            assert len(_list_items(app)) == 4
+
+    @pytest.mark.asyncio
+    async def test_expand_all_toggles_every_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """X expands all multi-version series, then collapses them."""
+        _seed_multiver('expand-all', change_id='multi-a')
+        _seed_multiver('expand-all', change_id='multi-b')
+
+        app = TrackingApp('expand-all')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert len(_list_items(app)) == 2
+
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 8  # 2 parents + 3 versions each
+            assert app._expanded_change_ids == {'multi-a', 'multi-b'}
+
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 2
+            assert not app._expanded_change_ids
+
+            # A partially expanded list expands the rest before collapsing
+            await pilot.press('x')
+            await pilot.pause()
+            assert len(_list_items(app)) == 5
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 8
+
+    @pytest.mark.asyncio
+    async def test_child_msgs_column(self, tmp_path: pathlib.Path) -> None:
+        """Child counts render like the parent, with '-' when never fetched."""
+        _seed_multiver('expand-msgs')
+
+        app = TrackingApp('expand-msgs')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            # v1 was never fetched, v2 is tracked (5 messages, 2 unseen)
+            assert items[1].rev['message_count'] is None
+            assert _msgs_column(_row_text(items[1])) == '-'
+            assert _msgs_column(_row_text(items[2])) == '5(2)'
+            assert _msgs_column(_row_text(items[0])) == '5(2)'
+
+    @pytest.mark.asyncio
+    async def test_child_rows_show_subjects(self, tmp_path: pathlib.Path) -> None:
+        """Every version row carries its own prefix-stripped title."""
+        _seed_multiver('expand-subject', revisions=[1, 2])
+        conn = tracking.get_db('expand-subject')
+        tracking.add_revision(
+            conn,
+            'multi-1',
+            3,
+            '[email protected]',
+            subject='[PATCH v3 0/2] multi: renamed after review',
+        )
+        conn.close()
+
+        app = TrackingApp('expand-subject')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            # Versions keeping the series title still show it -- an empty
+            # subject cell reads as missing data.
+            assert 'multi: test series' in _row_text(items[1])
+            assert 'multi: test series' in _row_text(items[2])
+            # A retitled version shows its own title, prefix-stripped.
+            assert 'multi: renamed after review' in _row_text(items[3])
+            assert '[PATCH v3' not in _row_text(items[3])
+
+    @pytest.mark.asyncio
+    async def test_details_panel_version_row(self, tmp_path: pathlib.Path) -> None:
+        """The Version row describes the highlighted child, and hides for parents."""
+        _seed_multiver('expand-details')
+
+        app = TrackingApp('expand-details')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            row = app.query_one('#detail-version-row')
+            assert not row.display
+
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            assert row.display
+            text = _static_text(app.query_one('#detail-version', Static))
+            assert text.startswith('v2 (tracked)')
+            assert '5 msgs (2 unseen)' in text
+            assert ', found ' in text
+
+            # A version that was never fetched has no counts to show
+            await pilot.press('k')
+            await pilot.pause()
+            text = _static_text(app.query_one('#detail-version', Static))
+            assert text.startswith('v1')
+            assert '(tracked)' not in text
+            assert '- msgs (- unseen)' in text
+
+            # Back on the parent row the version detail disappears
+            await pilot.press('k')
+            await pilot.pause()
+            assert not row.display
+
+
+class TestChildRowActions:
+    """Tests for actions taken while a version row is highlighted."""
+
+    @pytest.mark.asyncio
+    async def test_e_opens_that_version_thread(self, tmp_path: pathlib.Path) -> None:
+        """e on a child views the child's thread, not the tracked one."""
+        _seed_multiver('child-thread')
+
+        app = TrackingApp('child-thread')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+
+            with patch.object(app, 'push_screen') as mock_push:
+                await pilot.press('e')
+                await pilot.pause()
+
+            assert mock_push.call_count == 1
+            screen = mock_push.call_args[0][0]
+            assert isinstance(screen, LiteThreadScreen)
+            assert screen._message_id == '[email protected]'
+            assert screen._tracking_info is not None
+            assert screen._tracking_info['change_id'] == 'multi-1'
+            assert screen._tracking_info['revision'] == 1
+
+    @pytest.mark.asyncio
+    async def test_enter_opens_that_version_thread(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Enter on a child opens the thread instead of the action menu."""
+        _seed_multiver('child-enter')
+
+        app = TrackingApp('child-enter')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            for _ in range(3):
+                await pilot.press('j')
+            await pilot.pause()
+
+            with patch.object(app, 'push_screen') as mock_push:
+                await pilot.press('enter')
+                await pilot.pause()
+
+            assert mock_push.call_count == 1
+            screen = mock_push.call_args[0][0]
+            assert isinstance(screen, LiteThreadScreen)
+            assert screen._message_id == '[email protected]'
+            assert screen._tracking_info is not None
+            assert screen._tracking_info['revision'] == 3
+
+    @pytest.mark.asyncio
+    async def test_e_on_parent_still_opens_tracked_thread(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """With no child highlighted, e views the tracked revision's thread."""
+        _seed_multiver('child-parent-thread')
+
+        app = TrackingApp('child-parent-thread')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+
+            with patch.object(app, 'push_screen') as mock_push:
+                await pilot.press('e')
+                await pilot.pause()
+
+            screen = mock_push.call_args[0][0]
+            assert screen._message_id == '[email protected]'
+            assert screen._tracking_info['revision'] == 2
+
+    @pytest.mark.asyncio
+    async def test_d_range_diffs_child_against_tracked(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """d on a child diffs it against the tracked revision, no picker."""
+        _seed_multiver('child-rangediff')
+
+        app = TrackingApp('child-rangediff')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            for _ in range(3):
+                await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 3
+
+            with (
+                patch.object(app, '_do_range_diff') as mock_diff,
+                patch.object(app, 'suspend', return_value=contextlib.nullcontext()),
+            ):
+                await pilot.press('d')
+                await pilot.pause()
+
+            mock_diff.assert_called_once_with('multi-1', 2, 3)
+            assert not isinstance(app.screen, RangeDiffScreen)
+
+    @pytest.mark.asyncio
+    async def test_d_on_tracked_child_shows_picker(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The tracked version has no implied other side — pick one."""
+        _seed_multiver('child-rangediff-self')
+
+        app = TrackingApp('child-rangediff-self')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 2
+
+            with patch.object(app, '_do_range_diff') as mock_diff:
+                await pilot.press('d')
+                await pilot.pause()
+                assert isinstance(app.screen, RangeDiffScreen)
+                await pilot.press('escape')
+                await pilot.pause()
+
+            mock_diff.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_series_actions_use_parent_from_child_row(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The action menu on a child row acts on the parent series."""
+        _seed_multiver('child-action')
+
+        app = TrackingApp('child-action')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+
+            await pilot.press('a')
+            await pilot.pause()
+            assert isinstance(app.screen, ActionScreen)
+            lv = app.screen.query_one('#action-list', ListView)
+            actions = [c.key for c in lv.children if isinstance(c, ActionItem)]
+            assert 'review' in actions
+            await pilot.press('escape')
+            await pilot.pause()
+            assert app._selected_series is not None
+            assert app._selected_series['change_id'] == 'multi-1'
+
+
 class TestTrackingMultipleSeries:
     """Tests for workflows involving multiple series."""
 

-- 
2.53.0