feat/rafactor #31

Merged
killingdruid merged 2 commits from feat/rafactor into main 2026-05-17 22:22:44 +00:00
27 changed files with 3435 additions and 23 deletions

725
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ license = "MIT"
repository = "https://github.com/your-username/tele-tui"
keywords = ["telegram", "tui", "terminal", "cli"]
categories = ["command-line-utilities"]
default-run = "tele-tui"
[features]
default = ["clipboard", "url-open", "notifications", "images"]
@@ -15,6 +16,7 @@ clipboard = ["dep:arboard"]
url-open = ["dep:open"]
notifications = ["dep:notify-rust"]
images = ["dep:ratatui-image", "dep:image"]
test-support = []
[dependencies]
ratatui = "0.29"
@@ -43,6 +45,12 @@ fs2 = "0.4"
insta = "1.34"
tokio-test = "0.4"
criterion = "0.5"
termwright = "0.2"
[[bin]]
name = "tele-tui-test-fixture"
path = "src/bin/tele-tui-test-fixture.rs"
required-features = ["test-support"]
[[bench]]
name = "group_messages"

33
build.rs Normal file
View File

@@ -0,0 +1,33 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
fn main() {
println!("cargo:rerun-if-changed=build.rs");
for lib_dir in tdlib_lib_dirs() {
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir.display());
}
}
fn tdlib_lib_dirs() -> Vec<PathBuf> {
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
let build_dir = manifest_dir.join("target").join(profile).join("build");
let Ok(entries) = fs::read_dir(build_dir) else {
return Vec::new();
};
entries
.flatten()
.map(|entry| entry.path().join("out").join("tdlib").join("lib"))
.filter(|path| has_tdjson(path))
.collect()
}
fn has_tdjson(path: &Path) -> bool {
path.join("libtdjson.1.8.29.dylib").exists()
|| path.join("libtdjson.dylib").exists()
|| path.join("libtdjson.so").exists()
}

View File

@@ -0,0 +1,182 @@
use std::io;
use std::time::Duration;
use crossterm::{
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tele_tui::{
app::{App, AppScreen},
input::handle_main_input,
test_support::{
app_builder::TestAppBuilder,
fake_tdclient::FakeTdClient,
test_data::{TestChatBuilder, TestMessageBuilder},
},
};
#[tokio::main]
async fn main() -> io::Result<()> {
let scenario = parse_scenario();
let mut app = build_app(&scenario);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_fixture(&mut terminal, &mut app).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste
)?;
terminal.show_cursor()?;
result
}
fn parse_scenario() -> String {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--scenario" {
return args.next().unwrap_or_else(|| "inbox".to_string());
}
}
"inbox".to_string()
}
async fn run_fixture(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App<FakeTdClient>,
) -> io::Result<()> {
loop {
if app.needs_redraw {
terminal.draw(|f| tele_tui::ui::render(f, app))?;
app.needs_redraw = false;
}
if event::poll(Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) => {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
return Ok(());
}
if key.code == KeyCode::F(10) {
return Ok(());
}
handle_main_input(app, normalize_fixture_key(key)).await;
app.needs_redraw = true;
}
Event::Resize(_, _) => {
app.needs_redraw = true;
}
Event::Paste(text) => {
for ch in text.chars() {
handle_main_input(
app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
)
.await;
}
app.needs_redraw = true;
}
_ => {}
}
}
}
}
fn normalize_fixture_key(key: KeyEvent) -> KeyEvent {
match (key.code, key.modifiers) {
(KeyCode::Char('/'), KeyModifiers::NONE) => {
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)
}
(KeyCode::Char('j' | 'm'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}
_ => key,
}
}
fn build_app(scenario: &str) -> App<FakeTdClient> {
match scenario {
"open-chat" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.selected_chat(102)
.with_messages(102, sample_messages())
.build(),
"compose-draft" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.selected_chat(102)
.message_input("hello from e2e")
.with_messages(102, sample_messages())
.build(),
"inbox" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.with_messages(101, mom_messages())
.with_messages(102, sample_messages())
.with_messages(103, boss_messages())
.build(),
other => {
eprintln!("unknown scenario: {other}");
std::process::exit(2);
}
}
}
fn sample_chats() -> Vec<tele_tui::tdlib::ChatInfo> {
vec![
TestChatBuilder::new("Mom", 101)
.last_message("Dinner at 7?")
.unread_count(2)
.build(),
TestChatBuilder::new("Work Group", 102)
.last_message("Standup notes are ready")
.unread_mentions(1)
.build(),
TestChatBuilder::new("Boss", 103)
.last_message("Please review the deck")
.build(),
]
}
fn sample_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![
TestMessageBuilder::new("Morning, team", 201)
.sender("Alice")
.build(),
TestMessageBuilder::new("Standup notes are ready", 202)
.sender("Bob")
.build(),
TestMessageBuilder::new("Thanks, I will review them after lunch", 203)
.outgoing()
.build(),
]
}
fn mom_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![TestMessageBuilder::new("Dinner at 7?", 301)
.sender("Mom")
.build()]
}
fn boss_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![TestMessageBuilder::new("Please review the deck", 401)
.sender("Boss")
.build()]
}

View File

@@ -14,6 +14,8 @@ pub mod media;
pub mod message_grouping;
pub mod notifications;
pub mod tdlib;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub mod types;
pub mod ui;
pub mod utils;

View File

@@ -0,0 +1,288 @@
// Test App builder
use super::FakeTdClient;
use crate::app::{App, AppScreen, ChatState, InputMode};
use crate::config::Config;
use crate::tdlib::AuthState;
use crate::tdlib::{ChatInfo, MessageInfo};
use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState;
use std::collections::HashMap;
/// 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: crate::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
}
}

View File

@@ -0,0 +1,12 @@
// 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,
};

View File

@@ -0,0 +1,86 @@
use super::{FakeTdClient, TdUpdate};
use crate::tdlib::types::FolderInfo;
use crate::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 crate::tdlib::types::FolderInfo;
use crate::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 crate::tdlib::types::ReactionInfo;
use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use crate::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: crate::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 crate::tdlib::types::{FolderInfo, ReactionInfo};
use crate::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
use crate::types::{ChatId, MessageId, UserId};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
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,360 @@
//! Test implementation of the TDLib client traits for FakeTdClient.
use super::fake_tdclient::FakeTdClient;
use crate::tdlib::{
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient,
};
use crate::tdlib::{
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 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) -> crate::tdlib::types::NetworkState {
FakeTdClient::get_network_state(self)
}
}
impl NotificationClient for FakeTdClient {
fn configure_notifications(&mut self, _config: &crate::config::NotificationsConfig) {}
fn sync_notification_muted_chats(&mut 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) {}
}

9
src/test_support/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! Test-only support for deterministic UI fixtures and integration tests.
pub mod app_builder;
pub mod fake_tdclient;
mod fake_tdclient_impl;
pub mod snapshot_utils;
pub mod test_data;
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::test_support::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 crate::tdlib::types::{ForwardInfo, ReactionInfo};
use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use crate::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,
}
}

167
tests/e2e_termwright.rs Normal file
View File

@@ -0,0 +1,167 @@
#![cfg(feature = "test-support")]
use std::time::{Duration, Instant};
use termwright::prelude::*;
fn fixture_path() -> &'static str {
env!("CARGO_BIN_EXE_tele-tui-test-fixture")
}
async fn spawn_fixture(scenario: &str) -> Result<Terminal> {
let mut builder = Terminal::builder()
.size(100, 30)
.working_dir(env!("CARGO_MANIFEST_DIR"));
if let Some(lib_path) = tdlib_library_path() {
builder = builder
.env("DYLD_LIBRARY_PATH", &lib_path)
.env("LD_LIBRARY_PATH", &lib_path);
}
let command = format!(
"stty -echo -ixon; exec {} --scenario {}",
shell_quote(fixture_path()),
shell_quote(scenario)
);
builder.spawn("/bin/sh", &["-lc", &command]).await
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn tdlib_library_path() -> Option<String> {
let build_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("debug")
.join("build");
let entries = std::fs::read_dir(build_dir).ok()?;
let mut paths = Vec::new();
for entry in entries.flatten() {
let lib_dir = entry.path().join("out").join("tdlib").join("lib");
if lib_dir.join("libtdjson.1.8.29.dylib").exists() || lib_dir.join("libtdjson.so").exists()
{
paths.push(lib_dir);
}
}
(!paths.is_empty()).then(|| {
paths
.into_iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(":")
})
}
async fn stop_fixture(term: &mut Terminal) {
let _ = tokio::time::timeout(Duration::from_millis(500), term.send_key(Key::F(10))).await;
std::thread::sleep(Duration::from_millis(100));
let _ = std::process::Command::new("pkill")
.arg("-f")
.arg("tele-tui-test-fixture")
.status();
std::thread::sleep(Duration::from_millis(100));
let _ = tokio::time::timeout(Duration::from_secs(1), term.kill()).await;
}
async fn wait_for_text(term: &Terminal, needle: &str) -> Result<()> {
let started = Instant::now();
let mut last_screen = String::new();
for _ in 0..100 {
let Ok(screen) = screen_text(term).await else {
continue;
};
if screen.contains(needle) {
return Ok(());
}
last_screen = screen;
std::thread::sleep(Duration::from_millis(50));
}
let elapsed = started.elapsed();
Err(TermwrightError::Timeout {
condition: format!("text '{needle}' to appear\n\n{last_screen}"),
timeout: elapsed,
})
}
async fn screen_text(term: &Terminal) -> Result<String> {
tokio::time::timeout(Duration::from_millis(500), term.screen())
.await
.map(|screen| screen.text())
.map_err(|_| TermwrightError::Timeout {
condition: "terminal screen snapshot".to_string(),
timeout: Duration::from_millis(500),
})
}
async fn enter_insert_mode(term: &Terminal) -> Result<()> {
for _ in 0..5 {
term.send_key(Key::Char('i')).await?;
std::thread::sleep(Duration::from_millis(150));
if !screen_text(term).await?.contains("Press i to type") {
return Ok(());
}
}
let screen = screen_text(term).await?;
Err(TermwrightError::Timeout {
condition: format!("insert mode to start\n\n{screen}"),
timeout: Duration::from_millis(750),
})
}
#[test]
fn e2e_termwright_user_flows() -> Result<()> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("failed to build e2e runtime");
let result = runtime.block_on(async {
tokio::time::timeout(Duration::from_secs(15), compose_and_send_message()).await
});
kill_fixture_processes();
match result {
Ok(result) => result,
Err(_) => Err(TermwrightError::Timeout {
condition: "termwright e2e user flow".to_string(),
timeout: Duration::from_secs(15),
}),
}
}
async fn compose_and_send_message() -> Result<()> {
let mut term = spawn_fixture("compose-draft").await?;
let result = async {
wait_for_text(&term, "Work Group").await?;
wait_for_text(&term, "Standup notes are ready").await?;
wait_for_text(&term, "hello from e2e").await?;
enter_insert_mode(&term).await?;
wait_for_text(&term, "hello from e2e").await?;
term.send_key(Key::Enter).await?;
std::thread::sleep(Duration::from_millis(500));
let screen = screen_text(&term).await?;
assert!(screen.contains("hello from e2e"), "sent message should appear\n\n{}", screen);
assert!(
!screen.contains("Сообщение: hello from e2e"),
"compose input should clear after send"
);
Ok(())
}
.await;
stop_fixture(&mut term).await;
result
}
fn kill_fixture_processes() {
let _ = std::process::Command::new("pkill")
.arg("-f")
.arg("tele-tui-test-fixture")
.status();
}

View File

@@ -1,9 +1,22 @@
// Test helpers module
// 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

@@ -2,7 +2,7 @@
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use ratatui::Terminal;
/// Конвертирует Buffer в читаемую строку для snapshot тестов
@@ -25,6 +25,64 @@ pub fn buffer_to_string(buffer: &Buffer) -> String {
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
@@ -52,6 +110,7 @@ macro_rules! assert_ui_snapshot {
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders};
#[test]

View File

@@ -4,8 +4,10 @@ mod helpers;
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::create_test_chat;
use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder};
use insta::assert_snapshot;
use tele_tui::accounts::AccountProfile;
use tele_tui::app::AccountSwitcherState;
use tele_tui::app::AppScreen;
use tele_tui::tdlib::AuthState;
@@ -113,3 +115,114 @@ fn snapshot_main_screen_terminal_too_small() {
let output = buffer_to_string(&buffer);
assert_snapshot!("main_screen_terminal_too_small", output);
}
#[test]
fn snapshot_main_screen_chat_list_loaded() {
let mut app = TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.build();
let buffer = render_to_buffer(100, 30, |f| {
tele_tui::ui::render(f, &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("main_screen_chat_list_loaded", output);
}
#[test]
fn snapshot_main_screen_chat_open_with_messages() {
let mut app = TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.selected_chat(102)
.with_messages(102, sample_work_messages())
.message_input("Draft reply")
.insert_mode()
.build();
let buffer = render_to_buffer(100, 30, |f| {
tele_tui::ui::render(f, &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("main_screen_chat_open_with_messages", output);
}
#[test]
fn snapshot_main_screen_chat_open_narrow_valid() {
let mut app = TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.selected_chat(102)
.with_messages(102, sample_work_messages())
.build();
let buffer = render_to_buffer(60, 16, |f| {
tele_tui::ui::render(f, &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("main_screen_chat_open_narrow_valid", output);
}
#[test]
fn snapshot_main_screen_account_switcher_overlay() {
let mut app = TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.build();
app.current_account_name = "personal".to_string();
app.account_switcher = Some(AccountSwitcherState::SelectAccount {
accounts: vec![
AccountProfile {
name: "personal".to_string(),
display_name: "Personal".to_string(),
},
AccountProfile {
name: "work".to_string(),
display_name: "Work".to_string(),
},
],
selected_index: 1,
current_account: "personal".to_string(),
});
let buffer = render_to_buffer(100, 30, |f| {
tele_tui::ui::render(f, &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("main_screen_account_switcher_overlay", output);
}
fn sample_chats() -> Vec<tele_tui::tdlib::ChatInfo> {
vec![
TestChatBuilder::new("Mom", 101)
.last_message("Dinner at 7?")
.unread_count(2)
.build(),
TestChatBuilder::new("Work Group", 102)
.last_message("Standup notes are ready")
.unread_mentions(1)
.build(),
TestChatBuilder::new("Boss", 103)
.last_message("Please review the deck")
.build(),
]
}
fn sample_work_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![
TestMessageBuilder::new("Morning, team", 201)
.sender("Alice")
.build(),
TestMessageBuilder::new("Standup notes are ready", 202)
.sender("Bob")
.build(),
TestMessageBuilder::new("Thanks, I will review them after lunch", 203)
.outgoing()
.build(),
]
}

View File

@@ -0,0 +1,35 @@
---
source: tests/screens.rs
assertion_line: 197
expression: output
---
┌ TTUI ────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1:All │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐
│🔍 Ctrl+S для поиска ││ Выберите чат │
└────────────────────────────┘│ │
┌────────────────────────────┐│ │
│ Mom (2) ││ │
│ Work Group @ ││ │
│ Boss ││ │
│ │┌ АККАУНТЫ ────────────────────────────┐ │
│ ││ │ │
│ ││ ● personal (Personal) (текущий) │ │
│ ││ work (Work) │ │
│ ││ ────────────────────── │ │
│ ││ + Добавить аккаунт │ │
│ ││ │ │
│ ││ j/k Nav Enter Select a Add Esc │ │
│ │└──────────────────────────────────────┘ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└────────────────────────────┘│ │
┌────────────────────────────┐│ │
│ ││ │
└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘
[personal] Инициализация TDLib...

View File

@@ -0,0 +1,35 @@
---
source: tests/screens.rs
assertion_line: 131
expression: output
---
┌ TTUI ────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1:All │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐
│🔍 Ctrl+S для поиска ││ Выберите чат │
└────────────────────────────┘│ │
┌────────────────────────────┐│ │
│ Mom (2) ││ │
│ Work Group @ ││ │
│ Boss ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└────────────────────────────┘│ │
┌────────────────────────────┐│ │
│ ││ │
└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘
[default] Инициализация TDLib...

View File

@@ -0,0 +1,21 @@
---
source: tests/screens.rs
assertion_line: 167
expression: output
---
┌ TTUI ────────────────────────────────────────────────────┐
│ 1:All │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│👤 Work Group │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ (14:33) Standup notes are ready │
│ │
│ Вы ──────────────── │
│ Thanks, I will review them after lunch (14:33 ✓✓) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│> Press i to type... │
└──────────────────────────────────────────────────────────┘
[default] Инициализация TDLib...

View File

@@ -0,0 +1,35 @@
---
source: tests/screens.rs
assertion_line: 150
expression: output
---
┌ TTUI ────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1:All │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐
│🔍 Ctrl+S для поиска ││👤 Work Group │
└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐
│ Mom (2) ││ ──────── 20.12.2021 ──────── │
│▌ Work Group @ ││ │
│ Boss ││Alice ──────────────── │
│ ││ (14:33) Morning, team │
│ ││ │
│ ││Bob ──────────────── │
│ ││ (14:33) Standup notes are ready │
│ ││ │
│ ││ Вы ──────────────── │
│ ││ Thanks, I will review them after lunch (14:33 ✓✓) │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐
│ ││> Draft reply │
└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘
[default] Инициализация TDLib...

View File

@@ -0,0 +1,15 @@
---
source: tests/style_snapshots.rs
assertion_line: 78
expression: buffer_to_style_snapshot(&buffer)
---
y=1: 1..1 Cyan/Reset/BOLD: "👤" | 3..6 Cyan/Reset/BOLD: " Mom"
y=4: 21..48 Gray/Reset/NONE: "──────── 20.12.2021 ────────"
y=6: 1..4 Cyan/Reset/BOLD: "Mom" | 5..9 Gray/Reset/NONE: "─────" | 10..10 Yellow/Reset/NONE: "┌" | 11..26 Yellow/Reset/BOLD: " Выбери реакцию" | 27..59 Yellow/Reset/NONE: "────────────────────────────────┐"
y=7: 1..2 Yellow/Reset/BOLD: "" | 3..9 Gray/Reset/NONE: " (14:33" | 10..10 Yellow/Reset/NONE: "│" | 59..59 Yellow/Reset/NONE: "│"
y=8: 10..10 Yellow/Reset/NONE: "│" | 26..27 White/Reset/NONE: " 👍" | 29..29 White/Reset/NONE: "" | 31..32 White/Reset/NONE: " ❤\u{fe0f}" | 34..34 White/Reset/NONE: "" | 36..37 Yellow/Reset/BOLD | REVERSED: " 😂" | 39..39 Yellow/Reset/BOLD | REVERSED: "" | 41..42 White/Reset/NONE: " 🔥" | 44..44 White/Reset/NONE: "" | 59..59 Yellow/Reset/NONE: "│"
y=9: 10..10 Yellow/Reset/NONE: "│" | 59..59 Yellow/Reset/NONE: "│"
y=10: 10..59 Yellow/Reset/NONE: "└────────────────────────────────────────────────┘"
y=15: 0..69 DarkGray/Reset/NONE: "┌────────────────────────────────────────────────────────────────────┐"
y=16: 0..20 DarkGray/Reset/NONE: "│> Press i to type..." | 69..69 DarkGray/Reset/NONE: "│"
y=17: 0..69 DarkGray/Reset/NONE: "└────────────────────────────────────────────────────────────────────┘"

View File

@@ -0,0 +1,14 @@
---
source: tests/style_snapshots.rs
assertion_line: 24
expression: buffer_to_style_snapshot(&buffer)
---
y=0: 0..35 Rgb(160, 160, 160)/Reset/NONE: "┌──────────────────────────────────┐"
y=1: 0..1 Rgb(160, 160, 160)/Reset/NONE: "│🔍" | 3..35 Rgb(160, 160, 160)/Reset/NONE: " Ctrl+S для поиска │"
y=2: 0..35 Rgb(160, 160, 160)/Reset/NONE: "└──────────────────────────────────┘"
y=4: 1..34 White/Reset/NONE: " Mom"
y=5: 1..34 Yellow/Reset/ITALIC: " Work Group"
y=6: 1..34 White/Reset/NONE: " Boss"
y=9: 0..35 DarkGray/Reset/NONE: "┌──────────────────────────────────┐"
y=10: 0..35 DarkGray/Reset/NONE: "│ │"
y=11: 0..35 DarkGray/Reset/NONE: "└──────────────────────────────────┘"

View File

@@ -0,0 +1,12 @@
---
source: tests/style_snapshots.rs
assertion_line: 47
expression: buffer_to_style_snapshot(&buffer)
---
y=1: 1..1 Cyan/Reset/BOLD: "👤" | 3..6 Cyan/Reset/BOLD: " Mom"
y=4: 21..48 Gray/Reset/NONE: "──────── 20.12.2021 ────────"
y=6: 1..4 Cyan/Reset/BOLD: "Mom" | 5..20 Gray/Reset/NONE: "────────────────"
y=7: 1..2 Yellow/Reset/BOLD: "" | 3..10 Gray/Reset/NONE: " (14:33)" | 12..24 White/Reset/NONE: "First message"
y=8: 1..2 Yellow/Reset/BOLD: "▶" | 3..10 Gray/Reset/NONE: " (14:33)" | 12..27 Yellow/Reset/NONE: "Selected message"
y=15: 1..17 Magenta/Reset/BOLD: " Выбор сообщения"
y=16: 1..55 Cyan/Reset/NONE: "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc"

81
tests/style_snapshots.rs Normal file
View File

@@ -0,0 +1,81 @@
// Focused style snapshot tests.
mod helpers;
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{buffer_to_style_snapshot, render_to_buffer};
use helpers::test_data::{TestChatBuilder, TestMessageBuilder};
use insta::assert_snapshot;
#[test]
fn snapshot_style_selected_chat() {
let chats = vec![
TestChatBuilder::new("Mom", 101).build(),
TestChatBuilder::new("Work Group", 102).build(),
TestChatBuilder::new("Boss", 103).build(),
];
let mut app = TestAppBuilder::new().with_chats(chats).build();
app.chat_list_state.select(Some(1));
let buffer = render_to_buffer(36, 12, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
assert_snapshot!("style_selected_chat", buffer_to_style_snapshot(&buffer));
}
#[test]
fn snapshot_style_selected_message() {
let chat = TestChatBuilder::new("Mom", 101).build();
let messages = vec![
TestMessageBuilder::new("First message", 201)
.sender("Mom")
.build(),
TestMessageBuilder::new("Selected message", 202)
.sender("Mom")
.build(),
];
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(101)
.with_messages(101, messages)
.selecting_message(1)
.build();
let buffer = render_to_buffer(70, 18, |f| {
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
assert_snapshot!("style_selected_message", buffer_to_style_snapshot(&buffer));
}
#[test]
fn snapshot_style_reaction_picker_selection() {
let chat = TestChatBuilder::new("Mom", 101).build();
let message = TestMessageBuilder::new("React to this", 201)
.sender("Mom")
.build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(101)
.with_message(101, message)
.reaction_picker(
201,
vec![
"👍".to_string(),
"❤️".to_string(),
"😂".to_string(),
"🔥".to_string(),
],
)
.build();
if let tele_tui::app::ChatState::ReactionPicker { selected_index, .. } = &mut app.chat_state {
*selected_index = 2;
}
let buffer = render_to_buffer(70, 18, |f| {
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
assert_snapshot!("style_reaction_picker_selection", buffer_to_style_snapshot(&buffer));
}