focus/crates/focus-tui/tests/app_session_tests.rs

177 lines
6.1 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! App 级会话持久化的测试(临时数据目录,零网络)。
//! App-level session persistence tests (temp data dir, zero network).
use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
use focus_core::model::Message;
use focus_harness::session::SessionStore;
use focus_tui::app::App;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
static DIR_SEQ: AtomicUsize = AtomicUsize::new(0);
fn temp_data_dir(tag: &str) -> PathBuf {
let seq = DIR_SEQ.fetch_add(1, Ordering::SeqCst);
let d = std::env::temp_dir().join(format!(
"focus-tui-app-{}-{}-{}",
tag,
std::process::id(),
seq
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// 构造一个使用临时数据目录的 App避免环境变量在并行测试间竞争
/// Build an App on a temp data dir (avoids env-var races in parallel tests).
fn app_on(dir: &PathBuf) -> App {
let mut app = App::new();
app.store = SessionStore::new(dir);
app
}
#[test]
fn persist_and_reload_session() {
let dir = temp_data_dir("persist");
let mut app = app_on(&dir);
assert_eq!(app.session_id, None);
app.ensure_session();
let sid = app.session_id.clone().unwrap();
app.persist_entries(&[Message::user_text("first")]);
app.persist_entries(&[Message::user_text("second")]);
assert_eq!(app.persisted, 2);
assert_eq!(app.last_entry_id.as_deref(), Some("e2"));
// 另一个 App 实例加载同一会话。
// Another App instance loads the same session.
let mut app2 = app_on(&dir);
app2.load_session(&sid);
assert_eq!(app2.transcript.len(), 2);
assert_eq!(app2.persisted, 2);
let text = match &app2.transcript[1] {
Message::User(u) => u.content[0].as_text().map(|t| t.text.clone()),
_ => None,
};
assert_eq!(text.as_deref(), Some("second"));
// 续写追加消息parentId 链延续到 e3
// Continue: appending continues the parentId chain at e3.
app2.persist_entries(&[Message::user_text("third")]);
assert_eq!(app2.last_entry_id.as_deref(), Some("e3"));
let sessions = app2.store.list_sessions().unwrap();
assert_eq!(sessions, vec![sid.clone()]);
}
#[test]
fn new_session_resets_state() {
let dir = temp_data_dir("new");
let mut app = app_on(&dir);
app.ensure_session();
let sid1 = app.session_id.clone().unwrap();
app.persist_entries(&[Message::user_text("hi")]);
assert_eq!(app.persisted, 1);
app.new_session();
assert_eq!(app.session_id, None);
assert_eq!(app.transcript.len(), 0);
assert_eq!(app.persisted, 0);
assert_eq!(app.last_entry_id, None);
app.ensure_session();
let sid2 = app.session_id.clone().unwrap();
assert_ne!(sid1, sid2);
}
/// 滚轮方向与逐行滚动(回归:此前 ScrollUp/ScrollDown 方向写反,且贴底时
/// 上滚被钳回底部导致滚轮失灵)。
/// Wheel direction and line-by-line scrolling (regression: both wheel
/// directions used to scroll down, and scrolling up at the bottom was clamped
/// back, killing the wheel).
#[test]
fn wheel_scrolls_line_by_line_in_correct_direction() {
let mut app = app_on(&temp_data_dir("wheel"));
// 模拟内容 100 行、视口 10 行的几何。
// Simulate geometry: 100 content lines, 10-line viewport.
app.content_lines = 100;
app.view_height = 10;
// 贴底时向上滚:偏移减 1、取消 follow。
// At the bottom, scrolling up: offset -1, follow cleared.
app.scroll = 90;
app.follow = true;
app.handle_mouse(scroll_event(MouseEventKind::ScrollUp));
assert_eq!(app.scroll, 89, "wheel up must move up one line");
assert!(!app.follow);
// 向下滚:偏移 +1逐行。
// Wheel down: offset +1, line by line.
app.handle_mouse(scroll_event(MouseEventKind::ScrollDown));
assert_eq!(app.scroll, 90);
assert!(app.follow, "reaching the bottom restores follow");
// 从顶部向下滚一次只动一行(不是直接跳到底部)。
// From the top, one notch moves exactly one line (no jump to the bottom).
app.scroll = 0;
app.follow = false;
app.handle_mouse(scroll_event(MouseEventKind::ScrollDown));
assert_eq!(app.scroll, 1);
// 顶部再向上滚不会下溢。
// Scrolling up at the top does not underflow.
app.handle_mouse(scroll_event(MouseEventKind::ScrollUp));
assert_eq!(app.scroll, 0);
}
fn scroll_event(kind: MouseEventKind) -> MouseEvent {
MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
}
}
/// 消息在 MessageEnd 时实时提交进 transcript回归实时提交曾被重构丢失
/// 导致多轮运行中前一轮的思考/工具不可见,要等整个对话结束)。
/// Messages are committed into the transcript in real time at MessageEnd
/// (regression: the real-time commit was lost in a refactor, hiding earlier
/// thinking/tools until the whole conversation ended).
#[test]
fn transcript_grows_in_real_time() {
use focus_core::event::AgentEvent;
use focus_core::model::*;
use focus_tui::state::{real_clock, RunState};
let mut app = app_on(&temp_data_dir("realtime"));
app.run_state = Some(RunState::new(real_clock()));
let asst = AssistantMessage {
content: vec![ContentBlock::text("hello")],
model: "m".into(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
error_message: None,
timestamp: 0,
};
// 流式中:未提交。
// Streaming: not committed yet.
app.on_agent_event(AgentEvent::MessageStart {
message: Message::Assistant(asst.clone()),
});
assert_eq!(app.transcript.len(), 0);
assert!(app.run_state.as_ref().unwrap().partial.is_some());
// MessageEnd提交 + 清 partial。
// MessageEnd: commit + clear the partial.
app.on_agent_event(AgentEvent::MessageEnd {
message: Message::Assistant(asst.clone()),
});
assert_eq!(app.transcript.len(), 1);
assert!(app.run_state.as_ref().unwrap().partial.is_none());
assert!(matches!(&app.transcript[0], Message::Assistant(_)));
}