Split core and TUI crates

This commit is contained in:
Mikhail Kilin
2026-05-20 00:31:18 +03:00
parent 91a8700b8e
commit eefac431e5
238 changed files with 624 additions and 191 deletions

View File

@@ -0,0 +1,352 @@
// Test App builder
use super::FakeTdClient;
use ratatui::widgets::ListState;
use std::collections::HashMap;
use tele_tui::app::{App, AppScreen, ChatState, InputMode};
use tele_tui::config::Config;
use tele_tui::tdlib::AuthState;
use tele_tui::tdlib::{ChatInfo, MessageInfo};
use tele_tui::types::{ChatId, MessageId};
/// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах.
#[allow(dead_code)]
pub struct TestAppBuilder {
config: Config,
screen: AppScreen,
chats: Vec<ChatInfo>,
selected_chat_id: Option<i64>,
message_input: String,
is_searching: bool,
search_query: String,
chat_state: Option<ChatState>,
input_mode: Option<InputMode>,
messages: HashMap<i64, Vec<MessageInfo>>,
status_message: Option<String>,
auth_state: Option<AuthState>,
phone_input: Option<String>,
code_input: Option<String>,
password_input: Option<String>,
}
impl Default for TestAppBuilder {
fn default() -> Self {
Self::new()
}
}
#[allow(dead_code)]
impl TestAppBuilder {
pub fn new() -> Self {
Self {
config: Config::default(),
screen: AppScreen::Main,
chats: vec![],
selected_chat_id: None,
message_input: String::new(),
is_searching: false,
search_query: String::new(),
chat_state: None,
input_mode: None,
messages: HashMap::new(),
status_message: None,
auth_state: None,
phone_input: None,
code_input: None,
password_input: None,
}
}
/// Установить экран
pub fn screen(mut self, screen: AppScreen) -> Self {
self.screen = screen;
self
}
/// Установить конфиг
pub fn config(mut self, config: Config) -> Self {
self.config = config;
self
}
/// Добавить чат
pub fn with_chat(mut self, chat: ChatInfo) -> Self {
self.chats.push(chat);
self
}
/// Добавить несколько чатов
pub fn with_chats(mut self, chats: Vec<ChatInfo>) -> Self {
self.chats.extend(chats);
self
}
/// Выбрать чат
pub fn selected_chat(mut self, chat_id: i64) -> Self {
self.selected_chat_id = Some(chat_id);
self
}
/// Установить текст в инпуте
pub fn message_input(mut self, text: &str) -> Self {
self.message_input = text.to_string();
self
}
/// Режим поиска
pub fn searching(mut self, query: &str) -> Self {
self.is_searching = true;
self.search_query = query.to_string();
self
}
/// Режим редактирования сообщения
pub fn editing_message(mut self, message_id: i64, selected_index: usize) -> Self {
self.chat_state = Some(ChatState::Editing {
message_id: MessageId::new(message_id),
selected_index,
});
self
}
/// Режим ответа на сообщение
pub fn replying_to(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::Reply { message_id: MessageId::new(message_id) });
self
}
/// Режим выбора реакции
pub fn reaction_picker(mut self, message_id: i64, available_reactions: Vec<String>) -> Self {
self.chat_state = Some(ChatState::ReactionPicker {
message_id: MessageId::new(message_id),
available_reactions,
selected_index: 0,
});
self
}
/// Режим профиля
pub fn profile_mode(mut self, info: tele_tui::tdlib::ProfileInfo) -> Self {
self.chat_state = Some(ChatState::Profile {
info,
selected_action: 0,
leave_group_confirmation_step: 0,
});
self
}
/// Подтверждение удаления
pub fn delete_confirmation(mut self, message_id: i64) -> Self {
self.chat_state =
Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
self
}
/// Добавить сообщение для чата
pub fn with_message(mut self, chat_id: i64, message: MessageInfo) -> Self {
self.messages.entry(chat_id).or_default().push(message);
self
}
/// Добавить несколько сообщений для чата
pub fn with_messages(mut self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages.entry(chat_id).or_default().extend(messages);
self
}
/// Установить выбранное сообщение (режим selection)
pub fn selecting_message(mut self, selected_index: usize) -> Self {
self.chat_state = Some(ChatState::MessageSelection { selected_index });
self
}
/// Режим поиска по сообщениям в чате
pub fn message_search(mut self, query: &str) -> Self {
self.chat_state = Some(ChatState::SearchInChat {
query: query.to_string(),
results: Vec::new(),
selected_index: 0,
});
self
}
/// Установить Insert mode
pub fn insert_mode(mut self) -> Self {
self.input_mode = Some(InputMode::Insert);
self
}
/// Режим пересылки сообщения
pub fn forward_mode(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::Forward { message_id: MessageId::new(message_id) });
self
}
/// Установить статус сообщение (для loading screen)
pub fn status_message(mut self, message: &str) -> Self {
self.status_message = Some(message.to_string());
self
}
/// Установить auth state
pub fn auth_state(mut self, state: AuthState) -> Self {
self.auth_state = Some(state);
self
}
/// Установить phone input
pub fn phone_input(mut self, phone: &str) -> Self {
self.phone_input = Some(phone.to_string());
self
}
/// Установить code input
pub fn code_input(mut self, code: &str) -> Self {
self.code_input = Some(code.to_string());
self
}
/// Установить password input
pub fn password_input(mut self, password: &str) -> Self {
self.password_input = Some(password.to_string());
self
}
/// Построить App с FakeTdClient
///
/// Создаёт App с FakeTdClient, подходит для любых тестов включая
/// интеграционные тесты логики.
pub fn build(self) -> App<FakeTdClient> {
// Создаём FakeTdClient с чатами и сообщениями
let mut fake_client = FakeTdClient::new();
// Добавляем чаты
for chat in &self.chats {
fake_client = fake_client.with_chat(chat.clone());
}
// Добавляем сообщения
for (chat_id, messages) in self.messages {
fake_client = fake_client.with_messages(chat_id, messages);
}
// Устанавливаем текущий чат если нужно
if let Some(chat_id) = self.selected_chat_id {
*fake_client.current_chat_id.lock().unwrap() = Some(chat_id);
}
// Устанавливаем auth state если нужно
if let Some(auth_state) = self.auth_state {
fake_client = fake_client.with_auth_state(auth_state);
}
// Создаём App с FakeTdClient
let mut app = App::with_client(self.config, fake_client);
app.screen = self.screen;
app.chats = self.chats;
app.selected_chat_id = self.selected_chat_id.map(ChatId::new);
app.message_input = self.message_input;
app.is_searching = self.is_searching;
app.search_query = self.search_query;
// Применяем chat_state если он установлен
if let Some(chat_state) = self.chat_state {
app.chat_state = chat_state;
}
// Применяем input_mode если он установлен
if let Some(input_mode) = self.input_mode {
app.input_mode = input_mode;
}
// Применяем status_message
if let Some(status) = self.status_message {
app.status_message = Some(status);
}
// Применяем auth inputs
if let Some(phone) = self.phone_input {
app.set_phone_input(phone);
}
if let Some(code) = self.code_input {
app.set_code_input(code);
}
if let Some(password) = self.password_input {
app.set_password_input(password);
}
// Выбираем первый чат если есть
if !app.chats.is_empty() {
let mut list_state = ListState::default();
list_state.select(Some(0));
app.chat_list_state = list_state;
}
app
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::helpers::test_data::create_test_chat;
use tele_tui::app::methods::messages::MessageMethods;
#[test]
fn test_builder_defaults() {
let app = TestAppBuilder::new().build();
assert_eq!(app.screen, AppScreen::Main);
assert_eq!(app.chats.len(), 0);
assert_eq!(app.selected_chat_id, None);
assert_eq!(app.message_input, "");
}
#[test]
fn test_builder_with_chats() {
let chat1 = create_test_chat("Mom", 123);
let chat2 = create_test_chat("Boss", 456);
let app = TestAppBuilder::new()
.with_chat(chat1)
.with_chat(chat2)
.build();
assert_eq!(app.chats.len(), 2);
assert_eq!(app.chats[0].title, "Mom");
assert_eq!(app.chats[1].title, "Boss");
}
#[test]
fn test_builder_with_selected_chat() {
let chat = create_test_chat("Mom", 123);
let app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(123)
.build();
assert_eq!(app.selected_chat_id, Some(ChatId::new(123)));
}
#[test]
fn test_builder_editing_mode() {
let app = TestAppBuilder::new()
.editing_message(999, 0)
.message_input("Edited text")
.build();
assert!(app.is_editing());
assert_eq!(app.chat_state.selected_message_id(), Some(MessageId::new(999)));
assert_eq!(app.message_input, "Edited text");
}
#[test]
fn test_builder_search_mode() {
let app = TestAppBuilder::new().searching("test query").build();
assert!(app.is_searching);
assert_eq!(app.search_query, "test query");
}
}

View File

@@ -0,0 +1,146 @@
// Fake TDLib client for testing.
mod builders;
mod inspect;
mod operations;
mod state;
#[allow(unused_imports)]
pub use state::{
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, PendingViewMessages,
SearchQuery, SentMessage, TdUpdate, ViewedMessages,
};
#[cfg(test)]
mod tests {
use super::*;
use crate::helpers::test_data::create_test_chat;
use tele_tui::types::ChatId;
#[test]
fn test_fake_client_creation() {
let client = FakeTdClient::new();
assert_eq!(client.get_chats().len(), 0);
assert_eq!(client.folders.lock().unwrap().len(), 1); // Default "All" folder
}
#[test]
fn test_fake_client_with_chat() {
let chat = create_test_chat("Mom", 123);
let client = FakeTdClient::new().with_chat(chat);
let chats = client.get_chats();
assert_eq!(chats.len(), 1);
assert_eq!(chats[0].title, "Mom");
}
#[tokio::test]
async fn test_send_message() {
let client = FakeTdClient::new();
let chat_id = ChatId::new(123);
let result = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await;
assert!(result.is_ok());
let sent = client.get_sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].text, "Hello");
assert_eq!(client.get_messages(123).len(), 1);
}
#[tokio::test]
async fn test_edit_message() {
let client = FakeTdClient::new();
let chat_id = ChatId::new(123);
let msg = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg_id = msg.id();
let _ = client
.edit_message(chat_id, msg_id, "Hello World".to_string())
.await;
let edited = client.get_edited_messages();
assert_eq!(edited.len(), 1);
assert_eq!(client.get_messages(123)[0].text(), "Hello World");
assert!(client.get_messages(123)[0].metadata.edit_date > 0);
}
#[tokio::test]
async fn test_delete_message() {
let client = FakeTdClient::new();
let chat_id = ChatId::new(123);
let msg = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg_id = msg.id();
let _ = client.delete_messages(chat_id, vec![msg_id], false).await;
let deleted = client.get_deleted_messages();
assert_eq!(deleted.len(), 1);
assert_eq!(client.get_messages(123).len(), 0);
}
#[tokio::test]
async fn test_update_channel() {
let (client, mut rx) = FakeTdClient::new().with_update_channel();
let chat_id = ChatId::new(123);
let _ = client
.send_message(chat_id, "Test".to_string(), None, None)
.await;
if let Some(update) = rx.recv().await {
match update {
TdUpdate::NewMessage { chat_id: updated_chat, .. } => {
assert_eq!(updated_chat, chat_id);
}
_ => panic!("Expected NewMessage update"),
}
} else {
panic!("No update received");
}
}
#[tokio::test]
async fn test_simulate_incoming_message() {
let (client, mut rx) = FakeTdClient::new().with_update_channel();
let chat_id = ChatId::new(123);
client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob");
if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await {
assert_eq!(message.text(), "Hello from Bob");
assert_eq!(message.sender_name(), "Bob");
assert!(!message.is_outgoing());
}
assert_eq!(client.get_messages(123).len(), 1);
}
#[tokio::test]
async fn test_fail_next_operation() {
let client = FakeTdClient::new();
let chat_id = ChatId::new(123);
client.fail_next();
let result = client
.send_message(chat_id, "Test".to_string(), None, None)
.await;
assert!(result.is_err());
let result2 = client
.send_message(chat_id, "Test2".to_string(), None, None)
.await;
assert!(result2.is_ok());
}
}

View File

@@ -0,0 +1,86 @@
use super::{FakeTdClient, TdUpdate};
use tele_tui::tdlib::types::FolderInfo;
use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo};
use tokio::sync::mpsc;
#[allow(dead_code)]
impl FakeTdClient {
/// Create an update channel for receiving simulated TDLib events.
pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver<TdUpdate>) {
let (tx, rx) = mpsc::unbounded_channel();
*self.update_tx.lock().unwrap() = Some(tx);
(self, rx)
}
/// Enable simulated delays, closer to real TDLib behavior.
pub fn with_delays(mut self) -> Self {
self.simulate_delays = true;
self
}
pub fn with_chat(self, chat: ChatInfo) -> Self {
self.chats.lock().unwrap().push(chat);
self
}
pub fn with_chats(self, chats: Vec<ChatInfo>) -> Self {
self.chats.lock().unwrap().extend(chats);
self
}
pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self {
self.messages
.lock()
.unwrap()
.entry(chat_id)
.or_default()
.push(message);
self
}
pub fn with_messages(self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages.lock().unwrap().insert(chat_id, messages);
self
}
pub fn with_folder(self, id: i32, name: &str) -> Self {
self.folders
.lock()
.unwrap()
.push(FolderInfo { id, name: name.to_string() });
self
}
pub fn with_user(self, id: i64, name: &str) -> Self {
self.user_names.lock().unwrap().insert(id, name.to_string());
self
}
pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self {
self.profiles.lock().unwrap().insert(chat_id, profile);
self
}
pub fn with_network_state(self, state: NetworkState) -> Self {
*self.network_state.lock().unwrap() = state;
self
}
pub fn with_auth_state(self, state: AuthState) -> Self {
*self.auth_state.lock().unwrap() = state;
self
}
pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self {
self.downloaded_files
.lock()
.unwrap()
.insert(file_id, path.to_string());
self
}
pub fn with_available_reactions(self, reactions: Vec<String>) -> Self {
*self.available_reactions.lock().unwrap() = reactions;
self
}
}

View File

@@ -0,0 +1,92 @@
use super::{
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
TdUpdate,
};
use tele_tui::tdlib::types::FolderInfo;
use tele_tui::tdlib::{ChatInfo, MessageInfo, NetworkState};
use tokio::sync::mpsc;
#[allow(dead_code)]
impl FakeTdClient {
pub fn get_chats(&self) -> Vec<ChatInfo> {
self.chats.lock().unwrap().clone()
}
pub fn get_folders(&self) -> Vec<FolderInfo> {
self.folders.lock().unwrap().clone()
}
pub fn get_messages(&self, chat_id: i64) -> Vec<MessageInfo> {
self.messages
.lock()
.unwrap()
.get(&chat_id)
.cloned()
.unwrap_or_default()
}
pub fn get_sent_messages(&self) -> Vec<SentMessage> {
self.sent_messages.lock().unwrap().clone()
}
pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
self.edited_messages.lock().unwrap().clone()
}
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
self.deleted_messages.lock().unwrap().clone()
}
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
self.forwarded_messages.lock().unwrap().clone()
}
pub fn get_search_queries(&self) -> Vec<SearchQuery> {
self.searched_queries.lock().unwrap().clone()
}
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
self.viewed_messages.lock().unwrap().clone()
}
pub fn get_chat_actions(&self) -> Vec<(i64, String)> {
self.chat_actions.lock().unwrap().clone()
}
pub fn get_network_state(&self) -> NetworkState {
self.network_state.lock().unwrap().clone()
}
pub fn get_current_chat_id(&self) -> Option<i64> {
*self.current_chat_id.lock().unwrap()
}
pub fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
*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<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
self.viewed_messages
.lock()
.unwrap()
.push((chat_id.as_i64(), ids));
}
}
pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) {
*self.update_tx.lock().unwrap() = Some(tx);
}
pub fn clear_all_history(&self) {
self.sent_messages.lock().unwrap().clear();
self.edited_messages.lock().unwrap().clear();
self.deleted_messages.lock().unwrap().clear();
self.forwarded_messages.lock().unwrap().clear();
self.searched_queries.lock().unwrap().clear();
self.viewed_messages.lock().unwrap().clear();
self.chat_actions.lock().unwrap().clear();
}
}

View File

@@ -0,0 +1,458 @@
use super::{
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
TdUpdate,
};
use tele_tui::tdlib::types::ReactionInfo;
use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use tele_tui::types::{ChatId, MessageId, UserId};
#[allow(dead_code)]
impl FakeTdClient {
pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, String> {
if self.should_fail() {
return Err("Failed to load chats".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
let chats = self
.chats
.lock()
.unwrap()
.iter()
.take(limit)
.cloned()
.collect();
Ok(chats)
}
pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to open chat".to_string());
}
*self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64());
Ok(())
}
pub async fn get_chat_history(
&self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to load history".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
let messages = self
.messages
.lock()
.unwrap()
.get(&chat_id.as_i64())
.map(|msgs| msgs.iter().take(limit as usize).cloned().collect())
.unwrap_or_default();
Ok(messages)
}
pub async fn load_older_messages(
&self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to load older messages".to_string());
}
let messages = self.messages.lock().unwrap();
let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?;
if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) {
let older = chat_messages.iter().take(idx).cloned().collect();
Ok(older)
} else {
Ok(vec![])
}
}
pub async fn send_message(
&self,
chat_id: ChatId,
text: String,
reply_to: Option<MessageId>,
reply_info: Option<ReplyInfo>,
) -> Result<MessageInfo, String> {
if self.should_fail() {
return Err("Failed to send message".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000);
self.sent_messages.lock().unwrap().push(SentMessage {
chat_id: chat_id.as_i64(),
text: text.clone(),
reply_to,
reply_info: reply_info.clone(),
});
let message = MessageInfo::new(
message_id,
"You".to_string(),
true,
text,
vec![],
chrono::Utc::now().timestamp() as i32,
0,
false,
true,
true,
true,
reply_info,
None,
vec![],
);
self.messages
.lock()
.unwrap()
.entry(chat_id.as_i64())
.or_default()
.push(message.clone());
self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) });
Ok(message)
}
pub async fn edit_message(
&self,
chat_id: ChatId,
message_id: MessageId,
new_text: String,
) -> Result<MessageInfo, String> {
if self.should_fail() {
return Err("Failed to edit message".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
}
self.edited_messages.lock().unwrap().push(EditedMessage {
chat_id: chat_id.as_i64(),
message_id,
new_text: new_text.clone(),
});
let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
msg.content.text = new_text.clone();
msg.metadata.edit_date = msg.metadata.date + 60;
let updated = msg.clone();
drop(messages);
self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text });
return Ok(updated);
}
}
Err("Message not found".to_string())
}
pub async fn delete_messages(
&self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to delete messages".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
self.deleted_messages.lock().unwrap().push(DeletedMessages {
chat_id: chat_id.as_i64(),
message_ids: message_ids.clone(),
revoke,
});
let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
chat_msgs.retain(|m| !message_ids.contains(&m.id()));
}
drop(messages);
self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids });
Ok(())
}
pub async fn forward_messages(
&self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to forward messages".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
}
self.forwarded_messages
.lock()
.unwrap()
.push(ForwardedMessages {
from_chat_id: from_chat_id.as_i64(),
to_chat_id: to_chat_id.as_i64(),
message_ids,
});
Ok(())
}
pub async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to search messages".to_string());
}
let messages = self.messages.lock().unwrap();
let results: Vec<_> = messages
.get(&chat_id.as_i64())
.map(|msgs| {
msgs.iter()
.filter(|m| m.text().to_lowercase().contains(&query.to_lowercase()))
.cloned()
.collect()
})
.unwrap_or_default();
self.searched_queries.lock().unwrap().push(SearchQuery {
chat_id: chat_id.as_i64(),
query: query.to_string(),
results_count: results.len(),
});
Ok(results)
}
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
if text.is_empty() {
self.drafts.lock().unwrap().remove(&chat_id.as_i64());
} else {
self.drafts
.lock()
.unwrap()
.insert(chat_id.as_i64(), text.clone());
}
self.send_update(TdUpdate::ChatDraftMessage {
chat_id,
draft_text: if text.is_empty() { None } else { Some(text) },
});
Ok(())
}
pub async fn send_chat_action(&self, chat_id: ChatId, action: String) {
self.chat_actions
.lock()
.unwrap()
.push((chat_id.as_i64(), action.clone()));
if action == "Typing" {
*self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64());
} else if action == "Cancel" {
*self.typing_chat_id.lock().unwrap() = None;
}
}
pub async fn get_message_available_reactions(
&self,
_chat_id: ChatId,
_message_id: MessageId,
) -> Result<Vec<String>, String> {
if self.should_fail() {
return Err("Failed to get available reactions".to_string());
}
Ok(self.available_reactions.lock().unwrap().clone())
}
pub async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
emoji: String,
) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to toggle reaction".to_string());
}
let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
let reactions = &mut msg.interactions.reactions;
if let Some(pos) = reactions
.iter()
.position(|reaction| reaction.emoji == emoji && reaction.is_chosen)
{
reactions.remove(pos);
} else if let Some(reaction) = reactions
.iter_mut()
.find(|reaction| reaction.emoji == emoji)
{
reaction.is_chosen = true;
reaction.count += 1;
} else {
reactions.push(ReactionInfo {
emoji: emoji.clone(),
count: 1,
is_chosen: true,
});
}
let updated_reactions = reactions.clone();
drop(messages);
self.send_update(TdUpdate::MessageInteractionInfo {
chat_id,
message_id,
reactions: updated_reactions,
});
}
}
Ok(())
}
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
if self.should_fail() {
return Err("Failed to download file".to_string());
}
self.downloaded_files
.lock()
.unwrap()
.get(&file_id)
.cloned()
.ok_or_else(|| format!("File {} not found", file_id))
}
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
if self.should_fail() {
return Err("Failed to get profile info".to_string());
}
self.profiles
.lock()
.unwrap()
.get(&chat_id.as_i64())
.cloned()
.ok_or_else(|| "Profile not found".to_string())
}
pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec<MessageId>) {
self.viewed_messages
.lock()
.unwrap()
.push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect()));
}
pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to load folder chats".to_string());
}
Ok(())
}
fn send_update(&self, update: TdUpdate) {
if let Some(tx) = self.update_tx.lock().unwrap().as_ref() {
let _ = tx.send(update);
}
}
fn should_fail(&self) -> bool {
let mut fail = self.fail_next_operation.lock().unwrap();
if *fail {
*fail = false;
true
} else {
false
}
}
pub fn fail_next(&self) {
*self.fail_next_operation.lock().unwrap() = true;
}
pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) {
let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp());
let message = MessageInfo::new(
message_id,
sender_name.to_string(),
false,
text,
vec![],
chrono::Utc::now().timestamp() as i32,
0,
false,
false,
false,
true,
None,
None,
vec![],
);
self.messages
.lock()
.unwrap()
.entry(chat_id.as_i64())
.or_default()
.push(message.clone());
self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) });
}
pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) {
self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() });
}
pub fn simulate_network_change(&self, state: tele_tui::tdlib::NetworkState) {
*self.network_state.lock().unwrap() = state.clone();
self.send_update(TdUpdate::ConnectionState { state });
}
pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) {
self.send_update(TdUpdate::ChatReadOutbox {
chat_id,
last_read_outbox_message_id: last_read_message_id,
});
}
}

View File

@@ -0,0 +1,201 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tele_tui::tdlib::types::{FolderInfo, ReactionInfo};
use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
use tele_tui::types::{ChatId, MessageId, UserId};
use tokio::sync::mpsc;
pub type ViewedMessages = Vec<(i64, Vec<i64>)>;
pub type PendingViewMessages = Vec<(ChatId, Vec<MessageId>)>;
/// Update events from TDLib, simplified for tests.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum TdUpdate {
NewMessage {
chat_id: ChatId,
message: Box<MessageInfo>,
},
MessageContent {
chat_id: ChatId,
message_id: MessageId,
new_text: String,
},
DeleteMessages {
chat_id: ChatId,
message_ids: Vec<MessageId>,
},
ChatAction {
chat_id: ChatId,
user_id: UserId,
action: String,
},
MessageInteractionInfo {
chat_id: ChatId,
message_id: MessageId,
reactions: Vec<ReactionInfo>,
},
ConnectionState {
state: NetworkState,
},
ChatReadOutbox {
chat_id: ChatId,
last_read_outbox_message_id: MessageId,
},
ChatDraftMessage {
chat_id: ChatId,
draft_text: Option<String>,
},
}
/// Simplified mock TDLib client for tests.
#[allow(dead_code)]
pub struct FakeTdClient {
pub chats: Arc<Mutex<Vec<ChatInfo>>>,
pub messages: Arc<Mutex<HashMap<i64, Vec<MessageInfo>>>>,
pub folders: Arc<Mutex<Vec<FolderInfo>>>,
pub user_names: Arc<Mutex<HashMap<i64, String>>>,
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
pub drafts: Arc<Mutex<HashMap<i64, String>>>,
pub available_reactions: Arc<Mutex<Vec<String>>>,
pub network_state: Arc<Mutex<NetworkState>>,
pub typing_chat_id: Arc<Mutex<Option<i64>>>,
pub current_chat_id: Arc<Mutex<Option<i64>>>,
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
pub auth_state: Arc<Mutex<AuthState>>,
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
pub deleted_messages: Arc<Mutex<Vec<DeletedMessages>>>,
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
pub viewed_messages: Arc<Mutex<ViewedMessages>>,
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>,
pub pending_view_messages: Arc<Mutex<PendingViewMessages>>,
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
pub simulate_delays: bool,
pub fail_next_operation: Arc<Mutex<bool>>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SentMessage {
pub chat_id: i64,
pub text: String,
pub reply_to: Option<MessageId>,
pub reply_info: Option<ReplyInfo>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EditedMessage {
pub chat_id: i64,
pub message_id: MessageId,
pub new_text: String,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DeletedMessages {
pub chat_id: i64,
pub message_ids: Vec<MessageId>,
pub revoke: bool,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ForwardedMessages {
pub from_chat_id: i64,
pub to_chat_id: i64,
pub message_ids: Vec<MessageId>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SearchQuery {
pub chat_id: i64,
pub query: String,
pub results_count: usize,
}
impl Default for FakeTdClient {
fn default() -> Self {
Self::new()
}
}
impl Clone for FakeTdClient {
fn clone(&self) -> Self {
Self {
chats: Arc::clone(&self.chats),
messages: Arc::clone(&self.messages),
folders: Arc::clone(&self.folders),
user_names: Arc::clone(&self.user_names),
profiles: Arc::clone(&self.profiles),
drafts: Arc::clone(&self.drafts),
available_reactions: Arc::clone(&self.available_reactions),
network_state: Arc::clone(&self.network_state),
typing_chat_id: Arc::clone(&self.typing_chat_id),
current_chat_id: Arc::clone(&self.current_chat_id),
current_pinned_message: Arc::clone(&self.current_pinned_message),
auth_state: Arc::clone(&self.auth_state),
sent_messages: Arc::clone(&self.sent_messages),
edited_messages: Arc::clone(&self.edited_messages),
deleted_messages: Arc::clone(&self.deleted_messages),
forwarded_messages: Arc::clone(&self.forwarded_messages),
searched_queries: Arc::clone(&self.searched_queries),
viewed_messages: Arc::clone(&self.viewed_messages),
chat_actions: Arc::clone(&self.chat_actions),
pending_view_messages: Arc::clone(&self.pending_view_messages),
downloaded_files: Arc::clone(&self.downloaded_files),
update_tx: Arc::clone(&self.update_tx),
simulate_delays: self.simulate_delays,
fail_next_operation: Arc::clone(&self.fail_next_operation),
}
}
}
#[allow(dead_code)]
impl FakeTdClient {
pub fn new() -> Self {
Self {
chats: Arc::new(Mutex::new(vec![])),
messages: Arc::new(Mutex::new(HashMap::new())),
folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])),
user_names: Arc::new(Mutex::new(HashMap::new())),
profiles: Arc::new(Mutex::new(HashMap::new())),
drafts: Arc::new(Mutex::new(HashMap::new())),
available_reactions: Arc::new(Mutex::new(vec![
"👍".to_string(),
"❤️".to_string(),
"😂".to_string(),
"😮".to_string(),
"😢".to_string(),
"🙏".to_string(),
"👏".to_string(),
"🔥".to_string(),
])),
network_state: Arc::new(Mutex::new(NetworkState::Ready)),
typing_chat_id: Arc::new(Mutex::new(None)),
current_chat_id: Arc::new(Mutex::new(None)),
current_pinned_message: Arc::new(Mutex::new(None)),
auth_state: Arc::new(Mutex::new(AuthState::Ready)),
sent_messages: Arc::new(Mutex::new(vec![])),
edited_messages: Arc::new(Mutex::new(vec![])),
deleted_messages: Arc::new(Mutex::new(vec![])),
forwarded_messages: Arc::new(Mutex::new(vec![])),
searched_queries: Arc::new(Mutex::new(vec![])),
viewed_messages: Arc::new(Mutex::new(vec![])),
chat_actions: Arc::new(Mutex::new(vec![])),
pending_view_messages: Arc::new(Mutex::new(vec![])),
downloaded_files: Arc::new(Mutex::new(HashMap::new())),
update_tx: Arc::new(Mutex::new(None)),
simulate_delays: false,
fail_next_operation: Arc::new(Mutex::new(false)),
}
}
}

View File

@@ -0,0 +1,358 @@
//! 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::{
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
MessageClient, ReactionClient, UpdateClient, UserClient,
};
use tele_tui::tdlib::{
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
UserOnlineStatus,
};
use tele_tui::types::{ChatId, MessageId, UserId};
#[async_trait]
impl AuthClient for FakeTdClient {
async fn send_phone_number(&self, _phone: String) -> Result<(), String> {
Ok(())
}
async fn send_code(&self, _code: String) -> Result<(), String> {
Ok(())
}
async fn send_password(&self, _password: String) -> Result<(), String> {
Ok(())
}
}
#[async_trait]
impl ChatClient for FakeTdClient {
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
let _ = FakeTdClient::load_chats(self, limit as usize).await?;
Ok(())
}
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
FakeTdClient::load_folder_chats(self, folder_id, limit as usize).await
}
async fn leave_chat(&self, _chat_id: ChatId) -> Result<(), String> {
Ok(())
}
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
FakeTdClient::get_profile_info(self, chat_id).await
}
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<F>(&mut self, updater: F)
where
F: FnOnce(&mut Vec<ChatInfo>),
{
updater(&mut self.chats.lock().unwrap());
}
fn update_folders<F>(&mut self, updater: F)
where
F: FnOnce(&mut Vec<FolderInfo>),
{
updater(&mut self.folders.lock().unwrap());
}
}
#[async_trait]
impl ChatActionClient for FakeTdClient {
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
FakeTdClient::send_chat_action(self, chat_id, format!("{:?}", action)).await;
}
fn clear_stale_typing_status(&mut self) -> bool {
false
}
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,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::get_chat_history(self, chat_id, limit).await
}
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::load_older_messages(self, chat_id, from_message_id).await
}
async fn get_pinned_messages(&mut self, _chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
Ok(vec![])
}
async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {}
async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::search_messages(self, chat_id, query).await
}
async fn send_message(
&mut self,
chat_id: ChatId,
text: String,
reply_to_message_id: Option<MessageId>,
reply_info: Option<ReplyInfo>,
) -> Result<MessageInfo, String> {
FakeTdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await
}
async fn edit_message(
&mut self,
chat_id: ChatId,
message_id: MessageId,
new_text: String,
) -> Result<MessageInfo, String> {
FakeTdClient::edit_message(self, chat_id, message_id, new_text).await
}
async fn delete_messages(
&mut self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String> {
FakeTdClient::delete_messages(self, chat_id, message_ids, revoke).await
}
async fn forward_messages(
&mut self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String> {
FakeTdClient::forward_messages(self, from_chat_id, to_chat_id, message_ids).await
}
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
FakeTdClient::set_draft_message(self, chat_id, text).await
}
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())
}
}
fn current_chat_id(&self) -> Option<ChatId> {
self.get_current_chat_id().map(ChatId::new)
}
fn current_pinned_message(&self) -> Option<MessageInfo> {
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<MessageInfo>) {
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
self.messages.lock().unwrap().insert(chat_id, messages);
}
}
fn update_current_chat_messages<F>(&mut self, updater: F)
where
F: FnOnce(&mut Vec<MessageInfo>),
{
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<ChatId>) {
*self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64());
}
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
*self.current_pinned_message.lock().unwrap() = msg;
}
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
&[]
}
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
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) {
let mut pending = self.pending_view_messages.lock().unwrap();
for (chat_id, message_ids) in pending.drain(..) {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
self.viewed_messages
.lock()
.unwrap()
.push((chat_id.as_i64(), ids));
}
}
}
#[async_trait]
impl UserClient for FakeTdClient {
fn get_user_status_by_chat_id(&self, _chat_id: ChatId) -> Option<&UserOnlineStatus> {
None
}
fn pending_user_ids(&self) -> &[UserId] {
&[]
}
fn user_cache(&self) -> &UserCache {
use std::sync::OnceLock;
static EMPTY_CACHE: OnceLock<UserCache> = OnceLock::new();
EMPTY_CACHE.get_or_init(|| UserCache::new(0))
}
fn update_user_cache<F>(&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,
message_id: MessageId,
) -> Result<Vec<String>, String> {
FakeTdClient::get_message_available_reactions(self, chat_id, message_id).await
}
async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
reaction: String,
) -> Result<(), String> {
FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await
}
}
#[async_trait]
impl FileClient for FakeTdClient {
async fn download_file(&self, file_id: i32) -> Result<String, String> {
FakeTdClient::download_file(self, file_id).await
}
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
Ok(format!("/tmp/fake_voice_{}.ogg", file_id))
}
}
#[async_trait]
impl ClientState for FakeTdClient {
fn client_id(&self) -> i32 {
0
}
async fn get_me(&self) -> Result<i64, String> {
Ok(12345)
}
fn auth_state(&self) -> &AuthState {
use std::sync::OnceLock;
static AUTH_STATE_READY: AuthState = AuthState::Ready;
static AUTH_STATE_WAIT_PHONE: OnceLock<AuthState> = OnceLock::new();
static AUTH_STATE_WAIT_CODE: OnceLock<AuthState> = OnceLock::new();
static AUTH_STATE_WAIT_PASSWORD: OnceLock<AuthState> = OnceLock::new();
let current = self.auth_state.lock().unwrap();
match *current {
AuthState::Ready => &AUTH_STATE_READY,
AuthState::WaitPhoneNumber => {
AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber)
}
AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode),
AuthState::WaitPassword => {
AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword)
}
_ => &AUTH_STATE_READY,
}
}
fn network_state(&self) -> tele_tui::tdlib::types::NetworkState {
FakeTdClient::get_network_state(self)
}
}
#[async_trait]
impl AccountClient for FakeTdClient {
async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> {
Ok(())
}
}
impl UpdateClient for FakeTdClient {
fn handle_update(&mut self, _update: Update) {}
fn drain_incoming_message_events(&mut self) -> Vec<tele_tui::tdlib::IncomingMessageEvent> {
Vec::new()
}
}

View File

@@ -0,0 +1,22 @@
// Test helpers module.
//
// In all-features runs, integration tests exercise the same gated support module
// used by the PTY fixture binary. Plain `cargo test` keeps the local copies so
// existing tests do not need the internal feature enabled.
#[cfg(feature = "test-support")]
pub use tele_tui::test_support::*;
#[cfg(not(feature = "test-support"))]
pub mod app_builder;
#[cfg(not(feature = "test-support"))]
pub mod fake_tdclient;
#[cfg(not(feature = "test-support"))]
mod fake_tdclient_impl; // TdClientTrait implementation for FakeTdClient
#[cfg(not(feature = "test-support"))]
pub mod snapshot_utils;
#[cfg(not(feature = "test-support"))]
pub mod test_data;
#[cfg(not(feature = "test-support"))]
pub use fake_tdclient::FakeTdClient;

View File

@@ -0,0 +1,144 @@
// Snapshot testing utilities
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Modifier};
use ratatui::Terminal;
/// Конвертирует Buffer в читаемую строку для snapshot тестов
pub fn buffer_to_string(buffer: &Buffer) -> String {
let area = buffer.area();
let mut result = String::new();
for y in 0..area.height {
let mut line = String::new();
for x in 0..area.width {
line.push_str(buffer[(x, y)].symbol());
}
// Убираем trailing spaces в конце строки
result.push_str(line.trim_end());
if y < area.height - 1 {
result.push('\n');
}
}
result
}
/// Serializes only cells with non-default style, grouped by row and style.
pub fn buffer_to_style_snapshot(buffer: &Buffer) -> String {
let area = buffer.area();
let mut rows = Vec::new();
for y in 0..area.height {
let mut segments = Vec::new();
let mut x = 0;
while x < area.width {
let cell = &buffer[(x, y)];
if is_default_style(cell) {
x += 1;
continue;
}
let start = x;
let fg = cell.fg;
let bg = cell.bg;
let modifier = cell.modifier;
let mut text = String::new();
while x < area.width {
let next = &buffer[(x, y)];
if is_default_style(next)
|| next.fg != fg
|| next.bg != bg
|| next.modifier != modifier
{
break;
}
text.push_str(next.symbol());
x += 1;
}
segments.push(format!(
"{}..{} {:?}/{:?}/{:?}: {:?}",
start,
x.saturating_sub(1),
fg,
bg,
modifier,
text.trim_end()
));
}
if !segments.is_empty() {
rows.push(format!("y={}: {}", y, segments.join(" | ")));
}
}
rows.join("\n")
}
fn is_default_style(cell: &ratatui::buffer::Cell) -> bool {
cell.fg == Color::Reset && cell.bg == Color::Reset && cell.modifier == Modifier::empty()
}
/// Создаёт TestBackend с заданным размером и рендерит UI
pub fn render_to_buffer<F>(width: u16, height: u16, render_fn: F) -> Buffer
where
F: FnOnce(&mut ratatui::Frame),
{
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(render_fn).unwrap();
terminal.backend().buffer().clone()
}
/// Макрос для упрощения snapshot тестов
#[macro_export]
macro_rules! assert_ui_snapshot {
($name:expr, $width:expr, $height:expr, $render_fn:expr) => {{
use $crate::helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
let buffer = render_to_buffer($width, $height, $render_fn);
let output = buffer_to_string(&buffer);
insta::assert_snapshot!($name, output);
}};
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders};
#[test]
fn test_buffer_to_string_simple() {
let buffer = render_to_buffer(10, 3, |f| {
let block = Block::default().borders(Borders::ALL).title("Hi");
f.render_widget(block, f.area());
});
let result = buffer_to_string(&buffer);
assert!(result.contains("Hi"));
assert!(result.contains(""));
assert!(result.contains(""));
}
#[test]
fn test_buffer_to_string_removes_trailing_spaces() {
let buffer = render_to_buffer(20, 3, |f| {
let block = Block::default().title("Test");
f.render_widget(block, Rect::new(0, 0, 10, 3));
});
let result = buffer_to_string(&buffer);
let lines: Vec<&str> = result.lines().collect();
// Проверяем что trailing spaces убраны
for line in lines {
assert!(!line.ends_with(' ') || line.trim().is_empty());
}
}
}

View File

@@ -0,0 +1,252 @@
// Test data builders and fixtures
use tele_tui::tdlib::types::{ForwardInfo, ReactionInfo};
use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use tele_tui::types::{ChatId, MessageId};
/// Builder для создания тестового чата
#[allow(dead_code)]
pub struct TestChatBuilder {
id: i64,
title: String,
username: Option<String>,
last_message: String,
last_message_date: i32,
unread_count: i32,
unread_mention_count: i32,
is_pinned: bool,
order: i64,
last_read_outbox_message_id: i64,
folder_ids: Vec<i32>,
is_muted: bool,
draft_text: Option<String>,
}
#[allow(dead_code)]
impl TestChatBuilder {
pub fn new(title: &str, id: i64) -> Self {
Self {
id,
title: title.to_string(),
username: None,
last_message: "".to_string(),
last_message_date: 1640000000,
unread_count: 0,
unread_mention_count: 0,
is_pinned: false,
order: id,
last_read_outbox_message_id: 0,
folder_ids: vec![0],
is_muted: false,
draft_text: None,
}
}
pub fn username(mut self, username: &str) -> Self {
self.username = Some(username.to_string());
self
}
pub fn last_message(mut self, text: &str) -> Self {
self.last_message = text.to_string();
self
}
pub fn unread_count(mut self, count: i32) -> Self {
self.unread_count = count;
self
}
pub fn unread_mentions(mut self, count: i32) -> Self {
self.unread_mention_count = count;
self
}
pub fn pinned(mut self) -> Self {
self.is_pinned = true;
self
}
pub fn muted(mut self) -> Self {
self.is_muted = true;
self
}
pub fn draft(mut self, text: &str) -> Self {
self.draft_text = Some(text.to_string());
self
}
pub fn folder(mut self, folder_id: i32) -> Self {
self.folder_ids = vec![folder_id];
self
}
pub fn build(self) -> ChatInfo {
ChatInfo {
id: ChatId::new(self.id),
title: self.title,
username: self.username,
last_message: self.last_message,
last_message_date: self.last_message_date,
unread_count: self.unread_count,
unread_mention_count: self.unread_mention_count,
is_pinned: self.is_pinned,
order: self.order,
last_read_outbox_message_id: MessageId::new(self.last_read_outbox_message_id),
folder_ids: self.folder_ids,
is_muted: self.is_muted,
draft_text: self.draft_text,
}
}
}
/// Builder для создания тестового сообщения
#[allow(dead_code)]
pub struct TestMessageBuilder {
id: i64,
sender_name: String,
is_outgoing: bool,
content: String,
entities: Vec<tdlib_rs::types::TextEntity>,
date: i32,
edit_date: i32,
is_read: bool,
can_be_edited: bool,
can_be_deleted_only_for_self: bool,
can_be_deleted_for_all_users: bool,
reply_to: Option<ReplyInfo>,
forward_from: Option<ForwardInfo>,
reactions: Vec<ReactionInfo>,
media_album_id: i64,
}
#[allow(dead_code)]
impl TestMessageBuilder {
pub fn new(content: &str, id: i64) -> Self {
Self {
id,
sender_name: "User".to_string(),
is_outgoing: false,
content: content.to_string(),
entities: vec![],
date: 1640000000,
edit_date: 0,
is_read: true,
can_be_edited: false,
can_be_deleted_only_for_self: true,
can_be_deleted_for_all_users: false,
reply_to: None,
forward_from: None,
reactions: vec![],
media_album_id: 0,
}
}
pub fn outgoing(mut self) -> Self {
self.is_outgoing = true;
self.sender_name = "You".to_string();
self.can_be_edited = true;
self.can_be_deleted_for_all_users = true;
self
}
pub fn sender(mut self, name: &str) -> Self {
self.sender_name = name.to_string();
self
}
pub fn date(mut self, timestamp: i32) -> Self {
self.date = timestamp;
self
}
pub fn edited(mut self) -> Self {
self.edit_date = self.date + 60;
self
}
pub fn unread(mut self) -> Self {
self.is_read = false;
self
}
pub fn reply_to(mut self, message_id: i64, sender: &str, text: &str) -> Self {
self.reply_to = Some(ReplyInfo {
message_id: MessageId::new(message_id),
sender_name: sender.to_string(),
text: text.to_string(),
});
self
}
pub fn forwarded_from(mut self, sender: &str) -> Self {
self.forward_from = Some(ForwardInfo { sender_name: sender.to_string() });
self
}
pub fn reaction(mut self, emoji: &str, count: i32, chosen: bool) -> Self {
self.reactions
.push(ReactionInfo { emoji: emoji.to_string(), count, is_chosen: chosen });
self
}
pub fn media_album_id(mut self, id: i64) -> Self {
self.media_album_id = id;
self
}
pub fn build(self) -> MessageInfo {
let mut msg = MessageInfo::new(
MessageId::new(self.id),
self.sender_name,
self.is_outgoing,
self.content,
self.entities,
self.date,
self.edit_date,
self.is_read,
self.can_be_edited,
self.can_be_deleted_only_for_self,
self.can_be_deleted_for_all_users,
self.reply_to,
self.forward_from,
self.reactions,
);
msg.metadata.media_album_id = self.media_album_id;
msg
}
}
/// Хелперы для быстрого создания тестовых данных
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),
title: title.to_string(),
username: None,
bio: None,
phone_number: None,
chat_type: "Личный чат".to_string(),
member_count: None,
description: None,
invite_link: None,
is_group: false,
online_status: None,
}
}