This commit is contained in:
Mikhail Kilin
2026-01-28 11:39:21 +03:00
parent 051c4a0265
commit 68a2b7a982
56 changed files with 4424 additions and 5 deletions

View File

@@ -0,0 +1,277 @@
// Test App builder
use tele_tui::app::{App, AppScreen};
use tele_tui::config::Config;
use tele_tui::tdlib::{ChatInfo, MessageInfo};
use ratatui::widgets::ListState;
use std::collections::HashMap;
/// Builder для создания тестового App
///
/// Примечание: Так как App содержит реальный TdClient,
/// этот билдер подходит только для UI/snapshot тестов.
/// Для интеграционных тестов логики понадобится рефакторинг
/// с выделением trait для TdClient.
pub struct TestAppBuilder {
config: Config,
screen: AppScreen,
chats: Vec<ChatInfo>,
selected_chat_id: Option<i64>,
message_input: String,
is_searching: bool,
search_query: String,
editing_message_id: Option<i64>,
replying_to_message_id: Option<i64>,
is_reaction_picker_mode: bool,
is_profile_mode: bool,
confirm_delete_message_id: Option<i64>,
messages: HashMap<i64, Vec<MessageInfo>>,
selected_message_index: Option<usize>,
message_search_mode: bool,
message_search_query: String,
forwarding_message_id: Option<i64>,
is_selecting_forward_chat: bool,
}
impl Default for TestAppBuilder {
fn default() -> Self {
Self::new()
}
}
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(),
editing_message_id: None,
replying_to_message_id: None,
is_reaction_picker_mode: false,
is_profile_mode: false,
confirm_delete_message_id: None,
messages: HashMap::new(),
selected_message_index: None,
message_search_mode: false,
message_search_query: String::new(),
forwarding_message_id: None,
is_selecting_forward_chat: false,
}
}
/// Установить экран
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) -> Self {
self.editing_message_id = Some(message_id);
self
}
/// Режим ответа на сообщение
pub fn replying_to(mut self, message_id: i64) -> Self {
self.replying_to_message_id = Some(message_id);
self
}
/// Режим выбора реакции
pub fn reaction_picker(mut self) -> Self {
self.is_reaction_picker_mode = true;
self
}
/// Режим профиля
pub fn profile_mode(mut self) -> Self {
self.is_profile_mode = true;
self
}
/// Подтверждение удаления
pub fn delete_confirmation(mut self, message_id: i64) -> Self {
self.confirm_delete_message_id = Some(message_id);
self
}
/// Добавить сообщение для чата
pub fn with_message(mut self, chat_id: i64, message: MessageInfo) -> Self {
self.messages.entry(chat_id).or_insert_with(Vec::new).push(message);
self
}
/// Добавить несколько сообщений для чата
pub fn with_messages(mut self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages.entry(chat_id).or_insert_with(Vec::new).extend(messages);
self
}
/// Установить выбранное сообщение (режим selection)
pub fn selecting_message(mut self, message_index: usize) -> Self {
self.selected_message_index = Some(message_index);
self
}
/// Режим поиска по сообщениям в чате
pub fn message_search(mut self, query: &str) -> Self {
self.message_search_mode = true;
self.message_search_query = query.to_string();
self
}
/// Режим пересылки сообщения
pub fn forward_mode(mut self, message_id: i64) -> Self {
self.forwarding_message_id = Some(message_id);
self.is_selecting_forward_chat = true;
self
}
/// Построить App
///
/// ВАЖНО: Этот метод создаёт App с реальным TdClient,
/// поэтому он подходит только для UI тестов, где мы
/// не вызываем методы TdClient.
pub fn build(self) -> App {
let mut app = App::new(self.config);
app.screen = self.screen;
app.chats = self.chats;
app.selected_chat_id = self.selected_chat_id;
app.message_input = self.message_input;
app.is_searching = self.is_searching;
app.search_query = self.search_query;
app.editing_message_id = self.editing_message_id;
app.replying_to_message_id = self.replying_to_message_id;
app.is_reaction_picker_mode = self.is_reaction_picker_mode;
app.is_profile_mode = self.is_profile_mode;
app.confirm_delete_message_id = self.confirm_delete_message_id;
app.selected_message_index = self.selected_message_index;
app.is_message_search_mode = self.message_search_mode;
app.message_search_query = self.message_search_query;
app.forwarding_message_id = self.forwarding_message_id;
app.is_selecting_forward_chat = self.is_selecting_forward_chat;
// Выбираем первый чат если есть
if !app.chats.is_empty() {
let mut list_state = ListState::default();
list_state.select(Some(0));
app.chat_list_state = list_state;
}
// Применяем сообщения к текущему открытому чату
if let Some(chat_id) = self.selected_chat_id {
if let Some(messages) = self.messages.get(&chat_id) {
app.td_client.current_chat_messages = messages.clone();
app.td_client.current_chat_id = Some(chat_id);
}
}
app
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::helpers::test_data::create_test_chat;
#[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(123));
}
#[test]
fn test_builder_editing_mode() {
let app = TestAppBuilder::new()
.editing_message(999)
.message_input("Edited text")
.build();
assert_eq!(app.editing_message_id, Some(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,280 @@
// Fake TDLib client for testing
use std::collections::HashMap;
use tele_tui::tdlib::{ChatInfo, MessageInfo, FolderInfo, NetworkState};
/// Упрощённый mock TDLib клиента для тестов
#[derive(Clone)]
pub struct FakeTdClient {
pub chats: Vec<ChatInfo>,
pub messages: HashMap<i64, Vec<MessageInfo>>,
pub folders: Vec<FolderInfo>,
pub user_names: HashMap<i64, String>,
pub network_state: NetworkState,
pub typing_chat_id: Option<i64>,
pub sent_messages: Vec<SentMessage>,
pub edited_messages: Vec<EditedMessage>,
pub deleted_messages: Vec<i64>,
pub reactions: HashMap<i64, Vec<String>>, // message_id -> emojis
}
#[derive(Debug, Clone)]
pub struct SentMessage {
pub chat_id: i64,
pub text: String,
pub reply_to: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct EditedMessage {
pub message_id: i64,
pub new_text: String,
}
impl Default for FakeTdClient {
fn default() -> Self {
Self::new()
}
}
impl FakeTdClient {
pub fn new() -> Self {
Self {
chats: vec![],
messages: HashMap::new(),
folders: vec![
FolderInfo {
id: 0,
name: "All".to_string(),
},
],
user_names: HashMap::new(),
network_state: NetworkState::Ready,
typing_chat_id: None,
sent_messages: vec![],
edited_messages: vec![],
deleted_messages: vec![],
reactions: HashMap::new(),
}
}
/// Добавить чат
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 with_message(mut self, chat_id: i64, message: MessageInfo) -> Self {
self.messages
.entry(chat_id)
.or_insert_with(Vec::new)
.push(message);
self
}
/// Добавить несколько сообщений в чат
pub fn with_messages(mut self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages
.entry(chat_id)
.or_insert_with(Vec::new)
.extend(messages);
self
}
/// Добавить папку
pub fn with_folder(mut self, id: i32, name: &str) -> Self {
self.folders.push(FolderInfo {
id,
name: name.to_string(),
});
self
}
/// Добавить пользователя
pub fn with_user(mut self, id: i64, name: &str) -> Self {
self.user_names.insert(id, name.to_string());
self
}
/// Установить состояние сети
pub fn with_network_state(mut self, state: NetworkState) -> Self {
self.network_state = state;
self
}
/// Получить чаты
pub fn get_chats(&self) -> &[ChatInfo] {
&self.chats
}
/// Получить сообщения для чата
pub fn get_messages(&self, chat_id: i64) -> Vec<MessageInfo> {
self.messages
.get(&chat_id)
.cloned()
.unwrap_or_default()
}
/// Получить папки
pub fn get_folders(&self) -> &[FolderInfo] {
&self.folders
}
/// Отправить сообщение (мок)
pub fn send_message(&mut self, chat_id: i64, text: String, reply_to: Option<i64>) -> i64 {
let message_id = (self.sent_messages.len() as i64) + 1000;
self.sent_messages.push(SentMessage {
chat_id,
text: text.clone(),
reply_to,
});
// Добавляем сообщение в список сообщений чата
let message = MessageInfo {
id: message_id,
sender_name: "You".to_string(),
is_outgoing: true,
content: text,
entities: vec![],
date: 1640000000,
edit_date: 0,
is_read: true,
can_be_edited: true,
can_be_deleted_only_for_self: true,
can_be_deleted_for_all_users: true,
reply_to: None,
forward_from: None,
reactions: vec![],
};
self.messages
.entry(chat_id)
.or_insert_with(Vec::new)
.push(message);
message_id
}
/// Редактировать сообщение (мок)
pub fn edit_message(&mut self, chat_id: i64, message_id: i64, new_text: String) {
self.edited_messages.push(EditedMessage {
message_id,
new_text: new_text.clone(),
});
// Обновляем сообщение в списке
if let Some(messages) = self.messages.get_mut(&chat_id) {
if let Some(msg) = messages.iter_mut().find(|m| m.id == message_id) {
msg.content = new_text;
msg.edit_date = msg.date + 60;
}
}
}
/// Удалить сообщение (мок)
pub fn delete_message(&mut self, chat_id: i64, message_id: i64) {
self.deleted_messages.push(message_id);
// Удаляем сообщение из списка
if let Some(messages) = self.messages.get_mut(&chat_id) {
messages.retain(|m| m.id != message_id);
}
}
/// Добавить реакцию (мок)
pub fn add_reaction(&mut self, message_id: i64, emoji: String) {
self.reactions
.entry(message_id)
.or_insert_with(Vec::new)
.push(emoji);
}
/// Установить статус "печатает"
pub fn set_typing(&mut self, chat_id: Option<i64>) {
self.typing_chat_id = chat_id;
}
/// Получить список отправленных сообщений
pub fn sent_messages(&self) -> &[SentMessage] {
&self.sent_messages
}
/// Получить список отредактированных сообщений
pub fn edited_messages(&self) -> &[EditedMessage] {
&self.edited_messages
}
/// Получить список удалённых сообщений
pub fn deleted_messages(&self) -> &[i64] {
&self.deleted_messages
}
/// Очистить историю действий
pub fn clear_history(&mut self) {
self.sent_messages.clear();
self.edited_messages.clear();
self.deleted_messages.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::helpers::test_data::create_test_chat;
#[test]
fn test_fake_client_creation() {
let client = FakeTdClient::new();
assert_eq!(client.chats.len(), 0);
assert_eq!(client.folders.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);
assert_eq!(client.chats.len(), 1);
assert_eq!(client.chats[0].title, "Mom");
}
#[test]
fn test_send_message() {
let mut client = FakeTdClient::new();
let msg_id = client.send_message(123, "Hello".to_string(), None);
assert_eq!(client.sent_messages().len(), 1);
assert_eq!(client.sent_messages()[0].text, "Hello");
assert_eq!(client.get_messages(123).len(), 1);
assert_eq!(client.get_messages(123)[0].id, msg_id);
}
#[test]
fn test_edit_message() {
let mut client = FakeTdClient::new();
let msg_id = client.send_message(123, "Hello".to_string(), None);
client.edit_message(123, msg_id, "Hello World".to_string());
assert_eq!(client.edited_messages().len(), 1);
assert_eq!(client.get_messages(123)[0].content, "Hello World");
assert!(client.get_messages(123)[0].edit_date > 0);
}
#[test]
fn test_delete_message() {
let mut client = FakeTdClient::new();
let msg_id = client.send_message(123, "Hello".to_string(), None);
client.delete_message(123, msg_id);
assert_eq!(client.deleted_messages().len(), 1);
assert_eq!(client.get_messages(123).len(), 0);
}
}

11
tests/helpers/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
// Test helpers module
pub mod app_builder;
pub mod fake_tdclient;
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};

View File

@@ -0,0 +1,89 @@
// Snapshot testing utilities
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
/// Конвертирует 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
}
/// Создаёт 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::{render_to_buffer, buffer_to_string};
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::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());
}
}
}

241
tests/helpers/test_data.rs Normal file
View File

@@ -0,0 +1,241 @@
// Test data builders and fixtures
use tele_tui::tdlib::{ChatInfo, MessageInfo, ReactionInfo, ReplyInfo, ForwardInfo, ProfileInfo};
/// Builder для создания тестового чата
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>,
}
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: 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: self.last_read_outbox_message_id,
folder_ids: self.folder_ids,
is_muted: self.is_muted,
draft_text: self.draft_text,
}
}
}
/// Builder для создания тестового сообщения
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>,
}
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![],
}
}
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,
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(),
date: self.date - 3600,
});
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 build(self) -> MessageInfo {
MessageInfo {
id: self.id,
sender_name: self.sender_name,
is_outgoing: self.is_outgoing,
content: self.content,
entities: self.entities,
date: self.date,
edit_date: self.edit_date,
is_read: self.is_read,
can_be_edited: self.can_be_edited,
can_be_deleted_only_for_self: self.can_be_deleted_only_for_self,
can_be_deleted_for_all_users: self.can_be_deleted_for_all_users,
reply_to: self.reply_to,
forward_from: self.forward_from,
reactions: self.reactions,
}
}
}
/// Хелперы для быстрого создания тестовых данных
pub fn create_test_chat(title: &str, id: i64) -> ChatInfo {
TestChatBuilder::new(title, id).build()
}
pub fn create_test_message(content: &str, id: i64) -> MessageInfo {
TestMessageBuilder::new(content, id).build()
}
pub fn create_test_user(name: &str, id: i64) -> (i64, String) {
(id, name.to_string())
}
/// Хелпер для создания профиля
pub fn create_test_profile(title: &str, chat_id: i64) -> ProfileInfo {
ProfileInfo {
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,
}
}