From d8af6a76a165b75746a8a73cf0e3f34ba4f53852 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sun, 17 May 2026 18:16:26 +0300 Subject: [PATCH] Split fake TDLib client helpers --- docs/REFACTOR_PLAN.md | 250 ++++++ tests/helpers/fake_tdclient.rs | 907 +--------------------- tests/helpers/fake_tdclient/builders.rs | 86 ++ tests/helpers/fake_tdclient/inspect.rs | 92 +++ tests/helpers/fake_tdclient/operations.rs | 458 +++++++++++ tests/helpers/fake_tdclient/state.rs | 201 +++++ 6 files changed, 1099 insertions(+), 895 deletions(-) create mode 100644 docs/REFACTOR_PLAN.md create mode 100644 tests/helpers/fake_tdclient/builders.rs create mode 100644 tests/helpers/fake_tdclient/inspect.rs create mode 100644 tests/helpers/fake_tdclient/operations.rs create mode 100644 tests/helpers/fake_tdclient/state.rs diff --git a/docs/REFACTOR_PLAN.md b/docs/REFACTOR_PLAN.md new file mode 100644 index 0000000..095f264 --- /dev/null +++ b/docs/REFACTOR_PLAN.md @@ -0,0 +1,250 @@ +# tele-tui Refactor Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the next review/refactor layer after the TDLib facade split, keeping behavior stable while making the code easier to test, review, and change. + +**Architecture:** The current working tree already introduces scoped TDLib traits, removes the local `build.rs`, switches message formatting to the system local timezone, moves media chat handlers into a submodule, and makes fake TDLib state more explicit. The remaining work should continue in small vertical slices with focused tests after each slice. + +**Tech Stack:** Rust 2021, Tokio, tdlib-rs, ratatui, crossterm, insta, criterion, Woodpecker CI. + +--- + +## Current Baseline + +The current uncommitted layer should be treated as the baseline before starting the next refactor tasks. + +- TDLib facade is split into scoped traits in `src/tdlib/trait.rs`. +- `src/tdlib/client_impl.rs` implements the scoped traits for `TdClient`. +- `current_chat_messages()` returns `Cow<'_, [MessageInfo]>`; mutation goes through `update_current_chat_messages`. +- Runtime date formatting uses the system local timezone; tests can inject deterministic time through `FixedLocalTime`. +- Media/image/voice chat handling is moved from `src/input/handlers/chat.rs` into `src/input/handlers/chat/media.rs`. +- The repository no longer uses the local `build.rs` that tried to link `tdlib-rs` during build-script execution. + +Verification already used for this baseline: + +```bash +cargo check --all-targets --all-features +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features +git diff --check +``` + +## Task 0: Commit Current Layer + +Goal: preserve the completed facade/timezone/media/test-cleanup work before deeper refactors. + +Files to review before commit: + +- `CONTEXT.md` +- `Cargo.toml` +- `src/tdlib/trait.rs` +- `src/tdlib/mod.rs` +- `src/tdlib/client_impl.rs` +- `src/utils/formatting.rs` +- `src/input/handlers/chat.rs` +- `src/input/handlers/chat/media.rs` +- `tests/helpers/fake_tdclient.rs` +- `tests/helpers/fake_tdclient_impl.rs` +- touched tests and benches + +Steps: + +- [x] Review `git diff --stat` and `git diff --check`. +- [x] Run the full verification commands from the baseline section. +- [x] Commit this layer separately from the follow-up refactors. + +## Task 1: Split `FakeTdClient` + +Goal: reduce `tests/helpers/fake_tdclient.rs` from one large mixed helper into smaller modules with clear responsibilities. + +Target files: + +- `tests/helpers/fake_tdclient.rs` +- `tests/helpers/fake_tdclient_impl.rs` +- `tests/helpers/mod.rs` +- new `tests/helpers/fake_tdclient/state.rs` +- new `tests/helpers/fake_tdclient/builders.rs` +- new `tests/helpers/fake_tdclient/operations.rs` +- new `tests/helpers/fake_tdclient/inspect.rs` + +Steps: + +- [x] Move state aliases and shared storage fields into `state.rs`. +- [x] Move fixture construction helpers such as `with_chat`, `with_messages`, and account setup helpers into `builders.rs`. +- [x] Move behavior helpers such as send/edit/delete/reaction operations into `operations.rs`. +- [x] Move read/assertion helpers such as sent-message inspection and viewed-message inspection into `inspect.rs`. +- [x] Keep the public test API stable unless a call site becomes simpler and safer. +- [x] Remove direct test access to internal `Arc>` fields where helper methods are clearer. +- [x] Run `cargo test --all-features`. + +Acceptance criteria: + +- `FakeTdClient` remains easy to construct in integration tests. +- No test loses behavior coverage. +- `tests/helpers/fake_tdclient.rs` becomes a small module entry point instead of the main implementation body. + +## Task 2: Tighten Internal TDLib Mutation API + +Goal: limit raw mutable access to TDLib client internals and replace cross-module state poking with domain-specific methods. + +Target files: + +- `src/tdlib/client.rs` +- `src/tdlib/chat_helpers.rs` +- `src/tdlib/update_handlers.rs` +- `src/tdlib/message_converter.rs` +- `src/tdlib/client_impl.rs` + +Search command: + +```bash +rg -n "current_chat_messages_mut|chats_mut|folders_mut|pending_user_ids_mut|user_cache_mut" src/tdlib +``` + +Steps: + +- [ ] Add focused methods on `TdClient` for common mutations: update chat, update message by id, queue pending user, update user cache, update folders. +- [ ] Replace raw `*_mut()` usage in helper/update modules with those methods. +- [ ] Keep raw mutable access private to `TdClient` implementation where it is still needed. +- [ ] Add or update tests around message updates, user-cache updates, and chat-list updates. +- [ ] Run `cargo test --all-features`. + +Acceptance criteria: + +- External and helper modules express intent through domain methods. +- Raw state access is either gone or contained in a small internal area. + +## Task 3: Split Remaining Large Input and UI Files + +Goal: make modal, message rendering, and app/input code easier to review independently. + +Target files: + +- `src/input/handlers/modal.rs` +- `src/input/handlers/chat.rs` +- `src/app/mod.rs` +- `src/ui/messages.rs` +- new `src/input/handlers/modal/account.rs` +- new `src/input/handlers/modal/delete.rs` +- new `src/input/handlers/modal/profile.rs` +- new `src/input/handlers/modal/reactions.rs` +- new `src/input/handlers/modal/pinned.rs` +- new `src/ui/messages/header.rs` +- new `src/ui/messages/list.rs` +- new `src/ui/messages/pinned.rs` + +Steps: + +- [ ] Split modal handlers by modal type and keep `modal.rs` as the dispatcher/module entry point. +- [ ] Split message UI rendering into header, pinned-message, and list rendering modules. +- [ ] Keep public function names stable until each split is covered by tests. +- [ ] Avoid mixing behavior changes with file movement. +- [ ] Run focused modal/navigation/message tests after each split. +- [ ] Run `cargo test --all-features` after the full split. + +Acceptance criteria: + +- Large files are reduced to dispatch/orchestration roles. +- The split does not change key handling or rendering behavior. +- Module names match user-facing concepts instead of implementation accidents. + +## Task 4: Remove Production `unwrap()` Risk + +Goal: keep test unwraps where useful, but remove production unwraps where runtime data can be absent. + +Target files: + +- `src/input/handlers/chat/media.rs` +- `src/input/handlers/chat.rs` +- `src/ui/components/message_bubble.rs` +- `src/utils/tdlib.rs` +- `src/audio/player.rs` + +Search command: + +```bash +rg -n "unwrap\\(|expect\\(|panic!\\(" src +``` + +Steps: + +- [ ] Replace `photo_info().unwrap()` and `voice_info().unwrap()` with `let Some(...) else { ... }`. +- [ ] Replace `selected_chat_id.unwrap()` with an early return or status message. +- [ ] Review playback/message unwraps in `message_bubble.rs` and convert absent data into graceful UI fallback. +- [ ] Audit mutex unwraps separately; leave only cases where poisoning should be fatal and documented by context. +- [ ] Add tests for missing media metadata and absent selected chat. +- [ ] Run `cargo clippy --all-targets --all-features -- -D warnings`. + +Acceptance criteria: + +- Malformed or partial TDLib data does not panic in normal UI paths. +- Error handling stays local and does not add noisy user-facing text. + +## Task 5: Resolve TODO and Compatibility Paths + +Goal: make unfinished behavior explicit: either implement it, test it, or remove stale comments. + +Target files: + +- `src/input/key_handler.rs` +- `src/tdlib/reactions.rs` +- `src/tdlib/messages/operations.rs` + +Steps: + +- [ ] Review every TODO in `src/`. +- [ ] Convert active TODOs into tests or tracked plan items. +- [ ] Remove stale TODOs whose behavior is already implemented. +- [ ] For pinned-message compatibility in `messages/operations.rs`, decide whether the fallback is still needed and document the decision in code or tests. +- [ ] Run `cargo test --all-features`. + +Acceptance criteria: + +- Remaining TODOs point to real unresolved behavior. +- No stale TODO describes behavior that no longer exists. + +## Task 6: Add CI Quality Gate + +Goal: make local quality checks reproducible in CI. + +Target files: + +- `.woodpecker/check.yml` +- `DEVELOPMENT.md` +- `AGENT.md` + +Steps: + +- [ ] Add CI steps for `cargo check --all-targets --all-features`. +- [ ] Add CI steps for `cargo clippy --all-targets --all-features -- -D warnings`. +- [ ] Add CI steps for `cargo test --all-features`. +- [ ] Document the same commands in `DEVELOPMENT.md` or `AGENT.md`. +- [ ] Keep CI commands aligned with the commands used by agents and humans locally. + +Acceptance criteria: + +- CI catches compile, lint, and test failures before merge. +- Local documentation and CI use the same command set. + +## Global Acceptance Criteria + +Before considering the refactor layer complete: + +- [ ] `cargo check --all-targets --all-features` passes. +- [ ] `cargo clippy --all-targets --all-features -- -D warnings` passes. +- [ ] `cargo test --all-features` passes. +- [ ] `git diff --check` passes. +- [ ] No unexpected `*.snap.new` files remain. +- [ ] `rg -n "current_chat_messages_mut|chats_mut|folders_mut|pending_user_ids_mut|user_cache_mut" src/tdlib` shows only intentionally contained internal access. +- [ ] `rg -n "unwrap\\(|expect\\(|panic!\\(" src` has no risky production UI or TDLib data-path panics left. + +## Recommended Commit Order + +1. Baseline commit for already completed facade/timezone/media/test cleanup. +2. `FakeTdClient` split. +3. TDLib internal mutation API cleanup. +4. Modal and message UI file splits. +5. Production unwrap cleanup. +6. TODO cleanup. +7. CI quality gate. diff --git a/tests/helpers/fake_tdclient.rs b/tests/helpers/fake_tdclient.rs index 6d5624e..393fee4 100644 --- a/tests/helpers/fake_tdclient.rs +++ b/tests/helpers/fake_tdclient.rs @@ -1,891 +1,15 @@ -// Fake TDLib client for testing - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use tele_tui::tdlib::types::{FolderInfo, ReactionInfo}; -use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo}; -use tele_tui::types::{ChatId, MessageId, UserId}; -use tokio::sync::mpsc; - -pub type ViewedMessages = Vec<(i64, Vec)>; -pub type PendingViewMessages = Vec<(ChatId, Vec)>; - -/// Update события от TDLib (упрощённая версия) -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum TdUpdate { - NewMessage { - chat_id: ChatId, - message: Box, - }, - MessageContent { - chat_id: ChatId, - message_id: MessageId, - new_text: String, - }, - DeleteMessages { - chat_id: ChatId, - message_ids: Vec, - }, - ChatAction { - chat_id: ChatId, - user_id: UserId, - action: String, - }, - MessageInteractionInfo { - chat_id: ChatId, - message_id: MessageId, - reactions: Vec, - }, - ConnectionState { - state: NetworkState, - }, - ChatReadOutbox { - chat_id: ChatId, - last_read_outbox_message_id: MessageId, - }, - ChatDraftMessage { - chat_id: ChatId, - draft_text: Option, - }, -} - -/// Упрощённый mock TDLib клиента для тестов -#[allow(dead_code)] -pub struct FakeTdClient { - // Данные - pub chats: Arc>>, - pub messages: Arc>>>, - pub folders: Arc>>, - pub user_names: Arc>>, - pub profiles: Arc>>, - pub drafts: Arc>>, - pub available_reactions: Arc>>, - - // Состояние - pub network_state: Arc>, - pub typing_chat_id: Arc>>, - pub current_chat_id: Arc>>, - pub current_pinned_message: Arc>>, - pub auth_state: Arc>, - - // История действий (для проверки в тестах) - pub sent_messages: Arc>>, - pub edited_messages: Arc>>, - pub deleted_messages: Arc>>, - pub forwarded_messages: Arc>>, - pub searched_queries: Arc>>, - pub viewed_messages: Arc>, // (chat_id, message_ids) - pub chat_actions: Arc>>, // (chat_id, action) - pub pending_view_messages: Arc>, // Очередь для отметки как прочитанные - - // Update channel для симуляции событий - pub update_tx: Arc>>>, - - // Скачанные файлы (file_id -> local_path) - pub downloaded_files: Arc>>, - - // Настройки поведения - pub simulate_delays: bool, - pub fail_next_operation: Arc>, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct SentMessage { - pub chat_id: i64, - pub text: String, - pub reply_to: Option, - pub reply_info: Option, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct EditedMessage { - pub chat_id: i64, - pub message_id: MessageId, - pub new_text: String, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct DeletedMessages { - pub chat_id: i64, - pub message_ids: Vec, - pub revoke: bool, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct ForwardedMessages { - pub from_chat_id: i64, - pub to_chat_id: i64, - pub message_ids: Vec, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct SearchQuery { - pub chat_id: i64, - pub query: String, - pub results_count: usize, -} - -impl Default for FakeTdClient { - fn default() -> Self { - Self::new() - } -} - -impl Clone for FakeTdClient { - fn clone(&self) -> Self { - Self { - chats: Arc::clone(&self.chats), - messages: Arc::clone(&self.messages), - folders: Arc::clone(&self.folders), - user_names: Arc::clone(&self.user_names), - profiles: Arc::clone(&self.profiles), - drafts: Arc::clone(&self.drafts), - available_reactions: Arc::clone(&self.available_reactions), - network_state: Arc::clone(&self.network_state), - typing_chat_id: Arc::clone(&self.typing_chat_id), - current_chat_id: Arc::clone(&self.current_chat_id), - current_pinned_message: Arc::clone(&self.current_pinned_message), - auth_state: Arc::clone(&self.auth_state), - sent_messages: Arc::clone(&self.sent_messages), - edited_messages: Arc::clone(&self.edited_messages), - deleted_messages: Arc::clone(&self.deleted_messages), - forwarded_messages: Arc::clone(&self.forwarded_messages), - searched_queries: Arc::clone(&self.searched_queries), - viewed_messages: Arc::clone(&self.viewed_messages), - chat_actions: Arc::clone(&self.chat_actions), - pending_view_messages: Arc::clone(&self.pending_view_messages), - downloaded_files: Arc::clone(&self.downloaded_files), - update_tx: Arc::clone(&self.update_tx), - simulate_delays: self.simulate_delays, - fail_next_operation: Arc::clone(&self.fail_next_operation), - } - } -} - -#[allow(dead_code)] -impl FakeTdClient { - pub fn new() -> Self { - Self { - chats: Arc::new(Mutex::new(vec![])), - messages: Arc::new(Mutex::new(HashMap::new())), - folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])), - user_names: Arc::new(Mutex::new(HashMap::new())), - profiles: Arc::new(Mutex::new(HashMap::new())), - drafts: Arc::new(Mutex::new(HashMap::new())), - available_reactions: Arc::new(Mutex::new(vec![ - "👍".to_string(), - "❤️".to_string(), - "😂".to_string(), - "😮".to_string(), - "😢".to_string(), - "🙏".to_string(), - "👏".to_string(), - "🔥".to_string(), - ])), - network_state: Arc::new(Mutex::new(NetworkState::Ready)), - typing_chat_id: Arc::new(Mutex::new(None)), - current_chat_id: Arc::new(Mutex::new(None)), - current_pinned_message: Arc::new(Mutex::new(None)), - auth_state: Arc::new(Mutex::new(AuthState::Ready)), - sent_messages: Arc::new(Mutex::new(vec![])), - edited_messages: Arc::new(Mutex::new(vec![])), - deleted_messages: Arc::new(Mutex::new(vec![])), - forwarded_messages: Arc::new(Mutex::new(vec![])), - searched_queries: Arc::new(Mutex::new(vec![])), - viewed_messages: Arc::new(Mutex::new(vec![])), - chat_actions: Arc::new(Mutex::new(vec![])), - pending_view_messages: Arc::new(Mutex::new(vec![])), - downloaded_files: Arc::new(Mutex::new(HashMap::new())), - update_tx: Arc::new(Mutex::new(None)), - simulate_delays: false, - fail_next_operation: Arc::new(Mutex::new(false)), - } - } - - /// Создать update channel для получения событий - pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - *self.update_tx.lock().unwrap() = Some(tx); - (self, rx) - } - - /// Включить симуляцию задержек (как в реальном TDLib) - pub fn with_delays(mut self) -> Self { - self.simulate_delays = true; - self - } - - // ==================== Builder Methods ==================== - - /// Добавить чат - pub fn with_chat(self, chat: ChatInfo) -> Self { - self.chats.lock().unwrap().push(chat); - self - } - - /// Добавить несколько чатов - pub fn with_chats(self, chats: Vec) -> Self { - self.chats.lock().unwrap().extend(chats); - self - } - - /// Добавить сообщение в чат - pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self { - self.messages - .lock() - .unwrap() - .entry(chat_id) - .or_default() - .push(message); - self - } - - /// Добавить несколько сообщений в чат - pub fn with_messages(self, chat_id: i64, messages: Vec) -> Self { - self.messages.lock().unwrap().insert(chat_id, messages); - self - } - - /// Добавить папку - pub fn with_folder(self, id: i32, name: &str) -> Self { - self.folders - .lock() - .unwrap() - .push(FolderInfo { id, name: name.to_string() }); - self - } - - /// Добавить пользователя - pub fn with_user(self, id: i64, name: &str) -> Self { - self.user_names.lock().unwrap().insert(id, name.to_string()); - self - } - - /// Добавить профиль - pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self { - self.profiles.lock().unwrap().insert(chat_id, profile); - self - } - - /// Установить состояние сети - pub fn with_network_state(self, state: NetworkState) -> Self { - *self.network_state.lock().unwrap() = state; - self - } - - /// Установить состояние авторизации - pub fn with_auth_state(self, state: AuthState) -> Self { - *self.auth_state.lock().unwrap() = state; - self - } - - /// Добавить скачанный файл (для mock download_file) - pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self { - self.downloaded_files - .lock() - .unwrap() - .insert(file_id, path.to_string()); - self - } - - /// Установить доступные реакции - pub fn with_available_reactions(self, reactions: Vec) -> Self { - *self.available_reactions.lock().unwrap() = reactions; - self - } - - // ==================== Async TDLib Operations ==================== - - /// Загрузить список чатов - pub async fn load_chats(&self, limit: usize) -> Result, String> { - if self.should_fail() { - return Err("Failed to load chats".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - } - - let chats = self - .chats - .lock() - .unwrap() - .iter() - .take(limit) - .cloned() - .collect(); - Ok(chats) - } - - /// Открыть чат - pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to open chat".to_string()); - } - - *self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64()); - Ok(()) - } - - /// Получить историю чата - pub async fn get_chat_history( - &self, - chat_id: ChatId, - limit: i32, - ) -> Result, String> { - if self.should_fail() { - return Err("Failed to load history".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - - let messages = self - .messages - .lock() - .unwrap() - .get(&chat_id.as_i64()) - .map(|msgs| msgs.iter().take(limit as usize).cloned().collect()) - .unwrap_or_default(); - - Ok(messages) - } - - /// Загрузить старые сообщения - pub async fn load_older_messages( - &self, - chat_id: ChatId, - from_message_id: MessageId, - ) -> Result, String> { - if self.should_fail() { - return Err("Failed to load older messages".to_string()); - } - - let messages = self.messages.lock().unwrap(); - let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?; - - // Найти индекс сообщения и вернуть предыдущие - if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) { - let older: Vec<_> = chat_messages.iter().take(idx).cloned().collect(); - Ok(older) - } else { - Ok(vec![]) - } - } - - /// Отправить сообщение - pub async fn send_message( - &self, - chat_id: ChatId, - text: String, - reply_to: Option, - reply_info: Option, - ) -> Result { - if self.should_fail() { - return Err("Failed to send message".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - } - - let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000); - - self.sent_messages.lock().unwrap().push(SentMessage { - chat_id: chat_id.as_i64(), - text: text.clone(), - reply_to, - reply_info: reply_info.clone(), - }); - - let message = MessageInfo::new( - message_id, - "You".to_string(), - true, // is_outgoing - text.clone(), - vec![], // entities - chrono::Utc::now().timestamp() as i32, - 0, - false, // is_read (станет true после Update) - true, // can_be_edited - true, // can_be_deleted_only_for_self - true, // can_be_deleted_for_all_users - reply_info, - None, // forward_from - vec![], // reactions - ); - - // Добавляем в историю - self.messages - .lock() - .unwrap() - .entry(chat_id.as_i64()) - .or_default() - .push(message.clone()); - - // Отправляем Update::NewMessage - self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) }); - - Ok(message) - } - - /// Редактировать сообщение - pub async fn edit_message( - &self, - chat_id: ChatId, - message_id: MessageId, - new_text: String, - ) -> Result { - if self.should_fail() { - return Err("Failed to edit message".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; - } - - self.edited_messages.lock().unwrap().push(EditedMessage { - chat_id: chat_id.as_i64(), - message_id, - new_text: new_text.clone(), - }); - - // Обновляем сообщение - let mut messages = self.messages.lock().unwrap(); - if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { - if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { - msg.content.text = new_text.clone(); - msg.metadata.edit_date = msg.metadata.date + 60; - - let updated = msg.clone(); - drop(messages); // Освобождаем lock перед отправкой update - - // Отправляем Update - self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text }); - - return Ok(updated); - } - } - - Err("Message not found".to_string()) - } - - /// Удалить сообщения - pub async fn delete_messages( - &self, - chat_id: ChatId, - message_ids: Vec, - revoke: bool, - ) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to delete messages".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - - self.deleted_messages.lock().unwrap().push(DeletedMessages { - chat_id: chat_id.as_i64(), - message_ids: message_ids.clone(), - revoke, - }); - - // Удаляем из истории - let mut messages = self.messages.lock().unwrap(); - if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { - chat_msgs.retain(|m| !message_ids.contains(&m.id())); - } - drop(messages); - - // Отправляем Update - self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids }); - - Ok(()) - } - - /// Переслать сообщения - pub async fn forward_messages( - &self, - to_chat_id: ChatId, - from_chat_id: ChatId, - message_ids: Vec, - ) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to forward messages".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; - } - - self.forwarded_messages - .lock() - .unwrap() - .push(ForwardedMessages { - from_chat_id: from_chat_id.as_i64(), - to_chat_id: to_chat_id.as_i64(), - message_ids, - }); - - Ok(()) - } - - /// Поиск сообщений в чате - pub async fn search_messages( - &self, - chat_id: ChatId, - query: &str, - ) -> Result, String> { - if self.should_fail() { - return Err("Failed to search messages".to_string()); - } - - let messages = self.messages.lock().unwrap(); - let results: Vec<_> = messages - .get(&chat_id.as_i64()) - .map(|msgs| { - msgs.iter() - .filter(|m| m.text().to_lowercase().contains(&query.to_lowercase())) - .cloned() - .collect() - }) - .unwrap_or_default(); - - self.searched_queries.lock().unwrap().push(SearchQuery { - chat_id: chat_id.as_i64(), - query: query.to_string(), - results_count: results.len(), - }); - - Ok(results) - } - - /// Установить черновик - pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> { - if text.is_empty() { - self.drafts.lock().unwrap().remove(&chat_id.as_i64()); - } else { - self.drafts - .lock() - .unwrap() - .insert(chat_id.as_i64(), text.clone()); - } - - self.send_update(TdUpdate::ChatDraftMessage { - chat_id, - draft_text: if text.is_empty() { None } else { Some(text) }, - }); - - Ok(()) - } - - /// Отправить действие в чате (typing, etc.) - pub async fn send_chat_action(&self, chat_id: ChatId, action: String) { - self.chat_actions - .lock() - .unwrap() - .push((chat_id.as_i64(), action.clone())); - - if action == "Typing" { - *self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64()); - } else if action == "Cancel" { - *self.typing_chat_id.lock().unwrap() = None; - } - } - - /// Получить доступные реакции для сообщения - pub async fn get_message_available_reactions( - &self, - _chat_id: ChatId, - _message_id: MessageId, - ) -> Result, String> { - if self.should_fail() { - return Err("Failed to get available reactions".to_string()); - } - - Ok(self.available_reactions.lock().unwrap().clone()) - } - - /// Установить/удалить реакцию - pub async fn toggle_reaction( - &self, - chat_id: ChatId, - message_id: MessageId, - emoji: String, - ) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to toggle reaction".to_string()); - } - - // Обновляем реакции на сообщении - let mut messages = self.messages.lock().unwrap(); - if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { - if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { - let reactions = &mut msg.interactions.reactions; - - // Toggle logic - if let Some(pos) = reactions - .iter() - .position(|r| r.emoji == emoji && r.is_chosen) - { - // Удаляем свою реакцию - reactions.remove(pos); - } else if let Some(reaction) = reactions.iter_mut().find(|r| r.emoji == emoji) { - // Добавляем себя к существующей реакции - reaction.is_chosen = true; - reaction.count += 1; - } else { - // Добавляем новую реакцию - reactions.push(ReactionInfo { - emoji: emoji.clone(), - count: 1, - is_chosen: true, - }); - } - - let updated_reactions = reactions.clone(); - drop(messages); - - // Отправляем Update - self.send_update(TdUpdate::MessageInteractionInfo { - chat_id, - message_id, - reactions: updated_reactions, - }); - } - } - - Ok(()) - } - - /// Скачать файл (mock) - pub async fn download_file(&self, file_id: i32) -> Result { - if self.should_fail() { - return Err("Failed to download file".to_string()); - } - - self.downloaded_files - .lock() - .unwrap() - .get(&file_id) - .cloned() - .ok_or_else(|| format!("File {} not found", file_id)) - } - - /// Получить информацию о профиле - pub async fn get_profile_info(&self, chat_id: ChatId) -> Result { - if self.should_fail() { - return Err("Failed to get profile info".to_string()); - } - - self.profiles - .lock() - .unwrap() - .get(&chat_id.as_i64()) - .cloned() - .ok_or_else(|| "Profile not found".to_string()) - } - - /// Отметить сообщения как просмотренные - pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec) { - self.viewed_messages - .lock() - .unwrap() - .push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect())); - } - - /// Загрузить чаты папки - pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to load folder chats".to_string()); - } - - Ok(()) - } - - // ==================== Helper Methods ==================== - - /// Отправить update в канал (если он установлен) - fn send_update(&self, update: TdUpdate) { - if let Some(tx) = self.update_tx.lock().unwrap().as_ref() { - let _ = tx.send(update); - } - } - - /// Проверить нужно ли симулировать ошибку - fn should_fail(&self) -> bool { - let mut fail = self.fail_next_operation.lock().unwrap(); - if *fail { - *fail = false; // Сбрасываем после первого использования - true - } else { - false - } - } - - /// Симулировать ошибку в следующей операции - pub fn fail_next(&self) { - *self.fail_next_operation.lock().unwrap() = true; - } - - /// Симулировать входящее сообщение - pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) { - let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp()); - - let message = MessageInfo::new( - message_id, - sender_name.to_string(), - false, // is_outgoing - text, - vec![], - chrono::Utc::now().timestamp() as i32, - 0, - false, - false, - false, - true, - None, - None, - vec![], - ); - - // Добавляем в историю - self.messages - .lock() - .unwrap() - .entry(chat_id.as_i64()) - .or_default() - .push(message.clone()); - - // Отправляем Update - self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) }); - } - - /// Симулировать typing от собеседника - pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) { - self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() }); - } - - /// Симулировать изменение состояния сети - pub fn simulate_network_change(&self, state: NetworkState) { - *self.network_state.lock().unwrap() = state.clone(); - self.send_update(TdUpdate::ConnectionState { state }); - } - - /// Симулировать прочтение сообщений - pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) { - self.send_update(TdUpdate::ChatReadOutbox { - chat_id, - last_read_outbox_message_id: last_read_message_id, - }); - } - - // ==================== Getters for Test Assertions ==================== - - /// Получить все чаты - pub fn get_chats(&self) -> Vec { - self.chats.lock().unwrap().clone() - } - - /// Получить все папки - pub fn get_folders(&self) -> Vec { - self.folders.lock().unwrap().clone() - } - - /// Получить сообщения чата - pub fn get_messages(&self, chat_id: i64) -> Vec { - self.messages - .lock() - .unwrap() - .get(&chat_id) - .cloned() - .unwrap_or_default() - } - - /// Получить отправленные сообщения - pub fn get_sent_messages(&self) -> Vec { - self.sent_messages.lock().unwrap().clone() - } - - /// Получить отредактированные сообщения - pub fn get_edited_messages(&self) -> Vec { - self.edited_messages.lock().unwrap().clone() - } - - /// Получить удалённые сообщения - pub fn get_deleted_messages(&self) -> Vec { - self.deleted_messages.lock().unwrap().clone() - } - - /// Получить пересланные сообщения - pub fn get_forwarded_messages(&self) -> Vec { - self.forwarded_messages.lock().unwrap().clone() - } - - /// Получить поисковые запросы - pub fn get_search_queries(&self) -> Vec { - self.searched_queries.lock().unwrap().clone() - } - - /// Получить просмотренные сообщения - pub fn get_viewed_messages(&self) -> Vec<(i64, Vec)> { - self.viewed_messages.lock().unwrap().clone() - } - - /// Получить действия в чатах - pub fn get_chat_actions(&self) -> Vec<(i64, String)> { - self.chat_actions.lock().unwrap().clone() - } - - /// Получить текущее состояние сети - pub fn get_network_state(&self) -> NetworkState { - self.network_state.lock().unwrap().clone() - } - - /// Получить ID текущего открытого чата - pub fn get_current_chat_id(&self) -> Option { - *self.current_chat_id.lock().unwrap() - } - - pub fn set_current_pinned_message(&mut self, msg: Option) { - *self.current_pinned_message.lock().unwrap() = msg; - } - - pub async fn process_pending_view_messages(&mut self) { - let mut pending = self.pending_view_messages.lock().unwrap(); - for (chat_id, message_ids) in pending.drain(..) { - let ids: Vec = message_ids.iter().map(|id| id.as_i64()).collect(); - self.viewed_messages - .lock() - .unwrap() - .push((chat_id.as_i64(), ids)); - } - } - - /// Установить update channel для получения событий - pub fn set_update_channel(&self, tx: mpsc::UnboundedSender) { - *self.update_tx.lock().unwrap() = Some(tx); - } - - /// Очистить всю историю действий - pub fn clear_all_history(&self) { - self.sent_messages.lock().unwrap().clear(); - self.edited_messages.lock().unwrap().clear(); - self.deleted_messages.lock().unwrap().clear(); - self.forwarded_messages.lock().unwrap().clear(); - self.searched_queries.lock().unwrap().clear(); - self.viewed_messages.lock().unwrap().clear(); - self.chat_actions.lock().unwrap().clear(); - } -} +// Fake TDLib client for testing. + +mod builders; +mod inspect; +mod operations; +mod state; + +#[allow(unused_imports)] +pub use state::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, PendingViewMessages, + SearchQuery, SentMessage, TdUpdate, ViewedMessages, +}; #[cfg(test)] mod tests { @@ -970,12 +94,10 @@ mod tests { let (client, mut rx) = FakeTdClient::new().with_update_channel(); let chat_id = ChatId::new(123); - // Отправляем сообщение let _ = client .send_message(chat_id, "Test".to_string(), None, None) .await; - // Проверяем что получили Update if let Some(update) = rx.recv().await { match update { TdUpdate::NewMessage { chat_id: updated_chat, .. } => { @@ -995,14 +117,12 @@ mod tests { client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob"); - // Проверяем Update if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await { assert_eq!(message.text(), "Hello from Bob"); assert_eq!(message.sender_name(), "Bob"); assert!(!message.is_outgoing()); } - // Проверяем что сообщение добавилось assert_eq!(client.get_messages(123).len(), 1); } @@ -1011,16 +131,13 @@ mod tests { let client = FakeTdClient::new(); let chat_id = ChatId::new(123); - // Устанавливаем флаг ошибки client.fail_next(); - // Следующая операция должна упасть let result = client .send_message(chat_id, "Test".to_string(), None, None) .await; assert!(result.is_err()); - // Но следующая должна пройти let result2 = client .send_message(chat_id, "Test2".to_string(), None, None) .await; diff --git a/tests/helpers/fake_tdclient/builders.rs b/tests/helpers/fake_tdclient/builders.rs new file mode 100644 index 0000000..a1a2bb1 --- /dev/null +++ b/tests/helpers/fake_tdclient/builders.rs @@ -0,0 +1,86 @@ +use super::{FakeTdClient, TdUpdate}; +use tele_tui::tdlib::types::FolderInfo; +use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo}; +use tokio::sync::mpsc; + +#[allow(dead_code)] +impl FakeTdClient { + /// Create an update channel for receiving simulated TDLib events. + pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + *self.update_tx.lock().unwrap() = Some(tx); + (self, rx) + } + + /// Enable simulated delays, closer to real TDLib behavior. + pub fn with_delays(mut self) -> Self { + self.simulate_delays = true; + self + } + + pub fn with_chat(self, chat: ChatInfo) -> Self { + self.chats.lock().unwrap().push(chat); + self + } + + pub fn with_chats(self, chats: Vec) -> Self { + self.chats.lock().unwrap().extend(chats); + self + } + + pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self { + self.messages + .lock() + .unwrap() + .entry(chat_id) + .or_default() + .push(message); + self + } + + pub fn with_messages(self, chat_id: i64, messages: Vec) -> Self { + self.messages.lock().unwrap().insert(chat_id, messages); + self + } + + pub fn with_folder(self, id: i32, name: &str) -> Self { + self.folders + .lock() + .unwrap() + .push(FolderInfo { id, name: name.to_string() }); + self + } + + pub fn with_user(self, id: i64, name: &str) -> Self { + self.user_names.lock().unwrap().insert(id, name.to_string()); + self + } + + pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self { + self.profiles.lock().unwrap().insert(chat_id, profile); + self + } + + pub fn with_network_state(self, state: NetworkState) -> Self { + *self.network_state.lock().unwrap() = state; + self + } + + pub fn with_auth_state(self, state: AuthState) -> Self { + *self.auth_state.lock().unwrap() = state; + self + } + + pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self { + self.downloaded_files + .lock() + .unwrap() + .insert(file_id, path.to_string()); + self + } + + pub fn with_available_reactions(self, reactions: Vec) -> Self { + *self.available_reactions.lock().unwrap() = reactions; + self + } +} diff --git a/tests/helpers/fake_tdclient/inspect.rs b/tests/helpers/fake_tdclient/inspect.rs new file mode 100644 index 0000000..87059f6 --- /dev/null +++ b/tests/helpers/fake_tdclient/inspect.rs @@ -0,0 +1,92 @@ +use super::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage, + TdUpdate, +}; +use tele_tui::tdlib::types::FolderInfo; +use tele_tui::tdlib::{ChatInfo, MessageInfo, NetworkState}; +use tokio::sync::mpsc; + +#[allow(dead_code)] +impl FakeTdClient { + pub fn get_chats(&self) -> Vec { + self.chats.lock().unwrap().clone() + } + + pub fn get_folders(&self) -> Vec { + self.folders.lock().unwrap().clone() + } + + pub fn get_messages(&self, chat_id: i64) -> Vec { + self.messages + .lock() + .unwrap() + .get(&chat_id) + .cloned() + .unwrap_or_default() + } + + pub fn get_sent_messages(&self) -> Vec { + self.sent_messages.lock().unwrap().clone() + } + + pub fn get_edited_messages(&self) -> Vec { + self.edited_messages.lock().unwrap().clone() + } + + pub fn get_deleted_messages(&self) -> Vec { + self.deleted_messages.lock().unwrap().clone() + } + + pub fn get_forwarded_messages(&self) -> Vec { + self.forwarded_messages.lock().unwrap().clone() + } + + pub fn get_search_queries(&self) -> Vec { + self.searched_queries.lock().unwrap().clone() + } + + pub fn get_viewed_messages(&self) -> Vec<(i64, Vec)> { + self.viewed_messages.lock().unwrap().clone() + } + + pub fn get_chat_actions(&self) -> Vec<(i64, String)> { + self.chat_actions.lock().unwrap().clone() + } + + pub fn get_network_state(&self) -> NetworkState { + self.network_state.lock().unwrap().clone() + } + + pub fn get_current_chat_id(&self) -> Option { + *self.current_chat_id.lock().unwrap() + } + + pub fn set_current_pinned_message(&mut self, msg: Option) { + *self.current_pinned_message.lock().unwrap() = msg; + } + + pub async fn process_pending_view_messages(&mut self) { + let mut pending = self.pending_view_messages.lock().unwrap(); + for (chat_id, message_ids) in pending.drain(..) { + let ids: Vec = message_ids.iter().map(|id| id.as_i64()).collect(); + self.viewed_messages + .lock() + .unwrap() + .push((chat_id.as_i64(), ids)); + } + } + + pub fn set_update_channel(&self, tx: mpsc::UnboundedSender) { + *self.update_tx.lock().unwrap() = Some(tx); + } + + pub fn clear_all_history(&self) { + self.sent_messages.lock().unwrap().clear(); + self.edited_messages.lock().unwrap().clear(); + self.deleted_messages.lock().unwrap().clear(); + self.forwarded_messages.lock().unwrap().clear(); + self.searched_queries.lock().unwrap().clear(); + self.viewed_messages.lock().unwrap().clear(); + self.chat_actions.lock().unwrap().clear(); + } +} diff --git a/tests/helpers/fake_tdclient/operations.rs b/tests/helpers/fake_tdclient/operations.rs new file mode 100644 index 0000000..b087617 --- /dev/null +++ b/tests/helpers/fake_tdclient/operations.rs @@ -0,0 +1,458 @@ +use super::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage, + TdUpdate, +}; +use tele_tui::tdlib::types::ReactionInfo; +use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo}; +use tele_tui::types::{ChatId, MessageId, UserId}; + +#[allow(dead_code)] +impl FakeTdClient { + pub async fn load_chats(&self, limit: usize) -> Result, String> { + if self.should_fail() { + return Err("Failed to load chats".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + let chats = self + .chats + .lock() + .unwrap() + .iter() + .take(limit) + .cloned() + .collect(); + Ok(chats) + } + + pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to open chat".to_string()); + } + + *self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64()); + Ok(()) + } + + pub async fn get_chat_history( + &self, + chat_id: ChatId, + limit: i32, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to load history".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + let messages = self + .messages + .lock() + .unwrap() + .get(&chat_id.as_i64()) + .map(|msgs| msgs.iter().take(limit as usize).cloned().collect()) + .unwrap_or_default(); + + Ok(messages) + } + + pub async fn load_older_messages( + &self, + chat_id: ChatId, + from_message_id: MessageId, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to load older messages".to_string()); + } + + let messages = self.messages.lock().unwrap(); + let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?; + + if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) { + let older = chat_messages.iter().take(idx).cloned().collect(); + Ok(older) + } else { + Ok(vec![]) + } + } + + pub async fn send_message( + &self, + chat_id: ChatId, + text: String, + reply_to: Option, + reply_info: Option, + ) -> Result { + if self.should_fail() { + return Err("Failed to send message".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } + + let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000); + + self.sent_messages.lock().unwrap().push(SentMessage { + chat_id: chat_id.as_i64(), + text: text.clone(), + reply_to, + reply_info: reply_info.clone(), + }); + + let message = MessageInfo::new( + message_id, + "You".to_string(), + true, + text, + vec![], + chrono::Utc::now().timestamp() as i32, + 0, + false, + true, + true, + true, + reply_info, + None, + vec![], + ); + + self.messages + .lock() + .unwrap() + .entry(chat_id.as_i64()) + .or_default() + .push(message.clone()); + + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) }); + + Ok(message) + } + + pub async fn edit_message( + &self, + chat_id: ChatId, + message_id: MessageId, + new_text: String, + ) -> Result { + if self.should_fail() { + return Err("Failed to edit message".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + } + + self.edited_messages.lock().unwrap().push(EditedMessage { + chat_id: chat_id.as_i64(), + message_id, + new_text: new_text.clone(), + }); + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { + msg.content.text = new_text.clone(); + msg.metadata.edit_date = msg.metadata.date + 60; + + let updated = msg.clone(); + drop(messages); + + self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text }); + + return Ok(updated); + } + } + + Err("Message not found".to_string()) + } + + pub async fn delete_messages( + &self, + chat_id: ChatId, + message_ids: Vec, + revoke: bool, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to delete messages".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + self.deleted_messages.lock().unwrap().push(DeletedMessages { + chat_id: chat_id.as_i64(), + message_ids: message_ids.clone(), + revoke, + }); + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + chat_msgs.retain(|m| !message_ids.contains(&m.id())); + } + drop(messages); + + self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids }); + + Ok(()) + } + + pub async fn forward_messages( + &self, + to_chat_id: ChatId, + from_chat_id: ChatId, + message_ids: Vec, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to forward messages".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + } + + self.forwarded_messages + .lock() + .unwrap() + .push(ForwardedMessages { + from_chat_id: from_chat_id.as_i64(), + to_chat_id: to_chat_id.as_i64(), + message_ids, + }); + + Ok(()) + } + + pub async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to search messages".to_string()); + } + + let messages = self.messages.lock().unwrap(); + let results: Vec<_> = messages + .get(&chat_id.as_i64()) + .map(|msgs| { + msgs.iter() + .filter(|m| m.text().to_lowercase().contains(&query.to_lowercase())) + .cloned() + .collect() + }) + .unwrap_or_default(); + + self.searched_queries.lock().unwrap().push(SearchQuery { + chat_id: chat_id.as_i64(), + query: query.to_string(), + results_count: results.len(), + }); + + Ok(results) + } + + pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> { + if text.is_empty() { + self.drafts.lock().unwrap().remove(&chat_id.as_i64()); + } else { + self.drafts + .lock() + .unwrap() + .insert(chat_id.as_i64(), text.clone()); + } + + self.send_update(TdUpdate::ChatDraftMessage { + chat_id, + draft_text: if text.is_empty() { None } else { Some(text) }, + }); + + Ok(()) + } + + pub async fn send_chat_action(&self, chat_id: ChatId, action: String) { + self.chat_actions + .lock() + .unwrap() + .push((chat_id.as_i64(), action.clone())); + + if action == "Typing" { + *self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64()); + } else if action == "Cancel" { + *self.typing_chat_id.lock().unwrap() = None; + } + } + + pub async fn get_message_available_reactions( + &self, + _chat_id: ChatId, + _message_id: MessageId, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to get available reactions".to_string()); + } + + Ok(self.available_reactions.lock().unwrap().clone()) + } + + pub async fn toggle_reaction( + &self, + chat_id: ChatId, + message_id: MessageId, + emoji: String, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to toggle reaction".to_string()); + } + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { + let reactions = &mut msg.interactions.reactions; + + if let Some(pos) = reactions + .iter() + .position(|reaction| reaction.emoji == emoji && reaction.is_chosen) + { + reactions.remove(pos); + } else if let Some(reaction) = reactions + .iter_mut() + .find(|reaction| reaction.emoji == emoji) + { + reaction.is_chosen = true; + reaction.count += 1; + } else { + reactions.push(ReactionInfo { + emoji: emoji.clone(), + count: 1, + is_chosen: true, + }); + } + + let updated_reactions = reactions.clone(); + drop(messages); + + self.send_update(TdUpdate::MessageInteractionInfo { + chat_id, + message_id, + reactions: updated_reactions, + }); + } + } + + Ok(()) + } + + pub async fn download_file(&self, file_id: i32) -> Result { + if self.should_fail() { + return Err("Failed to download file".to_string()); + } + + self.downloaded_files + .lock() + .unwrap() + .get(&file_id) + .cloned() + .ok_or_else(|| format!("File {} not found", file_id)) + } + + pub async fn get_profile_info(&self, chat_id: ChatId) -> Result { + if self.should_fail() { + return Err("Failed to get profile info".to_string()); + } + + self.profiles + .lock() + .unwrap() + .get(&chat_id.as_i64()) + .cloned() + .ok_or_else(|| "Profile not found".to_string()) + } + + pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec) { + self.viewed_messages + .lock() + .unwrap() + .push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect())); + } + + pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to load folder chats".to_string()); + } + + Ok(()) + } + + fn send_update(&self, update: TdUpdate) { + if let Some(tx) = self.update_tx.lock().unwrap().as_ref() { + let _ = tx.send(update); + } + } + + fn should_fail(&self) -> bool { + let mut fail = self.fail_next_operation.lock().unwrap(); + if *fail { + *fail = false; + true + } else { + false + } + } + + pub fn fail_next(&self) { + *self.fail_next_operation.lock().unwrap() = true; + } + + pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) { + let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp()); + + let message = MessageInfo::new( + message_id, + sender_name.to_string(), + false, + text, + vec![], + chrono::Utc::now().timestamp() as i32, + 0, + false, + false, + false, + true, + None, + None, + vec![], + ); + + self.messages + .lock() + .unwrap() + .entry(chat_id.as_i64()) + .or_default() + .push(message.clone()); + + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) }); + } + + pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) { + self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() }); + } + + pub fn simulate_network_change(&self, state: tele_tui::tdlib::NetworkState) { + *self.network_state.lock().unwrap() = state.clone(); + self.send_update(TdUpdate::ConnectionState { state }); + } + + pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) { + self.send_update(TdUpdate::ChatReadOutbox { + chat_id, + last_read_outbox_message_id: last_read_message_id, + }); + } +} diff --git a/tests/helpers/fake_tdclient/state.rs b/tests/helpers/fake_tdclient/state.rs new file mode 100644 index 0000000..581275c --- /dev/null +++ b/tests/helpers/fake_tdclient/state.rs @@ -0,0 +1,201 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tele_tui::tdlib::types::{FolderInfo, ReactionInfo}; +use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo}; +use tele_tui::types::{ChatId, MessageId, UserId}; +use tokio::sync::mpsc; + +pub type ViewedMessages = Vec<(i64, Vec)>; +pub type PendingViewMessages = Vec<(ChatId, Vec)>; + +/// Update events from TDLib, simplified for tests. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum TdUpdate { + NewMessage { + chat_id: ChatId, + message: Box, + }, + MessageContent { + chat_id: ChatId, + message_id: MessageId, + new_text: String, + }, + DeleteMessages { + chat_id: ChatId, + message_ids: Vec, + }, + ChatAction { + chat_id: ChatId, + user_id: UserId, + action: String, + }, + MessageInteractionInfo { + chat_id: ChatId, + message_id: MessageId, + reactions: Vec, + }, + ConnectionState { + state: NetworkState, + }, + ChatReadOutbox { + chat_id: ChatId, + last_read_outbox_message_id: MessageId, + }, + ChatDraftMessage { + chat_id: ChatId, + draft_text: Option, + }, +} + +/// Simplified mock TDLib client for tests. +#[allow(dead_code)] +pub struct FakeTdClient { + pub chats: Arc>>, + pub messages: Arc>>>, + pub folders: Arc>>, + pub user_names: Arc>>, + pub profiles: Arc>>, + pub drafts: Arc>>, + pub available_reactions: Arc>>, + + pub network_state: Arc>, + pub typing_chat_id: Arc>>, + pub current_chat_id: Arc>>, + pub current_pinned_message: Arc>>, + pub auth_state: Arc>, + + pub sent_messages: Arc>>, + pub edited_messages: Arc>>, + pub deleted_messages: Arc>>, + pub forwarded_messages: Arc>>, + pub searched_queries: Arc>>, + pub viewed_messages: Arc>, + pub chat_actions: Arc>>, + pub pending_view_messages: Arc>, + + pub update_tx: Arc>>>, + pub downloaded_files: Arc>>, + + pub simulate_delays: bool, + pub fail_next_operation: Arc>, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SentMessage { + pub chat_id: i64, + pub text: String, + pub reply_to: Option, + pub reply_info: Option, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct EditedMessage { + pub chat_id: i64, + pub message_id: MessageId, + pub new_text: String, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DeletedMessages { + pub chat_id: i64, + pub message_ids: Vec, + pub revoke: bool, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ForwardedMessages { + pub from_chat_id: i64, + pub to_chat_id: i64, + pub message_ids: Vec, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SearchQuery { + pub chat_id: i64, + pub query: String, + pub results_count: usize, +} + +impl Default for FakeTdClient { + fn default() -> Self { + Self::new() + } +} + +impl Clone for FakeTdClient { + fn clone(&self) -> Self { + Self { + chats: Arc::clone(&self.chats), + messages: Arc::clone(&self.messages), + folders: Arc::clone(&self.folders), + user_names: Arc::clone(&self.user_names), + profiles: Arc::clone(&self.profiles), + drafts: Arc::clone(&self.drafts), + available_reactions: Arc::clone(&self.available_reactions), + network_state: Arc::clone(&self.network_state), + typing_chat_id: Arc::clone(&self.typing_chat_id), + current_chat_id: Arc::clone(&self.current_chat_id), + current_pinned_message: Arc::clone(&self.current_pinned_message), + auth_state: Arc::clone(&self.auth_state), + sent_messages: Arc::clone(&self.sent_messages), + edited_messages: Arc::clone(&self.edited_messages), + deleted_messages: Arc::clone(&self.deleted_messages), + forwarded_messages: Arc::clone(&self.forwarded_messages), + searched_queries: Arc::clone(&self.searched_queries), + viewed_messages: Arc::clone(&self.viewed_messages), + chat_actions: Arc::clone(&self.chat_actions), + pending_view_messages: Arc::clone(&self.pending_view_messages), + downloaded_files: Arc::clone(&self.downloaded_files), + update_tx: Arc::clone(&self.update_tx), + simulate_delays: self.simulate_delays, + fail_next_operation: Arc::clone(&self.fail_next_operation), + } + } +} + +#[allow(dead_code)] +impl FakeTdClient { + pub fn new() -> Self { + Self { + chats: Arc::new(Mutex::new(vec![])), + messages: Arc::new(Mutex::new(HashMap::new())), + folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])), + user_names: Arc::new(Mutex::new(HashMap::new())), + profiles: Arc::new(Mutex::new(HashMap::new())), + drafts: Arc::new(Mutex::new(HashMap::new())), + available_reactions: Arc::new(Mutex::new(vec![ + "👍".to_string(), + "❤️".to_string(), + "😂".to_string(), + "😮".to_string(), + "😢".to_string(), + "🙏".to_string(), + "👏".to_string(), + "🔥".to_string(), + ])), + network_state: Arc::new(Mutex::new(NetworkState::Ready)), + typing_chat_id: Arc::new(Mutex::new(None)), + current_chat_id: Arc::new(Mutex::new(None)), + current_pinned_message: Arc::new(Mutex::new(None)), + auth_state: Arc::new(Mutex::new(AuthState::Ready)), + sent_messages: Arc::new(Mutex::new(vec![])), + edited_messages: Arc::new(Mutex::new(vec![])), + deleted_messages: Arc::new(Mutex::new(vec![])), + forwarded_messages: Arc::new(Mutex::new(vec![])), + searched_queries: Arc::new(Mutex::new(vec![])), + viewed_messages: Arc::new(Mutex::new(vec![])), + chat_actions: Arc::new(Mutex::new(vec![])), + pending_view_messages: Arc::new(Mutex::new(vec![])), + downloaded_files: Arc::new(Mutex::new(HashMap::new())), + update_tx: Arc::new(Mutex::new(None)), + simulate_delays: false, + fail_next_operation: Arc::new(Mutex::new(false)), + } + } +}