test: improve coverage and raise threshold to 80% (#154)
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from takopi.config import ConfigError
|
||||
from takopi.telegram import onboarding
|
||||
from takopi.telegram.api_models import Update, User
|
||||
from takopi.telegram.client import TelegramRetryAfter
|
||||
|
||||
|
||||
class DummyUI:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
confirms: list[bool | None] | None = None,
|
||||
selects: list[Any] | None = None,
|
||||
passwords: list[str | None] | None = None,
|
||||
) -> None:
|
||||
self.confirms = iter(confirms or [])
|
||||
self.selects = iter(selects or [])
|
||||
self.passwords = iter(passwords or [])
|
||||
self.printed: list[object] = []
|
||||
self.steps: list[tuple[str, int]] = []
|
||||
|
||||
def panel(
|
||||
self, title: str | None, body: str, *, border_style: str = "yellow"
|
||||
) -> None:
|
||||
self.printed.append(("panel", title, body, border_style))
|
||||
|
||||
def step(self, title: str, *, number: int) -> None:
|
||||
self.steps.append((title, number))
|
||||
|
||||
def print(self, text: object = "", *, markup: bool | None = None) -> None:
|
||||
_ = markup
|
||||
self.printed.append(text)
|
||||
|
||||
async def confirm(self, _prompt: str, default: bool = True) -> bool | None:
|
||||
_ = default
|
||||
return next(self.confirms)
|
||||
|
||||
async def select(self, _prompt: str, choices: list[tuple[str, Any]]) -> Any | None:
|
||||
_ = choices
|
||||
return next(self.selects)
|
||||
|
||||
async def password(self, _prompt: str) -> str | None:
|
||||
return next(self.passwords)
|
||||
|
||||
|
||||
class DummyServices:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot_info: list[User | None] | None = None,
|
||||
chat: onboarding.ChatInfo | None = None,
|
||||
topics_issue: ConfigError | None = None,
|
||||
engines: list[tuple[str, bool, str | None]] | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.bot_info = iter(bot_info or [])
|
||||
self.chat = chat
|
||||
self.topics_issue = topics_issue
|
||||
self.engines = engines or []
|
||||
self.config = config or {}
|
||||
self.writes: list[tuple[Path, dict[str, Any]]] = []
|
||||
|
||||
async def get_bot_info(self, _token: str) -> User | None:
|
||||
return next(self.bot_info)
|
||||
|
||||
async def wait_for_chat(self, _token: str) -> onboarding.ChatInfo:
|
||||
assert self.chat is not None
|
||||
return self.chat
|
||||
|
||||
async def validate_topics(
|
||||
self, _token: str, _chat_id: int, _scope: onboarding.TopicScope
|
||||
) -> ConfigError | None:
|
||||
return self.topics_issue
|
||||
|
||||
def list_engines(self) -> list[tuple[str, bool, str | None]]:
|
||||
return list(self.engines)
|
||||
|
||||
def read_config(self, _path: Path) -> dict[str, Any]:
|
||||
return dict(self.config)
|
||||
|
||||
def write_config(self, path: Path, data: dict[str, Any]) -> None:
|
||||
self.writes.append((path, data))
|
||||
|
||||
|
||||
def test_chat_info_display_and_kind() -> None:
|
||||
chat = onboarding.ChatInfo(
|
||||
chat_id=1,
|
||||
username="alice",
|
||||
title=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="private",
|
||||
)
|
||||
assert chat.display == "@alice"
|
||||
assert chat.kind == "private chat"
|
||||
|
||||
group = onboarding.ChatInfo(
|
||||
chat_id=2,
|
||||
username=None,
|
||||
title="Team",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="supergroup",
|
||||
)
|
||||
assert group.display == 'group "Team"'
|
||||
assert group.kind == 'supergroup "Team"'
|
||||
|
||||
channel = onboarding.ChatInfo(
|
||||
chat_id=3,
|
||||
username=None,
|
||||
title=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="channel",
|
||||
)
|
||||
assert channel.display == "channel"
|
||||
assert channel.kind == "channel"
|
||||
|
||||
unnamed = onboarding.ChatInfo(
|
||||
chat_id=4,
|
||||
username=None,
|
||||
title=None,
|
||||
first_name="Ada",
|
||||
last_name="Lovelace",
|
||||
chat_type=None,
|
||||
)
|
||||
assert unnamed.display == "Ada Lovelace"
|
||||
assert unnamed.kind == "private chat"
|
||||
|
||||
|
||||
def test_onboarding_state_helpers(tmp_path: Path) -> None:
|
||||
state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False)
|
||||
assert state.is_stateful is False
|
||||
assert state.bot_ref == "your bot"
|
||||
|
||||
state.session_mode = "chat"
|
||||
assert state.is_stateful is True
|
||||
|
||||
state.session_mode = "stateless"
|
||||
state.topics_enabled = True
|
||||
assert state.is_stateful is True
|
||||
|
||||
state.bot_name = "Takopi"
|
||||
assert state.bot_ref == "Takopi"
|
||||
state.bot_username = "takopi_bot"
|
||||
assert state.bot_ref == "@takopi_bot"
|
||||
|
||||
|
||||
def test_display_path(tmp_path: Path) -> None:
|
||||
home_path = Path.home() / "takopi" / "cfg.toml"
|
||||
assert onboarding.display_path(home_path).startswith("~/")
|
||||
assert onboarding.display_path(tmp_path / "cfg.toml") == str(tmp_path / "cfg.toml")
|
||||
|
||||
|
||||
def test_build_transport_patch_requires_fields(tmp_path: Path) -> None:
|
||||
state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False)
|
||||
with pytest.raises(RuntimeError, match="missing chat"):
|
||||
onboarding.build_transport_patch(state, bot_token="x")
|
||||
|
||||
state.chat = onboarding.ChatInfo(
|
||||
chat_id=1,
|
||||
username=None,
|
||||
title=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="private",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="missing session mode"):
|
||||
onboarding.build_transport_patch(state, bot_token="x")
|
||||
|
||||
state.session_mode = "chat"
|
||||
with pytest.raises(RuntimeError, match="missing resume choice"):
|
||||
onboarding.build_transport_patch(state, bot_token="x")
|
||||
|
||||
|
||||
def test_build_config_patch_and_merge(tmp_path: Path) -> None:
|
||||
state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False)
|
||||
state.chat = onboarding.ChatInfo(
|
||||
chat_id=10,
|
||||
username=None,
|
||||
title=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="private",
|
||||
)
|
||||
state.session_mode = "chat"
|
||||
state.show_resume_line = False
|
||||
state.default_engine = "codex"
|
||||
state.topics_enabled = True
|
||||
state.topics_scope = "all"
|
||||
|
||||
patch = onboarding.build_config_patch(state, bot_token="token")
|
||||
assert patch["default_engine"] == "codex"
|
||||
|
||||
merged = onboarding.merge_config(
|
||||
{"bot_token": "old", "chat_id": 1, "transports": {}},
|
||||
patch,
|
||||
config_path=tmp_path / "cfg.toml",
|
||||
)
|
||||
assert merged["transport"] == "telegram"
|
||||
assert merged["transports"]["telegram"]["bot_token"] == "token"
|
||||
assert merged["transports"]["telegram"]["topics"]["enabled"] is True
|
||||
assert "bot_token" not in merged
|
||||
assert "chat_id" not in merged
|
||||
|
||||
|
||||
def test_render_helpers() -> None:
|
||||
text = onboarding.render_botfather_instructions()
|
||||
assert "BotFather" in text.plain
|
||||
assert "send /newbot" in text.plain
|
||||
|
||||
topics = onboarding.render_topics_group_instructions("@bot")
|
||||
assert "topics" in topics.plain
|
||||
|
||||
generic = onboarding.render_generic_capture_prompt("@bot")
|
||||
assert "send /start" in generic.plain
|
||||
|
||||
warning = onboarding.render_topics_validation_warning(ConfigError("boom"))
|
||||
assert "warning" in warning.plain
|
||||
|
||||
config_warning = onboarding.render_config_malformed_warning(ConfigError("bad"))
|
||||
assert "config is malformed" in config_warning.plain
|
||||
|
||||
backup_warning = onboarding.render_backup_failed_warning(OSError("nope"))
|
||||
assert "failed to back up" in backup_warning.plain
|
||||
|
||||
tabs = onboarding.render_persona_tabs()
|
||||
assert isinstance(tabs, Table)
|
||||
|
||||
preview = onboarding.render_workspace_preview()
|
||||
assert "memory-box" in preview.plain
|
||||
|
||||
assistant = onboarding.render_assistant_preview()
|
||||
assert "make happy wings fit" in assistant.plain
|
||||
|
||||
handoff = onboarding.render_handoff_preview()
|
||||
assert "resume" in handoff.plain
|
||||
|
||||
convo = Text()
|
||||
onboarding.append_dialogue(convo, "bot", "hello", speaker_style="cyan")
|
||||
assert "hello" in convo.plain
|
||||
|
||||
|
||||
def test_render_engine_table_prints() -> None:
|
||||
ui = DummyUI()
|
||||
onboarding.render_engine_table(
|
||||
cast(onboarding.UI, ui),
|
||||
[("codex", True, None), ("other", False, "brew install other")],
|
||||
)
|
||||
assert ui.printed
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_bot_info_retries(monkeypatch) -> None:
|
||||
class _Bot:
|
||||
def __init__(self, _token: str) -> None:
|
||||
self.calls = 0
|
||||
self.closed = False
|
||||
|
||||
async def get_me(self) -> User | None:
|
||||
self.calls += 1
|
||||
if self.calls < 3:
|
||||
raise TelegramRetryAfter(0)
|
||||
return User(id=1, username="bot")
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
async def _sleep(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(onboarding, "TelegramClient", _Bot)
|
||||
monkeypatch.setattr(onboarding.anyio, "sleep", _sleep)
|
||||
|
||||
info = await onboarding.get_bot_info("token")
|
||||
assert info is not None
|
||||
assert info.username == "bot"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_bot_info_gives_up(monkeypatch) -> None:
|
||||
class _Bot:
|
||||
def __init__(self, _token: str) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def get_me(self) -> User | None:
|
||||
self.calls += 1
|
||||
raise TelegramRetryAfter(0)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def _sleep(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(onboarding, "TelegramClient", _Bot)
|
||||
monkeypatch.setattr(onboarding.anyio, "sleep", _sleep)
|
||||
|
||||
info = await onboarding.get_bot_info("token")
|
||||
assert info is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wait_for_chat_filters_updates(monkeypatch) -> None:
|
||||
updates = [
|
||||
[Update(update_id=1, message={"from": {"is_bot": True}, "chat": {"id": 1}})],
|
||||
None,
|
||||
[],
|
||||
[Update(update_id=2, message=None)],
|
||||
[
|
||||
Update(
|
||||
update_id=3,
|
||||
message={"from": {"is_bot": True}, "chat": {"id": 2}},
|
||||
)
|
||||
],
|
||||
[
|
||||
Update(
|
||||
update_id=4,
|
||||
message={"from": {"is_bot": False}, "chat": "nope"},
|
||||
)
|
||||
],
|
||||
[
|
||||
Update(
|
||||
update_id=5,
|
||||
message={"from": {"is_bot": False}, "chat": {"id": "bad"}},
|
||||
)
|
||||
],
|
||||
[
|
||||
Update(
|
||||
update_id=6,
|
||||
message={
|
||||
"from": {"is_bot": False},
|
||||
"chat": {"id": 7, "username": "bob", "type": "private"},
|
||||
},
|
||||
)
|
||||
],
|
||||
]
|
||||
|
||||
class _Bot:
|
||||
def __init__(self, _token: str) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def get_updates(self, *args, **kwargs):
|
||||
_ = args, kwargs
|
||||
idx = self.calls
|
||||
self.calls += 1
|
||||
return updates[idx]
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def _sleep(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(onboarding, "TelegramClient", _Bot)
|
||||
monkeypatch.setattr(onboarding.anyio, "sleep", _sleep)
|
||||
|
||||
chat = await onboarding.wait_for_chat("token")
|
||||
assert chat.chat_id == 7
|
||||
assert chat.username == "bob"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_topics_onboarding_errors(monkeypatch) -> None:
|
||||
class _Bot:
|
||||
def __init__(self, _token: str) -> None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def _raise_config(*_args, **_kwargs):
|
||||
raise ConfigError("bad")
|
||||
|
||||
async def _raise_other(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(onboarding, "TelegramClient", _Bot)
|
||||
monkeypatch.setattr(onboarding, "_validate_topics_setup_for", _raise_config)
|
||||
err = await onboarding.validate_topics_onboarding("token", 1, "auto", ())
|
||||
assert isinstance(err, ConfigError)
|
||||
|
||||
monkeypatch.setattr(onboarding, "_validate_topics_setup_for", _raise_other)
|
||||
err = await onboarding.validate_topics_onboarding("token", 1, "auto", ())
|
||||
assert isinstance(err, ConfigError)
|
||||
assert "topics validation failed" in str(err)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prompt_token_success_and_retry() -> None:
|
||||
ui = DummyUI(confirms=[True], passwords=["", "token", "token"]) # empty then retry
|
||||
svc = DummyServices(
|
||||
bot_info=[None, User(id=1, username="bot")],
|
||||
)
|
||||
|
||||
token, info = await onboarding.prompt_token(
|
||||
cast(onboarding.UI, ui), cast(onboarding.Services, svc)
|
||||
)
|
||||
assert token == "token"
|
||||
assert info.username == "bot"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prompt_token_cancel_on_failure() -> None:
|
||||
ui = DummyUI(confirms=[False], passwords=["token"])
|
||||
svc = DummyServices(bot_info=[None])
|
||||
|
||||
with pytest.raises(onboarding.OnboardingCancelled):
|
||||
await onboarding.prompt_token(
|
||||
cast(onboarding.UI, ui), cast(onboarding.Services, svc)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_capture_chat_sets_state() -> None:
|
||||
chat = onboarding.ChatInfo(
|
||||
chat_id=5,
|
||||
username=None,
|
||||
title="Team",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="group",
|
||||
)
|
||||
ui = DummyUI()
|
||||
svc = DummyServices(chat=chat)
|
||||
state = onboarding.OnboardingState(config_path=Path("/tmp/cfg"), force=False)
|
||||
state.token = "token"
|
||||
|
||||
await onboarding.capture_chat(
|
||||
cast(onboarding.UI, ui), cast(onboarding.Services, svc), state
|
||||
)
|
||||
assert state.chat == chat
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_step_capture_chat_workspace_switches_to_assistant(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
chat = onboarding.ChatInfo(
|
||||
chat_id=6,
|
||||
username=None,
|
||||
title=None,
|
||||
first_name="Ada",
|
||||
last_name=None,
|
||||
chat_type="private",
|
||||
)
|
||||
ui = DummyUI(selects=["assistant"])
|
||||
svc = DummyServices(chat=chat, topics_issue=ConfigError("nope"))
|
||||
state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False)
|
||||
state.token = "token"
|
||||
state.bot_name = "Takopi"
|
||||
state.persona = "workspace"
|
||||
state.topics_scope = "auto"
|
||||
state.topics_enabled = True
|
||||
|
||||
await onboarding.step_capture_chat(
|
||||
cast(onboarding.UI, ui), cast(onboarding.Services, svc), state
|
||||
)
|
||||
|
||||
assert state.persona == "assistant"
|
||||
assert state.topics_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_step_default_engine_no_installed(tmp_path: Path) -> None:
|
||||
ui = DummyUI(confirms=[False])
|
||||
svc = DummyServices(engines=[("codex", False, None)])
|
||||
state = onboarding.OnboardingState(config_path=tmp_path / "cfg", force=False)
|
||||
|
||||
with pytest.raises(onboarding.OnboardingCancelled):
|
||||
await onboarding.step_default_engine(
|
||||
cast(onboarding.UI, ui), cast(onboarding.Services, svc), state
|
||||
)
|
||||
Reference in New Issue
Block a user