Implemented Phase 10 (Desktop Notifications) with three stages: notify-rust integration, smart filtering, and production polish. Stage 1 - Base Implementation: - Add NotificationManager module (src/notifications.rs, 350+ lines) - Integrate notify-rust 4.11 with feature flag "notifications" - Implement NotificationsConfig in config.toml (enabled, only_mentions, show_preview) - Add notification_manager field to TdClient - Create configure_notifications() method for config integration - Hook into handle_new_message_update() to send notifications - Send notifications for messages outside current chat - Format notification body with sender name and message preview Stage 2 - Smart Filtering: - Sync muted chats from Telegram (sync_muted_chats method) - Filter muted chats from notifications automatically - Add MessageInfo::has_mention() to detect @username mentions - Implement only_mentions filter (notify only when mentioned) - Beautify media labels with emojis (📷 📹 🎤 🎨 📎 etc.) - Support 10+ media types in notification preview Stage 3 - Production Polish: - Add graceful error handling (no panics on notification failure) - Implement comprehensive logging (tracing::debug!/warn!) - Add timeout_ms configuration (0 = system default) - Add urgency configuration (low/normal/critical, Linux only) - Platform-specific #[cfg] for urgency support - Log all notification skip reasons at debug level Hotkey Change: - Move profile view from 'i' to Ctrl+i to avoid conflicts Technical Details: - Cross-platform support (macOS, Linux, Windows) - Feature flag for optional notifications support - Graceful fallback when notifications unavailable - LRU-friendly muted chats sync - Test coverage for all core notification logic - All 75 tests passing Files Changed: - NEW: src/notifications.rs - Complete NotificationManager - NEW: config.example.toml - Example configuration with notifications - Modified: Cargo.toml - Add notify-rust 4.11 dependency - Modified: src/config/mod.rs - Add NotificationsConfig struct - Modified: src/tdlib/types.rs - Add has_mention() method - Modified: src/tdlib/client.rs - Add notification integration - Modified: src/tdlib/update_handlers.rs - Hook notifications - Modified: src/config/keybindings.rs - Change profile to Ctrl+i - Modified: tests/* - Add notification config to tests Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
273 lines
9.9 KiB
Rust
273 lines
9.9 KiB
Rust
// Integration tests for config flow
|
||
|
||
use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings, NotificationsConfig};
|
||
|
||
/// Test: Дефолтные значения конфигурации
|
||
#[test]
|
||
fn test_config_default_values() {
|
||
let config = Config::default();
|
||
|
||
// Проверяем дефолтный timezone
|
||
assert_eq!(config.general.timezone, "+03:00");
|
||
|
||
// Проверяем дефолтные цвета
|
||
assert_eq!(config.colors.incoming_message, "white");
|
||
assert_eq!(config.colors.outgoing_message, "green");
|
||
assert_eq!(config.colors.selected_message, "yellow");
|
||
assert_eq!(config.colors.reaction_chosen, "yellow");
|
||
assert_eq!(config.colors.reaction_other, "gray");
|
||
}
|
||
|
||
/// Test: Создание конфига с кастомными значениями
|
||
#[test]
|
||
fn test_config_custom_values() {
|
||
let config = Config {
|
||
general: GeneralConfig {
|
||
timezone: "+05:00".to_string(),
|
||
},
|
||
colors: ColorsConfig {
|
||
incoming_message: "cyan".to_string(),
|
||
outgoing_message: "blue".to_string(),
|
||
selected_message: "red".to_string(),
|
||
reaction_chosen: "green".to_string(),
|
||
reaction_other: "white".to_string(),
|
||
},
|
||
keybindings: Keybindings::default(),
|
||
notifications: NotificationsConfig::default(),
|
||
};
|
||
|
||
assert_eq!(config.general.timezone, "+05:00");
|
||
assert_eq!(config.colors.incoming_message, "cyan");
|
||
assert_eq!(config.colors.outgoing_message, "blue");
|
||
}
|
||
|
||
/// Test: Парсинг валидных цветов
|
||
#[test]
|
||
fn test_parse_valid_colors() {
|
||
use ratatui::style::Color;
|
||
|
||
let config = Config::default();
|
||
|
||
assert_eq!(config.parse_color("red"), Color::Red);
|
||
assert_eq!(config.parse_color("green"), Color::Green);
|
||
assert_eq!(config.parse_color("blue"), Color::Blue);
|
||
assert_eq!(config.parse_color("yellow"), Color::Yellow);
|
||
assert_eq!(config.parse_color("cyan"), Color::Cyan);
|
||
assert_eq!(config.parse_color("magenta"), Color::Magenta);
|
||
assert_eq!(config.parse_color("white"), Color::White);
|
||
assert_eq!(config.parse_color("black"), Color::Black);
|
||
assert_eq!(config.parse_color("gray"), Color::Gray);
|
||
assert_eq!(config.parse_color("grey"), Color::Gray);
|
||
}
|
||
|
||
/// Test: Парсинг light цветов
|
||
#[test]
|
||
fn test_parse_light_colors() {
|
||
use ratatui::style::Color;
|
||
|
||
let config = Config::default();
|
||
|
||
assert_eq!(config.parse_color("lightred"), Color::LightRed);
|
||
assert_eq!(config.parse_color("lightgreen"), Color::LightGreen);
|
||
assert_eq!(config.parse_color("lightblue"), Color::LightBlue);
|
||
assert_eq!(config.parse_color("lightyellow"), Color::LightYellow);
|
||
assert_eq!(config.parse_color("lightcyan"), Color::LightCyan);
|
||
assert_eq!(config.parse_color("lightmagenta"), Color::LightMagenta);
|
||
}
|
||
|
||
/// Test: Парсинг невалидного цвета использует fallback (White)
|
||
#[test]
|
||
fn test_parse_invalid_color_fallback() {
|
||
use ratatui::style::Color;
|
||
|
||
let config = Config::default();
|
||
|
||
// Невалидные цвета должны возвращать White
|
||
assert_eq!(config.parse_color("invalid_color"), Color::White);
|
||
assert_eq!(config.parse_color(""), Color::White);
|
||
assert_eq!(config.parse_color("purple"), Color::White); // purple не поддерживается
|
||
assert_eq!(config.parse_color("Orange"), Color::White); // orange не поддерживается
|
||
}
|
||
|
||
/// Test: Case-insensitive парсинг цветов
|
||
#[test]
|
||
fn test_parse_color_case_insensitive() {
|
||
use ratatui::style::Color;
|
||
|
||
let config = Config::default();
|
||
|
||
assert_eq!(config.parse_color("RED"), Color::Red);
|
||
assert_eq!(config.parse_color("Green"), Color::Green);
|
||
assert_eq!(config.parse_color("BLUE"), Color::Blue);
|
||
assert_eq!(config.parse_color("YeLLoW"), Color::Yellow);
|
||
}
|
||
|
||
/// Test: Сериализация и десериализация TOML
|
||
#[test]
|
||
fn test_config_toml_serialization() {
|
||
let original_config = Config {
|
||
general: GeneralConfig {
|
||
timezone: "-05:00".to_string(),
|
||
},
|
||
colors: ColorsConfig {
|
||
incoming_message: "cyan".to_string(),
|
||
outgoing_message: "blue".to_string(),
|
||
selected_message: "red".to_string(),
|
||
reaction_chosen: "green".to_string(),
|
||
reaction_other: "white".to_string(),
|
||
},
|
||
keybindings: Keybindings::default(),
|
||
notifications: NotificationsConfig::default(),
|
||
};
|
||
|
||
// Сериализуем в TOML
|
||
let toml_string = toml::to_string(&original_config).expect("Failed to serialize config");
|
||
|
||
// Десериализуем обратно
|
||
let deserialized: Config = toml::from_str(&toml_string).expect("Failed to deserialize config");
|
||
|
||
// Проверяем что всё совпадает
|
||
assert_eq!(deserialized.general.timezone, "-05:00");
|
||
assert_eq!(deserialized.colors.incoming_message, "cyan");
|
||
assert_eq!(deserialized.colors.outgoing_message, "blue");
|
||
assert_eq!(deserialized.colors.selected_message, "red");
|
||
}
|
||
|
||
/// Test: Парсинг TOML с частичными данными использует дефолты
|
||
#[test]
|
||
fn test_config_partial_toml_uses_defaults() {
|
||
// TOML только с timezone, без colors
|
||
let toml_str = r#"
|
||
[general]
|
||
timezone = "+02:00"
|
||
"#;
|
||
|
||
let config: Config = toml::from_str(toml_str).expect("Failed to parse partial TOML");
|
||
|
||
// Timezone должен быть из TOML
|
||
assert_eq!(config.general.timezone, "+02:00");
|
||
|
||
// Colors должны быть дефолтными
|
||
assert_eq!(config.colors.incoming_message, "white");
|
||
assert_eq!(config.colors.outgoing_message, "green");
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod timezone_tests {
|
||
use super::*;
|
||
|
||
/// Test: Различные форматы timezone
|
||
#[test]
|
||
fn test_timezone_formats() {
|
||
let positive = Config {
|
||
general: GeneralConfig {
|
||
timezone: "+03:00".to_string(),
|
||
},
|
||
..Default::default()
|
||
};
|
||
assert_eq!(positive.general.timezone, "+03:00");
|
||
|
||
let negative = Config {
|
||
general: GeneralConfig {
|
||
timezone: "-05:00".to_string(),
|
||
},
|
||
..Default::default()
|
||
};
|
||
assert_eq!(negative.general.timezone, "-05:00");
|
||
|
||
let zero = Config {
|
||
general: GeneralConfig {
|
||
timezone: "+00:00".to_string(),
|
||
},
|
||
..Default::default()
|
||
};
|
||
assert_eq!(zero.general.timezone, "+00:00");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod credentials_tests {
|
||
use super::*;
|
||
use std::env;
|
||
|
||
/// Test: Загрузка credentials из переменных окружения
|
||
#[test]
|
||
fn test_load_credentials_from_env() {
|
||
// Устанавливаем env переменные для теста
|
||
unsafe {
|
||
env::set_var("API_ID", "12345");
|
||
env::set_var("API_HASH", "test_hash_from_env");
|
||
}
|
||
|
||
// Загружаем credentials
|
||
let result = Config::load_credentials();
|
||
|
||
// Проверяем что загрузилось из env
|
||
// Примечание: этот тест может зафейлиться если есть credentials файл,
|
||
// так как он имеет приоритет. Для полноценного тестирования нужно
|
||
// моковать файловую систему или использовать временные директории.
|
||
if result.is_ok() {
|
||
let (api_id, api_hash) = result.unwrap();
|
||
// Может быть либо из файла, либо из env
|
||
assert!(api_id > 0);
|
||
assert!(!api_hash.is_empty());
|
||
}
|
||
|
||
// Очищаем env переменные после теста
|
||
unsafe {
|
||
env::remove_var("API_ID");
|
||
env::remove_var("API_HASH");
|
||
}
|
||
}
|
||
|
||
/// Test: Проверка формата ошибки когда credentials не найдены
|
||
#[test]
|
||
fn test_load_credentials_error_message() {
|
||
// Проверяем есть ли credentials файл в системе
|
||
let has_credentials_file = Config::credentials_path()
|
||
.map(|p| p.exists())
|
||
.unwrap_or(false);
|
||
|
||
// Если есть credentials файл, тест не может проверить ошибку
|
||
if has_credentials_file {
|
||
// Просто проверяем что credentials загружаются
|
||
let result = Config::load_credentials();
|
||
assert!(result.is_ok(), "Credentials file exists but loading failed");
|
||
return;
|
||
}
|
||
|
||
// Временно сохраняем и удаляем env переменные
|
||
let original_api_id = env::var("API_ID").ok();
|
||
let original_api_hash = env::var("API_HASH").ok();
|
||
|
||
unsafe {
|
||
env::remove_var("API_ID");
|
||
env::remove_var("API_HASH");
|
||
}
|
||
|
||
// Пытаемся загрузить credentials без файла и без env
|
||
let result = Config::load_credentials();
|
||
|
||
// Должна быть ошибка
|
||
if result.is_ok() {
|
||
// Возможно env переменные установлены глобально и не удаляются
|
||
// Тест пропускается
|
||
eprintln!("Warning: credentials loaded despite removing env vars");
|
||
} else {
|
||
// Проверяем формат ошибки
|
||
let err_msg = result.unwrap_err();
|
||
assert!(!err_msg.is_empty(), "Error message should not be empty");
|
||
}
|
||
|
||
// Восстанавливаем env переменные
|
||
unsafe {
|
||
if let Some(api_id) = original_api_id {
|
||
env::set_var("API_ID", api_id);
|
||
}
|
||
if let Some(api_hash) = original_api_hash {
|
||
env::set_var("API_HASH", api_hash);
|
||
}
|
||
}
|
||
}
|
||
}
|