blob: a772be77f83fbf38408a5f49818e436c7d9a6173 [file] [edit]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2024 by the Linux Foundation
#
"""Unit tests for b4 bugs helpers.
Tests the pure-logic functions in _import.py and _tui.py that don't
need Textual, git-bug, or network access.
"""
from datetime import datetime, timezone
from typing import Set
from unittest import mock
from b4.bugs._import import (
format_comment,
is_comment_removed,
make_tombstone,
parse_comment_header,
parse_comment_msgid,
)
from b4.bugs._tui import (
BugListApp,
_bug_last_activity,
_bug_lifecycle,
_bug_tier,
_relative_time,
label_color,
)
from ezgb import Bug, BugSummary, Comment, Identity, Status
# ---------------------------------------------------------------------------
# Helpers -- factory functions for real Bug and BugSummary objects
# ---------------------------------------------------------------------------
_EPOCH = datetime(2026, 1, 1, tzinfo=timezone.utc)
_IDENTITY = Identity(id='test', name='Test', email='test@test.com', login='')
def make_bug(
*,
status: Status = Status.OPEN,
labels: Set[str] | None = None,
comments: list[Comment] | None = None,
created_at: datetime | None = None,
title: str = '',
) -> Bug:
return Bug(
id='deadbeef' * 8,
title=title,
status=status,
creator=_IDENTITY,
created_at=created_at or _EPOCH,
labels=labels or set(),
comments=comments or [],
)
def make_comment(created_at: datetime) -> Comment:
return Comment(
id='c0ffee' * 11,
author=_IDENTITY,
text='',
created_at=created_at,
count=0,
attachment_ids=[],
)
def make_summary(
*,
status: Status = Status.OPEN,
labels: frozenset[str] | None = None,
edited_at: datetime | None = None,
created_at: datetime | None = None,
title: str = '',
comment_count: int = 0,
author_name: str = '',
) -> BugSummary:
return BugSummary(
id='deadbeef' * 8,
title=title,
status=status,
creator_id='test',
created_at=created_at or _EPOCH,
labels=labels or frozenset(),
comment_count=comment_count,
author_name=author_name,
edited_at=edited_at or _EPOCH,
)
# ===========================================================================
# _import.py tests
# ===========================================================================
class TestParseCommentHeader:
def test_extracts_from(self) -> None:
text = 'From: Alice <alice@example.com>\nDate: Mon, 1 Jan 2026\n\nBody'
assert parse_comment_header(text, 'From') == 'Alice <alice@example.com>'
def test_extracts_message_id(self) -> None:
text = 'Message-ID: <abc@example.com>\n\nBody'
assert parse_comment_header(text, 'Message-ID') == '<abc@example.com>'
def test_case_insensitive(self) -> None:
text = 'message-id: <abc@example.com>\n\nBody'
assert parse_comment_header(text, 'Message-ID') == '<abc@example.com>'
def test_returns_none_for_missing(self) -> None:
text = 'From: Alice\n\nBody'
assert parse_comment_header(text, 'Date') is None
def test_does_not_match_body(self) -> None:
text = 'From: Alice\n\nMessage-ID: <fake@body>'
assert parse_comment_header(text, 'Message-ID') is None
def test_empty_text(self) -> None:
assert parse_comment_header('', 'From') is None
def test_no_body_separator(self) -> None:
text = 'From: Alice'
assert parse_comment_header(text, 'From') == 'Alice'
class TestParseCommentMsgid:
def test_strips_angle_brackets(self) -> None:
text = 'Message-ID: <abc@example.com>\n\nBody'
assert parse_comment_msgid(text) == 'abc@example.com'
def test_returns_none_when_missing(self) -> None:
text = 'From: Alice\n\nBody'
assert parse_comment_msgid(text) is None
class TestIsCommentRemoved:
def test_detects_tombstone(self) -> None:
text = 'Message-ID: <abc@example.com>\nX-B4-Bug-Comment: removed by Alice <alice@test.com>'
assert is_comment_removed(text) is True
def test_normal_comment(self) -> None:
text = 'From: Alice\nMessage-ID: <abc@example.com>\n\nNormal body'
assert is_comment_removed(text) is False
def test_empty_text(self) -> None:
assert is_comment_removed('') is False
class TestMakeTombstone:
def test_preserves_message_id(self) -> None:
text = 'From: Alice\nMessage-ID: <abc@example.com>\nIn-Reply-To: <parent@example.com>\n\nBody text'
tomb = make_tombstone(text, 'Admin <admin@test.com>')
assert 'Message-ID: <abc@example.com>' in tomb
assert 'In-Reply-To: <parent@example.com>' in tomb
assert 'X-B4-Bug-Comment: removed by Admin <admin@test.com>' in tomb
def test_strips_from_and_body(self) -> None:
text = 'From: Alice\nDate: Mon, 1 Jan\nMessage-ID: <abc@example.com>\n\nSensitive body'
tomb = make_tombstone(text, 'Admin <admin@test.com>')
assert 'Alice' not in tomb
assert 'Sensitive body' not in tomb
assert 'Date:' not in tomb
def test_roundtrip_with_is_removed(self) -> None:
text = 'Message-ID: <abc@example.com>\n\nBody'
tomb = make_tombstone(text, 'Admin <admin@test.com>')
assert is_comment_removed(tomb)
def test_msgid_preserved_for_dedup(self) -> None:
text = 'Message-ID: <abc@example.com>\n\nBody'
tomb = make_tombstone(text, 'Admin <admin@test.com>')
assert parse_comment_msgid(tomb) == 'abc@example.com'
def test_no_message_id(self) -> None:
text = 'From: Alice\n\nInternal comment'
tomb = make_tombstone(text, 'Admin <admin@test.com>')
assert 'X-B4-Bug-Comment: removed by Admin' in tomb
assert 'Message-ID' not in tomb
class TestFormatComment:
def test_includes_headers(self) -> None:
msg = mock.MagicMock()
msg.get.side_effect = lambda h, *a: {
'From': 'Alice <alice@test.com>',
'Date': 'Mon, 1 Jan 2026 00:00:00 +0000',
'Message-ID': '<abc@test.com>',
'In-Reply-To': '<parent@test.com>',
}.get(h)
with mock.patch('b4.LoreMessage.clean_header', side_effect=lambda x: x):
with mock.patch(
'b4.LoreMessage.get_payload', return_value=('Body text', 'utf-8')
):
result = format_comment(msg)
assert 'From: Alice <alice@test.com>' in result
assert 'Message-ID: <abc@test.com>' in result
assert 'Body text' in result
def test_scope_header(self) -> None:
msg = mock.MagicMock()
msg.get.side_effect = lambda h, *a: {
'From': 'Alice',
'Message-ID': '<abc@test.com>',
}.get(h)
with mock.patch('b4.LoreMessage.clean_header', side_effect=lambda x: x):
with mock.patch(
'b4.LoreMessage.get_payload', return_value=('Body', 'utf-8')
):
result = format_comment(msg, scope='no-parent')
assert 'X-B4-Bug-Scope: no-parent' in result
# ===========================================================================
# _tui.py tests
# ===========================================================================
class TestLabelColor:
def test_deterministic(self) -> None:
c1 = label_color('review')
c2 = label_color('review')
assert c1 == c2
def test_different_labels_differ(self) -> None:
c1 = label_color('review')
c2 = label_color('priority/high')
# Not guaranteed but extremely likely with different inputs
assert isinstance(c1, str) and c1.startswith('#')
assert isinstance(c2, str) and c2.startswith('#')
def test_returns_hex_color(self) -> None:
c = label_color('test')
assert c.startswith('#')
assert len(c) == 7 # #rrggbb
class TestBugTier:
def test_open_new_is_tier_0(self) -> None:
bug = make_bug()
assert _bug_tier(bug) == 0
def test_confirmed_is_tier_0(self) -> None:
bug = make_bug(labels={'lifecycle:confirmed'})
assert _bug_tier(bug) == 0
def test_needinfo_is_tier_1(self) -> None:
bug = make_bug(labels={'lifecycle:needinfo'})
assert _bug_tier(bug) == 1
def test_fixed_is_tier_2(self) -> None:
bug = make_bug(labels={'lifecycle:fixed'})
assert _bug_tier(bug) == 2
def test_closed_is_tier_2(self) -> None:
from ezgb import Status
bug = make_bug(status=Status.CLOSED)
assert _bug_tier(bug) == 2
def test_summary_works(self) -> None:
s = make_summary(labels=frozenset({'lifecycle:needinfo'}))
assert _bug_tier(s) == 1
class TestBugLifecycle:
def test_new_default(self) -> None:
bug = make_bug()
assert _bug_lifecycle(bug) == '\u2605' # ★
def test_confirmed(self) -> None:
bug = make_bug(labels={'lifecycle:confirmed'})
assert _bug_lifecycle(bug) == '\u00a4' # ¤
def test_fixed(self) -> None:
bug = make_bug(labels={'lifecycle:fixed'})
assert _bug_lifecycle(bug) == '\u2713' # ✓
def test_duplicate(self) -> None:
bug = make_bug(labels={'lifecycle:duplicate'})
assert _bug_lifecycle(bug) == '\u2261' # ≡
def test_closed_no_lifecycle(self) -> None:
from ezgb import Status
bug = make_bug(status=Status.CLOSED)
assert _bug_lifecycle(bug) == '\u00d7' # ×
class TestBugLastActivity:
def test_uses_last_comment(self) -> None:
comment_time = datetime(2026, 3, 15, tzinfo=timezone.utc)
created_time = datetime(2026, 1, 1, tzinfo=timezone.utc)
bug = make_bug(
created_at=created_time,
comments=[make_comment(comment_time)],
)
assert _bug_last_activity(bug) == comment_time
def test_fallback_to_created_at(self) -> None:
created_time = datetime(2026, 1, 1, tzinfo=timezone.utc)
bug = make_bug(created_at=created_time, comments=[])
assert _bug_last_activity(bug) == created_time
def test_summary_uses_edited_at(self) -> None:
from ezgb import BugSummary, Status
edited_time = datetime(2026, 4, 1, tzinfo=timezone.utc)
s = BugSummary(
id='a' * 64,
title='Test',
status=Status.OPEN,
creator_id='b' * 64,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
labels=frozenset(),
comment_count=1,
edited_at=edited_time,
)
assert _bug_last_activity(s) == edited_time
class TestRelativeTime:
def test_just_now(self) -> None:
now = datetime.now(tz=timezone.utc)
assert _relative_time(now) == 'just now'
def test_minutes(self) -> None:
from datetime import timedelta
t = datetime.now(tz=timezone.utc) - timedelta(minutes=5)
assert '5m ago' == _relative_time(t)
def test_hours(self) -> None:
from datetime import timedelta
t = datetime.now(tz=timezone.utc) - timedelta(hours=3)
assert '3h ago' == _relative_time(t)
def test_days(self) -> None:
from datetime import timedelta
t = datetime.now(tz=timezone.utc) - timedelta(days=7)
assert '7d ago' == _relative_time(t)
class TestMatchesLimit:
def test_bare_text_matches_title(self) -> None:
bug = make_bug(title='Crash on startup')
assert BugListApp._matches_limit(bug, 'crash') is True
def test_bare_text_case_insensitive(self) -> None:
bug = make_bug(title='Crash on startup')
assert BugListApp._matches_limit(bug, 'CRASH') is True
def test_bare_text_no_match(self) -> None:
bug = make_bug(title='Crash on startup')
assert BugListApp._matches_limit(bug, 'login') is False
def test_status_filter_open(self) -> None:
from ezgb import Status
bug = make_bug(status=Status.OPEN)
assert BugListApp._matches_limit(bug, 's:open') is True
assert BugListApp._matches_limit(bug, 's:closed') is False
def test_status_filter_closed(self) -> None:
from ezgb import Status
bug = make_bug(status=Status.CLOSED)
assert BugListApp._matches_limit(bug, 's:closed') is True
assert BugListApp._matches_limit(bug, 's:open') is False
def test_label_filter(self) -> None:
bug = make_bug(labels={'area/network', 'priority/high'})
assert BugListApp._matches_limit(bug, 'l:network') is True
assert BugListApp._matches_limit(bug, 'l:web') is False
def test_combined_tokens(self) -> None:
bug = make_bug(
title='Network crash',
labels={'area/network'},
)
assert BugListApp._matches_limit(bug, 'crash l:network') is True
assert BugListApp._matches_limit(bug, 'crash l:web') is False
def test_empty_pattern(self) -> None:
bug = make_bug(title='Anything')
assert BugListApp._matches_limit(bug, '') is True
def test_summary_works(self) -> None:
from ezgb import Status
s = make_summary(
title='Network bug',
status=Status.OPEN,
labels=frozenset({'area/network'}),
)
assert BugListApp._matches_limit(s, 'network l:network s:open') is True
class TestBuildActions:
"""Test context-sensitive action list building."""
@staticmethod
def _build(status: str = 'open', lifecycle: str = '') -> list[tuple[str, str]]:
labels: Set[str] = set()
if lifecycle:
labels.add(f'lifecycle:{lifecycle}')
bug = make_bug(
status=Status.CLOSED if status == 'closed' else Status.OPEN,
labels=labels,
)
return BugListApp._build_actions(bug)
def test_new_bug_actions(self) -> None:
actions = self._build()
keys = [k for k, _label in actions]
assert 'confirmed' in keys
assert 'needinfo' in keys
assert 'fixed' in keys
assert 'worksforme' in keys
assert 'wontfix' in keys
assert 'duplicate' in keys
assert 'delete' in keys
def test_confirmed_bug_has_fixed(self) -> None:
actions = self._build(lifecycle='confirmed')
keys = [k for k, _label in actions]
assert 'fixed' in keys
assert 'confirmed' not in keys # already confirmed
def test_closed_bug_has_reopen(self) -> None:
actions = self._build(status='closed')
keys = [k for k, _label in actions]
assert 'reopen' in keys
assert 'delete' in keys
assert len(keys) == 2 # only reopen + delete
def test_needinfo_has_confirm(self) -> None:
actions = self._build(lifecycle='needinfo')
keys = [k for k, _label in actions]
assert 'confirmed' in keys
assert 'needinfo' not in keys
def test_close_reasons_always_available(self) -> None:
"""All close reasons are available from any open lifecycle state."""
for lifecycle in ('', 'new', 'confirmed', 'needinfo'):
actions = self._build(lifecycle=lifecycle)
keys = [k for k, _label in actions]
assert 'fixed' in keys, f'fixed missing for {lifecycle!r}'
assert 'worksforme' in keys, f'worksforme missing for {lifecycle!r}'
assert 'wontfix' in keys, f'wontfix missing for {lifecycle!r}'
assert 'duplicate' in keys, f'duplicate missing for {lifecycle!r}'
class TestParseMsgidForImport:
"""Verify that import_thread accepts URLs and bare message-ids."""
def test_bare_msgid(self) -> None:
import b4
result = b4.parse_msgid('abc123@example.com')
assert result == 'abc123@example.com'
def test_angle_bracketed(self) -> None:
import b4
result = b4.parse_msgid('<abc123@example.com>')
assert result == 'abc123@example.com'
def test_lore_url(self) -> None:
import b4
result = b4.parse_msgid('https://lore.kernel.org/all/abc123@example.com/')
assert result == 'abc123@example.com'
def test_patch_msgid_link(self) -> None:
import b4
result = b4.parse_msgid('https://patch.msgid.link/abc123@example.com')
assert result == 'abc123@example.com'
def test_garbage_has_no_at(self) -> None:
import b4
result = b4.parse_msgid('not-a-msgid')
assert '@' not in result