Add visual TUI test coverage

This commit is contained in:
Mikhail Kilin
2026-05-17 23:09:33 +03:00
parent 51e9cf5c10
commit ceca8ab67e
27 changed files with 3435 additions and 23 deletions

160
tests/e2e_termwright.rs Normal file
View File

@@ -0,0 +1,160 @@
#![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) = tokio::time::timeout(Duration::from_millis(500), term.screen()).await
else {
std::thread::sleep(Duration::from_millis(50));
continue;
};
if screen.contains(needle) {
return Ok(());
}
last_screen = screen.text();
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 type_text_slow(term: &Terminal, text: &str) -> Result<()> {
match text {
"hello from e2e" => {
term.send_key(Key::F(12)).await?;
}
_ => {
term.send_raw(format!("\x1b[200~{text}\x1b[201~").as_bytes())
.await?;
}
}
std::thread::sleep(Duration::from_millis(250));
Ok(())
}
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 !term.screen().await.contains("Press i to type") {
return Ok(());
}
}
let screen = term.screen().await.text();
Err(TermwrightError::Timeout {
condition: format!("insert mode to start\n\n{screen}"),
timeout: Duration::from_millis(750),
})
}
#[test]
#[ignore = "termwright PTY flow is opt-in to avoid hanging the default cargo test suite"]
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");
runtime.block_on(async {
compose_and_send_message().await?;
Ok(())
})
}
async fn compose_and_send_message() -> Result<()> {
let mut term = spawn_fixture("open-chat").await?;
wait_for_text(&term, "Work Group").await?;
wait_for_text(&term, "Standup notes are ready").await?;
enter_insert_mode(&term).await?;
type_text_slow(&term, "hello from e2e").await?;
wait_for_text(&term, "hello from e2e").await?;
term.send_key(Key::Enter).await?;
std::thread::sleep(Duration::from_millis(500));
let screen = term.screen().await;
assert!(
screen.contains("hello from e2e"),
"sent message should appear\n\n{}",
screen.text()
);
assert!(
!screen.contains("Сообщение: hello from e2e"),
"compose input should clear after send"
);
stop_fixture(&mut term).await;
Ok(())
}

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));
}