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
prev 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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.