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 03/11] review: test per-revision message tracking
Date: Sat, 18 Jul 2026 00:37:39 +0200 [thread overview]
Message-ID: <20260718-work-b4-multiver-rows-v1-3-3c539d2a3095@kernel.org> (raw)
In-Reply-To: <20260718-work-b4-multiver-rows-v1-0-3c539d2a3095@kernel.org>
Cover the v11 migration (column adds, catalog backfill from live and
archived series rows, idempotence), the stitched count reads (live
wins, archived never shadows, NULL falls back), the per-revision
poller (first fetch, quiet incremental with zero writes, new-mail
bump, error paths, skip statuses, tracked-row guarantee, newest-first
cap, rethreaded first fetch and per-patch incremental, blob caching),
and the revision-aware fallbacks in refresh_message_count() and
sync_seen_from_unseen_count().
Assisted-by: LLM
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
src/tests/test_review_tracking.py | 705 ++++++++++++++++++++++++++++++++++++++
1 file changed, 705 insertions(+)
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index bcf8745..5002aaa 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -4190,3 +4190,708 @@ class TestSyncRevisionsCatalogToBranch:
)
is False
)
+
+
+def _make_legacy_v10_db(identifier: str) -> str:
+ """Create a schema-v10 database (revisions without count columns)."""
+ path = review_tracking.get_db_path(identifier)
+ conn = sqlite3.connect(path)
+ conn.executescript(
+ """
+ CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
+ CREATE TABLE series (
+ track_id INTEGER PRIMARY KEY,
+ change_id TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ subject TEXT,
+ sender_name TEXT,
+ sender_email TEXT,
+ sent_at TEXT,
+ added_at TEXT,
+ message_id TEXT,
+ num_patches INTEGER,
+ pw_series_id INTEGER,
+ status TEXT DEFAULT 'new',
+ fingerprint TEXT,
+ branch_sha TEXT,
+ message_count INT,
+ seen_message_count INT,
+ last_update_check TEXT,
+ last_activity_at TEXT,
+ snoozed_until TEXT,
+ attestation TEXT DEFAULT 'pending',
+ target_branch TEXT,
+ is_rethreaded INTEGER DEFAULT 0,
+ UNIQUE (change_id, revision)
+ );
+ CREATE TABLE revisions (
+ change_id TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ message_id TEXT NOT NULL,
+ subject TEXT,
+ link TEXT,
+ found_at TEXT,
+ thread_blob TEXT,
+ fingerprint TEXT,
+ source TEXT DEFAULT 'heuristic',
+ is_rethreaded INTEGER DEFAULT 0,
+ PRIMARY KEY (change_id, revision)
+ );
+ """
+ )
+ conn.execute('INSERT INTO schema_version (version) VALUES (10)')
+ # (i) live tracked series with counts and a matching catalog row,
+ # plus a catalog-only older revision.
+ conn.execute(
+ 'INSERT INTO series (change_id, revision, subject, message_id, status,'
+ ' message_count, seen_message_count, last_update_check,'
+ ' last_activity_at, added_at)'
+ " VALUES ('cid-live', 2, 'live v2', 'live-v2@x', 'reviewing',"
+ " 8, 6, '2026-07-01T00:00:00+00:00', '2026-06-30T00:00:00+00:00',"
+ " '2026-06-01T00:00:00+00:00')"
+ )
+ conn.execute(
+ 'INSERT INTO revisions (change_id, revision, message_id)'
+ " VALUES ('cid-live', 2, 'live-v2@x')"
+ )
+ conn.execute(
+ 'INSERT INTO revisions (change_id, revision, message_id)'
+ " VALUES ('cid-live', 1, 'live-v1@x')"
+ )
+ # (ii) archived series row (upgrade leftover) with old counts.
+ conn.execute(
+ 'INSERT INTO series (change_id, revision, message_id, status,'
+ ' message_count, seen_message_count)'
+ " VALUES ('cid-live', 1, 'live-v1@x', 'archived', 4, 1)"
+ )
+ # (iii) tracked series with no catalog row at all.
+ conn.execute(
+ 'INSERT INTO series (change_id, revision, message_id, status,'
+ ' message_count, seen_message_count)'
+ " VALUES ('cid-norow', 3, 'norow-v3@x', 'new', 5, 5)"
+ )
+ # (iv) series row without a message-id.
+ conn.execute(
+ 'INSERT INTO series (change_id, revision, message_id, status)'
+ " VALUES ('cid-nomsgid', 1, '', 'new')"
+ )
+ conn.commit()
+ conn.close()
+ return path
+
+
+class TestSchemaV11RevisionCounts:
+ """Schema v11: per-revision unread tracking lands on the catalog."""
+
+ def test_schema_version_at_least_11(self) -> None:
+ assert review_tracking.SCHEMA_VERSION >= 11
+
+ def test_new_db_has_count_columns(self, tmp_path: pytest.TempPathFactory) -> None:
+ conn = review_tracking.init_db('v11-cols')
+ cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+ conn.close()
+ assert {
+ 'message_count',
+ 'seen_message_count',
+ 'last_update_check',
+ 'last_activity_at',
+ } <= cols
+
+ def test_migration_adds_columns_and_bumps_version(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ _make_legacy_v10_db('v11-migrate')
+ conn = review_tracking.get_db('v11-migrate') # runs migration on open
+ cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+ version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+ conn.close()
+ assert 'message_count' in cols
+ assert version == review_tracking.SCHEMA_VERSION
+
+ def test_migration_backfills_catalog_rows(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ _make_legacy_v10_db('v11-backfill')
+ conn = review_tracking.get_db('v11-backfill')
+ norow = review_tracking.get_revisions(conn, 'cid-norow')
+ nomsgid = review_tracking.get_revisions(conn, 'cid-nomsgid')
+ conn.close()
+ # The series row without a catalog entry gets one, carrying counts.
+ assert len(norow) == 1
+ assert norow[0]['message_id'] == 'norow-v3@x'
+ assert norow[0]['message_count'] == 5
+ assert norow[0]['seen_message_count'] == 5
+ # No catalog row is invented without a message-id.
+ assert nomsgid == []
+
+ def test_migration_seeds_existing_catalog_rows(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ _make_legacy_v10_db('v11-seed')
+ conn = review_tracking.get_db('v11-seed')
+ revs = {
+ r['revision']: r for r in review_tracking.get_revisions(conn, 'cid-live')
+ }
+ conn.close()
+ # v2 counts come from the live series row via the stitched read.
+ assert revs[2]['message_count'] == 8
+ assert revs[2]['seen_message_count'] == 6
+ # v1 counts were seeded from the archived series row (the only
+ # historical data) into the catalog columns.
+ assert revs[1]['message_count'] == 4
+ assert revs[1]['seen_message_count'] == 1
+
+ def test_migration_idempotent(self, tmp_path: pytest.TempPathFactory) -> None:
+ _make_legacy_v10_db('v11-idem')
+ review_tracking.get_db('v11-idem').close()
+ conn = review_tracking.get_db('v11-idem')
+ nrevs = conn.execute('SELECT COUNT(*) FROM revisions').fetchone()[0]
+ version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+ conn.close()
+ assert nrevs == 3
+ assert version == review_tracking.SCHEMA_VERSION
+
+
+class TestStitchedRevisionReads:
+ """Per-revision counts stitch series (live) over catalog columns."""
+
+ def test_live_series_counts_win(self, tmp_path: pytest.TempPathFactory) -> None:
+ conn = review_tracking.init_db('stitch-live')
+ review_tracking.add_series_to_db(
+ conn,
+ change_id='cid',
+ revision=2,
+ subject='s',
+ sender_name='n',
+ sender_email='e@x',
+ sent_at='2026-06-01T00:00:00+00:00',
+ message_id='v2@x',
+ num_patches=1,
+ )
+ conn.execute(
+ 'UPDATE series SET message_count = 10, seen_message_count = 7'
+ " WHERE change_id = 'cid'"
+ )
+ review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+ conn.execute(
+ 'UPDATE revisions SET message_count = 3, seen_message_count = 3'
+ " WHERE change_id = 'cid' AND revision = 2"
+ )
+ conn.commit()
+ revs = review_tracking.get_revisions(conn, 'cid')
+ conn.close()
+ assert revs[0]['message_count'] == 10
+ assert revs[0]['seen_message_count'] == 7
+
+ def test_archived_series_does_not_shadow(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ conn = review_tracking.init_db('stitch-arch')
+ review_tracking.add_series_to_db(
+ conn,
+ change_id='cid',
+ revision=1,
+ subject='s',
+ sender_name='n',
+ sender_email='e@x',
+ sent_at='2026-06-01T00:00:00+00:00',
+ message_id='v1@x',
+ num_patches=1,
+ )
+ conn.execute(
+ 'UPDATE series SET message_count = 4, seen_message_count = 0,'
+ " status = 'archived' WHERE change_id = 'cid'"
+ )
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ conn.execute(
+ 'UPDATE revisions SET message_count = 6, seen_message_count = 6'
+ " WHERE change_id = 'cid' AND revision = 1"
+ )
+ conn.commit()
+ revs = review_tracking.get_revisions(conn, 'cid')
+ conn.close()
+ assert revs[0]['message_count'] == 6
+ assert revs[0]['seen_message_count'] == 6
+
+ def test_null_series_counts_fall_back(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ conn = review_tracking.init_db('stitch-null')
+ review_tracking.add_series_to_db(
+ conn,
+ change_id='cid',
+ revision=2,
+ subject='s',
+ sender_name='n',
+ sender_email='e@x',
+ sent_at='2026-06-01T00:00:00+00:00',
+ message_id='v2@x',
+ num_patches=1,
+ )
+ review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+ conn.execute(
+ 'UPDATE revisions SET message_count = 9, seen_message_count = 9'
+ " WHERE change_id = 'cid' AND revision = 2"
+ )
+ conn.commit()
+ revs = review_tracking.get_revisions(conn, 'cid')
+ conn.close()
+ assert revs[0]['message_count'] == 9
+ assert revs[0]['seen_message_count'] == 9
+
+ def test_grouped_returns_full_columns(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ conn = review_tracking.init_db('stitch-grouped')
+ review_tracking.add_revision(
+ conn, 'cid', 1, 'v1@x', fingerprint='fp1', is_rethreaded=True
+ )
+ grouped = review_tracking.get_all_revisions_grouped(conn)
+ conn.close()
+ entry = grouped['cid'][0]
+ assert entry['is_rethreaded']
+ assert entry['fingerprint'] == 'fp1'
+ assert entry['source'] == 'heuristic'
+ assert entry['message_count'] is None
+
+ def test_summarize_revision_unseen(self) -> None:
+ revs = [
+ {'revision': 1, 'message_count': 5, 'seen_message_count': 3},
+ {'revision': 2, 'message_count': None, 'seen_message_count': None},
+ {'revision': 3, 'message_count': 9, 'seen_message_count': 0},
+ ]
+ assert review_tracking.summarize_revision_unseen(revs, 3) == 2
+ assert review_tracking.summarize_revision_unseen(revs) == 11
+
+
+def _thread_msgs(count: int, base: str = 'm') -> list[EmailMessage]:
+ """Build a minimal thread of EmailMessage objects with Date headers."""
+ msgs = []
+ for i in range(count):
+ msg = EmailMessage()
+ msg['Subject'] = f'Re: thread {i}'
+ msg['From'] = 'Dev <dev@example.com>'
+ msg['Message-Id'] = f'<{base}-{i}@example.com>'
+ msg['Date'] = f'Thu, {i + 1:02d} Jul 2026 08:00:00 +0000'
+ msg.set_payload('body\n')
+ msgs.append(msg)
+ return msgs
+
+
+def _poller_series(
+ change_id: str, revision: int, message_id: str, status: str = 'new'
+) -> Dict[str, Any]:
+ """Series dict shaped like the TUI's loaded rows, for the poller."""
+ return {
+ 'change_id': change_id,
+ 'revision': revision,
+ 'message_id': message_id,
+ 'subject': 'test subject',
+ 'status': status,
+ }
+
+
+class TestUpdateRevisionMessageCounts:
+ """The per-revision poller for non-tracked versions."""
+
+ def test_first_fetch_initializes_counts(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-first')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ conn.close()
+ monkeypatch.setattr(
+ review_tracking,
+ '_fetch_revision_thread_msgs',
+ lambda identifier, conn, change_id, rev: _thread_msgs(3),
+ )
+ result = review_tracking.update_revision_message_counts(
+ 'poll-first', [_poller_series('cid', 2, 'v2@x')]
+ )
+ assert result == {'updated': 1, 'errors': 0}
+ conn = review_tracking.get_db('poll-first')
+ revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+ conn.close()
+ assert revs[1]['message_count'] == 3
+ assert revs[1]['seen_message_count'] == 3
+ assert revs[1]['last_update_check'] is not None
+ assert revs[1]['last_activity_at'] is not None
+ # The tracked revision's row was backfilled but not polled.
+ assert revs[2]['message_count'] is None
+
+ def test_quiet_incremental_makes_zero_writes(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-quiet')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+ conn.execute(
+ 'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+ " last_update_check = '2026-07-01T00:00:00+00:00'"
+ " WHERE change_id = 'cid' AND revision = 1"
+ )
+ conn.commit()
+ conn.close()
+ db_path = review_tracking.get_db_path('poll-quiet')
+ before_mtime = os.stat(db_path).st_mtime_ns
+ monkeypatch.setattr(
+ review_tracking, '_fetch_new_since', lambda msgid, since: (0, None)
+ )
+ result = review_tracking.update_revision_message_counts(
+ 'poll-quiet', [_poller_series('cid', 2, 'v2@x')]
+ )
+ assert result == {'updated': 0, 'errors': 0}
+ assert os.stat(db_path).st_mtime_ns == before_mtime
+
+ def test_incremental_new_mail_bumps_count(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-new')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+ conn.execute(
+ 'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+ " last_update_check = '2026-07-01T00:00:00+00:00',"
+ " last_activity_at = '2026-06-30T00:00:00+00:00'"
+ " WHERE change_id = 'cid' AND revision = 1"
+ )
+ conn.commit()
+ conn.close()
+ monkeypatch.setattr(
+ review_tracking,
+ '_fetch_new_since',
+ lambda msgid, since: (2, '2026-07-10T00:00:00+00:00'),
+ )
+ result = review_tracking.update_revision_message_counts(
+ 'poll-new', [_poller_series('cid', 2, 'v2@x')]
+ )
+ assert result == {'updated': 1, 'errors': 0}
+ conn = review_tracking.get_db('poll-new')
+ revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+ conn.close()
+ assert revs[1]['message_count'] == 7
+ assert revs[1]['seen_message_count'] == 5
+ assert revs[1]['last_activity_at'] == '2026-07-10T00:00:00+00:00'
+ assert revs[1]['last_update_check'] > '2026-07-01T00:00:00+00:00'
+
+ def test_incremental_fetch_error_counts_error(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-err')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+ conn.execute(
+ 'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+ " last_update_check = '2026-07-01T00:00:00+00:00'"
+ " WHERE change_id = 'cid' AND revision = 1"
+ )
+ conn.commit()
+ conn.close()
+ monkeypatch.setattr(
+ review_tracking, '_fetch_new_since', lambda msgid, since: None
+ )
+ result = review_tracking.update_revision_message_counts(
+ 'poll-err', [_poller_series('cid', 2, 'v2@x')]
+ )
+ assert result == {'updated': 0, 'errors': 1}
+ conn = review_tracking.get_db('poll-err')
+ revs = review_tracking.get_revisions(conn, 'cid')
+ conn.close()
+ assert revs[0]['message_count'] == 5
+
+ def test_skip_statuses_not_polled(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-skip')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ conn.close()
+ calls: list[int] = []
+ monkeypatch.setattr(
+ review_tracking,
+ '_fetch_revision_thread_msgs',
+ lambda identifier, conn, change_id, rev: calls.append(rev['revision']),
+ )
+ for status in ('archived', 'accepted', 'thanked', 'snoozed'):
+ result = review_tracking.update_revision_message_counts(
+ 'poll-skip', [_poller_series('cid', 2, 'v2@x', status=status)]
+ )
+ assert result == {'updated': 0, 'errors': 0}
+ assert calls == []
+
+ def test_tracked_revision_not_polled(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-tracked')
+ review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+ conn.close()
+ calls: list[int] = []
+ monkeypatch.setattr(
+ review_tracking,
+ '_fetch_revision_thread_msgs',
+ lambda identifier, conn, change_id, rev: calls.append(rev['revision']),
+ )
+ result = review_tracking.update_revision_message_counts(
+ 'poll-tracked', [_poller_series('cid', 2, 'v2@x')]
+ )
+ assert result == {'updated': 0, 'errors': 0}
+ assert calls == []
+
+ def test_tracked_row_backfilled_when_missing(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ review_tracking.init_db('poll-backfill').close()
+ review_tracking.update_revision_message_counts(
+ 'poll-backfill', [_poller_series('cid', 2, 'v2@x')]
+ )
+ conn = review_tracking.get_db('poll-backfill')
+ revs = review_tracking.get_revisions(conn, 'cid')
+ conn.close()
+ assert len(revs) == 1
+ assert revs[0]['revision'] == 2
+ assert revs[0]['message_id'] == 'v2@x'
+ assert revs[0]['message_count'] is None
+
+ def test_newest_first_with_cap(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-cap')
+ for rev in (1, 2, 3, 4):
+ review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+ conn.close()
+ polled: list[int] = []
+
+ def _fake_fetch(
+ identifier: str, conn: Any, change_id: str, rev: Dict[str, Any]
+ ) -> list[EmailMessage]:
+ polled.append(int(rev['revision']))
+ return _thread_msgs(2)
+
+ monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fake_fetch)
+ result = review_tracking.update_revision_message_counts(
+ 'poll-cap',
+ [_poller_series('cid', 4, 'v4@x')],
+ max_revisions_per_series=1,
+ )
+ assert result == {'updated': 1, 'errors': 0}
+ assert polled == [3]
+
+ def test_rethreaded_first_fetch_reassembles(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-rt-first')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+ _insert_patches(conn, 'cid', 1, ['p1@x', 'p2@x'])
+ conn.close()
+ seen_dicts: list[Dict[str, Any]] = []
+
+ def _fake_retrieve(
+ series: Dict[str, Any], identifier: str
+ ) -> list[EmailMessage]:
+ seen_dicts.append(series)
+ return _thread_msgs(4)
+
+ monkeypatch.setattr(b4.review, 'retrieve_series_messages', _fake_retrieve)
+ result = review_tracking.update_revision_message_counts(
+ 'poll-rt-first', [_poller_series('cid', 2, 'v2@x')]
+ )
+ assert result == {'updated': 1, 'errors': 0}
+ assert seen_dicts and seen_dicts[0]['is_rethreaded'] is True
+ assert seen_dicts[0]['revision'] == 1
+ conn = review_tracking.get_db('poll-rt-first')
+ revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+ conn.close()
+ assert revs[1]['message_count'] == 4
+
+ def test_rethreaded_incremental_sums_patch_threads(
+ self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-rt-incr')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+ _insert_patches(conn, 'cid', 1, ['p1@x', 'p2@x'])
+ conn.execute(
+ 'UPDATE revisions SET message_count = 6, seen_message_count = 6,'
+ " last_update_check = '2026-07-01T00:00:00+00:00'"
+ " WHERE change_id = 'cid' AND revision = 1"
+ )
+ conn.commit()
+ conn.close()
+ queried: list[str] = []
+
+ def _fake_new_since(msgid: str, since: str) -> tuple[int, str]:
+ queried.append(msgid)
+ return (1, '2026-07-10T00:00:00+00:00')
+
+ monkeypatch.setattr(review_tracking, '_fetch_new_since', _fake_new_since)
+ result = review_tracking.update_revision_message_counts(
+ 'poll-rt-incr', [_poller_series('cid', 2, 'v2@x')]
+ )
+ assert result == {'updated': 1, 'errors': 0}
+ assert queried == ['p1@x', 'p2@x']
+ conn = review_tracking.get_db('poll-rt-incr')
+ revs = review_tracking.get_revisions(conn, 'cid')
+ conn.close()
+ assert revs[0]['message_count'] == 8
+
+ def test_first_fetch_stores_blob_with_topdir(
+ self, gitdir: str, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ conn = review_tracking.init_db('poll-blob')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ conn.close()
+ monkeypatch.setattr(
+ review_tracking,
+ '_fetch_revision_thread_msgs',
+ lambda identifier, conn, change_id, rev: _thread_msgs(2),
+ )
+ review_tracking.update_revision_message_counts(
+ 'poll-blob', [_poller_series('cid', 2, 'v2@x')], topdir=gitdir
+ )
+ conn = review_tracking.get_db('poll-blob')
+ revs = review_tracking.get_revisions(conn, 'cid')
+ conn.close()
+ blob_sha = revs[0]['thread_blob']
+ assert blob_sha
+ mbox = review_tracking.get_thread_mbox(gitdir, blob_sha)
+ assert mbox is not None
+ assert b'm-0@example.com' in mbox
+
+
+class TestRevisionAwareSyncHelpers:
+ """refresh_message_count / sync_seen fall back to the catalog."""
+
+ def _seed(self, identifier: str) -> None:
+ conn = review_tracking.init_db(identifier)
+ review_tracking.add_series_to_db(
+ conn,
+ change_id='cid',
+ revision=2,
+ subject='s',
+ sender_name='n',
+ sender_email='e@x',
+ sent_at='2026-06-01T00:00:00+00:00',
+ message_id='v2@x',
+ num_patches=1,
+ )
+ conn.execute(
+ 'UPDATE series SET message_count = 8, seen_message_count = 8'
+ " WHERE change_id = 'cid'"
+ )
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+ conn.execute(
+ 'UPDATE revisions SET message_count = 6, seen_message_count = 6'
+ " WHERE change_id = 'cid' AND revision = 1"
+ )
+ conn.execute(
+ 'UPDATE revisions SET message_count = 3, seen_message_count = 3'
+ " WHERE change_id = 'cid' AND revision = 2"
+ )
+ conn.commit()
+ conn.close()
+
+ def test_sync_seen_prefers_live_series_row(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ self._seed('sync-live')
+ assert review_tracking.sync_seen_from_unseen_count('sync-live', 'cid', 2, 2)
+ conn = review_tracking.get_db('sync-live')
+ series_seen = conn.execute(
+ "SELECT seen_message_count FROM series WHERE change_id = 'cid'"
+ ).fetchone()[0]
+ rev_seen = conn.execute(
+ 'SELECT seen_message_count FROM revisions'
+ " WHERE change_id = 'cid' AND revision = 2"
+ ).fetchone()[0]
+ conn.close()
+ assert series_seen == 6
+ assert rev_seen == 3
+
+ def test_sync_seen_falls_back_to_catalog(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ self._seed('sync-fall')
+ assert review_tracking.sync_seen_from_unseen_count('sync-fall', 'cid', 1, 4)
+ conn = review_tracking.get_db('sync-fall')
+ rev_seen = conn.execute(
+ 'SELECT seen_message_count FROM revisions'
+ " WHERE change_id = 'cid' AND revision = 1"
+ ).fetchone()[0]
+ conn.close()
+ assert rev_seen == 2
+
+ def test_sync_seen_ignores_archived_series(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ self._seed('sync-arch')
+ conn = review_tracking.get_db('sync-arch')
+ conn.execute(
+ 'INSERT INTO series (change_id, revision, message_id, status,'
+ ' message_count, seen_message_count)'
+ " VALUES ('cid', 1, 'v1@x', 'archived', 4, 4)"
+ )
+ conn.commit()
+ conn.close()
+ assert review_tracking.sync_seen_from_unseen_count('sync-arch', 'cid', 1, 1)
+ conn = review_tracking.get_db('sync-arch')
+ archived_seen = conn.execute(
+ "SELECT seen_message_count FROM series WHERE change_id = 'cid'"
+ ' AND revision = 1'
+ ).fetchone()[0]
+ rev_seen = conn.execute(
+ 'SELECT seen_message_count FROM revisions'
+ " WHERE change_id = 'cid' AND revision = 1"
+ ).fetchone()[0]
+ conn.close()
+ assert archived_seen == 4
+ assert rev_seen == 5
+
+ def test_sync_seen_no_rows_returns_false(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ review_tracking.init_db('sync-none').close()
+ assert not review_tracking.sync_seen_from_unseen_count('sync-none', 'cid', 9, 1)
+
+ def test_refresh_count_falls_back_to_catalog(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ conn = review_tracking.init_db('refresh-fall')
+ review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+ conn.close()
+ assert review_tracking.refresh_message_count('refresh-fall', 'cid', 1, 7)
+ conn = review_tracking.get_db('refresh-fall')
+ revs = review_tracking.get_revisions(conn, 'cid')
+ conn.close()
+ # First fetch initialises both counts equally (no badge).
+ assert revs[0]['message_count'] == 7
+ assert revs[0]['seen_message_count'] == 7
+
+ def test_refresh_count_unchanged_series_skips_catalog(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ self._seed('refresh-skip')
+ assert not review_tracking.refresh_message_count('refresh-skip', 'cid', 2, 8)
+ conn = review_tracking.get_db('refresh-skip')
+ rev_count = conn.execute(
+ 'SELECT message_count FROM revisions'
+ " WHERE change_id = 'cid' AND revision = 2"
+ ).fetchone()[0]
+ conn.close()
+ assert rev_count == 3
+
+ def test_mark_all_revision_messages_seen(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
+ self._seed('mark-rev')
+ conn = review_tracking.get_db('mark-rev')
+ conn.execute(
+ 'UPDATE revisions SET seen_message_count = 1'
+ " WHERE change_id = 'cid' AND revision = 1"
+ )
+ conn.commit()
+ review_tracking.mark_all_revision_messages_seen(conn, 'cid', 1)
+ rev_seen = conn.execute(
+ 'SELECT seen_message_count FROM revisions'
+ " WHERE change_id = 'cid' AND revision = 1"
+ ).fetchone()[0]
+ conn.close()
+ assert rev_seen == 6
--
2.53.0
next prev parent reply other threads:[~2026-07-17 22:38 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-17 22:37 [PATCH RFC 00/11] review: track and browse every version of a tracked series Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 01/11] review-tui: fix rethreaded series thread viewing Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 02/11] review: track message counts for all revisions of a series Christian Brauner
2026-07-17 22:37 ` Christian Brauner [this message]
2026-07-17 22:37 ` [PATCH RFC 04/11] review-tui: poll every revision on u/U updates Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 05/11] review-tui: guarantee the tracked revision in revision lists Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 06/11] review: add backward discovery of older series revisions Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 07/11] review-tui: add a "Find older revisions" action Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 08/11] review: test backward revision discovery Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 09/11] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 10/11] review-tui: expand tracked series into per-version rows Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 11/11] review-tui: test per-version tracker rows Christian Brauner
2026-07-27 20:43 ` [PATCH RFC 00/11] review: track and browse every version of a tracked series Konstantin Ryabitsev
2026-07-27 21:27 ` Christian Brauner
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=20260718-work-b4-multiver-rows-v1-3-3c539d2a3095@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