From 2e510dc93254941d482ac787277c2ea67fe22422 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sun, 17 May 2026 17:58:29 +0300 Subject: [PATCH] Refactor TDLib facade and local time handling --- CONTEXT.md | 4 + Cargo.toml | 3 - benches/format_markdown.rs | 28 ++- benches/group_messages.rs | 4 +- build.rs | 3 - src/app/chat_filter.rs | 1 + src/input/handlers/chat.rs | 376 ++-------------------------- src/input/handlers/chat/media.rs | 322 ++++++++++++++++++++++++ src/input/handlers/chat_loader.rs | 25 +- src/input/handlers/mod.rs | 2 +- src/input/handlers/modal.rs | 5 +- src/main.rs | 16 +- src/tdlib/client_impl.rs | 230 +++++++++-------- src/tdlib/mod.rs | 6 +- src/tdlib/trait.rs | 150 ++++++++--- src/utils/formatting.rs | 233 ++++++++++------- tests/account_switcher.rs | 1 - tests/chat_list.rs | 21 +- tests/config.rs | 12 +- tests/copy.rs | 1 - tests/delete_message.rs | 8 +- tests/drafts.rs | 5 +- tests/e2e_smoke.rs | 20 +- tests/e2e_user_journey.rs | 2 +- tests/edit_message.rs | 4 +- tests/footer.rs | 10 +- tests/helpers/app_builder.rs | 4 +- tests/helpers/fake_tdclient.rs | 34 ++- tests/helpers/fake_tdclient_impl.rs | 312 ++++++++++++----------- tests/helpers/mod.rs | 3 - tests/helpers/test_data.rs | 4 +- tests/modals.rs | 1 - tests/navigation.rs | 4 +- tests/profile.rs | 4 +- tests/reactions.rs | 18 +- tests/reply_forward.rs | 1 - tests/search.rs | 4 +- tests/send_message.rs | 6 +- 38 files changed, 1025 insertions(+), 862 deletions(-) delete mode 100644 build.rs create mode 100644 src/input/handlers/chat/media.rs diff --git a/CONTEXT.md b/CONTEXT.md index b3e2d49..5437459 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,8 @@ - Keybindings стали детерминированными; русская vim-раскладка: `h/j/k/l` -> `р/о/л/д`. - `AudioPlayer` проверяет наличие `ffplay`. - `message_grouping` группирует альбомы без клонирования сообщений. +- TDLib facade split на scoped traits; generic код больше не получает raw `*_mut` доступ к сообщениям. +- Локальный `build.rs` удалён: линковкой TDLib управляет зависимость `tdlib-rs`, `cargo check --all-targets --all-features` снова воспроизводим. ## Осталось @@ -40,6 +42,8 @@ ## Ключевые решения - Главный state хранится в `App`, чтобы тесты могли использовать `FakeTdClient`. +- `TdClientTrait` теперь facade поверх scoped traits; чтение текущих сообщений идёт через `Cow`, mutation - через явные update-операции. +- Пользовательская timezone не хранится в config: runtime использует системную timezone, тесты форматирования используют deterministic time source. - Методы `App` разбиты на traits: navigation, messages, compose, search, modal. - UI рендерится только при `needs_redraw`; текстовый интерфейс целится в 60 FPS. - Фото под feature `images`: inline Halfblocks + modal iTerm2/Sixel. diff --git a/Cargo.toml b/Cargo.toml index 5d7dcf6..947b5b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,9 +44,6 @@ insta = "1.34" tokio-test = "0.4" criterion = "0.5" -[build-dependencies] -tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] } - [[bench]] name = "group_messages" harness = false diff --git a/benches/format_markdown.rs b/benches/format_markdown.rs index e722f17..15d776f 100644 --- a/benches/format_markdown.rs +++ b/benches/format_markdown.rs @@ -1,5 +1,7 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use tdlib_rs::enums::{TextEntity, TextEntityType}; +use ratatui::style::Color; +use tdlib_rs::enums::TextEntityType; +use tdlib_rs::types::TextEntity; use tele_tui::formatting::format_text_with_entities; fn create_text_with_entities() -> (String, Vec) { @@ -9,27 +11,27 @@ fn create_text_with_entities() -> (String, Vec) { TextEntity { offset: 8, length: 4, // bold - type_: TextEntityType::Bold, + r#type: TextEntityType::Bold, }, TextEntity { offset: 17, length: 6, // italic - type_: TextEntityType::Italic, + r#type: TextEntityType::Italic, }, TextEntity { offset: 34, length: 4, // code - type_: TextEntityType::Code, + r#type: TextEntityType::Code, }, TextEntity { offset: 45, length: 4, // link - type_: TextEntityType::Url, + r#type: TextEntityType::Url, }, TextEntity { offset: 54, length: 7, // mention - type_: TextEntityType::Mention, + r#type: TextEntityType::Mention, }, ]; @@ -41,7 +43,9 @@ fn benchmark_format_simple_text(c: &mut Criterion) { let entities = vec![]; c.bench_function("format_simple_text", |b| { - b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities))); + b.iter(|| { + format_text_with_entities(black_box(&text), black_box(&entities), Color::White) + }); }); } @@ -49,7 +53,9 @@ fn benchmark_format_markdown_text(c: &mut Criterion) { let (text, entities) = create_text_with_entities(); c.bench_function("format_markdown_text", |b| { - b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities))); + b.iter(|| { + format_text_with_entities(black_box(&text), black_box(&entities), Color::White) + }); }); } @@ -67,13 +73,15 @@ fn benchmark_format_long_text(c: &mut Criterion) { entities.push(TextEntity { offset: start as i32, length: format!("Word{}", i).len() as i32, - type_: TextEntityType::Bold, + r#type: TextEntityType::Bold, }); } } c.bench_function("format_long_text_with_100_entities", |b| { - b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities))); + b.iter(|| { + format_text_with_entities(black_box(&text), black_box(&entities), Color::White) + }); }); } diff --git a/benches/group_messages.rs b/benches/group_messages.rs index d4c604c..60fbc99 100644 --- a/benches/group_messages.rs +++ b/benches/group_messages.rs @@ -7,8 +7,8 @@ fn create_test_messages(count: usize) -> Vec { (0..count) .map(|i| { let builder = MessageBuilder::new(MessageId::new(i as i64)) - .sender_name(&format!("User{}", i % 10)) - .text(&format!( + .sender_name(format!("User{}", i % 10)) + .text(format!( "Test message number {} with some longer text to make it more realistic", i )) diff --git a/build.rs b/build.rs deleted file mode 100644 index cc8ba10..0000000 --- a/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tdlib_rs::build::build(None); -} diff --git a/src/app/chat_filter.rs b/src/app/chat_filter.rs index 32615fa..6d196f8 100644 --- a/src/app/chat_filter.rs +++ b/src/app/chat_filter.rs @@ -226,6 +226,7 @@ mod tests { use super::*; use crate::types::ChatId; + #[allow(clippy::too_many_arguments)] fn create_test_chat( id: i64, title: &str, diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index 39e1c3c..d701a36 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -6,6 +6,8 @@ //! - Editing and sending messages //! - Loading older messages +mod media; + use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data}; use crate::app::methods::{ compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods, @@ -77,16 +79,16 @@ pub async fn handle_message_selection( } } Some(crate::config::Command::ViewImage) => { - handle_view_or_play_media(app).await; + media::handle_view_or_play_media(app).await; } Some(crate::config::Command::TogglePlayback) => { - handle_toggle_voice_playback(app).await; + media::handle_toggle_voice_playback(app).await; } Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => { - handle_voice_seek(app, 5.0); + media::handle_voice_seek(app, 5.0); } Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => { - handle_voice_seek(app, -5.0); + media::handle_voice_seek(app, -5.0); } Some(crate::config::Command::ReactMessage) => { let Some(msg) = app.get_selected_message() else { @@ -163,23 +165,24 @@ pub async fn edit_message( { Ok(mut edited_msg) => { // Сохраняем reply_to из старого сообщения (если есть) - let messages = app.td_client.current_chat_messages_mut(); - if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { - let old_reply_to = messages[pos].interactions.reply_to.clone(); - // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый - if let Some(old_reply) = old_reply_to { - if edited_msg - .interactions - .reply_to - .as_ref() - .is_none_or(|r| r.sender_name == "Unknown") - { - edited_msg.interactions.reply_to = Some(old_reply); + app.td_client.update_current_chat_messages(|messages| { + if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { + let old_reply_to = messages[pos].interactions.reply_to.clone(); + // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый + if let Some(old_reply) = old_reply_to { + if edited_msg + .interactions + .reply_to + .as_ref() + .is_none_or(|r| r.sender_name == "Unknown") + { + edited_msg.interactions.reply_to = Some(old_reply); + } } + // Заменяем сообщение + messages[pos] = edited_msg; } - // Заменяем сообщение - messages[pos] = edited_msg; - } + }); // Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования app.message_input.clear(); app.cursor_position = 0; @@ -451,341 +454,6 @@ pub async fn handle_open_chat_keyboard_input(app: &mut App, } } -/// Обработка команды ViewImage — только фото -async fn handle_view_or_play_media(app: &mut App) { - let Some(msg) = app.get_selected_message() else { - return; - }; - - if msg.has_photo() { - #[cfg(feature = "images")] - handle_view_image(app).await; - #[cfg(not(feature = "images"))] - { - app.status_message = Some("Просмотр изображений отключён".to_string()); - } - } else { - app.status_message = Some("Сообщение не содержит фото".to_string()); - } -} - -/// Space: play/pause toggle для голосовых сообщений -async fn handle_toggle_voice_playback(app: &mut App) { - use crate::tdlib::PlaybackStatus; - - // Если уже есть активное воспроизведение — toggle pause/resume - if let Some(ref mut playback) = app.playback_state { - if let Some(ref player) = app.audio_player { - match playback.status { - PlaybackStatus::Playing => { - player.pause(); - playback.status = PlaybackStatus::Paused; - app.last_playback_tick = None; - app.status_message = Some("⏸ Пауза".to_string()); - } - PlaybackStatus::Paused => { - // Откатываем на 1 секунду для контекста - let resume_pos = (playback.position - 1.0).max(0.0); - // Перезапускаем ffplay с нужной позиции (-ss) - if player.resume_from(resume_pos).is_ok() { - playback.position = resume_pos; - } else { - // Fallback: простой SIGCONT без перемотки - player.resume(); - } - playback.status = PlaybackStatus::Playing; - app.last_playback_tick = Some(Instant::now()); - app.status_message = Some("▶ Воспроизведение".to_string()); - } - _ => {} - } - app.needs_redraw = true; - } - return; - } - - // Нет активного воспроизведения — пробуем запустить текущее голосовое - let Some(msg) = app.get_selected_message() else { - return; - }; - if msg.has_voice() { - handle_play_voice(app).await; - } -} - -/// Seek голосового сообщения на delta секунд -fn handle_voice_seek(app: &mut App, delta: f32) { - use crate::tdlib::PlaybackStatus; - - let Some(ref mut playback) = app.playback_state else { - return; - }; - let Some(ref player) = app.audio_player else { - return; - }; - - let was_playing = matches!(playback.status, PlaybackStatus::Playing); - let was_paused = matches!(playback.status, PlaybackStatus::Paused); - - if was_playing || was_paused { - let new_position = (playback.position + delta).clamp(0.0, playback.duration); - - if was_playing { - // Перезапускаем ffplay с новой позиции - if player.resume_from(new_position).is_ok() { - playback.position = new_position; - app.last_playback_tick = Some(std::time::Instant::now()); - } - } else { - // На паузе — только двигаем позицию, воспроизведение начнётся при resume - player.stop(); - playback.position = new_position; - } - - let arrow = if delta > 0.0 { "→" } else { "←" }; - app.status_message = Some(format!("{} {:.0}s", arrow, new_position)); - app.needs_redraw = true; - } -} - -/// Обработка команды ViewImage — открыть модальное окно с фото -#[cfg(feature = "images")] -async fn handle_view_image(app: &mut App) { - use crate::tdlib::{ImageModalState, PhotoDownloadState}; - - if !app.config().images.show_images { - return; - } - - let Some(msg) = app.get_selected_message() else { - return; - }; - - if !msg.has_photo() { - app.status_message = Some("Сообщение не содержит фото".to_string()); - return; - } - - let photo = msg.photo_info().unwrap(); - let msg_id = msg.id(); - let file_id = photo.file_id; - let photo_width = photo.width; - let photo_height = photo.height; - let download_state = photo.download_state.clone(); - - match download_state { - PhotoDownloadState::Downloaded(path) => { - // Открываем модальное окно - app.image_modal = Some(ImageModalState { - message_id: msg_id, - photo_path: path, - photo_width, - photo_height, - }); - app.needs_redraw = true; - } - PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => { - // Запоминаем намерение открыть модалку — откроется когда загрузится - app.pending_image_open = Some(crate::app::PendingImageOpen { - file_id, - message_id: msg_id, - photo_width, - photo_height, - }); - app.status_message = Some("Загрузка фото...".to_string()); - app.needs_redraw = true; - - // Если нет активной фоновой загрузки — запускаем свою - if app.photo_download_rx.is_none() { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - app.photo_download_rx = Some(rx); - let client_id = app.td_client.client_id(); - tokio::spawn(async move { - let result = tokio::time::timeout(Duration::from_secs(30), async { - match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id) - .await - { - Ok(tdlib_rs::enums::File::File(f)) - if f.local.is_downloading_completed && !f.local.path.is_empty() => - { - Ok(f.local.path) - } - Ok(_) => Err("Файл не скачан".to_string()), - Err(e) => Err(format!("{:?}", e)), - } - }) - .await; - let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string())); - let _ = tx.send((file_id, result)); - }); - } - } - PhotoDownloadState::Error(_) => { - // Повторная попытка загрузки - app.status_message = Some("Повторная загрузка фото...".to_string()); - app.needs_redraw = true; - match app.td_client.download_file(file_id).await { - Ok(path) => { - for msg in app.td_client.current_chat_messages_mut() { - if let Some(photo) = msg.photo_info_mut() { - if photo.file_id == file_id { - photo.download_state = PhotoDownloadState::Downloaded(path.clone()); - break; - } - } - } - app.image_modal = Some(ImageModalState { - message_id: msg_id, - photo_path: path, - photo_width, - photo_height, - }); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(format!("Ошибка загрузки фото: {}", e)); - app.status_message = None; - } - } - } - } -} - -/// Вспомогательная функция для воспроизведения из конкретного пути -async fn handle_play_voice_from_path( - app: &mut App, - path: &str, - voice: &crate::tdlib::VoiceInfo, - msg: &crate::tdlib::MessageInfo, -) { - use crate::tdlib::{PlaybackState, PlaybackStatus}; - - if let Some(ref player) = app.audio_player { - match player.play(path) { - Ok(_) => { - app.playback_state = Some(PlaybackState { - message_id: msg.id(), - status: PlaybackStatus::Playing, - position: 0.0, - duration: voice.duration as f32, - volume: player.volume(), - }); - app.last_playback_tick = Some(Instant::now()); - app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration)); - app.needs_redraw = true; - } - Err(e) => { - app.error_message = Some(format!("Ошибка воспроизведения: {}", e)); - } - } - } else { - app.error_message = Some("Аудиоплеер не инициализирован".to_string()); - } -} - -/// Воспроизведение голосового сообщения -async fn handle_play_voice(app: &mut App) { - use crate::tdlib::VoiceDownloadState; - - let Some(msg) = app.get_selected_message() else { - return; - }; - - if !msg.has_voice() { - return; - } - - let voice = msg.voice_info().unwrap(); - let file_id = voice.file_id; - - match &voice.download_state { - VoiceDownloadState::Downloaded(path) => { - // TDLib может вернуть путь без расширения — ищем файл с .oga - use std::path::Path; - let audio_path = if Path::new(path).exists() { - path.clone() - } else { - // Пробуем добавить .oga - let with_oga = format!("{}.oga", path); - if Path::new(&with_oga).exists() { - with_oga - } else { - // Пробуем найти файл с похожим именем в той же папке - if let Some(parent) = Path::new(path).parent() { - if let Some(stem) = Path::new(path).file_name() { - if let Ok(entries) = std::fs::read_dir(parent) { - for entry in entries.flatten() { - let entry_name = entry.file_name(); - if entry_name - .to_string_lossy() - .starts_with(&stem.to_string_lossy().to_string()) - { - let found_path = entry.path().to_string_lossy().to_string(); - // Кэшируем найденный файл - if let Some(ref mut cache) = app.voice_cache { - let _ = cache.store( - &file_id.to_string(), - Path::new(&found_path), - ); - } - return handle_play_voice_from_path( - app, - &found_path, - voice, - &msg, - ) - .await; - } - } - } - } - } - app.error_message = Some(format!("Файл не найден: {}", path)); - return; - } - }; - - // Кэшируем файл если ещё не в кэше - if let Some(ref mut cache) = app.voice_cache { - let _ = cache.store(&file_id.to_string(), Path::new(&audio_path)); - } - - handle_play_voice_from_path(app, &audio_path, voice, &msg).await; - } - VoiceDownloadState::Downloading => { - app.status_message = Some("Загрузка голосового...".to_string()); - } - VoiceDownloadState::NotDownloaded => { - // Проверяем кэш перед загрузкой - let cache_key = file_id.to_string(); - if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) { - let path_str = cached_path.to_string_lossy().to_string(); - handle_play_voice_from_path(app, &path_str, voice, &msg).await; - return; - } - - // Начинаем загрузку - app.status_message = Some("Загрузка голосового...".to_string()); - match app.td_client.download_voice_note(file_id).await { - Ok(path) => { - // Кэшируем загруженный файл - if let Some(ref mut cache) = app.voice_cache { - let _ = cache.store(&cache_key, std::path::Path::new(&path)); - } - - handle_play_voice_from_path(app, &path, voice, &msg).await; - } - Err(e) => { - app.error_message = Some(format!("Ошибка загрузки: {}", e)); - } - } - } - VoiceDownloadState::Error(e) => { - app.error_message = Some(format!("Ошибка загрузки: {}", e)); - } - } -} - // TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика /* #[cfg(feature = "images")] diff --git a/src/input/handlers/chat/media.rs b/src/input/handlers/chat/media.rs new file mode 100644 index 0000000..9a7ffa7 --- /dev/null +++ b/src/input/handlers/chat/media.rs @@ -0,0 +1,322 @@ +//! Media actions for the open chat input handler. + +use crate::app::methods::messages::MessageMethods; +use crate::app::App; +use crate::tdlib::TdClientTrait; +use std::time::{Duration, Instant}; + +/// Обработка команды ViewImage — только фото. +pub(super) async fn handle_view_or_play_media(app: &mut App) { + let Some(msg) = app.get_selected_message() else { + return; + }; + + if msg.has_photo() { + #[cfg(feature = "images")] + handle_view_image(app).await; + #[cfg(not(feature = "images"))] + { + app.status_message = Some("Просмотр изображений отключён".to_string()); + } + } else { + app.status_message = Some("Сообщение не содержит фото".to_string()); + } +} + +/// Space: play/pause toggle для голосовых сообщений. +pub(super) async fn handle_toggle_voice_playback(app: &mut App) { + use crate::tdlib::PlaybackStatus; + + if let Some(ref mut playback) = app.playback_state { + if let Some(ref player) = app.audio_player { + match playback.status { + PlaybackStatus::Playing => { + player.pause(); + playback.status = PlaybackStatus::Paused; + app.last_playback_tick = None; + app.status_message = Some("⏸ Пауза".to_string()); + } + PlaybackStatus::Paused => { + let resume_pos = (playback.position - 1.0).max(0.0); + if player.resume_from(resume_pos).is_ok() { + playback.position = resume_pos; + } else { + player.resume(); + } + playback.status = PlaybackStatus::Playing; + app.last_playback_tick = Some(Instant::now()); + app.status_message = Some("▶ Воспроизведение".to_string()); + } + _ => {} + } + app.needs_redraw = true; + } + return; + } + + let Some(msg) = app.get_selected_message() else { + return; + }; + if msg.has_voice() { + handle_play_voice(app).await; + } +} + +/// Seek голосового сообщения на delta секунд. +pub(super) fn handle_voice_seek(app: &mut App, delta: f32) { + use crate::tdlib::PlaybackStatus; + + let Some(ref mut playback) = app.playback_state else { + return; + }; + let Some(ref player) = app.audio_player else { + return; + }; + + let was_playing = matches!(playback.status, PlaybackStatus::Playing); + let was_paused = matches!(playback.status, PlaybackStatus::Paused); + + if was_playing || was_paused { + let new_position = (playback.position + delta).clamp(0.0, playback.duration); + + if was_playing { + if player.resume_from(new_position).is_ok() { + playback.position = new_position; + app.last_playback_tick = Some(Instant::now()); + } + } else { + player.stop(); + playback.position = new_position; + } + + let arrow = if delta > 0.0 { "→" } else { "←" }; + app.status_message = Some(format!("{} {:.0}s", arrow, new_position)); + app.needs_redraw = true; + } +} + +#[cfg(feature = "images")] +async fn handle_view_image(app: &mut App) { + use crate::tdlib::{ImageModalState, PhotoDownloadState}; + + if !app.config().images.show_images { + return; + } + + let Some(msg) = app.get_selected_message() else { + return; + }; + + if !msg.has_photo() { + app.status_message = Some("Сообщение не содержит фото".to_string()); + return; + } + + let photo = msg.photo_info().unwrap(); + let msg_id = msg.id(); + let file_id = photo.file_id; + let photo_width = photo.width; + let photo_height = photo.height; + let download_state = photo.download_state.clone(); + + match download_state { + PhotoDownloadState::Downloaded(path) => { + app.image_modal = Some(ImageModalState { + message_id: msg_id, + photo_path: path, + photo_width, + photo_height, + }); + app.needs_redraw = true; + } + PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => { + app.pending_image_open = Some(crate::app::PendingImageOpen { + file_id, + message_id: msg_id, + photo_width, + photo_height, + }); + app.status_message = Some("Загрузка фото...".to_string()); + app.needs_redraw = true; + + if app.photo_download_rx.is_none() { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + app.photo_download_rx = Some(rx); + let client_id = app.td_client.client_id(); + tokio::spawn(async move { + let result = tokio::time::timeout(Duration::from_secs(30), async { + match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id) + .await + { + Ok(tdlib_rs::enums::File::File(f)) + if f.local.is_downloading_completed && !f.local.path.is_empty() => + { + Ok(f.local.path) + } + Ok(_) => Err("Файл не скачан".to_string()), + Err(e) => Err(format!("{:?}", e)), + } + }) + .await; + let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string())); + let _ = tx.send((file_id, result)); + }); + } + } + PhotoDownloadState::Error(_) => { + app.status_message = Some("Повторная загрузка фото...".to_string()); + app.needs_redraw = true; + match app.td_client.download_file(file_id).await { + Ok(path) => { + app.td_client.update_current_chat_messages(|messages| { + for msg in messages { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = + PhotoDownloadState::Downloaded(path.clone()); + break; + } + } + } + }); + app.image_modal = Some(ImageModalState { + message_id: msg_id, + photo_path: path, + photo_width, + photo_height, + }); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(format!("Ошибка загрузки фото: {}", e)); + app.status_message = None; + } + } + } + } +} + +async fn handle_play_voice_from_path( + app: &mut App, + path: &str, + voice: &crate::tdlib::VoiceInfo, + msg: &crate::tdlib::MessageInfo, +) { + use crate::tdlib::{PlaybackState, PlaybackStatus}; + + if let Some(ref player) = app.audio_player { + match player.play(path) { + Ok(_) => { + app.playback_state = Some(PlaybackState { + message_id: msg.id(), + status: PlaybackStatus::Playing, + position: 0.0, + duration: voice.duration as f32, + volume: player.volume(), + }); + app.last_playback_tick = Some(Instant::now()); + app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration)); + app.needs_redraw = true; + } + Err(e) => { + app.error_message = Some(format!("Ошибка воспроизведения: {}", e)); + } + } + } else { + app.error_message = Some("Аудиоплеер не инициализирован".to_string()); + } +} + +async fn handle_play_voice(app: &mut App) { + use crate::tdlib::VoiceDownloadState; + + let Some(msg) = app.get_selected_message() else { + return; + }; + + if !msg.has_voice() { + return; + } + + let voice = msg.voice_info().unwrap(); + let file_id = voice.file_id; + + match &voice.download_state { + VoiceDownloadState::Downloaded(path) => { + use std::path::Path; + let audio_path = if Path::new(path).exists() { + path.clone() + } else { + let with_oga = format!("{}.oga", path); + if Path::new(&with_oga).exists() { + with_oga + } else { + if let Some(parent) = Path::new(path).parent() { + if let Some(stem) = Path::new(path).file_name() { + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + let entry_name = entry.file_name(); + if entry_name + .to_string_lossy() + .starts_with(&stem.to_string_lossy().to_string()) + { + let found_path = entry.path().to_string_lossy().to_string(); + if let Some(ref mut cache) = app.voice_cache { + let _ = cache.store( + &file_id.to_string(), + Path::new(&found_path), + ); + } + return handle_play_voice_from_path( + app, + &found_path, + voice, + &msg, + ) + .await; + } + } + } + } + } + app.error_message = Some(format!("Файл не найден: {}", path)); + return; + } + }; + + if let Some(ref mut cache) = app.voice_cache { + let _ = cache.store(&file_id.to_string(), Path::new(&audio_path)); + } + + handle_play_voice_from_path(app, &audio_path, voice, &msg).await; + } + VoiceDownloadState::Downloading => { + app.status_message = Some("Загрузка голосового...".to_string()); + } + VoiceDownloadState::NotDownloaded => { + let cache_key = file_id.to_string(); + if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) { + let path_str = cached_path.to_string_lossy().to_string(); + handle_play_voice_from_path(app, &path_str, voice, &msg).await; + return; + } + + app.status_message = Some("Загрузка голосового...".to_string()); + match app.td_client.download_voice_note(file_id).await { + Ok(path) => { + if let Some(ref mut cache) = app.voice_cache { + let _ = cache.store(&cache_key, std::path::Path::new(&path)); + } + + handle_play_voice_from_path(app, &path, voice, &msg).await; + } + Err(e) => { + app.error_message = Some(format!("Ошибка загрузки: {}", e)); + } + } + } + VoiceDownloadState::Error(e) => { + app.error_message = Some(format!("Ошибка загрузки: {}", e)); + } + } +} diff --git a/src/input/handlers/chat_loader.rs b/src/input/handlers/chat_loader.rs index 051fd5e..d2b929e 100644 --- a/src/input/handlers/chat_loader.rs +++ b/src/input/handlers/chat_loader.rs @@ -228,16 +228,18 @@ pub fn process_chat_init_events(app: &mut App) { } let mut changed = false; - for msg in app.td_client.current_chat_messages_mut() { - let Some(reply) = msg.interactions.reply_to.as_mut() else { - continue; - }; - if reply.message_id == message_id { - reply.sender_name = sender_name.clone(); - reply.text = text.clone(); - changed = true; + app.td_client.update_current_chat_messages(|messages| { + for msg in messages { + let Some(reply) = msg.interactions.reply_to.as_mut() else { + continue; + }; + if reply.message_id == message_id { + reply.sender_name = sender_name.clone(); + reply.text = text.clone(); + changed = true; + } } - } + }); if changed { app.needs_redraw = true; @@ -286,7 +288,8 @@ pub async fn load_older_messages_if_needed(app: &mut App) { // Add older messages to the beginning if any were loaded if !older.is_empty() { - let msgs = app.td_client.current_chat_messages_mut(); - msgs.splice(0..0, older); + app.td_client.update_current_chat_messages(|messages| { + messages.splice(0..0, older); + }); } } diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index cdaa9e0..ae9ee51 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -22,7 +22,7 @@ pub mod profile; pub mod search; pub use chat_loader::{ - load_older_messages_if_needed, open_chat_and_load_data, process_chat_init_events, + process_chat_init_events, process_pending_chat_init, }; pub use clipboard::*; diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs index e40a697..4301bc1 100644 --- a/src/input/handlers/modal.rs +++ b/src/input/handlers/modal.rs @@ -293,8 +293,9 @@ pub async fn handle_delete_confirmation(app: &mut App, key: Ok(_) => { // Удаляем из локального списка app.td_client - .current_chat_messages_mut() - .retain(|m| m.id() != msg_id); + .update_current_chat_messages(|messages| { + messages.retain(|m| m.id() != msg_id); + }); // Сбрасываем состояние app.chat_state = crate::app::ChatState::Normal; } diff --git a/src/main.rs b/src/main.rs index f31486d..36f917c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -222,15 +222,17 @@ async fn run_app( Ok(path) => PhotoDownloadState::Downloaded(path), Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()), }; - for msg in app.td_client.current_chat_messages_mut() { - if let Some(photo) = msg.photo_info_mut() { - if photo.file_id == file_id { - photo.download_state = new_state; - got_photos = true; - break; + app.td_client.update_current_chat_messages(|messages| { + for msg in messages { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = new_state; + got_photos = true; + break; + } } } - } + }); // Если это фото ждёт открытия в модалке — открываем let pending_matches = app .pending_image_open diff --git a/src/tdlib/client_impl.rs b/src/tdlib/client_impl.rs index 96b4cab..0511f8a 100644 --- a/src/tdlib/client_impl.rs +++ b/src/tdlib/client_impl.rs @@ -3,19 +3,22 @@ //! This file contains the trait implementation that delegates to existing TdClient methods. use super::client::TdClient; -use super::r#trait::TdClientTrait; +use super::r#trait::{ + AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient, + MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient, +}; use super::{ AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus, }; use crate::types::{ChatId, MessageId, UserId}; use async_trait::async_trait; +use std::borrow::Cow; use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; #[async_trait] -impl TdClientTrait for TdClient { - // ============ Auth methods ============ +impl AuthClient for TdClient { async fn send_phone_number(&self, phone: String) -> Result<(), String> { self.send_phone_number(phone).await } @@ -27,8 +30,10 @@ impl TdClientTrait for TdClient { async fn send_password(&self, password: String) -> Result<(), String> { self.send_password(password).await } +} - // ============ Chat methods ============ +#[async_trait] +impl ChatClient for TdClient { async fn load_chats(&mut self, limit: i32) -> Result<(), String> { self.load_chats(limit).await } @@ -45,7 +50,39 @@ impl TdClientTrait for TdClient { self.get_profile_info(chat_id).await } - // ============ Chat actions ============ + fn chats(&self) -> &[ChatInfo] { + self.chats() + } + + fn folders(&self) -> &[FolderInfo] { + self.folders() + } + + fn main_chat_list_position(&self) -> i32 { + self.main_chat_list_position() + } + + fn set_main_chat_list_position(&mut self, position: i32) { + self.set_main_chat_list_position(position) + } + + fn update_chats(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(self.chats_mut()); + } + + fn update_folders(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(self.folders_mut()); + } +} + +#[async_trait] +impl ChatActionClient for TdClient { async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { self.send_chat_action(chat_id, action).await } @@ -54,7 +91,17 @@ impl TdClientTrait for TdClient { self.clear_stale_typing_status() } - // ============ Message methods ============ + fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { + self.typing_status() + } + + fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) { + self.set_typing_status(status) + } +} + +#[async_trait] +impl MessageClient for TdClient { async fn get_chat_history( &mut self, chat_id: ChatId, @@ -132,6 +179,18 @@ impl TdClientTrait for TdClient { self.set_draft_message(chat_id, text).await } + fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> { + Cow::Borrowed(self.current_chat_messages()) + } + + fn current_chat_id(&self) -> Option { + self.current_chat_id() + } + + fn current_pinned_message(&self) -> Option { + self.current_pinned_message().cloned() + } + fn push_message(&mut self, msg: MessageInfo) { self.push_message(msg) } @@ -144,16 +203,66 @@ impl TdClientTrait for TdClient { self.process_pending_view_messages().await } - // ============ User methods ============ + fn clear_current_chat_messages(&mut self) { + self.current_chat_messages_mut().clear() + } + + fn set_current_chat_messages(&mut self, messages: Vec) { + *self.current_chat_messages_mut() = messages; + } + + fn update_current_chat_messages(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(self.current_chat_messages_mut()); + } + + fn set_current_chat_id(&mut self, chat_id: Option) { + self.set_current_chat_id(chat_id) + } + + fn set_current_pinned_message(&mut self, msg: Option) { + self.set_current_pinned_message(msg) + } + + fn pending_view_messages(&self) -> &[(ChatId, Vec)] { + self.pending_view_messages() + } + + fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { + self.enqueue_pending_view_messages(chat_id, message_ids); + } +} + +#[async_trait] +impl UserClient for TdClient { fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> { self.get_user_status_by_chat_id(chat_id) } + fn pending_user_ids(&self) -> &[UserId] { + self.pending_user_ids() + } + + fn user_cache(&self) -> &UserCache { + self.user_cache() + } + + fn update_user_cache(&mut self, updater: F) + where + F: FnOnce(&mut UserCache), + { + updater(self.user_cache_mut()); + } + async fn process_pending_user_ids(&mut self) { self.process_pending_user_ids().await } +} - // ============ Reaction methods ============ +#[async_trait] +impl ReactionClient for TdClient { async fn get_message_available_reactions( &self, chat_id: ChatId, @@ -171,8 +280,10 @@ impl TdClientTrait for TdClient { ) -> Result<(), String> { self.toggle_reaction(chat_id, message_id, reaction).await } +} - // ============ File methods ============ +#[async_trait] +impl FileClient for TdClient { async fn download_file(&self, file_id: i32) -> Result { self.download_file(file_id).await } @@ -181,7 +292,10 @@ impl TdClientTrait for TdClient { // Voice notes use the same download mechanism as photos self.download_file(file_id).await } +} +#[async_trait] +impl ClientState for TdClient { fn client_id(&self) -> i32 { self.client_id() } @@ -194,99 +308,12 @@ impl TdClientTrait for TdClient { self.auth_state() } - fn chats(&self) -> &[ChatInfo] { - self.chats() - } - - fn folders(&self) -> &[FolderInfo] { - self.folders() - } - - fn current_chat_messages(&self) -> Vec { - self.message_manager.current_chat_messages.to_vec() - } - - fn current_chat_id(&self) -> Option { - self.current_chat_id() - } - - fn current_pinned_message(&self) -> Option { - self.message_manager.current_pinned_message.clone() - } - - fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { - self.typing_status() - } - - fn pending_view_messages(&self) -> &[(ChatId, Vec)] { - self.pending_view_messages() - } - - fn pending_user_ids(&self) -> &[UserId] { - self.pending_user_ids() - } - - fn main_chat_list_position(&self) -> i32 { - self.main_chat_list_position() - } - - fn user_cache(&self) -> &UserCache { - self.user_cache() - } - fn network_state(&self) -> super::types::NetworkState { self.network_state.clone() } +} - fn chats_mut(&mut self) -> &mut Vec { - self.chats_mut() - } - - fn folders_mut(&mut self) -> &mut Vec { - self.folders_mut() - } - - fn current_chat_messages_mut(&mut self) -> &mut Vec { - self.current_chat_messages_mut() - } - - fn clear_current_chat_messages(&mut self) { - self.current_chat_messages_mut().clear() - } - - fn set_current_chat_messages(&mut self, messages: Vec) { - *self.current_chat_messages_mut() = messages; - } - - fn set_current_chat_id(&mut self, chat_id: Option) { - self.set_current_chat_id(chat_id) - } - - fn set_current_pinned_message(&mut self, msg: Option) { - self.set_current_pinned_message(msg) - } - - fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) { - self.set_typing_status(status) - } - - fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { - self.enqueue_pending_view_messages(chat_id, message_ids); - } - - fn pending_user_ids_mut(&mut self) -> &mut Vec { - self.pending_user_ids_mut() - } - - fn set_main_chat_list_position(&mut self, position: i32) { - self.set_main_chat_list_position(position) - } - - fn user_cache_mut(&mut self) -> &mut UserCache { - &mut self.user_cache - } - - // ============ Notification methods ============ +impl NotificationClient for TdClient { fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) { self.configure_notifications(config); } @@ -295,13 +322,16 @@ impl TdClientTrait for TdClient { self.notification_manager .sync_muted_chats(&self.chat_manager.chats); } +} - // ============ Account switching ============ +#[async_trait] +impl AccountClient for TdClient { async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> { TdClient::recreate_client(self, db_path).await } +} - // ============ Update handling ============ +impl UpdateClient for TdClient { fn handle_update(&mut self, update: Update) { // Delegate to the real implementation TdClient::handle_update(self, update) diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index 7d32549..3bfe8b1 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -16,7 +16,11 @@ pub mod users; // Экспорт основных типов pub use auth::AuthState; pub use client::TdClient; -pub use r#trait::TdClientTrait; +#[allow(unused_imports)] +pub use r#trait::{ + AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient, + MessageClient, NotificationClient, ReactionClient, TdClientTrait, UpdateClient, UserClient, +}; #[allow(unused_imports)] pub use types::{ ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState, diff --git a/src/tdlib/trait.rs b/src/tdlib/trait.rs index 96d5287..deb3e58 100644 --- a/src/tdlib/trait.rs +++ b/src/tdlib/trait.rs @@ -1,38 +1,57 @@ //! Trait definition for TdClient to enable dependency injection //! //! This trait allows tests to use FakeTdClient instead of real TDLib client. +#![allow(dead_code)] use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus}; use crate::types::{ChatId, MessageId, UserId}; use async_trait::async_trait; +use std::borrow::Cow; use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; use super::ChatInfo; -/// Trait for TDLib client operations -/// -/// This trait defines the interface for both real and fake TDLib clients, -/// enabling dependency injection and easier testing. -#[allow(dead_code)] +/// Auth operations. #[async_trait] -pub trait TdClientTrait: Send { - // ============ Auth methods ============ +pub trait AuthClient: Send { async fn send_phone_number(&self, phone: String) -> Result<(), String>; async fn send_code(&self, code: String) -> Result<(), String>; async fn send_password(&self, password: String) -> Result<(), String>; +} - // ============ Chat methods ============ +/// Chat list and profile operations. +#[async_trait] +pub trait ChatClient: Send { async fn load_chats(&mut self, limit: i32) -> Result<(), String>; async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String>; async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String>; async fn get_profile_info(&self, chat_id: ChatId) -> Result; - // ============ Chat actions ============ + fn chats(&self) -> &[ChatInfo]; + fn folders(&self) -> &[FolderInfo]; + fn main_chat_list_position(&self) -> i32; + fn set_main_chat_list_position(&mut self, position: i32); + fn update_chats(&mut self, updater: F) + where + F: FnOnce(&mut Vec); + fn update_folders(&mut self, updater: F) + where + F: FnOnce(&mut Vec); +} + +/// Ephemeral chat actions such as typing status. +#[async_trait] +pub trait ChatActionClient: Send { async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction); fn clear_stale_typing_status(&mut self) -> bool; + fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>; + fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>); +} - // ============ Message methods ============ +/// Message history, search, and mutation operations. +#[async_trait] +pub trait MessageClient: Send { async fn get_chat_history( &mut self, chat_id: ChatId, @@ -82,15 +101,38 @@ pub trait TdClientTrait: Send { async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String>; + fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]>; + fn current_chat_id(&self) -> Option; + fn current_pinned_message(&self) -> Option; fn push_message(&mut self, msg: MessageInfo); + fn clear_current_chat_messages(&mut self); + fn set_current_chat_messages(&mut self, messages: Vec); + fn update_current_chat_messages(&mut self, updater: F) + where + F: FnOnce(&mut Vec); + fn set_current_chat_id(&mut self, chat_id: Option); + fn set_current_pinned_message(&mut self, msg: Option); + fn pending_view_messages(&self) -> &[(ChatId, Vec)]; + fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec); async fn fetch_missing_reply_info(&mut self); async fn process_pending_view_messages(&mut self); +} - // ============ User methods ============ +/// User cache and user-status operations. +#[async_trait] +pub trait UserClient: Send { fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus>; + fn pending_user_ids(&self) -> &[UserId]; + fn user_cache(&self) -> &UserCache; + fn update_user_cache(&mut self, updater: F) + where + F: FnOnce(&mut UserCache); async fn process_pending_user_ids(&mut self); +} - // ============ Reaction methods ============ +/// Message reaction operations. +#[async_trait] +pub trait ReactionClient: Send { async fn get_message_available_reactions( &self, chat_id: ChatId, @@ -103,52 +145,78 @@ pub trait TdClientTrait: Send { message_id: MessageId, reaction: String, ) -> Result<(), String>; +} - // ============ File methods ============ +/// File download operations. +#[async_trait] +pub trait FileClient: Send { async fn download_file(&self, file_id: i32) -> Result; async fn download_voice_note(&self, file_id: i32) -> Result; +} - // ============ Getters (immutable) ============ +/// Shared client state that does not belong to one feature area. +#[async_trait] +pub trait ClientState: Send { fn client_id(&self) -> i32; async fn get_me(&self) -> Result; fn auth_state(&self) -> &AuthState; - fn chats(&self) -> &[ChatInfo]; - fn folders(&self) -> &[FolderInfo]; - fn current_chat_messages(&self) -> Vec; - fn current_chat_id(&self) -> Option; - fn current_pinned_message(&self) -> Option; - fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>; - fn pending_view_messages(&self) -> &[(ChatId, Vec)]; - fn pending_user_ids(&self) -> &[UserId]; - fn main_chat_list_position(&self) -> i32; - fn user_cache(&self) -> &UserCache; fn network_state(&self) -> super::types::NetworkState; +} - // ============ Setters (mutable) ============ - fn chats_mut(&mut self) -> &mut Vec; - fn folders_mut(&mut self) -> &mut Vec; - fn current_chat_messages_mut(&mut self) -> &mut Vec; - fn clear_current_chat_messages(&mut self); - fn set_current_chat_messages(&mut self, messages: Vec); - fn set_current_chat_id(&mut self, chat_id: Option); - fn set_current_pinned_message(&mut self, msg: Option); - fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>); - fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec); - fn pending_user_ids_mut(&mut self) -> &mut Vec; - fn set_main_chat_list_position(&mut self, position: i32); - fn user_cache_mut(&mut self) -> &mut UserCache; - - // ============ Notification methods ============ +/// Notification configuration operations. +pub trait NotificationClient: Send { fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig); fn sync_notification_muted_chats(&mut self); +} - // ============ Account switching ============ +/// Account switching operations. +#[async_trait] +pub trait AccountClient: Send { /// Recreates the client with a new database path (for account switching). /// /// For real TdClient: closes old client, creates new one, inits TDLib parameters. /// For FakeTdClient: no-op. async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>; +} - // ============ Update handling ============ +/// TDLib update routing. +pub trait UpdateClient: Send { fn handle_update(&mut self, update: Update); } + +/// Facade trait for TDLib client operations +/// +/// This trait defines the interface for both real and fake TDLib clients, +/// enabling dependency injection and easier testing. +#[allow(dead_code)] +pub trait TdClientTrait: + AuthClient + + ChatClient + + ChatActionClient + + MessageClient + + UserClient + + ReactionClient + + FileClient + + ClientState + + NotificationClient + + AccountClient + + UpdateClient + + Send +{ +} + +impl TdClientTrait for T where + T: AuthClient + + ChatClient + + ChatActionClient + + MessageClient + + UserClient + + ReactionClient + + FileClient + + ClientState + + NotificationClient + + AccountClient + + UpdateClient + + Send +{ +} diff --git a/src/utils/formatting.rs b/src/utils/formatting.rs index 2dfdb86..f2133b4 100644 --- a/src/utils/formatting.rs +++ b/src/utils/formatting.rs @@ -1,60 +1,145 @@ use chrono::{DateTime, Local, NaiveDate, Utc}; +#[cfg(test)] +use chrono::FixedOffset; +use std::time::{SystemTime, UNIX_EPOCH}; -fn as_local_datetime(timestamp: i32) -> Option> { - DateTime::::from_timestamp(timestamp as i64, 0).map(|dt| dt.with_timezone(&Local)) +pub trait LocalTimeSource { + fn now_date(&self) -> NaiveDate; + fn now_timestamp(&self) -> i32; + fn format_timestamp(&self, timestamp: i32, format: &str) -> Option; + fn date_for_timestamp(&self, timestamp: i32) -> Option; +} + +pub struct SystemLocalTime; + +impl LocalTimeSource for SystemLocalTime { + fn now_date(&self) -> NaiveDate { + Local::now().date_naive() + } + + fn now_timestamp(&self) -> i32 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i32 + } + + fn format_timestamp(&self, timestamp: i32, format: &str) -> Option { + DateTime::::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.with_timezone(&Local).format(format).to_string()) + } + + fn date_for_timestamp(&self, timestamp: i32) -> Option { + DateTime::::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.with_timezone(&Local).date_naive()) + } +} + +#[derive(Debug, Clone)] +#[cfg(test)] +pub struct FixedLocalTime { + offset: FixedOffset, + now: DateTime, +} + +#[cfg(test)] +impl FixedLocalTime { + fn new(offset: FixedOffset, now_timestamp: i32) -> Self { + let now = DateTime::::from_timestamp(now_timestamp as i64, 0) + .expect("valid fixed timestamp") + .with_timezone(&offset); + Self { offset, now } + } +} + +#[cfg(test)] +impl LocalTimeSource for FixedLocalTime { + fn now_date(&self) -> NaiveDate { + self.now.date_naive() + } + + fn now_timestamp(&self) -> i32 { + self.now.timestamp() as i32 + } + + fn format_timestamp(&self, timestamp: i32, format: &str) -> Option { + DateTime::::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.with_timezone(&self.offset).format(format).to_string()) + } + + fn date_for_timestamp(&self, timestamp: i32) -> Option { + DateTime::::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.with_timezone(&self.offset).date_naive()) + } +} + +fn system_time() -> SystemLocalTime { + SystemLocalTime } /// Форматирование timestamp во время HH:MM в системной таймзоне. pub fn format_timestamp(timestamp: i32) -> String { - as_local_datetime(timestamp) - .map(|dt| dt.format("%H:%M").to_string()) + format_timestamp_with(timestamp, &system_time()) +} + +pub fn format_timestamp_with(timestamp: i32, time: &impl LocalTimeSource) -> String { + time.format_timestamp(timestamp, "%H:%M") .unwrap_or_else(|| "00:00".to_string()) } /// Форматирование timestamp в дату для разделителя. pub fn format_date(timestamp: i32) -> String { - let Some(msg_dt) = as_local_datetime(timestamp) else { + format_date_with(timestamp, &system_time()) +} + +pub fn format_date_with(timestamp: i32, time: &impl LocalTimeSource) -> String { + let Some(msg_day) = time.date_for_timestamp(timestamp) else { return "01.01.1970".to_string(); }; - let msg_day = msg_dt.date_naive(); - let today = Local::now().date_naive(); + let today = time.now_date(); if msg_day == today { "Сегодня".to_string() } else if Some(msg_day) == today.pred_opt() { "Вчера".to_string() } else { - msg_dt.format("%d.%m.%Y").to_string() + time.format_timestamp(timestamp, "%d.%m.%Y") + .unwrap_or_else(|| "01.01.1970".to_string()) } } /// Получить день из timestamp для группировки. /// Возвращает число дней с 1970-01-01 в системной таймзоне. pub fn get_day(timestamp: i32) -> i64 { + get_day_with(timestamp, &system_time()) +} + +pub fn get_day_with(timestamp: i32, time: &impl LocalTimeSource) -> i64 { let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date"); - as_local_datetime(timestamp) - .map(|dt| dt.date_naive().signed_duration_since(epoch).num_days()) + time.date_for_timestamp(timestamp) + .map(|date| date.signed_duration_since(epoch).num_days()) .unwrap_or(0) } /// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM) в системной таймзоне. pub fn format_datetime(timestamp: i32) -> String { - as_local_datetime(timestamp) - .map(|dt| dt.format("%d.%m.%Y %H:%M").to_string()) + format_datetime_with(timestamp, &system_time()) +} + +pub fn format_datetime_with(timestamp: i32, time: &impl LocalTimeSource) -> String { + time.format_timestamp(timestamp, "%d.%m.%Y %H:%M") .unwrap_or_else(|| "01.01.1970 00:00".to_string()) } /// Форматирование "был(а) онлайн" из timestamp pub fn format_was_online(timestamp: i32) -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; + format_was_online_with(timestamp, &system_time()) +} +pub fn format_was_online_with(timestamp: i32, time: &impl LocalTimeSource) -> String { + let now = time.now_timestamp(); let diff = now - timestamp; if diff < 60 { @@ -67,8 +152,8 @@ pub fn format_was_online(timestamp: i32) -> String { format!("был(а) {} ч. назад", hours) } else { // Показываем локальную дату - let datetime = as_local_datetime(timestamp) - .map(|dt| dt.format("%d.%m %H:%M").to_string()) + let datetime = time + .format_timestamp(timestamp, "%d.%m %H:%M") .unwrap_or_else(|| "давно".to_string()); format!("был(а) {}", datetime) } @@ -78,83 +163,69 @@ pub fn format_was_online(timestamp: i32) -> String { mod tests { use super::*; + fn fixed_time() -> FixedLocalTime { + FixedLocalTime::new( + FixedOffset::east_opt(3 * 3600).unwrap(), + 1_640_448_000, // 25.12.2021 03:00:00 +03:00 + ) + } + #[test] - fn test_format_timestamp_matches_local_timezone() { + fn test_format_timestamp_uses_supplied_timezone() { let timestamp = 1640000000; - let expected = as_local_datetime(timestamp) - .unwrap() - .format("%H:%M") - .to_string(); - assert_eq!(format_timestamp(timestamp), expected); + assert_eq!(format_timestamp_with(timestamp, &fixed_time()), "14:33"); } #[test] fn test_get_day() { - assert_eq!(get_day(0), 0); - assert_eq!(get_day(86400), 1); + let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400); + assert_eq!(get_day_with(0, &time), 0); + assert_eq!(get_day_with(86400, &time), 1); } #[test] fn test_get_day_grouping() { + let time = fixed_time(); let msg1 = 1640000000; let msg2 = msg1 + 3600; - assert_eq!(get_day(msg1), get_day(msg2)); + assert_eq!(get_day_with(msg1, &time), get_day_with(msg2, &time)); let msg3 = msg1 + 172800; - assert_ne!(get_day(msg1), get_day(msg3)); + assert_ne!(get_day_with(msg1, &time), get_day_with(msg3, &time)); } #[test] fn test_format_datetime() { let timestamp = 1640000000; - let result = format_datetime(timestamp); - - assert_eq!(result.chars().filter(|&c| c == '.').count(), 2); - assert!(result.contains(":")); + assert_eq!(format_datetime_with(timestamp, &fixed_time()), "20.12.2021 14:33"); } #[test] fn test_format_date_today() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - - let result = format_date(now); + let time = fixed_time(); + let result = format_date_with(time.now_timestamp(), &time); assert_eq!(result, "Сегодня"); } #[test] fn test_format_date_yesterday() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - - let yesterday = now - 86400; - let result = format_date(yesterday); + let time = fixed_time(); + let yesterday = time.now_timestamp() - 86400; + let result = format_date_with(yesterday, &time); assert_eq!(result, "Вчера"); } #[test] fn test_format_date_old() { let old_timestamp = 1640000000; - let result = format_date(old_timestamp); - - assert!(result.contains('.'), "Expected date format with dots"); - assert_ne!(result, "Сегодня"); - assert_ne!(result, "Вчера"); - assert_eq!(result.split('.').count(), 3); + assert_eq!(format_date_with(old_timestamp, &fixed_time()), "20.12.2021"); } #[test] fn test_format_date_epoch() { let epoch = 0; - let result = format_date(epoch); + let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400); + let result = format_date_with(epoch, &time); assert!(result.contains('.')); assert!(result.contains("1970")); @@ -162,57 +233,37 @@ mod tests { #[test] fn test_format_was_online_just_now() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - + let time = fixed_time(); + let now = time.now_timestamp(); let recent = now - 30; - let result = format_was_online(recent); + let result = format_was_online_with(recent, &time); assert_eq!(result, "был(а) только что"); } #[test] fn test_format_was_online_minutes_ago() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - + let time = fixed_time(); + let now = time.now_timestamp(); let mins_ago = now - (15 * 60); - let result = format_was_online(mins_ago); + let result = format_was_online_with(mins_ago, &time); assert_eq!(result, "был(а) 15 мин. назад"); } #[test] fn test_format_was_online_hours_ago() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - + let time = fixed_time(); + let now = time.now_timestamp(); let hours_ago = now - (5 * 3600); - let result = format_was_online(hours_ago); + let result = format_was_online_with(hours_ago, &time); assert_eq!(result, "был(а) 5 ч. назад"); } #[test] fn test_format_was_online_days_ago() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - + let time = fixed_time(); + let now = time.now_timestamp(); let days_ago = now - (3 * 86400); - let result = format_was_online(days_ago); + let result = format_was_online_with(days_ago, &time); assert!(result.starts_with("был(а)")); assert!(result.contains('.') || result.contains(':')); @@ -221,7 +272,7 @@ mod tests { #[test] fn test_format_was_online_very_old() { let old = 1577836800; - let result = format_was_online(old); + let result = format_was_online_with(old, &fixed_time()); assert!(result.starts_with("был(а)")); assert!(result.contains('.')); diff --git a/tests/account_switcher.rs b/tests/account_switcher.rs index 26449db..1ef2a58 100644 --- a/tests/account_switcher.rs +++ b/tests/account_switcher.rs @@ -3,7 +3,6 @@ mod helpers; use helpers::app_builder::TestAppBuilder; -use helpers::test_data::create_test_chat; use tele_tui::app::AccountSwitcherState; // ============ Open/Close Tests ============ diff --git a/tests/chat_list.rs b/tests/chat_list.rs index 9695123..4f8dec2 100644 --- a/tests/chat_list.rs +++ b/tests/chat_list.rs @@ -56,8 +56,6 @@ fn snapshot_chat_with_unread_count() { #[test] fn test_incoming_message_shows_unread_badge() { - use tele_tui::tdlib::ChatInfo; - use tele_tui::types::ChatId; // Создаём чат БЕЗ непрочитанных сообщений let chat = TestChatBuilder::new("Friend", 999) @@ -97,7 +95,7 @@ fn test_incoming_message_shows_unread_badge() { #[tokio::test] async fn test_opening_chat_clears_unread_badge() { use helpers::test_data::TestMessageBuilder; - use tele_tui::tdlib::TdClientTrait; + use tele_tui::types::{ChatId, MessageId}; // Создаём чат с 3 непрочитанными сообщениями @@ -188,7 +186,7 @@ async fn test_opening_chat_clears_unread_badge() { #[tokio::test] async fn test_opening_chat_loads_many_messages() { use helpers::test_data::TestMessageBuilder; - use tele_tui::tdlib::TdClientTrait; + use tele_tui::types::ChatId; // Создаём чат с 50 сообщениями @@ -205,7 +203,7 @@ async fn test_opening_chat_loads_many_messages() { }) .collect(); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .with_messages(888, messages) .build(); @@ -230,7 +228,6 @@ async fn test_opening_chat_loads_many_messages() { #[tokio::test] async fn test_chat_history_chunked_loading() { - use tele_tui::tdlib::TdClientTrait; use tele_tui::types::ChatId; // Создаём чат с 120 сообщениями (больше чем TDLIB_MESSAGE_LIMIT = 50) @@ -247,7 +244,7 @@ async fn test_chat_history_chunked_loading() { }) .collect(); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .with_messages(999, messages) .build(); @@ -295,7 +292,6 @@ async fn test_chat_history_chunked_loading() { #[tokio::test] async fn test_chat_history_loads_all_without_limit() { - use tele_tui::tdlib::TdClientTrait; use tele_tui::types::ChatId; // Создаём чат с 200 сообщениями (4 чанка по 50) @@ -311,7 +307,7 @@ async fn test_chat_history_loads_all_without_limit() { }) .collect(); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .with_messages(1001, messages) .build(); @@ -331,8 +327,7 @@ async fn test_chat_history_loads_all_without_limit() { #[tokio::test] async fn test_load_older_messages_pagination() { - use tele_tui::tdlib::TdClientTrait; - use tele_tui::types::{ChatId, MessageId}; + use tele_tui::types::ChatId; // Создаём чат со 150 сообщениями let chat = TestChatBuilder::new("Paginated Chat", 1002) @@ -347,7 +342,7 @@ async fn test_load_older_messages_pagination() { }) .collect(); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .with_messages(1002, messages) .build(); @@ -490,8 +485,6 @@ fn snapshot_chat_search_mode() { #[test] fn snapshot_chat_with_online_status() { - use tele_tui::tdlib::UserOnlineStatus; - use tele_tui::types::ChatId; let chat = TestChatBuilder::new("Alice", 123) .last_message("Hey there!") diff --git a/tests/config.rs b/tests/config.rs index aad793e..93266fe 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -167,8 +167,7 @@ mod credentials_tests { // Примечание: этот тест может зафейлиться если есть credentials файл, // так как он имеет приоритет. Для полноценного тестирования нужно // моковать файловую систему или использовать временные директории. - if result.is_ok() { - let (api_id, api_hash) = result.unwrap(); + if let Ok((api_id, api_hash)) = result { // Может быть либо из файла, либо из env assert!(api_id > 0); assert!(!api_hash.is_empty()); @@ -210,14 +209,13 @@ mod credentials_tests { let result = Config::load_credentials(); // Должна быть ошибка - if result.is_ok() { + if let Err(err_msg) = result { + // Проверяем формат ошибки + assert!(!err_msg.is_empty(), "Error message should not be empty"); + } else { // Возможно env переменные установлены глобально и не удаляются // Тест пропускается eprintln!("Warning: credentials loaded despite removing env vars"); - } else { - // Проверяем формат ошибки - let err_msg = result.unwrap_err(); - assert!(!err_msg.is_empty(), "Error message should not be empty"); } // Восстанавливаем env переменные diff --git a/tests/copy.rs b/tests/copy.rs index cb174a0..a688c6e 100644 --- a/tests/copy.rs +++ b/tests/copy.rs @@ -113,7 +113,6 @@ fn format_message_for_test(msg: &tele_tui::tdlib::MessageInfo) -> String { #[cfg(all(test, feature = "clipboard"))] mod clipboard_tests { - use super::*; /// Test: Проверка что clipboard функции не падают /// Примечание: Реальное тестирование clipboard требует GUI окружения diff --git a/tests/delete_message.rs b/tests/delete_message.rs index 49cefbf..ce1ef99 100644 --- a/tests/delete_message.rs +++ b/tests/delete_message.rs @@ -96,12 +96,12 @@ async fn test_can_only_delete_own_messages_for_all() { // Проверяем флаги удаления let messages = client.get_messages(123); - assert_eq!(messages[0].can_be_deleted_for_all_users(), true); // Наше - assert_eq!(messages[1].can_be_deleted_for_all_users(), false); // Чужое + assert!(messages[0].can_be_deleted_for_all_users()); // Наше + assert!(!messages[1].can_be_deleted_for_all_users()); // Чужое // Оба можно удалить для себя - assert_eq!(messages[0].can_be_deleted_only_for_self(), true); - assert_eq!(messages[1].can_be_deleted_only_for_self(), true); + assert!(messages[0].can_be_deleted_only_for_self()); + assert!(messages[1].can_be_deleted_only_for_self()); } /// Test: Удаление несуществующего сообщения (ничего не происходит) diff --git a/tests/drafts.rs b/tests/drafts.rs index 69f0c27..1798621 100644 --- a/tests/drafts.rs +++ b/tests/drafts.rs @@ -4,7 +4,6 @@ mod helpers; use helpers::test_data::{create_test_chat, TestChatBuilder}; use std::collections::HashMap; -use tele_tui::types::{ChatId, MessageId}; /// Простая структура для хранения черновиков (как в реальном App) struct DraftManager { @@ -105,11 +104,11 @@ async fn test_draft_indicator_in_chat_list() { let mut drafts = DraftManager::new(); // Создаём несколько чатов - let chat1 = create_test_chat("Mom", 123); + let _chat1 = create_test_chat("Mom", 123); let chat2 = TestChatBuilder::new("Boss", 456) .draft("Draft: Meeting notes") .build(); - let chat3 = create_test_chat("Friend", 789); + let _chat3 = create_test_chat("Friend", 789); // В реальном App: chat.draft_text устанавливается из DraftManager // Здесь просто проверяем что у chat2 есть draft_text поле diff --git a/tests/e2e_smoke.rs b/tests/e2e_smoke.rs index 3a317ac..7c87cdb 100644 --- a/tests/e2e_smoke.rs +++ b/tests/e2e_smoke.rs @@ -34,8 +34,10 @@ fn test_minimum_terminal_size() { const MIN_HEIGHT: u16 = 20; // Проверяем что константы установлены разумно - assert!(MIN_WIDTH >= 80, "Минимальная ширина должна быть >= 80"); - assert!(MIN_HEIGHT >= 20, "Минимальная высота должна быть >= 20"); + const { + assert!(MIN_WIDTH >= 80, "Минимальная ширина должна быть >= 80"); + assert!(MIN_HEIGHT >= 20, "Минимальная высота должна быть >= 20"); + }; // Проверяем граничные случаи let too_small_width = MIN_WIDTH - 1; @@ -51,13 +53,13 @@ fn test_app_constants() { use tele_tui::constants::*; // Проверяем что лимиты установлены - assert!(MAX_MESSAGES_IN_CHAT > 0, "Лимит сообщений должен быть > 0"); - assert!(MAX_CHATS > 0, "Лимит чатов должен быть > 0"); - assert!(MAX_USER_CACHE_SIZE > 0, "Размер кэша пользователей должен быть > 0"); - - // Проверяем что лимиты разумные - assert!(MAX_MESSAGES_IN_CHAT <= 1000, "Слишком большой лимит сообщений"); - assert!(MAX_CHATS <= 500, "Слишком большой лимит чатов"); + const { + assert!(MAX_MESSAGES_IN_CHAT > 0, "Лимит сообщений должен быть > 0"); + assert!(MAX_CHATS > 0, "Лимит чатов должен быть > 0"); + assert!(MAX_USER_CACHE_SIZE > 0, "Размер кэша пользователей должен быть > 0"); + assert!(MAX_MESSAGES_IN_CHAT <= 1000, "Слишком большой лимит сообщений"); + assert!(MAX_CHATS <= 500, "Слишком большой лимит чатов"); + }; } /// Тест: Graceful shutdown флаг diff --git a/tests/e2e_user_journey.rs b/tests/e2e_user_journey.rs index e086223..6cfb145 100644 --- a/tests/e2e_user_journey.rs +++ b/tests/e2e_user_journey.rs @@ -5,7 +5,7 @@ mod helpers; use helpers::fake_tdclient::{FakeTdClient, TdUpdate}; use helpers::test_data::{TestChatBuilder, TestMessageBuilder}; use tele_tui::tdlib::NetworkState; -use tele_tui::types::{ChatId, MessageId}; +use tele_tui::types::ChatId; /// Тест 1: App Launch → Auth → Chat List /// Симулирует полный путь пользователя от запуска до загрузки чатов diff --git a/tests/edit_message.rs b/tests/edit_message.rs index ecb77af..4a4e498 100644 --- a/tests/edit_message.rs +++ b/tests/edit_message.rs @@ -81,8 +81,8 @@ async fn test_can_only_edit_own_messages() { // Проверяем флаги let messages = client.get_messages(123); - assert_eq!(messages[0].can_be_edited(), true); // Наше сообщение - assert_eq!(messages[1].can_be_edited(), false); // Чужое сообщение + assert!(messages[0].can_be_edited()); // Наше сообщение + assert!(!messages[1].can_be_edited()); // Чужое сообщение } /// Test: Множественные редактирования одного сообщения diff --git a/tests/footer.rs b/tests/footer.rs index 382e51a..17f5db3 100644 --- a/tests/footer.rs +++ b/tests/footer.rs @@ -26,7 +26,7 @@ fn snapshot_footer_chat_list() { fn snapshot_footer_open_chat() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) .build(); @@ -43,7 +43,7 @@ fn snapshot_footer_open_chat() { fn snapshot_footer_network_waiting() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new().with_chat(chat).build(); + let app = TestAppBuilder::new().with_chat(chat).build(); // Set network state to WaitingForNetwork *app.td_client.network_state.lock().unwrap() = NetworkState::WaitingForNetwork; @@ -60,7 +60,7 @@ fn snapshot_footer_network_waiting() { fn snapshot_footer_network_connecting_proxy() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new().with_chat(chat).build(); + let app = TestAppBuilder::new().with_chat(chat).build(); // Set network state to ConnectingToProxy *app.td_client.network_state.lock().unwrap() = NetworkState::ConnectingToProxy; @@ -77,7 +77,7 @@ fn snapshot_footer_network_connecting_proxy() { fn snapshot_footer_network_connecting() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new().with_chat(chat).build(); + let app = TestAppBuilder::new().with_chat(chat).build(); // Set network state to Connecting *app.td_client.network_state.lock().unwrap() = NetworkState::Connecting; @@ -94,7 +94,7 @@ fn snapshot_footer_network_connecting() { fn snapshot_footer_search_mode() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .searching("query") .build(); diff --git a/tests/helpers/app_builder.rs b/tests/helpers/app_builder.rs index 5301d8c..f7a2c68 100644 --- a/tests/helpers/app_builder.rs +++ b/tests/helpers/app_builder.rs @@ -146,7 +146,7 @@ impl TestAppBuilder { pub fn with_message(mut self, chat_id: i64, message: MessageInfo) -> Self { self.messages .entry(chat_id) - .or_insert_with(Vec::new) + .or_default() .push(message); self } @@ -155,7 +155,7 @@ impl TestAppBuilder { pub fn with_messages(mut self, chat_id: i64, messages: Vec) -> Self { self.messages .entry(chat_id) - .or_insert_with(Vec::new) + .or_default() .extend(messages); self } diff --git a/tests/helpers/fake_tdclient.rs b/tests/helpers/fake_tdclient.rs index c598546..6d5624e 100644 --- a/tests/helpers/fake_tdclient.rs +++ b/tests/helpers/fake_tdclient.rs @@ -7,13 +7,16 @@ use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInf 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: MessageInfo, + message: Box, }, MessageContent { chat_id: ChatId, @@ -72,9 +75,9 @@ pub struct FakeTdClient { pub deleted_messages: Arc>>, pub forwarded_messages: Arc>>, pub searched_queries: Arc>>, - pub viewed_messages: Arc)>>>, // (chat_id, message_ids) + pub viewed_messages: Arc>, // (chat_id, message_ids) pub chat_actions: Arc>>, // (chat_id, action) - pub pending_view_messages: Arc)>>>, // Очередь для отметки как прочитанные + pub pending_view_messages: Arc>, // Очередь для отметки как прочитанные // Update channel для симуляции событий pub update_tx: Arc>>>, @@ -238,7 +241,7 @@ impl FakeTdClient { .lock() .unwrap() .entry(chat_id) - .or_insert_with(Vec::new) + .or_default() .push(message); self } @@ -424,11 +427,11 @@ impl FakeTdClient { .lock() .unwrap() .entry(chat_id.as_i64()) - .or_insert_with(Vec::new) + .or_default() .push(message.clone()); // Отправляем Update::NewMessage - self.send_update(TdUpdate::NewMessage { chat_id, message: message.clone() }); + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) }); Ok(message) } @@ -759,11 +762,11 @@ impl FakeTdClient { .lock() .unwrap() .entry(chat_id.as_i64()) - .or_insert_with(Vec::new) + .or_default() .push(message.clone()); // Отправляем Update - self.send_update(TdUpdate::NewMessage { chat_id, message }); + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) }); } /// Симулировать typing от собеседника @@ -852,6 +855,21 @@ impl FakeTdClient { *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); diff --git a/tests/helpers/fake_tdclient_impl.rs b/tests/helpers/fake_tdclient_impl.rs index 4e99226..cf5e2fc 100644 --- a/tests/helpers/fake_tdclient_impl.rs +++ b/tests/helpers/fake_tdclient_impl.rs @@ -1,10 +1,14 @@ -//! Implementation of TdClientTrait for FakeTdClient +//! Test implementation of the TDLib client traits for FakeTdClient. use super::fake_tdclient::FakeTdClient; use async_trait::async_trait; +use std::borrow::Cow; use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; -use tele_tui::tdlib::TdClientTrait; +use tele_tui::tdlib::{ + AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient, + MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient, +}; use tele_tui::tdlib::{ AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus, @@ -12,8 +16,7 @@ use tele_tui::tdlib::{ use tele_tui::types::{ChatId, MessageId, UserId}; #[async_trait] -impl TdClientTrait for FakeTdClient { - // ============ Auth methods (not implemented for fake) ============ +impl AuthClient for FakeTdClient { async fn send_phone_number(&self, _phone: String) -> Result<(), String> { Ok(()) } @@ -25,10 +28,11 @@ impl TdClientTrait for FakeTdClient { async fn send_password(&self, _password: String) -> Result<(), String> { Ok(()) } +} - // ============ Chat methods ============ +#[async_trait] +impl ChatClient for FakeTdClient { async fn load_chats(&mut self, limit: i32) -> Result<(), String> { - // FakeTdClient loads chats but returns void let _ = FakeTdClient::load_chats(self, limit as usize).await?; Ok(()) } @@ -38,7 +42,6 @@ impl TdClientTrait for FakeTdClient { } async fn leave_chat(&self, _chat_id: ChatId) -> Result<(), String> { - // Not implemented for fake client Ok(()) } @@ -46,18 +49,54 @@ impl TdClientTrait for FakeTdClient { FakeTdClient::get_profile_info(self, chat_id).await } - // ============ Chat actions ============ + fn chats(&self) -> &[ChatInfo] { + &[] + } + + fn folders(&self) -> &[FolderInfo] { + &[] + } + + fn main_chat_list_position(&self) -> i32 { + 0 + } + + fn set_main_chat_list_position(&mut self, _position: i32) {} + + fn update_chats(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(&mut self.chats.lock().unwrap()); + } + + fn update_folders(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(&mut self.folders.lock().unwrap()); + } +} + +#[async_trait] +impl ChatActionClient for FakeTdClient { async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { - let action_str = format!("{:?}", action); - FakeTdClient::send_chat_action(self, chat_id, action_str).await; + FakeTdClient::send_chat_action(self, chat_id, format!("{:?}", action)).await; } fn clear_stale_typing_status(&mut self) -> bool { - // Not implemented for fake false } - // ============ Message methods ============ + fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { + None + } + + fn set_typing_status(&mut self, _status: Option<(UserId, String, std::time::Instant)>) {} +} + +#[async_trait] +impl MessageClient for FakeTdClient { async fn get_chat_history( &mut self, chat_id: ChatId, @@ -75,13 +114,10 @@ impl TdClientTrait for FakeTdClient { } async fn get_pinned_messages(&mut self, _chat_id: ChatId) -> Result, String> { - // Not implemented for fake Ok(vec![]) } - async fn load_current_pinned_message(&mut self, _chat_id: ChatId) { - // Not implemented for fake - } + async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {} async fn search_messages( &self, @@ -132,16 +168,77 @@ impl TdClientTrait for FakeTdClient { FakeTdClient::set_draft_message(self, chat_id, text).await } - fn push_message(&mut self, _msg: MessageInfo) { - // Not used in fake client + fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + Cow::Owned(self.get_messages(chat_id)) + } else { + Cow::Owned(Vec::new()) + } } - async fn fetch_missing_reply_info(&mut self) { - // Not used in fake client + fn current_chat_id(&self) -> Option { + self.get_current_chat_id().map(ChatId::new) } + fn current_pinned_message(&self) -> Option { + self.current_pinned_message.lock().unwrap().clone() + } + + fn push_message(&mut self, msg: MessageInfo) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages + .lock() + .unwrap() + .entry(chat_id) + .or_default() + .push(msg); + } + } + + fn clear_current_chat_messages(&mut self) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages.lock().unwrap().remove(&chat_id); + } + } + + fn set_current_chat_messages(&mut self, messages: Vec) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages.lock().unwrap().insert(chat_id, messages); + } + } + + fn update_current_chat_messages(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + let mut all_messages = self.messages.lock().unwrap(); + updater(all_messages.entry(chat_id).or_default()); + } + } + + fn set_current_chat_id(&mut self, chat_id: Option) { + *self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64()); + } + + fn set_current_pinned_message(&mut self, msg: Option) { + *self.current_pinned_message.lock().unwrap() = msg; + } + + fn pending_view_messages(&self) -> &[(ChatId, Vec)] { + &[] + } + + fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { + self.pending_view_messages + .lock() + .unwrap() + .push((chat_id, message_ids)); + } + + async fn fetch_missing_reply_info(&mut self) {} + async fn process_pending_view_messages(&mut self) { - // Перемещаем pending в viewed для проверки в тестах 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(); @@ -151,18 +248,35 @@ impl TdClientTrait for FakeTdClient { .push((chat_id.as_i64(), ids)); } } +} - // ============ User methods ============ +#[async_trait] +impl UserClient for FakeTdClient { fn get_user_status_by_chat_id(&self, _chat_id: ChatId) -> Option<&UserOnlineStatus> { - // Not implemented for fake None } - async fn process_pending_user_ids(&mut self) { - // Not used in fake client + fn pending_user_ids(&self) -> &[UserId] { + &[] } - // ============ Reaction methods ============ + fn user_cache(&self) -> &UserCache { + use std::sync::OnceLock; + static EMPTY_CACHE: OnceLock = OnceLock::new(); + EMPTY_CACHE.get_or_init(|| UserCache::new(0)) + } + + fn update_user_cache(&mut self, _updater: F) + where + F: FnOnce(&mut UserCache), + { + } + + async fn process_pending_user_ids(&mut self) {} +} + +#[async_trait] +impl ReactionClient for FakeTdClient { async fn get_message_available_reactions( &self, chat_id: ChatId, @@ -179,29 +293,30 @@ impl TdClientTrait for FakeTdClient { ) -> Result<(), String> { FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await } +} - // ============ File methods ============ +#[async_trait] +impl FileClient for FakeTdClient { async fn download_file(&self, file_id: i32) -> Result { FakeTdClient::download_file(self, file_id).await } async fn download_voice_note(&self, file_id: i32) -> Result { - // Fake implementation: return a fake path Ok(format!("/tmp/fake_voice_{}.ogg", file_id)) } +} - // ============ Getters (immutable) ============ +#[async_trait] +impl ClientState for FakeTdClient { fn client_id(&self) -> i32 { - 0 // Fake client ID + 0 } async fn get_me(&self) -> Result { - Ok(12345) // Fake user ID + Ok(12345) } fn auth_state(&self) -> &AuthState { - // Can't return reference from Arc, need to use a different approach - // For now, return a static reference based on the current state use std::sync::OnceLock; static AUTH_STATE_READY: AuthState = AuthState::Ready; static AUTH_STATE_WAIT_PHONE: OnceLock = OnceLock::new(); @@ -222,133 +337,24 @@ impl TdClientTrait for FakeTdClient { } } - fn chats(&self) -> &[ChatInfo] { - // FakeTdClient uses Arc, can't return direct reference - // This is a limitation - we'll need to work around it - &[] - } - - fn folders(&self) -> &[FolderInfo] { - &[] - } - - fn current_chat_messages(&self) -> Vec { - if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { - return self.get_messages(chat_id); - } - Vec::new() - } - - fn current_chat_id(&self) -> Option { - self.get_current_chat_id().map(ChatId::new) - } - - fn current_pinned_message(&self) -> Option { - self.current_pinned_message.lock().unwrap().clone() - } - - fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { - None - } - - fn pending_view_messages(&self) -> &[(ChatId, Vec)] { - &[] - } - - fn pending_user_ids(&self) -> &[UserId] { - &[] - } - - fn main_chat_list_position(&self) -> i32 { - 0 - } - - fn user_cache(&self) -> &UserCache { - // Not implemented for fake - return empty cache - use std::sync::OnceLock; - static EMPTY_CACHE: OnceLock = OnceLock::new(); - EMPTY_CACHE.get_or_init(|| UserCache::new(0)) - } - fn network_state(&self) -> tele_tui::tdlib::types::NetworkState { FakeTdClient::get_network_state(self) } +} - // ============ Setters (mutable) ============ - fn chats_mut(&mut self) -> &mut Vec { - // Can't return mutable reference from Arc - // This is a design limitation - we need a different approach - panic!("chats_mut not supported for FakeTdClient - use get_chats() instead") - } +impl NotificationClient for FakeTdClient { + fn configure_notifications(&mut self, _config: &tele_tui::config::NotificationsConfig) {} - fn folders_mut(&mut self) -> &mut Vec { - panic!("folders_mut not supported for FakeTdClient") - } + fn sync_notification_muted_chats(&mut self) {} +} - fn current_chat_messages_mut(&mut self) -> &mut Vec { - panic!("current_chat_messages_mut not supported for FakeTdClient") - } - - fn clear_current_chat_messages(&mut self) { - if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { - self.messages.lock().unwrap().remove(&chat_id); - } - } - - fn set_current_chat_messages(&mut self, messages: Vec) { - if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { - self.messages.lock().unwrap().insert(chat_id, messages); - } - } - - fn set_current_chat_id(&mut self, chat_id: Option) { - *self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64()); - } - - fn set_current_pinned_message(&mut self, msg: Option) { - *self.current_pinned_message.lock().unwrap() = msg; - } - - fn set_typing_status(&mut self, _status: Option<(UserId, String, std::time::Instant)>) { - // Not implemented - } - - fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { - self.pending_view_messages - .lock() - .unwrap() - .push((chat_id, message_ids)); - } - - fn pending_user_ids_mut(&mut self) -> &mut Vec { - panic!("pending_user_ids_mut not supported for FakeTdClient") - } - - fn set_main_chat_list_position(&mut self, _position: i32) { - // Not implemented - } - - fn user_cache_mut(&mut self) -> &mut UserCache { - panic!("user_cache_mut not supported for FakeTdClient") - } - - // ============ Notification methods ============ - fn configure_notifications(&mut self, _config: &tele_tui::config::NotificationsConfig) { - // Not implemented for fake client (notifications are not tested) - } - - fn sync_notification_muted_chats(&mut self) { - // Not implemented for fake client (notifications are not tested) - } - - // ============ Account switching ============ +#[async_trait] +impl AccountClient for FakeTdClient { async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> { - // No-op for fake client Ok(()) } - - // ============ Update handling ============ - fn handle_update(&mut self, _update: Update) { - // Not implemented for fake client - } +} + +impl UpdateClient for FakeTdClient { + fn handle_update(&mut self, _update: Update) {} } diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index 0a51768..1eb1b9c 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -6,7 +6,4 @@ mod fake_tdclient_impl; // TdClientTrait implementation for FakeTdClient pub mod snapshot_utils; pub mod test_data; -pub use app_builder::TestAppBuilder; pub use fake_tdclient::FakeTdClient; -pub use snapshot_utils::{buffer_to_string, render_to_buffer}; -pub use test_data::{create_test_chat, create_test_message, create_test_user}; diff --git a/tests/helpers/test_data.rs b/tests/helpers/test_data.rs index 9d655da..4fdc605 100644 --- a/tests/helpers/test_data.rs +++ b/tests/helpers/test_data.rs @@ -219,20 +219,22 @@ impl TestMessageBuilder { } /// Хелперы для быстрого создания тестовых данных - pub fn create_test_chat(title: &str, id: i64) -> ChatInfo { TestChatBuilder::new(title, id).build() } +#[allow(dead_code)] pub fn create_test_message(content: &str, id: i64) -> MessageInfo { TestMessageBuilder::new(content, id).build() } +#[allow(dead_code)] pub fn create_test_user(name: &str, id: i64) -> (i64, String) { (id, name.to_string()) } /// Хелпер для создания профиля +#[allow(dead_code)] pub fn create_test_profile(title: &str, chat_id: i64) -> ProfileInfo { ProfileInfo { chat_id: ChatId::new(chat_id), diff --git a/tests/modals.rs b/tests/modals.rs index 99421a3..48f71d3 100644 --- a/tests/modals.rs +++ b/tests/modals.rs @@ -8,7 +8,6 @@ use helpers::test_data::{ create_test_chat, create_test_profile, TestChatBuilder, TestMessageBuilder, }; use insta::assert_snapshot; -use tele_tui::tdlib::TdClientTrait; #[test] fn snapshot_delete_confirmation_modal() { diff --git a/tests/navigation.rs b/tests/navigation.rs index d58807d..83d74e1 100644 --- a/tests/navigation.rs +++ b/tests/navigation.rs @@ -4,7 +4,6 @@ mod helpers; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::{create_test_chat, TestMessageBuilder}; -use tele_tui::types::{ChatId, MessageId}; /// Test: Навигация вверх/вниз по списку чатов #[tokio::test] @@ -177,8 +176,7 @@ async fn test_russian_layout_navigation() { selected_index -= 1; assert_eq!(selected_index, 1); - // Проверяем что логика работает одинаково - assert!(true); // Реальный тест был бы в input handler + // Реальный end-to-end тест этого mapping живет в input handler. } /// Test: Подгрузка старых сообщений при скролле вверх diff --git a/tests/profile.rs b/tests/profile.rs index 18ab32c..57c194d 100644 --- a/tests/profile.rs +++ b/tests/profile.rs @@ -5,7 +5,7 @@ mod helpers; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::create_test_chat; use tele_tui::tdlib::ProfileInfo; -use tele_tui::types::{ChatId, MessageId}; +use tele_tui::types::ChatId; /// Test: Открытие профиля в личном чате (i) #[tokio::test] @@ -96,7 +96,7 @@ async fn test_profile_shows_channel_info() { #[tokio::test] async fn test_close_profile_with_esc() { // Профиль открыт - let profile_mode = true; + let _profile_mode = true; // Пользователь нажал Esc let profile_mode = false; diff --git a/tests/reactions.rs b/tests/reactions.rs index 8d1e12c..46b01d9 100644 --- a/tests/reactions.rs +++ b/tests/reactions.rs @@ -29,7 +29,7 @@ async fn test_add_reaction_to_message() { assert_eq!(messages[0].reactions().len(), 1); assert_eq!(messages[0].reactions()[0].emoji, "👍"); assert_eq!(messages[0].reactions()[0].count, 1); - assert_eq!(messages[0].reactions()[0].is_chosen, true); + assert!(messages[0].reactions()[0].is_chosen); } /// Test: Удаление реакции (toggle) - вторичное нажатие @@ -47,7 +47,7 @@ async fn test_toggle_reaction_removes_it() { // Проверяем что реакция есть let messages_before = client.get_messages(123); assert_eq!(messages_before[0].reactions().len(), 1); - assert_eq!(messages_before[0].reactions()[0].is_chosen, true); + assert!(messages_before[0].reactions()[0].is_chosen); let msg_id = messages_before[0].id(); @@ -116,7 +116,7 @@ async fn test_reactions_from_multiple_users() { assert_eq!(reaction.emoji, "👍"); assert_eq!(reaction.count, 3); - assert_eq!(reaction.is_chosen, false); + assert!(!reaction.is_chosen); } /// Test: Своя реакция (is_chosen = true) @@ -134,7 +134,7 @@ async fn test_own_reaction_is_chosen() { let messages = client.get_messages(123); let reaction = &messages[0].reactions()[0]; - assert_eq!(reaction.is_chosen, true); + assert!(reaction.is_chosen); // В UI это будет отображаться в рамках: [❤️] } @@ -153,7 +153,7 @@ async fn test_other_reaction_not_chosen() { let messages = client.get_messages(123); let reaction = &messages[0].reactions()[0]; - assert_eq!(reaction.is_chosen, false); + assert!(!reaction.is_chosen); // В UI это будет отображаться без рамок: 😂 2 } @@ -182,7 +182,7 @@ async fn test_reaction_counter_increases() { let messages = client.get_messages(123); assert_eq!(messages[0].reactions()[0].count, 2); - assert_eq!(messages[0].reactions()[0].is_chosen, true); + assert!(messages[0].reactions()[0].is_chosen); } /// Test: Обновление реакции - мы добавили свою к существующим @@ -199,7 +199,7 @@ async fn test_update_reaction_we_add_ours() { let messages_before = client.get_messages(123); assert_eq!(messages_before[0].reactions()[0].count, 2); - assert_eq!(messages_before[0].reactions()[0].is_chosen, false); + assert!(!messages_before[0].reactions()[0].is_chosen); let msg_id = messages_before[0].id(); @@ -213,7 +213,7 @@ async fn test_update_reaction_we_add_ours() { let reaction = &messages[0].reactions()[0]; assert_eq!(reaction.count, 3); - assert_eq!(reaction.is_chosen, true); + assert!(reaction.is_chosen); } /// Test: Реакция с count=1 отображается только emoji @@ -272,5 +272,5 @@ async fn test_reactions_on_multiple_messages() { assert_eq!(messages[2].reactions().len(), 2); assert_eq!(messages[2].reactions()[0].emoji, "😂"); assert_eq!(messages[2].reactions()[1].emoji, "🔥"); - assert_eq!(messages[2].reactions()[1].is_chosen, true); + assert!(messages[2].reactions()[1].is_chosen); } diff --git a/tests/reply_forward.rs b/tests/reply_forward.rs index c989e2f..4a4af5e 100644 --- a/tests/reply_forward.rs +++ b/tests/reply_forward.rs @@ -4,7 +4,6 @@ mod helpers; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::TestMessageBuilder; -use tele_tui::tdlib::types::ForwardInfo; use tele_tui::tdlib::ReplyInfo; use tele_tui::types::{ChatId, MessageId}; diff --git a/tests/search.rs b/tests/search.rs index 5fb9d12..ded0ad6 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -4,7 +4,6 @@ mod helpers; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder}; -use tele_tui::types::{ChatId, MessageId}; /// Test: Поиск по чатам фильтрует по названию #[tokio::test] @@ -213,7 +212,6 @@ async fn test_cancel_search_restores_normal_mode() { let client = client.with_chats(vec![chat1, chat2]); // Симулируем: пользователь начал поиск - let mut is_searching = true; let mut search_query = "mom".to_string(); // Фильтруем @@ -227,7 +225,7 @@ async fn test_cancel_search_restores_normal_mode() { assert_eq!(filtered.len(), 1); // Пользователь нажал Esc - is_searching = false; + let is_searching = false; search_query.clear(); // После отмены видим все чаты diff --git a/tests/send_message.rs b/tests/send_message.rs index 9f3c2ae..1ad184b 100644 --- a/tests/send_message.rs +++ b/tests/send_message.rs @@ -30,7 +30,7 @@ async fn test_send_text_message() { assert_eq!(messages.len(), 1); assert_eq!(messages[0].id(), msg.id()); assert_eq!(messages[0].text(), "Hello, Mom!"); - assert_eq!(messages[0].is_outgoing(), true); + assert!(messages[0].is_outgoing()); } /// Test: Отправка нескольких сообщений обновляет список @@ -170,8 +170,8 @@ async fn test_receive_incoming_message() { // Проверяем что в списке 2 сообщения let messages = client.get_messages(123); assert_eq!(messages.len(), 2); - assert_eq!(messages[0].is_outgoing(), true); // Наше сообщение - assert_eq!(messages[1].is_outgoing(), false); // Входящее + assert!(messages[0].is_outgoing()); // Наше сообщение + assert!(!messages[1].is_outgoing()); // Входящее assert_eq!(messages[1].text(), "Hey there!"); assert_eq!(messages[1].sender_name(), "Alice"); }