use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use futures::stream;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use kimun_core::nfs::VaultPath;
use kimun_notes::app::events::EventHandler;
use kimun_notes::app::{App, run_app};
use kimun_notes::app_screen::ScreenKind;
use kimun_notes::app_screen::editor::EditorScreen;
use kimun_notes::app_screen::preferences::PreferencesScreen;
use kimun_notes::components::events::AppEvent;
use kimun_notes::keys::action_shortcuts::ActionShortcuts;
use kimun_notes::keys::key_event_to_combo;
use kimun_notes::settings::{AppSettings, SharedSettings};
mod common;
use common::{buffer_text, input, press, write_config};
struct Fixture {
app: App,
settings: SharedSettings,
workspace: PathBuf,
config_path: PathBuf,
}
async fn fixture(name: &str) -> Fixture {
let workspace = tempfile::Builder::new()
.prefix(&format!("kimun_loop_{name}_ws_"))
.tempdir()
.unwrap()
.keep();
let config_dir = tempfile::Builder::new()
.prefix(&format!("kimun_loop_{name}_cfg_"))
.tempdir()
.unwrap()
.keep();
let config_path = config_dir.join("config.toml");
write_config(&config_path, &workspace);
let settings: SharedSettings = Arc::new(RwLock::new(
AppSettings::load_from_file(config_path.clone()).expect("settings load"),
));
let app = App::from_settings(settings.clone()).await;
app.vault
.as_ref()
.expect("the config names a workspace")
.validate_and_init()
.await
.expect("index init");
Fixture {
app,
settings,
workspace,
config_path,
}
}
fn terminal() -> Terminal<TestBackend> {
Terminal::new(TestBackend::new(120, 40)).unwrap()
}
fn kind(app: &App) -> ScreenKind {
app.current_screen.get_kind()
}
#[tokio::test(flavor = "multi_thread")]
async fn an_exhausted_input_source_quits_the_loop() {
let mut fx = fixture("exhausted").await;
fx.app.current_screen = Box::new(PreferencesScreen::new(fx.settings.clone()));
let mut events = EventHandler::from_input(stream::empty());
let mut term = terminal();
tokio::time::timeout(
std::time::Duration::from_secs(10),
run_app(&mut term, &mut fx.app, &mut events),
)
.await
.expect("the loop must return when input ends")
.expect("the loop returns Ok when input ends");
assert_eq!(kind(&fx.app), ScreenKind::Preferences);
}
#[tokio::test(flavor = "multi_thread")]
async fn the_quit_shortcut_fires_before_the_screen_and_on_exit_saves_the_note() {
let mut fx = fixture("quit").await;
let vault = fx.app.vault.clone().unwrap();
let note = VaultPath::note_path_from("loop-note");
vault.create_note(¬e, "hello").await.unwrap();
fx.app.current_screen = Box::new(EditorScreen::new(vault, note.clone(), fx.settings.clone()));
let quit = press(KeyCode::Char('q'), KeyModifiers::CONTROL);
let combo = key_event_to_combo(&quit).unwrap();
assert_eq!(
fx.settings.read().unwrap().key_bindings.get_action(&combo),
Some(ActionShortcuts::Quit)
);
let mut events = EventHandler::from_input(stream::iter([
input(press(KeyCode::Char('x'), KeyModifiers::NONE)),
input(quit),
input(press(KeyCode::Char('y'), KeyModifiers::NONE)),
]));
let mut term = terminal();
run_app(&mut term, &mut fx.app, &mut events).await.unwrap();
let on_disk = std::fs::read_to_string(note.to_pathbuf(&fx.workspace)).unwrap();
assert!(
on_disk.contains('x') && on_disk.len() == "hello".len() + 1,
"the key reached the editor and on_exit saved it: {on_disk:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn the_preferences_shortcut_switches_screens_before_the_editor_sees_it() {
let mut fx = fixture("prefs").await;
let vault = fx.app.vault.clone().unwrap();
let note = VaultPath::note_path_from("prefs-note");
vault.create_note(¬e, "hello").await.unwrap();
fx.app.current_screen = Box::new(EditorScreen::new(vault, note.clone(), fx.settings.clone()));
let prefs = press(KeyCode::Char(','), KeyModifiers::CONTROL);
let combo = key_event_to_combo(&prefs).unwrap();
assert_eq!(
fx.settings.read().unwrap().key_bindings.get_action(&combo),
Some(ActionShortcuts::OpenPreferences)
);
let gen_before = fx.app.screen_generation;
let mut events = EventHandler::from_input(stream::iter([
input(prefs),
input(press(KeyCode::Char('y'), KeyModifiers::NONE)),
]));
let mut term = terminal();
run_app(&mut term, &mut fx.app, &mut events).await.unwrap();
assert_eq!(kind(&fx.app), ScreenKind::Preferences);
assert_eq!(
fx.app.screen_generation,
gen_before + 1,
"one swap: editor → preferences"
);
let on_disk = std::fs::read_to_string(note.to_pathbuf(&fx.workspace)).unwrap();
assert_eq!(
on_disk, "hello",
"the shortcut never reached the editor; on_exit saved an unchanged note"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_vault_conflict_clears_the_workspace_and_opens_preferences_with_the_error() {
let mut fx = fixture("conflict").await;
fx.app.current_screen = Box::new(PreferencesScreen::new(fx.settings.clone()));
let mut events = EventHandler::from_input(stream::empty());
events
.app_sender()
.send(AppEvent::VaultConflict("case clash: a vs A".into()))
.unwrap();
let mut term = terminal();
let gen_before = fx.app.screen_generation;
run_app(&mut term, &mut fx.app, &mut events).await.unwrap();
assert_eq!(
fx.app.screen_generation,
gen_before + 1,
"VaultConflict swapped the screen exactly once"
);
assert!(fx.app.vault.is_none(), "the unusable vault is dropped");
assert_eq!(kind(&fx.app), ScreenKind::Preferences);
assert!(
fx.settings
.read()
.unwrap()
.current_workspace_name()
.is_none(),
"the workspace entry is cleared in memory"
);
let reloaded = AppSettings::load_from_file(fx.config_path.clone()).unwrap();
assert!(
reloaded.current_workspace_name().is_none(),
"…and the cleared config was saved to disk"
);
assert!(
buffer_text(&term).contains("case clash: a vs A"),
"the last frame shows the error overlay"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unhandled_note_path_switches_to_the_editor() {
let mut fx = fixture("route").await;
let vault = fx.app.vault.clone().unwrap();
let note = VaultPath::note_path_from("routed");
vault.create_note(¬e, "routed").await.unwrap();
assert_eq!(kind(&fx.app), ScreenKind::Start);
let mut events = EventHandler::from_input(stream::empty());
events.app_sender().send(AppEvent::open(note)).unwrap();
let mut term = terminal();
run_app(&mut term, &mut fx.app, &mut events).await.unwrap();
assert_eq!(kind(&fx.app), ScreenKind::Editor);
}