Linux maintainer tooling and workflows
 help / color / mirror / Atom feed
From: Christian Brauner <brauner@kernel.org>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
	 "Christian Brauner (Amutable)" <brauner@kernel.org>
Subject: [PATCH RFC v2 25/25] review-tui: test per-version tracker rows
Date: Wed, 12 Aug 2026 23:47:06 +0200	[thread overview]
Message-ID: <20260812-work-b4-multiver-rows-v2-25-305d53cd723a@kernel.org> (raw)
In-Reply-To: <20260812-work-b4-multiver-rows-v2-0-305d53cd723a@kernel.org>

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, the details-panel
version row, and NULL-count rendering.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/test_tui_tracking.py | 1629 +++++++++++++++++++++++++++++++++++++++-
 1 file changed, 1626 insertions(+), 3 deletions(-)

diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 7ba2443e..17cdf41a 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -11,11 +11,14 @@ core user workflows: series listing, navigation, filtering,
 status transitions, and modal interactions.
 """
 
+import contextlib
 import datetime
 import email.message
 import os
 import pathlib
+import re
 import sqlite3
+import time
 from typing import Any, Callable, Dict, List, Optional, Tuple
 from unittest.mock import patch
 
@@ -23,7 +26,7 @@ import pytest
 
 pytest.importorskip('textual')
 
-from textual.widgets import Input, ListView, Static
+from textual.widgets import Input, Label, ListView, Static
 
 import b4
 import b4.review
@@ -36,6 +39,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,
@@ -45,17 +49,21 @@ from b4.review_tui._modals import (
     HelpScreen,
     LimitScreen,
     LinkRevisionScreen,
+    RangeDiffScreen,
     RebaseScreen,
     SnoozeScreen,
     TakeConfirmScreen,
     TargetBranchScreen,
 )
 from b4.review_tui._tracking_app import (
+    TrackedRevisionItem,
     TrackedSeriesItem,
     TrackingApp,
     _build_base_suggestions,
     _detect_initial_base,
     _effective_tier,
+    _format_version,
+    _msgs_fields,
     _resolve_worktree_take_conflict,
     _shazam_merge_flags,
     _take_worktree,
@@ -5995,8 +6003,6 @@ class TestRethreadFlagReachesTheThreadFetch:
         self, tmp_path: pathlib.Path
     ) -> None:
         """The viewer's series dict is what selects the reassembly path."""
-        from b4.review_tui._lite_app import LiteThreadScreen
-
         seen: Dict[str, Any] = {}
 
         def _capture(series: Dict[str, Any], identifier: str) -> List[Any]:
@@ -6017,6 +6023,27 @@ class TestRethreadFlagReachesTheThreadFetch:
         assert seen['is_rethreaded'] is True
         assert seen['revision'] == 2
 
+    @pytest.mark.asyncio
+    async def test_a_plain_version_row_reports_its_own_flag(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """v1 is not rethreaded, so its row must not inherit v2's flag."""
+        self._seed_rethreaded('rt-child')
+
+        app = TrackingApp('rt-child')
+        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()
+            screen = mock_push.call_args[0][0]
+            assert screen._tracking_info['revision'] == 1
+            assert screen._tracking_info['is_rethreaded'] is False
+
 
 class TestUpdateAllDoesNotForceThePoll:
     """'u' asks about one series; 'U' must not force the schedule everywhere.
@@ -6064,6 +6091,45 @@ class TestDiscoverOlderAction:
             keys = [key for key, _label in getattr(app.screen, '_actions')]
             assert 'discover' in keys
 
+    @pytest.mark.asyncio
+    async def test_conflicts_suppress_the_nothing_found_notice(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A run that skipped every version it found reports 0 found.
+
+        Saying "No older revisions found" and then listing four of them is
+        two notifications contradicting each other in the same toast stack.
+        """
+        _seed_db(
+            'test-discover-both',
+            [{'change_id': 'cid-d', 'revision': 5, 'status': 'new'}],
+        )
+        notices: List[str] = []
+
+        app = TrackingApp('test-discover-both')
+        with patch.object(
+            tracking,
+            'discover_older_revisions',
+            lambda *a, **kw: {
+                'found': 0,
+                'revisions': [],
+                'conflicts': [1, 2],
+                'error': None,
+            },
+        ):
+            async with app.run_test(size=(120, 30)) as pilot:
+                await pilot.pause()
+                with patch.object(
+                    TrackingApp,
+                    'notify',
+                    lambda self, msg, **kw: notices.append(str(msg)),
+                ):
+                    app.action_discover_older()
+                    await app.workers.wait_for_complete()
+                    await pilot.pause()
+        assert not any('No older revisions found' in n for n in notices), notices
+        assert any('v1, v2' in n for n in notices), notices
+
     @pytest.mark.asyncio
     async def test_action_runs_discovery(self, tmp_path: pathlib.Path) -> None:
         _seed_db(
@@ -6092,3 +6158,1560 @@ class TestDiscoverOlderAction:
                 await app.workers.wait_for_complete()
                 await pilot.pause()
         assert calls == [('test-discover-run', 'cid-d')]
+
+
+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='vera@example.com',
+        sent_at='2026-03-10T10:00:00+00:00',
+        message_id=f'{change_id}-v{tracked}@example.com',
+        num_patches=2,
+    )
+    conn.execute(
+        'UPDATE revisions 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 _selected_rev(app: TrackingApp) -> Optional[Any]:
+    """Read _selected_revision without narrowing it for the rest of the test."""
+    return app._selected_revision
+
+
+def _version_row_shown(app: TrackingApp) -> bool:
+    """Whether the details panel's Version: row is currently displayed."""
+    return bool(app.query_one('#detail-version-row').display)
+
+
+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, a separator and 4 for the badge.  Slicing it
+    asserts that parent and child rows agree on the column layout.
+    """
+    return text[29:39].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='sam@example.com',
+            sent_at='2026-03-11T10:00:00+00:00',
+            message_id='single-v1@example.com',
+            num_patches=1,
+        )
+        tracking.add_revision(conn, 'single-1', 1, 'single-v1@example.com')
+        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 _selected_rev(app) 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()
+            sel_rev = _selected_rev(app)
+            assert sel_rev is not None
+            assert sel_rev['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 _selected_rev(app) 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_rows
+
+    @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_filtering_everything_away_drops_the_selection(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """An empty list must not leave a version row selected.
+
+        The empty-list branch returns above the block that re-derives the
+        selection from the cursor, so the stale pair kept every series
+        action enabled -- and on a version row 'd' skips the picker and
+        range-diffs a series the list is no longer showing.
+        """
+        _seed_multiver('expand-empty')
+
+        app = TrackingApp('expand-empty')
+        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()
+            # Bound to a local: asserting on the attribute narrows it for
+            # the rest of the function, and mypy then calls the checks
+            # below unreachable.
+            on_child = app._selected_revision
+            assert on_child is not None
+
+            await pilot.press('l')
+            await pilot.pause()
+            app.screen.query_one('#limit-input', Input).value = 'nomatch-xyzzy'
+            await pilot.press('enter')
+            await pilot.pause()
+            assert len(app.query('#tracking-list')) == 0
+            assert app.query_one('#tracking-empty', Static)
+            assert app._selected_revision is None
+            assert app._selected_series is None
+            assert app.check_action('range_diff', ()) is False
+            # 'thread' is ungated and self-guards instead; with nothing
+            # selected it must open no screen.
+            depth = len(app.screen_stack)
+            app.action_thread()
+            await pilot.pause()
+            assert len(app.screen_stack) == depth
+
+    @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_rows == {('multi-a', 2), ('multi-b', 2)}
+
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 2
+            assert not app._expanded_rows
+
+            # 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)'
+
+    def test_missing_seen_count_reads_the_same_both_ways(self) -> None:
+        """A pre-v11 row can arrive with a total but no seen count.
+
+        The Msgs column and the details panel one line below it must not
+        answer that differently.
+        """
+        assert _msgs_fields(5, None) == ('5', '', False)
+        assert '5 msgs (0 unseen)' in _format_version(
+            {'revision': 2, 'message_count': 5, 'seen_message_count': None},
+            {'revision': 3},
+        )
+
+    @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,
+            'multi-1-v3@example.com',
+            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()
+            assert not _version_row_shown(app)
+
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            assert _version_row_shown(app)
+            text = _static_text(app.query_one('#detail-version', Static))
+            assert text.startswith('v2 (tracked)')
+            assert '5 msgs (2 unseen)' in text
+            assert ', posted ' 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
+
+            # The catalog knows no patch count or send date per revision, so
+            # neither is filled in from the tracked revision.
+            subj = _static_text(app.query_one('#detail-subject', Static))
+            assert subj.startswith('[v1] ')
+            assert _static_text(app.query_one('#detail-sent', Static)) == 'Unknown'
+
+            # Back on the parent row the version detail disappears
+            await pilot.press('k')
+            await pilot.pause()
+            assert not _version_row_shown(app)
+
+
+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 == 'multi-1-v1@example.com'
+            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 == 'multi-1-v3@example.com'
+            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 == 'multi-1-v2@example.com'
+            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_enabled_when_only_synthesized_row_makes_two(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Both gates count the tracked revision the same way.
+
+        Gating d on a raw catalog count once left an expanded version row
+        with d disabled.  Tracking a series now catalogues the revision it
+        tracks, so the two counts agree by construction rather than by the
+        merge helper patching one of them up.
+        """
+        _seed_multiver('child-rangediff-gate', tracked=2, revisions=[3])
+
+        app = TrackingApp('child-rangediff-gate')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            sel = app._selected_series
+            assert sel is not None
+            assert len(sel.get('_revisions') or []) == 2
+            assert len(app._merge_tracked_revision(sel)) == 2
+            assert app.check_action('toggle_expand', ()) is True
+            assert app.check_action('range_diff', ()) is True
+
+            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'] == 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)
+
+    @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 'abandon' in actions
+            # 'r' is greyed out on another version's row because it checks out
+            # the *tracked* revision; the menu has to refuse it too, or the
+            # checkout the guard declines is one extra keystroke away.
+            assert 'review' not 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 TestUnseenVersionSignal:
+    """The collapsed row has to show that a non-tracked version has mail."""
+
+    @staticmethod
+    def _set_counts(identifier: str, rev: int, total: int, seen: int) -> None:
+        conn = tracking.get_db(identifier)
+        conn.execute(
+            'UPDATE revisions SET message_count = ?, seen_message_count = ?'
+            ' WHERE change_id = ? AND revision = ?',
+            (total, seen, 'multi-1', rev),
+        )
+        conn.commit()
+        conn.close()
+
+    @pytest.mark.asyncio
+    async def test_marker_flags_unread_on_an_older_version(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        _seed_multiver('unseen-child')
+        # v1 has 3 unread; the tracked v2 has none of its own.
+        self._set_counts('unseen-child', 1, 7, 4)
+
+        app = TrackingApp('unseen-child')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            item = _list_items(app)[0]
+            assert isinstance(item, TrackedSeriesItem)
+            assert item.has_unseen_versions
+            assert '▸' in _row_text(item)
+
+    @pytest.mark.asyncio
+    async def test_tracked_revisions_own_unread_does_not_flag_it(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The Msgs column already carries the tracked revision's badge."""
+        _seed_multiver('unseen-tracked-only')
+        # Only the tracked v2 has an unread delta (5 total, 3 seen).
+        for rev in (1, 3):
+            self._set_counts('unseen-tracked-only', rev, 4, 4)
+
+        app = TrackingApp('unseen-tracked-only')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            item = _list_items(app)[0]
+            assert isinstance(item, TrackedSeriesItem)
+            assert not item.has_unseen_versions
+
+    @pytest.mark.asyncio
+    async def test_never_counted_versions_do_not_flag_it(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A NULL count is "unknown", not "all unread"."""
+        _seed_multiver('unseen-null')
+
+        app = TrackingApp('unseen-null')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            item = _list_items(app)[0]
+            assert isinstance(item, TrackedSeriesItem)
+            assert not item.has_unseen_versions
+
+
+class TestVersionRowDetails:
+    @pytest.mark.asyncio
+    async def test_attestation_is_not_shown_for_another_version(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Attestation is stored per series row, so it describes v2 only."""
+        _seed_multiver('att-version')
+        conn = tracking.get_db('att-version')
+        conn.execute(
+            "UPDATE series SET attestation = 'signed:dkim/example.com'"
+            " WHERE change_id = 'multi-1'"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('att-version')
+
+        def att_shown() -> bool:
+            return bool(app.query_one('#detail-attestation-row').display)
+
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert att_shown()
+
+            # Move onto a child row for a different version.
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            sel = _selected_rev(app)
+            assert sel is not None and sel['revision'] == 1
+            assert not att_shown()
+
+            # ...and back on the tracked version's own row it returns.
+            await pilot.press('k')
+            await pilot.pause()
+            assert att_shown()
+
+
+class TestNeedsUpdateHintSurvivesTheMergedList:
+    @pytest.mark.asyncio
+    async def test_hint_is_shown_when_the_catalog_is_empty(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The row's '*' flag and the panel have to agree.
+
+        The merged version list always carries the tracked revision, so the
+        panel started rendering 'Revisions: v1' for a series with no catalog
+        data at all -- contradicting the '*' the same row was flying, and
+        leaving the documented "tracking data needs a refresh" hint
+        unreachable.
+        """
+        _seed_db(
+            'needs-update-hint',
+            [
+                {
+                    'change_id': 'no-cat',
+                    'revision': 1,
+                    'subject': '[PATCH] thing: do it',
+                    # Not one of the branch-backed states, or the startup
+                    # rescan marks it gone and the flag is suppressed.
+                    'status': 'accepted',
+                }
+            ],
+        )
+
+        app = TrackingApp('needs-update-hint')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app._all_series[0].get('needs_update')
+            row = app.query_one('#detail-revisions-row')
+            assert row.display
+            assert 'run [u]pdate' in _static_text(
+                app.query_one('#detail-revisions', Static)
+            )
+
+    @pytest.mark.asyncio
+    async def test_hint_survives_the_v11_catalog_backfill(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A migrated database must not lose the hint on the way in.
+
+        Schema v11 backfills a catalog row for every series row, so a
+        never-swept series stopped looking any different from one that was
+        swept and found nothing: counting catalog rows put the '*' flag and
+        the panel hint permanently out of reach for every upgrading user.
+        """
+        _seed_db(
+            'needs-update-migrated',
+            [
+                {
+                    'change_id': 'migrated',
+                    'revision': 2,
+                    'subject': '[PATCH v2] thing: do it',
+                    'status': 'accepted',
+                }
+            ],
+        )
+        conn = tracking.get_db('needs-update-migrated')
+        # Exactly what the migration leaves behind: an entry mirroring the
+        # tracked revision, and no watermark, because no sweep has run.
+        tracking.add_revision(conn, 'migrated', 2, 'migrated-v2@example.com')
+        conn.execute('UPDATE revisions SET last_update_check = NULL')
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('needs-update-migrated')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app._all_series[0].get('needs_update')
+            assert 'run [u]pdate' in _static_text(
+                app.query_one('#detail-revisions', Static)
+            )
+
+    @pytest.mark.asyncio
+    async def test_no_hint_once_a_sweep_has_run(self, tmp_path: pathlib.Path) -> None:
+        """A v1 series has no other version to find, and must not nag."""
+        _seed_db(
+            'needs-update-swept',
+            [
+                {
+                    'change_id': 'swept',
+                    'revision': 1,
+                    'subject': '[PATCH] thing: do it',
+                    'status': 'accepted',
+                }
+            ],
+        )
+        conn = tracking.get_db('needs-update-swept')
+        tracking.add_revision(conn, 'swept', 1, 'swept-v1@example.com')
+        conn.execute(
+            "UPDATE revisions SET last_update_check = '2026-03-10T10:00:00+00:00'"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('needs-update-swept')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert not app._all_series[0].get('needs_update')
+
+
+class TestVersionRowDates:
+    def test_panel_dates_are_local_not_a_utc_slice(self) -> None:
+        """The details panel and the version row must not disagree by a day.
+
+        The row converts to local time on purpose; the panel used to slice
+        the stored ISO string, so within the local offset of midnight the
+        two rendered different dates for the same revision.
+        """
+        old_tz = os.environ.get('TZ')
+        os.environ['TZ'] = 'Australia/Sydney'
+        time.tzset()
+        try:
+            stamp = '2026-03-12T23:30:00+00:00'
+            out = _tracking_app._format_version(
+                {
+                    'revision': 1,
+                    'message_count': 4,
+                    'seen_message_count': 4,
+                    'found_at': stamp,
+                    'last_mail_at': stamp,
+                },
+                {'revision': 2},
+            )
+            # 23:30 UTC is already the 13th in Sydney; the ISO slice says 12th.
+            assert ', posted 2026-03-13' in out
+            assert ', last activity 2026-03-13' in out
+            assert _tracking_app._local_stamp(stamp, '%d %b') == '13 Mar'
+        finally:
+            if old_tz is None:
+                os.environ.pop('TZ', None)
+            else:
+                os.environ['TZ'] = old_tz
+            time.tzset()
+
+    def test_unparseable_stamp_renders_empty(self) -> None:
+        assert _tracking_app._local_stamp('not a date', '%d %b') == ''
+        assert _tracking_app._local_stamp(None, '%d %b') == ''
+
+
+class TestDuplicateChangeIdRows:
+    """rescan_branches can leave one change_id with two live series rows."""
+
+    @staticmethod
+    def _seed(identifier: str) -> None:
+        conn = tracking.init_db(identifier)
+        for rev, added in ((2, '2026-03-01T00:00:00+00:00'), (5, '2026-03-02')):
+            conn.execute(
+                'INSERT INTO series (change_id, revision, message_id, subject,'
+                ' sender_name, sender_email, sent_at, added_at, status,'
+                " num_patches) VALUES ('dup',?,?,?,'Dee','dee@example.com',"
+                "?,?,'new',2)",
+                (
+                    rev,
+                    f'dup-v{rev}@example.com',
+                    f'[PATCH v{rev} 0/2] dup: a series',
+                    added,
+                    added,
+                ),
+            )
+        for rev in (1, 2, 5):
+            tracking.add_revision(conn, 'dup', rev, f'dup-v{rev}@example.com')
+        conn.commit()
+        conn.close()
+
+    @pytest.mark.asyncio
+    async def test_expanding_one_row_leaves_its_twin_collapsed(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Expansion is per row, not per change_id.
+
+        Both rows are rendered separately and carry different tracked
+        revisions, so keying the expansion on the change_id alone unfolded
+        the pair together and gave each an identical set of child rows.
+        """
+        self._seed('dup-expand')
+
+        app = TrackingApp('dup-expand')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            items = _list_items(app)
+            assert len(items) == 2
+            first_rev = items[0].series['revision']
+
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            # One parent grew three children; the other is untouched.
+            assert len(items) == 5
+            assert app._expanded_rows == {('dup', first_rev)}
+            parents = [i for i in items if isinstance(i, TrackedSeriesItem)]
+            assert [p.expanded for p in parents] == [True, False]
+
+    @pytest.mark.asyncio
+    async def test_unread_on_a_twins_revision_is_not_this_rows_badge(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Another live row's revision is that row's business, not this one's.
+
+        Its counts are badged in its own Msgs column; accenting this row's
+        expander for them reports "an older version of this series has new
+        mail" about a version this series never tracked.
+        """
+        self._seed('dup-badge')
+        conn = tracking.get_db('dup-badge')
+        # Unread mail on v5 -- which the other live row tracks.
+        conn.execute(
+            'UPDATE revisions SET message_count = 9, seen_message_count = 4'
+            " WHERE change_id = 'dup' AND revision = 5"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('dup-badge')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            by_rev = {
+                i.series['revision']: i
+                for i in _list_items(app)
+                if isinstance(i, TrackedSeriesItem)
+            }
+            # v5 is the other row's own tracked revision...
+            assert by_rev[2].has_unseen_versions is False
+            # ...and v5's row does not badge itself for it either.
+            assert by_rev[5].has_unseen_versions is False
+
+
+class TestLimitCursorRestore:
+    @pytest.mark.asyncio
+    async def test_cursor_returns_after_a_limit_matched_nothing(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Clearing the selection must not also drop the restore hint.
+
+        The empty branch has to forget what is selected, or every action
+        stays enabled against a row the list no longer shows -- but the
+        stashed focus is only a hint, and dropping it lands the cursor at
+        the top once the filter is cleared again.
+        """
+        _seed_db('limit-empty', [SAMPLE_SERIES[0], SAMPLE_SERIES[1]])
+
+        app = TrackingApp('limit-empty')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            selected = app._selected_series
+            assert selected is not None
+            target = selected['change_id']
+
+            app._limit_pattern = 'nothing-matches-this'
+            app._stash_focus()
+            await app._refresh_list()
+            await pilot.pause()
+            while_empty = app._selected_series
+            assert while_empty is None
+
+            app._limit_pattern = ''
+            await app._refresh_list()
+            await pilot.pause()
+            restored = app._selected_series
+            assert restored is not None
+            assert restored['change_id'] == target
+
+
+class TestExpandAllScope:
+    @pytest.mark.asyncio
+    async def test_expand_all_ignores_filtered_out_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """[X] judged "already expanded?" against rows the filter hides."""
+        _seed_multiver('expand-scope', change_id='multi-1')
+        conn = tracking.get_db('expand-scope')
+        tracking.add_series_to_db(
+            conn,
+            change_id='other-1',
+            revision=2,
+            subject='[PATCH v2 0/2] other: hidden series',
+            sender_name='Hidden Hank',
+            sender_email='hank@example.com',
+            sent_at='2026-03-11T10:00:00+00:00',
+            message_id='other-1-v2@example.com',
+            num_patches=2,
+        )
+        for rev in (1, 2):
+            tracking.add_revision(conn, 'other-1', rev, f'other-1-v{rev}@example.com')
+        conn.close()
+
+        app = TrackingApp('expand-scope')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            # Hide 'other-1' behind the limit filter.
+            app._limit_pattern = 'multi'
+            await app._refresh_list()
+            await pilot.pause()
+            assert len(_list_items(app)) == 1
+
+            app.action_expand_all()
+            await pilot.pause()
+            # The visible series expanded, and the hidden one was not
+            # counted when deciding expand-vs-collapse.
+            assert app._expanded_rows == {('multi-1', 2)}
+            assert len(_list_items(app)) == 4
+
+    @pytest.mark.asyncio
+    async def test_expand_all_collapse_leaves_hidden_series_alone(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """[X] collapsing must not fold up what the filter is hiding."""
+        _seed_multiver('collapse-scope', change_id='multi-1')
+        conn = tracking.get_db('collapse-scope')
+        tracking.add_series_to_db(
+            conn,
+            change_id='other-1',
+            revision=2,
+            subject='[PATCH v2 0/2] other: hidden series',
+            sender_name='Hidden Hank',
+            sender_email='hank@example.com',
+            sent_at='2026-03-11T10:00:00+00:00',
+            message_id='other-1-v2@example.com',
+            num_patches=2,
+        )
+        for rev in (1, 2):
+            tracking.add_revision(conn, 'other-1', rev, f'other-1-v{rev}@example.com')
+        conn.close()
+
+        app = TrackingApp('collapse-scope')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            # Expand both, then hide one behind the limit filter.
+            app.action_expand_all()
+            await pilot.pause()
+            assert app._expanded_rows == {('multi-1', 2), ('other-1', 2)}
+            app._limit_pattern = 'multi'
+            await app._refresh_list()
+            await pilot.pause()
+
+            app.action_expand_all()
+            await pilot.pause()
+            # Only the displayed series collapsed.
+            assert app._expanded_rows == {('other-1', 2)}
+
+
+class TestUnseenVersionMarkerRendering:
+    """The computed flag has to reach the glyph, not just the attribute."""
+
+    @pytest.mark.asyncio
+    async def test_the_marker_is_accented_when_an_older_version_has_mail(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        _seed_multiver('unseen-style')
+        conn = tracking.get_db('unseen-style')
+        conn.execute(
+            'UPDATE revisions SET message_count = 7, seen_message_count = 4'
+            " WHERE change_id = 'multi-1' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('unseen-style')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            label = _list_items(app)[0].query_one(Label)
+            marker_spans = [
+                span
+                for span in label.content.spans
+                if label.content.plain[span.start : span.end] == '▸'
+            ]
+            assert marker_spans, 'the ▸ marker carries no style'
+            assert 'bold' in str(marker_spans[0].style)
+
+    @pytest.mark.asyncio
+    async def test_the_marker_is_plain_when_nothing_is_unread(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        _seed_multiver('unseen-style-none')
+
+        app = TrackingApp('unseen-style-none')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            label = _list_items(app)[0].query_one(Label)
+            marker_spans = [
+                span
+                for span in label.content.spans
+                if label.content.plain[span.start : span.end] == '▸'
+            ]
+            assert marker_spans == []
+
+
+class TestStatusActionsKeepTheVersionRow:
+    @pytest.mark.asyncio
+    async def test_waiting_from_a_version_row_stays_there(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A status change says nothing about which versions the series has.
+
+        Setting a target branch already left the cursor alone; snooze,
+        unsnooze, waiting and thank bounced it up to the parent, so two
+        status-only actions taken from the same row disagreed about where
+        the cursor belonged afterwards.
+        """
+        _seed_multiver('status-keeps-row')
+        app = TrackingApp('status-keeps-row')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')  # onto the first version row
+            await pilot.pause()
+            before = app.query_one('#tracking-list', ListView).index
+            assert isinstance(_list_items(app)[before], TrackedRevisionItem)
+            picked = _list_items(app)[before].rev['revision']
+
+            app.action_waiting()
+            await pilot.pause()
+            await pilot.pause()
+
+            item = _list_items(app)[
+                app.query_one('#tracking-list', ListView).index or 0
+            ]
+            assert isinstance(item, TrackedRevisionItem)
+            assert item.rev['revision'] == picked
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == picked
+
+
+class TestVersionRowDateIsLocal:
+    @pytest.mark.asyncio
+    async def test_the_date_is_rendered_in_local_time(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The column stores UTC; showing it raw puts a row a day out.
+
+        23:00 UTC is the next day in any eastward zone, which is exactly
+        when a maintainer notices the version row and the thread viewer
+        disagreeing about the same message.
+
+        Plain environ save/restore, not monkeypatch.setenv: the fixture
+        undoes its env change after this method's finally block, so the
+        last tzset() here would run with TZ unset and leave libc's cached
+        zone disagreeing with os.environ for the rest of the process.
+        """
+        old_tz = os.environ.get('TZ')
+        os.environ['TZ'] = 'Australia/Sydney'
+        time.tzset()
+        try:
+            _seed_multiver('rowdate-local')
+            conn = tracking.get_db('rowdate-local')
+            conn.execute(
+                "UPDATE revisions SET last_mail_at = '2026-03-11T23:30:00+00:00'"
+                " WHERE change_id = 'multi-1' AND revision = 1"
+            )
+            conn.commit()
+            conn.close()
+
+            app = TrackingApp('rowdate-local')
+            async with app.run_test(size=(120, 30)) as pilot:
+                await pilot.pause()
+                await pilot.press('x')
+                await pilot.pause()
+                child = _list_items(app)[1]
+                assert isinstance(child, TrackedRevisionItem)
+                assert '12 Mar' in _row_text(child)
+        finally:
+            if old_tz is None:
+                os.environ.pop('TZ', None)
+            else:
+                os.environ['TZ'] = old_tz
+            time.tzset()
+
+    @pytest.mark.asyncio
+    async def test_the_date_touches_neither_neighbour(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The date shares the submitter field with the version label.
+
+        Both are variable width, so the check is that blanks separate the
+        date from the version beside it and from the counts after it, and
+        that Subject still starts on the same column as the parent row's --
+        which is the column the header describes.
+        """
+        _seed_multiver('rowdate-gap')
+        conn = tracking.get_db('rowdate-gap')
+        conn.execute(
+            "UPDATE revisions SET last_mail_at = '2026-03-11T10:00:00+00:00'"
+            " WHERE change_id = 'multi-1' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('rowdate-gap')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            child = items[1]
+            assert isinstance(child, TrackedRevisionItem)
+            text = _row_text(child)
+            assert 'multi: test series' in text
+            # Blank on both sides of the date, whatever it renders as.
+            assert re.search(r'v1 +11 Mar +', text)
+            # Subject starts where the parent's does, and the header says so.
+            assert text.index('multi: test series') == _row_text(items[0]).index('[v2,')
+
+
+class TestConflictNoticeKeepsItsKeyHint:
+    def test_the_link_key_survives_markup_rendering(self) -> None:
+        """notify() renders Rich markup, so a bare [l] is eaten as a tag."""
+        from textual.content import Content
+
+        from b4.review_tui._tracking_app import _conflicts_notice
+
+        notice = _conflicts_notice([2, 3])
+        rendered = Content.from_markup(notice).plain
+        assert 'v2, v3' in rendered
+        assert '[l]' in rendered
+        # ...and nothing was mistaken for a style along the way.
+        assert Content.from_markup(notice).spans == []
+
+
+class TestDiscoveryErrorSurvivesMarkupRendering:
+    """A lore exception is arbitrary text, and notify() parses markup."""
+
+    def test_a_bracketed_subject_is_not_swallowed(self) -> None:
+        """Lowercase-initial brackets parse as a style tag and vanish."""
+        from textual.content import Content
+
+        from b4.review_tui._tracking_app import _discovery_error_notice
+
+        notice = _discovery_error_notice('no match for [patch v2 1/3] foo: fix')
+        rendered = Content.from_markup(notice).plain
+        assert '[patch v2 1/3] foo: fix' in rendered
+        assert Content.from_markup(notice).spans == []
+
+    def test_a_closing_tag_does_not_raise(self) -> None:
+        """An unbalanced '[/...]' raises MarkupError inside the toast."""
+        from textual.content import Content
+
+        from b4.review_tui._tracking_app import _discovery_error_notice
+
+        notice = _discovery_error_notice('cannot read [/var/tmp/x] while fetching')
+        rendered = Content.from_markup(notice).plain
+        assert '[/var/tmp/x]' in rendered
+
+
+class TestVersionRowActionGating:
+    @pytest.mark.asyncio
+    async def test_range_diff_is_offered_on_a_thanked_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """[x] expands in every state, and the docs promise 'd' on a child."""
+        _seed_multiver('gate-thanked')
+        conn = tracking.get_db('gate-thanked')
+        tracking.update_series_status(conn, 'multi-1', 'thanked', revision=2)
+        conn.close()
+
+        app = TrackingApp('gate-thanked')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app.check_action('toggle_expand', ()) is True
+            assert app.check_action('range_diff', ()) is True
+
+    @pytest.mark.asyncio
+    async def test_review_is_disabled_on_another_version(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """'r' checks out the tracked revision, not the highlighted one."""
+        _seed_multiver('gate-review')
+        app = TrackingApp('gate-review')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app.check_action('review', ()) is True
+            await pilot.press('x')
+            await pilot.pause()
+            # Cursor onto v1 -- not the tracked v2.
+            await pilot.press('j')
+            await pilot.pause()
+            assert _selected_rev(app) is not None
+            assert app.check_action('review', ()) is False
+            # ...and on the tracked revision's own row it is fine again.
+            await pilot.press('j')
+            await pilot.pause()
+            assert _selected_rev(app) is not None
+            assert app.check_action('review', ()) is True
+
+    @pytest.mark.asyncio
+    async def test_take_and_rebase_are_disabled_on_another_version(
+        self, gitdir: str
+    ) -> None:
+        """Both act on the review branch, which holds the tracked revision.
+
+        'r' was greyed out for that reason from the start; these two build
+        and move the very same branch, so offering them on a v1 row runs
+        them against v2 while every label on screen says v1.
+        """
+        # A real branch, or the startup rescan turns 'reviewing' into 'gone'
+        # and neither action is offered in any case.
+        _create_review_branch(gitdir, 'multi-1', identifier='gate-take', revision=2)
+        _seed_multiver('gate-take')
+        conn = tracking.get_db('gate-take')
+        tracking.update_series_status(conn, 'multi-1', 'reviewing', revision=2)
+        conn.close()
+
+        app = TrackingApp('gate-take')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app.check_action('take', ()) is True
+            assert app.check_action('rebase', ()) is True
+            await pilot.press('x')
+            await pilot.pause()
+            # Cursor onto v1 -- not the tracked v2.
+            await pilot.press('j')
+            await pilot.pause()
+            assert _selected_rev(app) is not None
+            assert app.check_action('take', ()) is False
+            assert app.check_action('rebase', ()) is False
+            # ...and the action menu drops them for the same row.
+            await pilot.press('a')
+            await pilot.pause()
+            assert isinstance(app.screen, ActionScreen)
+            lv = app.screen.query_one('#action-list', ListView)
+            from b4.review_tui._modals import ActionItem
+
+            offered = [c.key for c in lv.children if isinstance(c, ActionItem)]
+            assert 'take' not in offered
+            assert 'rebase' not in offered
+            # An action that does not touch the branch is still there.
+            assert 'snooze' in offered
+            await pilot.press('escape')
+
+
+class TestThreadViewLeavesNoStaleFocus:
+    """Viewing a version's thread must not capture a later reload.
+
+    _stash_focus() is a hint for the *next* _refresh_list(), and every
+    other caller reloads immediately.  action_thread() instead pushes a
+    screen and returns, so the hint outlives the action -- and re-reading
+    an already-read thread writes nothing, leaving the DB mtime alone and
+    _check_db_changed() asleep, so nothing consumes it either.  The next
+    reload from an unrelated action then parks the cursor on the version
+    row the maintainer looked at, arming _selected_revision behind their
+    back.
+    """
+
+    @pytest.mark.asyncio
+    async def test_a_viewed_version_does_not_capture_a_later_reload(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        from textual.screen import ModalScreen
+
+        class _StubThreadScreen(ModalScreen[None]):
+            """Stands in for LiteThreadScreen: pushes and pops, no fetch."""
+
+            def __init__(self, *args: Any, **kwargs: Any) -> None:
+                super().__init__()
+
+        monkeypatch.setattr(
+            'b4.review_tui._lite_app.LiteThreadScreen', _StubThreadScreen
+        )
+        # Re-reading a thread whose counts have not moved writes nothing, so
+        # in the real flow the mtime poller stays asleep and never reloads.
+        # The startup rescan does touch the DB here, so pin the poller off
+        # rather than race its one-second timer.
+        monkeypatch.setattr(TrackingApp, '_check_db_changed', lambda self: None)
+
+        # Seeded oldest-first: the list sorts newest-tracked first, so
+        # 'multi-b' heads the list and 'multi-a' follows it.
+        _seed_multiver('thread-focus', change_id='multi-a')
+        _seed_multiver('thread-focus', change_id='multi-b')
+
+        app = TrackingApp('thread-focus')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            items = _list_items(app)
+            assert [i.series['change_id'] for i in items] == ['multi-b', 'multi-a']
+
+            # Expand 'multi-a' and put the cursor on its v1 row.
+            await pilot.press('j')
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            on_child = app._selected_revision
+            assert on_child is not None
+            assert on_child['revision'] == 1
+
+            # View that version's thread and come back.  Nothing about the
+            # thread changed, so no reload happens on the way out.
+            depth = len(app.screen_stack)
+            app.action_thread()
+            await pilot.pause()
+            assert len(app.screen_stack) == depth + 1
+            app.pop_screen()
+            await pilot.pause()
+
+            # The hint must not outlive the action that set it.
+            assert app._focus_change_id is None
+            assert app._focus_revision is None
+
+            # Navigate back up to 'multi-b' and abandon it -- a reload that
+            # deliberately does not stash a focus of its own.
+            await pilot.press('k')
+            await pilot.press('k')
+            await pilot.pause()
+            selected = app._selected_series
+            assert selected is not None
+            assert selected['change_id'] == 'multi-b'
+
+            app._on_abandon_confirmed(True, 'multi-b', 'b4/review/multi-b', False)
+            await pilot.pause()
+
+            # The cursor lands on a series row, not on the version row that
+            # was looked at three actions ago.
+            lv = app.query_one('#tracking-list', ListView)
+            landed = lv.highlighted_child
+            assert isinstance(landed, TrackedSeriesItem)
+            assert app._selected_revision is None
+
+
+class TestExpandAllWithoutASelection:
+    """[X] needs no selection, so it must not restore by row index.
+
+    _stash_focus() records a hint only when something is selected, and
+    expanding is precisely the operation that inserts rows above the
+    cursor -- so the index fallback lands somewhere unrelated.
+    """
+
+    @pytest.mark.asyncio
+    async def test_expand_all_keeps_the_highlighted_series(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        # The mtime poller re-stashes and reloads on its own timer; pin it
+        # off so this exercises [X]'s restore and not a race with it.
+        monkeypatch.setattr(TrackingApp, '_check_db_changed', lambda self: None)
+
+        # Display order is newest-tracked first: multi-c, multi-b, multi-a.
+        for change_id in ('multi-a', 'multi-b', 'multi-c'):
+            _seed_multiver('expand-nosel', change_id=change_id)
+
+        app = TrackingApp('expand-nosel')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert [i.series['change_id'] for i in _list_items(app)] == [
+                'multi-c',
+                'multi-b',
+                'multi-a',
+            ]
+
+            # Put the cursor on the last row, away from index 0.
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            selected = app._selected_series
+            assert selected is not None
+            target = selected['change_id']
+            assert target == 'multi-a'
+
+            # Close the details panel: the selection goes, the cursor stays.
+            await pilot.press('escape')
+            await pilot.pause()
+            assert app._selected_series is None
+
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 12  # 3 parents + 3 versions each
+
+            # Still the same series, and still its parent row.
+            lv = app.query_one('#tracking-list', ListView)
+            landed = lv.highlighted_child
+            assert isinstance(landed, TrackedSeriesItem)
+            assert landed.series['change_id'] == target
+            assert app._selected_revision is None
+
+
+class TestTargetBranchKeepsTheVersionRow:
+    """Setting a target branch must not repaint the panel for another row.
+
+    [t] acts on the series whichever row the cursor is on, which is fine
+    -- but its callback refreshes the details panel without saying which
+    version the cursor is sitting on, so the panel silently reverts to
+    the tracked revision while the cursor visibly stays on the child row.
+    """
+
+    @pytest.mark.asyncio
+    async def test_setting_a_target_keeps_the_panel_on_the_version_row(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        # The mtime poller would reload and repaint on its own timer; pin
+        # it off so this pins the callback's own behaviour.
+        monkeypatch.setattr(TrackingApp, '_check_db_changed', lambda self: None)
+        _seed_multiver('target-version-row')
+
+        app = TrackingApp('target-version-row')
+        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()
+            on_child = app._selected_revision
+            assert on_child is not None
+            assert on_child['revision'] == 1
+            assert _version_row_shown(app)
+
+            app._on_target_branch_set('sound/for-next')
+            await pilot.pause()
+
+            # The cursor never moved, so the panel must still describe the
+            # version it is on.
+            still_on = app._selected_revision
+            assert still_on is not None
+            assert still_on['revision'] == 1
+            assert _version_row_shown(app)

-- 
2.53.0


      parent reply	other threads:[~2026-08-12 21:47 UTC|newest]

Thread overview: 26+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 02/25] review: do not clear fields a re-adding caller does not know Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 03/25] review-tui: keep the rethread flag on an upgraded series row Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 04/25] review: guard the tracking-commit amend on the worktree, not the checkout Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 05/25] review-tui: recompute an evicted A·R·T cache entry Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 06/25] review: test the prerequisite fixes Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 07/25] review: serialize schema migrations against a concurrent opener Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 08/25] review: test the migration serialization Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 09/25] review: track message counts for all revisions of a series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 10/25] review: give per-change_id state its own table Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 11/25] review: test per-revision message tracking Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 12/25] review-tui: poll every revision on u/U updates Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 13/25] review: test the per-revision poll sweep Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 14/25] review-tui: resolve the tracked revision in revision lists Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 15/25] review-tui: fall back when a cached thread blob has no series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 17/25] review: skip the catalog mirror when nothing moved Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 18/25] review: match a stray posting by message-id Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 19/25] review: add backward discovery of older series revisions Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 20/25] review-tui: add a "Find older revisions" action Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 21/25] review: test the catalog mirror, stray matching and backward discovery Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 22/25] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 23/25] review-tui: give the unseen badge a column of its own Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 24/25] review-tui: expand tracked series into per-version rows Christian Brauner
2026-08-12 21:47 ` Christian Brauner [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260812-work-b4-multiver-rows-v2-25-305d53cd723a@kernel.org \
    --to=brauner@kernel.org \
    --cc=konstantin@linuxfoundation.org \
    --cc=tools@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox