[PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback

Christian Brauner <[email protected]>
Newsgroups org.kernel.linux.tools
Message-ID <[email protected]>
Cover merge_tracked_revisions() and get_revisions_with_tracked(): the
synthesized entry, every live series row contributing one, and neither
blob nor read state coming across.  Cover fetch_fake_am_range()'s three
sources: the stitched series blob, a cached thread that does hold the
whole series, and the lore refetch for one that does not, including the
incomplete cache kept as a fallback and the stitched result stored back.

Signed-off-by: Christian Brauner (Amutable) <[email protected]>
---
 src/tests/test_review_tracking.py | 105 +++++++++++++++
 src/tests/test_tui_review.py      | 274 +++++++++++++++++++++++++++++++++++++-
 2 files changed, 378 insertions(+), 1 deletion(-)

diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index e2fcdf85..145fac8f 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -8352,3 +8352,108 @@ class TestThreadBlobResolution:
     def test_no_topdir_still_answers(self) -> None:
         assert review_tracking.resolve_thread_blob(None, {'thread-blob': 'x'}) == 'x'
         assert review_tracking.resolve_thread_blob(None, {}) == ''
+
+
+class TestGetRevisionsWithTracked:
+    def test_the_tracked_revision_is_always_present(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Manually linking a newer version used to record only that one.
+
+        Tracking a series now catalogues the revision it tracks, so the
+        entry is a real row rather than one synthesized on read -- which is
+        also what gives its read state somewhere to live.
+        """
+        conn = review_tracking.init_db('with-tracked')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.commit()
+        revs = review_tracking.get_revisions_with_tracked(conn, 'cid')
+        conn.close()
+        assert [r['revision'] for r in revs] == [2, 3]
+        tracked = revs[0]
+        assert tracked['message_id'] == 'v2@x'
+
+    def test_two_live_series_rows_both_resolve(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """rescan_branches can leave a change_id with two live rows.
+
+        The tracking list renders a row per live series row, and every
+        gate that offers the range-diff counts the same way, so both
+        revisions have to be resolvable here -- picking one would enable
+        an action on a version this function cannot find a message-id for.
+        """
+        conn = review_tracking.init_db('tracked-ambiguous')
+        for rev in (2, 5):
+            conn.execute(
+                'INSERT INTO series (change_id, revision, message_id, subject,'
+                " sender_name, sender_email, status) VALUES (?,?,?,?,?,?,'new')",
+                ('cid', rev, f'v{rev}@x', f'subj v{rev}', 'A', 'a@x'),
+            )
+        conn.commit()
+        revs = review_tracking.get_revisions_with_tracked(conn, 'cid')
+        conn.close()
+        assert [r['revision'] for r in revs] == [2, 5]
+        assert [r['message_id'] for r in revs] == ['v2@x', 'v5@x']
+
+    def test_present_tracked_revision_is_left_alone(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('with-tracked-noop')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='[email protected]',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.commit()
+        revs = review_tracking.get_revisions_with_tracked(conn, 'cid')
+        conn.close()
+        assert [r['revision'] for r in revs] == [2]
+        assert revs[0]['source'] == 'heuristic'
+
+
+class TestMergeTrackedRevisionsHelper:
+    """One merge rule for the DB resolver and the TUI's version rows."""
+
+    def test_every_series_row_resolves(self) -> None:
+        revs = [
+            {
+                'change_id': 'cid',
+                'revision': 2,
+                'message_id': 'v2@x',
+                'message_count': 4,
+            }
+        ]
+        rows = [
+            {'revision': 3, 'message_id': 'v3@x', 'subject': 's3'},
+            {'revision': 2, 'message_id': 'v2@x', 'subject': 's2'},
+            {'revision': 1, 'message_id': ''},
+        ]
+        merged = review_tracking.merge_tracked_revisions('cid', revs, rows)
+        assert [r['revision'] for r in merged] == [2, 3]
+        # The catalog row wins over a synthesized twin.
+        assert merged[0]['message_count'] == 4
+        synth = merged[1]
+        assert synth['source'] == 'tracked'
+        assert synth['message_id'] == 'v3@x'
+        # No read state: the entry supplies a message-id, not a badge.
+        assert synth['message_count'] is None
+        assert synth['seen_message_count'] is None
diff --git a/src/tests/test_tui_review.py b/src/tests/test_tui_review.py
index 8c4ce039..641b61c8 100644
--- a/src/tests/test_tui_review.py
+++ b/src/tests/test_tui_review.py
@@ -10,7 +10,7 @@ cosmetic commit edits (e.g. reworded subjects via git rebase -i).
 """
 
 import json
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Optional, Tuple
 from unittest import mock
 
 import pytest
@@ -18,6 +18,7 @@ import pytest
 pytest.importorskip('textual')
 
 import b4
+import b4.mbox
 import b4.review
 import b4.review.tracking
 from b4.review_tui._review_app import ReviewApp
@@ -995,3 +996,274 @@ class TestRangeDiffBindingGate:
         # Range-diff is a review-mode action; email mode hides it
         app._preview_mode = True
         assert app.check_action('range_diff', ()) is False
+
+
+class TestIncompleteCachedThreadBlob:
+    """A blob the poller cached may hold only part of a series."""
+
+    @staticmethod
+    def _revisions() -> List[Dict[str, Any]]:
+        return [
+            {
+                'revision': 1,
+                'message_id': '[email protected]',
+                'thread_blob': 'cafebabe',
+            }
+        ]
+
+    @staticmethod
+    def _series(complete: bool, patches: int) -> mock.Mock:
+        """A LoreSeries stub holding *patches* of its patches."""
+        lser = mock.Mock()
+        lser.complete = complete
+        # patches[0] is the cover slot, which _known_patches skips.
+        lser.patches = [None] + [mock.Mock() for _ in range(patches)]
+        lser.make_fake_am_range.return_value = ('start', 'end')
+        return lser
+
+    def _patch_blob(
+        self,
+        monkeypatch: pytest.MonkeyPatch,
+        complete: bool,
+        patches: int = 1,
+        then: Optional[List[mock.Mock]] = None,
+    ) -> mock.Mock:
+        """Make the cached blob decode to a series with the given completeness.
+
+        *then*, when given, is what the later _series_from() calls decode
+        to, in order -- a series blob, the thread blob and the lore refetch
+        are all parsed by the same helper, so each needs its own
+        LoreMailbox result.
+        """
+        lser = self._series(complete, patches)
+        monkeypatch.setattr(
+            b4.review.tracking, 'get_thread_mbox', lambda topdir, sha: b'From x\n'
+        )
+        monkeypatch.setattr(b4, 'split_and_dedupe_pi_results', lambda raw: ['m'])
+        results = [lser] + list(then or [])
+        lmbx = mock.Mock()
+        lmbx.get_series.side_effect = lambda *a, **kw: (
+            results.pop(0) if len(results) > 1 else results[0]
+        )
+        monkeypatch.setattr(b4, 'LoreMailbox', lambda: lmbx)
+        return lser
+
+    def test_an_incomplete_blob_is_used_when_the_refetch_fails(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """An incomplete range-diff beats none.
+
+        Discarding the blob outright turned a 'd' that used to work into a
+        silent failure whenever the refetch could not run -- offline, lore
+        down, or a message-id that 404s.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        lser = self._patch_blob(monkeypatch, complete=False)
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: None)
+
+        assert fetch_fake_am_range('/nonexistent', self._revisions(), 1) == (
+            'start',
+            'end',
+        )
+        assert lser.make_fake_am_range.called
+
+    def test_a_more_complete_refetch_replaces_the_cached_blob(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Otherwise the same refetch repeats on every press of 'd'.
+
+        The poller re-stores what it counted, so nothing else ever replaces
+        a blob holding one patch's thread.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        better = self._series(complete=True, patches=4)
+        self._patch_blob(monkeypatch, complete=False, patches=1, then=[better])
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: ['a', 'b']
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        recached: List[Tuple[int, int]] = []
+        fetch_fake_am_range(
+            '/nonexistent',
+            self._revisions(),
+            1,
+            recache=lambda rev, msgs: recached.append((rev, len(msgs))),
+        )
+        assert recached == [(1, 2)]
+        assert better.make_fake_am_range.called
+
+    def test_a_refetch_that_is_no_better_is_not_recorded_as_a_stitch(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A short refetch must not decide the range-diff -- or be cached.
+
+        Lore can truncate, and a rethreaded version's per-patch queries can
+        come back incomplete, so the fuller cached thread is what this
+        range-diff is built from.  Those same bytes must not then be
+        written back as the version's series blob: nothing was stitched,
+        and `series_blob` means "every patch of this version" -- which is
+        exactly what the thread arm has just found they are not.  Filed
+        under that name they are read back as an answer, and since a
+        settled version's thread never changes again, nothing ever drops
+        them.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        worse = self._series(complete=False, patches=1)
+        cached = self._patch_blob(monkeypatch, complete=False, patches=3, then=[worse])
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: ['a', 'b']
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        recached: List[Tuple[int, List[Any]]] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            self._revisions(),
+            1,
+            recache=lambda rev, msgs: recached.append((rev, msgs)),
+        ) == ('start', 'end')
+        # Nothing was stitched, so nothing is recorded as a stitch.
+        assert recached == []
+        # And the range-diff is built from the cache, not the short refetch.
+        assert cached.make_fake_am_range.called
+        assert not worse.make_fake_am_range.called
+
+    def test_an_incomplete_improvement_is_used_but_not_recorded(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Fuller than the thread is still not every patch.
+
+        Using it is right -- it is the best reconstruction there is -- but
+        recording it would let the series-blob arm hand it back as the
+        whole version on the next press, without the completeness test
+        that just judged it short.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        better = self._series(complete=False, patches=3)
+        self._patch_blob(monkeypatch, complete=False, patches=1, then=[better])
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: ['a', 'b']
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        recached: List[int] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            self._revisions(),
+            1,
+            recache=lambda rev, msgs: recached.append(rev),
+        ) == ('start', 'end')
+        assert better.make_fake_am_range.called
+        assert recached == []
+
+    def test_an_incomplete_series_blob_is_a_fallback_not_an_answer(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Otherwise a short stitch pins every later range-diff to itself.
+
+        The blob is dropped when its thread changes, and a settled old
+        version's thread never does -- so trusting a short stitch here is
+        for ever, and silently: the log line reads 'using cached series
+        blob' either way, while the thread arm would have said the version
+        was incomplete and refetched.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        thread = self._series(complete=False, patches=1)
+        better = self._series(complete=True, patches=4)
+        stitched = self._patch_blob(
+            monkeypatch, complete=False, patches=2, then=[thread, better]
+        )
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: ['a', 'b']
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        revisions = self._revisions()
+        revisions[0]['series_blob'] = 'deadbeef'
+        recached: List[int] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            revisions,
+            1,
+            recache=lambda rev, msgs: recached.append(rev),
+        ) == ('start', 'end')
+        assert better.make_fake_am_range.called
+        assert not stitched.make_fake_am_range.called
+        assert recached == [1]
+
+    def test_a_complete_series_blob_short_circuits_everything(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A whole version, already stitched, cannot be improved on.
+
+        It was stored because the thread would not do, and it is dropped
+        the moment that thread changes, so paying for the stitching passes
+        again buys nothing.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        stitched = self._patch_blob(monkeypatch, complete=True, patches=2)
+        fetched: List[str] = []
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: fetched.append(msgid)
+        )
+        revisions = self._revisions()
+        revisions[0]['series_blob'] = 'deadbeef'
+        recached: List[int] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            revisions,
+            1,
+            recache=lambda rev, msgs: recached.append(rev),
+        ) == ('start', 'end')
+        assert stitched.make_fake_am_range.called
+        assert fetched == []
+        assert recached == []
+
+    def test_a_complete_blob_is_not_refetched(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The cache still has to hit in the normal case."""
+        from b4.review_tui._common import fetch_fake_am_range
+
+        self._patch_blob(monkeypatch, complete=True)
+        fetched: List[str] = []
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4,
+            'get_pi_thread_by_msgid',
+            lambda msgid, **kw: fetched.append(msgid),
+        )
+        recached: List[int] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            self._revisions(),
+            1,
+            recache=lambda rev, msgs: recached.append(rev),
+        ) == ('start', 'end')
+        assert fetched == []
+        assert recached == []

-- 
2.53.0
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.