use std::path::{Path, PathBuf};
use std::time::Duration;
use crossterm::event::{
Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use tokio_util::sync::CancellationToken;
use super::App;
use crate::keymap::Key;
use crate::test_support::sample_dir_on_disk;
const STEP_TIMEOUT: Duration = Duration::from_secs(20);
pub(super) struct Harness {
pub app: App,
pub terminal: Terminal<TestBackend>,
root: PathBuf,
_fixture: tempfile::TempDir,
}
impl Harness {
pub async fn scanned() -> Self {
Self::scanned_sized(100, 30).await
}
pub async fn scanned_sized(width: u16, height: u16) -> Self {
let settings = super::UserSettings {
scan_on_startup: false,
..super::UserSettings::default()
};
Self::scanned_with(width, height, settings).await
}
pub async fn snapshot() -> Self {
let fixture = sample_dir_on_disk();
let scanned = dunce::canonicalize(fixture.path()).expect("canonical fixture");
let mut tree = gravityfile_scan::JwalkScanner::new()
.scan(
&gravityfile_core::ScanConfig::new(&scanned),
CancellationToken::new(),
)
.expect("scan the fixture");
let root = PathBuf::from("/gravityfile-test/elsewhere/home");
tree.root_path = root.clone();
let settings = super::UserSettings {
scan_on_startup: false,
..super::UserSettings::default()
};
let mut app = App::with_loaded_settings(
root.clone(),
crate::TuiConfig::default().with_snapshot(PathBuf::from("/saved/home.json"), tree),
Ok(settings),
);
app.search_state.enable_background();
let mut harness = Self {
app,
terminal: Terminal::new(TestBackend::new(100, 30)).expect("test terminal"),
root,
_fixture: fixture,
};
harness.settle().await;
harness
}
pub async fn scanned_with(width: u16, height: u16, settings: super::UserSettings) -> Self {
let fixture = sample_dir_on_disk();
let root = dunce::canonicalize(fixture.path()).expect("canonical fixture");
let mut app = App::with_loaded_settings(
root.clone(),
crate::TuiConfig {
replace_policy: gravityfile_ops::ReplacePolicy::Permanent,
delete_mode: gravityfile_ops::DeleteMode::Permanent,
..crate::TuiConfig::default()
},
Ok(settings),
);
app.search_state.enable_background();
let tree = gravityfile_scan::JwalkScanner::new()
.scan(
&gravityfile_core::ScanConfig::new(&root),
CancellationToken::new(),
)
.expect("scan the fixture");
app.handle_scan_result(super::ScanResult::ScanComplete(Ok(tree)));
let mut harness = Self {
app,
terminal: Terminal::new(TestBackend::new(width, height)).expect("test terminal"),
root,
_fixture: fixture,
};
harness.settle().await;
harness
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn path(&self, relative: &str) -> PathBuf {
self.root.join(relative)
}
pub async fn press(&mut self, keys: &str) {
for key in parse_keys(keys) {
self.event(Event::Key(key)).await;
}
}
pub async fn press_unsettled(&mut self, keys: &str) {
for key in parse_keys(keys) {
let old_view_root = self.app.nav.view_root.clone();
self.app.handle_terminal_event(Event::Key(key));
self.app.after_input(&old_view_root).await;
self.draw();
}
}
pub async fn type_text(&mut self, text: &str) {
for character in text.chars() {
let modifiers = if character.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
self.event(Event::Key(KeyEvent::new(
KeyCode::Char(character),
modifiers,
)))
.await;
}
}
pub async fn click(&mut self, column: u16, row: u16) {
self.mouse(MouseEventKind::Down(MouseButton::Left), column, row)
.await;
}
pub async fn right_click(&mut self, column: u16, row: u16) {
self.mouse(MouseEventKind::Down(MouseButton::Right), column, row)
.await;
}
pub async fn scroll_down(&mut self, column: u16, row: u16) {
self.mouse(MouseEventKind::ScrollDown, column, row).await;
}
async fn mouse(&mut self, kind: MouseEventKind, column: u16, row: u16) {
self.event(Event::Mouse(MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
}))
.await;
}
async fn event(&mut self, event: Event) {
let old_view_root = self.app.nav.view_root.clone();
self.app.handle_terminal_event(event);
self.app.after_input(&old_view_root).await;
self.settle().await;
}
pub async fn settle(&mut self) {
loop {
if self.app.has_background() {
let old_view_root = self.app.nav.view_root.clone();
let background = tokio::time::timeout(STEP_TIMEOUT, self.app.next_background())
.await
.expect("a background task stopped making progress");
self.app.handle_background(background);
self.app.after_input(&old_view_root).await;
} else if self.app.search_state.has_background_work() {
tokio::time::sleep(Duration::from_millis(5)).await;
self.app.search_state.poll_background();
} else {
self.draw();
return;
}
}
}
pub fn tree_shows(&self, name: &str) -> bool {
self.screen().lines().any(|line| {
line.split_once(name)
.is_some_and(|(before, _)| before.contains("├─") || before.contains("└─"))
})
}
pub fn draw(&mut self) -> String {
self.terminal
.draw(|frame| self.app.draw(frame))
.expect("draw to the test terminal");
self.app.adopt_rendered_offsets();
self.screen()
}
pub fn screen(&self) -> String {
let buffer = self.terminal.backend().buffer();
let width = buffer.area.width as usize;
buffer
.content()
.chunks(width)
.map(|row| {
let line: String = row.iter().map(|cell| cell.symbol()).collect();
line.trim_end().to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn selected(&self) -> Option<PathBuf> {
self.app.get_selected_path()
}
pub async fn select(&mut self, name: &str) {
self.press("g").await;
for _ in 0..100 {
if self
.selected()
.is_some_and(|path| path.file_name().is_some_and(|n| n == name))
{
return;
}
self.press("j").await;
}
panic!("{name:?} cannot be selected:\n{}", self.screen());
}
pub fn row_of(&self, needle: &str) -> u16 {
self.screen()
.lines()
.position(|line| line.contains(needle))
.unwrap_or_else(|| panic!("{needle:?} is not on screen:\n{}", self.screen()))
as u16
}
}
fn parse_keys(keys: &str) -> Vec<KeyEvent> {
let mut events = Vec::new();
let mut rest = keys;
while let Some(first) = rest.chars().next() {
let length = if first == '<' && rest.len() > 1 {
rest.find('>').expect("unclosed <key>") + 1
} else {
first.len_utf8()
};
let key = Key::parse(&rest[..length]).unwrap_or_else(|problem| panic!("{problem}"));
events.push(key.event());
rest = &rest[length..];
}
events
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_notation_covers_characters_specials_and_control() {
let keys = parse_keys("jG<enter><c-g><space>");
assert_eq!(
keys[0],
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)
);
assert_eq!(
keys[1],
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)
);
assert_eq!(keys[2].code, KeyCode::Enter);
assert_eq!(
keys[3],
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)
);
assert_eq!(keys[4].code, KeyCode::Char(' '));
}
}