diff --git a/REFACTORING_ROADMAP.md b/REFACTORING_ROADMAP.md index 1c011d4..09098aa 100644 --- a/REFACTORING_ROADMAP.md +++ b/REFACTORING_ROADMAP.md @@ -810,10 +810,10 @@ warn!("Could not load config: {}", e); - [x] P5.15 — Feature flags ✅ - [x] P5.16 — LRU cache обобщение ✅ - [x] P5.17 — Tracing ✅ -- [ ] Priority 6: 0/1 задач ⏳ В ПРОЦЕССЕ (25% завершено) - - [ ] P6.1 — Dependency Injection для TdClient (Этапы 1-2/8 завершены) +- [x] Priority 6: 1/1 задач ✅ ЗАВЕРШЕНО! 🎉🎉🎉🎉 + - [x] P6.1 — Dependency Injection для TdClient (ВСЕ 8 этапов завершены!) -**Всего**: 20/21 задач (95%) +**Всего**: 21/21 задач (100%) 🎊🎉 РЕФАКТОРИНГ ПОЛНОСТЬЮ ЗАВЕРШЁН! --- @@ -861,9 +861,9 @@ warn!("Could not load config: {}", e); ## Приоритет 6: Улучшение тестируемости -### P6.1 — Dependency Injection для TdClient +### P6.1 — Dependency Injection для TdClient ✅ ЗАВЕРШЕНО! -**Статус**: ⏳ В процессе (Этапы 1-2/8 завершены) - 2026-02-02 +**Статус**: ✅ ЗАВЕРШЕНО (ВСЕ 8 этапов завершены!) - 2026-02-02 **Проблема**: @@ -1090,12 +1090,25 @@ pub struct App { - **Для production-ready проекта**: Вариант 1 (trait injection) ⭐ - **Для быстрого улучшения**: Вариант 2 (enum dispatch) -**Текущее решение** (2026-02-02): Выбран **Вариант 3** как временное решение. Timeout'ы добавлены в следующих местах: -- `send_chat_action(Typing)` при вводе символов — 100ms timeout -- `set_draft_message()` при закрытии чата — 100ms timeout -- `send_chat_action(Cancel)` при отправке сообщения — 100ms timeout +**Финальное решение** (2026-02-02): Реализован **Вариант 1 (trait injection)** ✅🎉 -Это позволило разблокировать тесты без большого рефакторинга. В будущем, если проект вырастет, стоит мигрировать на **Вариант 1** для чистоты архитектуры. +После завершения всех 8 этапов рефакторинга: +- ✅ Создан `TdClientTrait` с 40+ методами +- ✅ Реализован trait для `TdClient` и `FakeTdClient` +- ✅ `App` стал generic: `App` +- ✅ Все UI и input handlers обновлены на generic +- ✅ Тесты используют `FakeTdClient` (быстро, без логов TDLib) +- ✅ Продакшн использует `TdClient` (реальный TDLib) +- ✅ Timeout'ы убраны из продакшн кода +- ✅ Исправлен stack overflow в 6 методах trait реализации +- ✅ Все 196+ тестов проходят + +**Преимущества реализации**: +- 🛡️ Чистая архитектура без timeout хаков +- ⚡ Быстрые тесты (FakeTdClient работает мгновенно) +- 📝 Нет verbose логов TDLib в тестах +- 🔧 Type-safe dependency injection +- 🎯 Легко добавлять новые реализации trait --- diff --git a/src/input/auth.rs b/src/input/auth.rs index 9ce6a5b..ef10e9c 100644 --- a/src/input/auth.rs +++ b/src/input/auth.rs @@ -1,8 +1,8 @@ use crate::app::App; use crate::tdlib::{AuthState, TdClientTrait}; +use crate::utils::with_timeout_msg; use crossterm::event::KeyCode; use std::time::Duration; -use tokio::time::timeout; pub async fn handle(app: &mut App, key_code: KeyCode) { match &app.td_client.auth_state() { @@ -18,24 +18,21 @@ pub async fn handle(app: &mut App, key_code: KeyCode) { KeyCode::Enter => { if !app.phone_input.is_empty() { app.status_message = Some("Отправка номера...".to_string()); - match timeout( + match with_timeout_msg( Duration::from_secs(10), app.td_client.send_phone_number(app.phone_input.clone()), + "Таймаут отправки номера", ) .await { - Ok(Ok(_)) => { + Ok(_) => { app.error_message = None; app.status_message = None; } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); app.status_message = None; } - Err(_) => { - app.error_message = Some("Таймаут".to_string()); - app.status_message = None; - } } } } @@ -53,24 +50,21 @@ pub async fn handle(app: &mut App, key_code: KeyCode) { KeyCode::Enter => { if !app.code_input.is_empty() { app.status_message = Some("Проверка кода...".to_string()); - match timeout( + match with_timeout_msg( Duration::from_secs(10), app.td_client.send_code(app.code_input.clone()), + "Таймаут проверки кода", ) .await { - Ok(Ok(_)) => { + Ok(_) => { app.error_message = None; app.status_message = None; } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); app.status_message = None; } - Err(_) => { - app.error_message = Some("Таймаут".to_string()); - app.status_message = None; - } } } } @@ -88,24 +82,21 @@ pub async fn handle(app: &mut App, key_code: KeyCode) { KeyCode::Enter => { if !app.password_input.is_empty() { app.status_message = Some("Проверка пароля...".to_string()); - match timeout( + match with_timeout_msg( Duration::from_secs(10), app.td_client.send_password(app.password_input.clone()), + "Таймаут проверки пароля", ) .await { - Ok(Ok(_)) => { + Ok(_) => { app.error_message = None; app.status_message = None; } - Ok(Err(e)) => { + Err(e) => { app.error_message = Some(e); app.status_message = None; } - Err(_) => { - app.error_message = Some("Таймаут".to_string()); - app.status_message = None; - } } } } diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index 12146b1..d729986 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -17,10 +17,10 @@ pub mod modal; pub mod profile; pub mod search; -pub use chat_list::*; +// pub use chat_list::*; // Пока не используется pub use clipboard::*; pub use global::*; -pub use messages::*; -pub use modal::*; -pub use profile::*; -pub use search::*; +// pub use messages::*; // Пока не используется +// pub use modal::*; // Пока не используется +pub use profile::get_available_actions_count; // Используется в main_input +// pub use search::*; // Пока не используется diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 235e192..e6513c9 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -6,7 +6,8 @@ use crate::input::handlers::{ }; use crate::tdlib::ChatAction; use crate::types::{ChatId, MessageId}; -use crate::utils::{with_timeout, with_timeout_msg}; +use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore}; +use crate::utils::modal_handler::handle_yes_no; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::time::{Duration, Instant}; @@ -23,8 +24,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Обработка подтверждения выхода из группы let confirmation_step = app.get_leave_group_confirmation_step(); if confirmation_step > 0 { - match key.code { - KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { + match handle_yes_no(key.code) { + Some(true) => { + // Подтверждение if confirmation_step == 1 { // Первое подтверждение - показываем второе app.show_leave_group_final_confirmation(); @@ -46,11 +48,13 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } } } - KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { + Some(false) => { // Отмена app.cancel_leave_group(); } - _ => {} + None => { + // Другая клавиша - игнорируем + } } return; } @@ -324,8 +328,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Модалка подтверждения удаления if app.is_confirm_delete_shown() { - match key.code { - KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { + match handle_yes_no(key.code) { + Some(true) => { // Подтверждение удаления if let Some(msg_id) = app.chat_state.selected_message_id() { if let Some(chat_id) = app.get_selected_chat_id() { @@ -366,11 +370,13 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Закрываем модалку app.chat_state = crate::app::ChatState::Normal; } - KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { + Some(false) => { // Отмена удаления app.chat_state = crate::app::ChatState::Normal; } - _ => {} + None => { + // Другая клавиша - игнорируем + } } return; } @@ -435,42 +441,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Выбрать чат из отфильтрованного списка app.select_filtered_chat(); if let Some(chat_id) = app.get_selected_chat_id() { - app.status_message = Some("Загрузка сообщений...".to_string()); - app.message_scroll_offset = 0; - match with_timeout_msg( - Duration::from_secs(10), - app.td_client.get_chat_history(ChatId::new(chat_id), 100), - "Таймаут загрузки сообщений", - ) - .await - { - Ok(messages) => { - // Сохраняем загруженные сообщения - app.td_client.set_current_chat_messages(messages); - // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории - // Это предотвращает race condition с Update::NewMessage - app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); - // Загружаем недостающие reply info - let _ = tokio::time::timeout( - Duration::from_secs(5), - app.td_client.fetch_missing_reply_info(), - ) - .await; - // Загружаем последнее закреплённое сообщение - let _ = tokio::time::timeout( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(ChatId::new(chat_id)), - ) - .await; - // Загружаем черновик - app.load_draft(); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } + open_chat_and_load_data(app, chat_id).await; } } KeyCode::Backspace => { @@ -620,42 +591,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { if app.selected_chat_id != prev_selected { if let Some(chat_id) = app.get_selected_chat_id() { - app.status_message = Some("Загрузка сообщений...".to_string()); - app.message_scroll_offset = 0; - match with_timeout_msg( - Duration::from_secs(10), - app.td_client.get_chat_history(ChatId::new(chat_id), 100), - "Таймаут загрузки сообщений", - ) - .await - { - Ok(messages) => { - // Сохраняем загруженные сообщения - app.td_client.set_current_chat_messages(messages); - // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории - // Это предотвращает race condition с Update::NewMessage - app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); - // Загружаем недостающие reply info - let _ = tokio::time::timeout( - Duration::from_secs(5), - app.td_client.fetch_missing_reply_info(), - ) - .await; - // Загружаем последнее закреплённое сообщение - let _ = tokio::time::timeout( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(ChatId::new(chat_id)), - ) - .await; - // Загружаем черновик - app.load_draft(); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } + open_chat_and_load_data(app, chat_id).await; } } } @@ -966,3 +902,57 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } } } + +/// Открывает чат и загружает все необходимые данные. +/// +/// Выполняет: +/// - Загрузку истории сообщений (с timeout) +/// - Установку current_chat_id (после загрузки, чтобы избежать race condition) +/// - Загрузку reply info (с timeout) +/// - Загрузку закреплённого сообщения (с timeout) +/// - Загрузку черновика +/// +/// При ошибке устанавливает error_message и очищает status_message. +async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { + app.status_message = Some("Загрузка сообщений...".to_string()); + app.message_scroll_offset = 0; + + match with_timeout_msg( + Duration::from_secs(10), + app.td_client.get_chat_history(ChatId::new(chat_id), 100), + "Таймаут загрузки сообщений", + ) + .await + { + Ok(messages) => { + // Сохраняем загруженные сообщения + app.td_client.set_current_chat_messages(messages); + + // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории + // Это предотвращает race condition с Update::NewMessage + app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); + + // Загружаем недостающие reply info (игнорируем ошибки) + with_timeout_ignore( + Duration::from_secs(5), + app.td_client.fetch_missing_reply_info(), + ) + .await; + + // Загружаем последнее закреплённое сообщение (игнорируем ошибки) + with_timeout_ignore( + Duration::from_secs(2), + app.td_client.load_current_pinned_message(ChatId::new(chat_id)), + ) + .await; + + // Загружаем черновик + app.load_draft(); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + } + } +} diff --git a/src/main.rs b/src/main.rs index 94f0f04..72e71ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -205,7 +205,7 @@ async fn run_app( /// Возвращает true если состояние изменилось и требуется перерисовка async fn update_screen_state(app: &mut App) -> bool { - use tokio::time::timeout; + use utils::with_timeout_ignore; let prev_screen = app.screen.clone(); let prev_status = app.status_message.clone(); @@ -227,8 +227,8 @@ async fn update_screen_state(app: &mut App) -> bool app.is_loading = true; app.status_message = Some("Загрузка чатов...".to_string()); - // Запрашиваем загрузку чатов с таймаутом - let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; + // Запрашиваем загрузку чатов с таймаутом (игнорируем ошибки) + with_timeout_ignore(Duration::from_secs(5), app.td_client.load_chats(50)).await; } // Синхронизируем чаты из td_client в app diff --git a/src/utils/mod.rs b/src/utils/mod.rs index e520de1..f81f2a7 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -5,7 +5,7 @@ pub mod tdlib; pub mod validation; pub use formatting::*; -pub use modal_handler::*; -pub use retry::{with_timeout, with_timeout_msg}; +// pub use modal_handler::*; // Используется через явный import +pub use retry::{with_timeout, with_timeout_msg, with_timeout_ignore}; pub use tdlib::*; -pub use validation::*; +// pub use validation::*; // Пока не используется diff --git a/src/utils/modal_handler.rs b/src/utils/modal_handler.rs index 2ff06ef..842985f 100644 --- a/src/utils/modal_handler.rs +++ b/src/utils/modal_handler.rs @@ -106,7 +106,7 @@ pub fn should_confirm_modal(key_code: KeyCode) -> bool { /// /// assert_eq!(handle_yes_no(KeyCode::Char('y')), Some(true)); /// assert_eq!(handle_yes_no(KeyCode::Char('Y')), Some(true)); -/// assert_eq!(handle_yes_no(KeyCode::Char('д')), Some(true)); // русская 'y' +/// assert_eq!(handle_yes_no(KeyCode::Char('н')), Some(true)); // русская 'y' /// assert_eq!(handle_yes_no(KeyCode::Enter), Some(true)); /// /// assert_eq!(handle_yes_no(KeyCode::Char('n')), Some(false)); @@ -118,7 +118,7 @@ pub fn should_confirm_modal(key_code: KeyCode) -> bool { pub fn handle_yes_no(key_code: KeyCode) -> Option { match key_code { // Yes - подтверждение (английская и русская раскладка) - KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('д') | KeyCode::Char('Д') => { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('н') | KeyCode::Char('Н') => { Some(true) } KeyCode::Enter => Some(true), @@ -165,8 +165,8 @@ mod tests { // Yes variants assert_eq!(handle_yes_no(KeyCode::Char('y')), Some(true)); assert_eq!(handle_yes_no(KeyCode::Char('Y')), Some(true)); - assert_eq!(handle_yes_no(KeyCode::Char('д')), Some(true)); // Russian - assert_eq!(handle_yes_no(KeyCode::Char('Д')), Some(true)); // Russian + assert_eq!(handle_yes_no(KeyCode::Char('н')), Some(true)); // Russian + assert_eq!(handle_yes_no(KeyCode::Char('Н')), Some(true)); // Russian assert_eq!(handle_yes_no(KeyCode::Enter), Some(true)); // No variants diff --git a/src/utils/retry.rs b/src/utils/retry.rs index 72168fa..5a139be 100644 --- a/src/utils/retry.rs +++ b/src/utils/retry.rs @@ -70,6 +70,34 @@ where } } +/// Выполняет операцию с таймаутом, игнорируя результат и ошибки. +/// +/// Используется для не критичных операций (например, загрузка дополнительных данных), +/// где таймаут или ошибка не должны прерывать основной flow. +/// +/// Работает как с Result, так и с void операциями. +/// +/// # Arguments +/// +/// * `duration` - Длительность таймаута +/// * `operation` - Асинхронная операция для выполнения +/// +/// # Examples +/// +/// ```ignore +/// // Загружаем reply info, но не ждём если долго +/// with_timeout_ignore( +/// Duration::from_secs(5), +/// client.fetch_missing_reply_info() +/// ).await; +/// ``` +pub async fn with_timeout_ignore(duration: Duration, operation: F) +where + F: Future, +{ + let _ = timeout(duration, operation).await; +} + #[cfg(test)] mod tests { use super::*;