//! 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, } }