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::tdlib::AuthState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
@@ -8,7 +9,7 @@ use ratatui::{
Frame,
};
pub fn render(f: &mut Frame, app: &App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
let area = f.area();
let vertical_chunks = Layout::default()

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
use ratatui::{
@@ -8,7 +9,7 @@ use ratatui::{
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let chat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState;
use ratatui::{
layout::Rect,
@@ -7,9 +8,9 @@ use ratatui::{
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Индикатор состояния сети
let network_indicator = match app.td_client.network_state {
let network_indicator = match app.td_client.network_state() {
NetworkState::Ready => "",
NetworkState::WaitingForNetwork => "⚠ Нет сети | ",
NetworkState::ConnectingToProxy => "⏳ Прокси... | ",
@@ -32,9 +33,9 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
)
};
let style = if matches!(app.td_client.network_state, NetworkState::WaitingForNetwork) {
let style = if matches!(app.td_client.network_state(), NetworkState::WaitingForNetwork) {
Style::default().fg(Color::Red)
} else if !matches!(app.td_client.network_state, NetworkState::Ready) {
} else if !matches!(app.td_client.network_state(), NetworkState::Ready) {
Style::default().fg(Color::Cyan)
} else if app.error_message.is_some() {
Style::default().fg(Color::Red)

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
@@ -6,7 +7,7 @@ use ratatui::{
Frame,
};
pub fn render(f: &mut Frame, app: &App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
let area = f.area();
let chunks = Layout::default()

View File

@@ -1,5 +1,6 @@
use super::{chat_list, footer, messages};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -11,7 +12,7 @@ use ratatui::{
/// Порог ширины для компактного режима (одна панель)
const COMPACT_WIDTH: u16 = 80;
pub fn render(f: &mut Frame, app: &mut App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
let area = f.area();
let is_compact = area.width < COMPACT_WIDTH;
@@ -52,7 +53,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
footer::render(f, chunks[2], app);
}
fn render_folders(f: &mut Frame, area: Rect, app: &App) {
fn render_folders<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let mut spans = vec![];
// "All" всегда первая (клавиша 1)

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::ui::components;
use ratatui::{
@@ -113,7 +114,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
result
}
pub fn render(f: &mut Frame, area: Rect, app: &App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим профиля
if app.is_profile_mode() {
if let Some(profile) = app.get_profile_info() {
@@ -213,7 +214,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(header, message_chunks[0]);
// Pinned bar (если есть закреплённое сообщение)
if let Some(pinned_msg) = &app.td_client.current_pinned_message() {
if let Some(pinned_msg) = app.td_client.current_pinned_message() {
let pinned_preview: String = pinned_msg.text().chars().take(40).collect();
let ellipsis = if pinned_msg.text().chars().count() > 40 {
"..."
@@ -251,7 +252,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let mut selected_msg_line: Option<usize> = None;
// Используем message_grouping для группировки сообщений
let grouped = group_messages(app.td_client.current_chat_messages());
let grouped = group_messages(&app.td_client.current_chat_messages());
let mut is_first_date = true;
let mut is_first_sender = true;
@@ -357,9 +358,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
@@ -501,7 +504,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
}
/// Рендерит режим поиска по сообщениям
fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
fn render_search_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
@@ -696,7 +699,7 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
}
/// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
fn render_pinned_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,

View File

@@ -8,6 +8,7 @@ pub mod messages;
pub mod profile;
use crate::app::{App, AppScreen};
use crate::tdlib::TdClientTrait;
use ratatui::layout::Alignment;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Paragraph;
@@ -18,7 +19,7 @@ const MIN_HEIGHT: u16 = 10;
/// Минимальная ширина терминала
const MIN_WIDTH: u16 = 40;
pub fn render(f: &mut Frame, app: &mut App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
let area = f.area();
// Проверяем минимальный размер терминала

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::ProfileInfo;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
@@ -9,7 +10,7 @@ use ratatui::{
};
/// Рендерит режим просмотра профиля
pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, profile: &ProfileInfo) {
// Проверяем, показывать ли модалку подтверждения
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {