refactor: implement trait-based DI for TdClient and fix stack overflow

Implement complete trait-based dependency injection pattern for TdClient
to enable testing with FakeTdClient mock. Fix critical stack overflow bugs
caused by infinite recursion in trait implementations.

Breaking Changes:
- App is now generic: App<T: TdClientTrait = TdClient>
- All UI and input handlers are generic over TdClientTrait
- TdClient methods now accessed through trait interface

New Files:
- src/tdlib/trait.rs: TdClientTrait definition with 40+ methods
- src/tdlib/client_impl.rs: TdClientTrait impl for TdClient
- tests/helpers/fake_tdclient_impl.rs: TdClientTrait impl for FakeTdClient

Critical Fixes:
- Fix stack overflow in send_message, edit_message, delete_messages
- Fix stack overflow in forward_messages, current_chat_messages
- Fix stack overflow in current_pinned_message
- All methods now call message_manager directly to avoid recursion

Testing:
- FakeTdClient supports configurable auth_state for auth screen tests
- Added pinned message support in FakeTdClient
- All 196+ tests passing (188 tests + 8 benchmarks)

Dependencies:
- Added async-trait = "0.1"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-02 05:42:19 +03:00
parent ed5a4f9c72
commit 8e48d076de
38 changed files with 1053 additions and 161 deletions

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::input::handlers::{
copy_to_clipboard, format_message_for_clipboard, get_available_actions_count,
handle_global_commands,
@@ -9,7 +10,7 @@ use crate::utils::{with_timeout, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
pub async fn handle(app: &mut App, key: KeyEvent) {
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// Глобальные команды (работают всегда)
if handle_global_commands(app, key).await {
return;
@@ -445,7 +446,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
{
Ok(messages) => {
// Сохраняем загруженные сообщения
*app.td_client.current_chat_messages_mut() = 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)));
@@ -589,10 +590,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.last_typing_sent = None;
// Отменяем typing status
let _ = tokio::time::timeout(
Duration::from_millis(100),
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
).await;
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await;
match with_timeout_msg(
Duration::from_secs(5),
@@ -633,7 +631,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
{
Ok(messages) => {
// Сохраняем загруженные сообщения
*app.td_client.current_chat_messages_mut() = 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)));
@@ -680,17 +678,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.selected_chat_id {
if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() {
let draft_text = app.message_input.clone();
// Timeout чтобы не блокировать UI в тестах
let _ = tokio::time::timeout(
Duration::from_millis(100),
app.td_client.set_draft_message(chat_id, draft_text)
).await;
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} else if app.message_input.is_empty() {
// Очищаем черновик если инпут пустой
let _ = tokio::time::timeout(
Duration::from_millis(100),
app.td_client.set_draft_message(chat_id, String::new())
).await;
let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
}
}
app.close_chat();
@@ -733,7 +724,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
KeyCode::Char('y') | KeyCode::Char('н') => {
// Копировать сообщение
if let Some(msg) = app.get_selected_message() {
let text = format_message_for_clipboard(msg);
let text = format_message_for_clipboard(&msg);
match copy_to_clipboard(&text) {
Ok(_) => {
app.status_message = Some("Сообщение скопировано".to_string());
@@ -864,11 +855,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
// Используем короткий timeout чтобы не блокировать UI (особенно в тестах)
let _ = tokio::time::timeout(
Duration::from_millis(100),
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
).await;
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await;
app.last_typing_sent = Some(Instant::now());
}
}