use ratatui::Terminal;
use crate::error;
use crate::annotation::{with_annotations, AnnotationRegistry, RegionInfo, WidgetType};
use crate::backend::CaptureBackend;
use crate::input::{Event, EventQueue};
use super::assertions::{Assertion, AssertionError, AssertionResult};
use super::snapshot::Snapshot;
pub struct TestHarness {
terminal: Terminal<CaptureBackend>,
events: EventQueue,
annotations: AnnotationRegistry,
frame_count: u64,
}
impl TestHarness {
pub fn new(width: u16, height: u16) -> Self {
let backend = CaptureBackend::new(width, height);
let terminal = Terminal::new(backend).expect("Failed to create terminal");
Self {
terminal,
events: EventQueue::new(),
annotations: AnnotationRegistry::new(),
frame_count: 0,
}
}
pub fn width(&self) -> u16 {
self.terminal.backend().width()
}
pub fn height(&self) -> u16 {
self.terminal.backend().height()
}
pub fn frame_count(&self) -> u64 {
self.frame_count
}
pub fn render<F>(&mut self, f: F) -> error::Result<()>
where
F: FnOnce(&mut ratatui::Frame),
{
self.annotations = with_annotations(|| {
self.terminal.draw(f).expect("Failed to draw");
});
self.frame_count += 1;
Ok(())
}
pub fn screen(&self) -> String {
self.terminal.backend().to_string()
}
pub fn row(&self, y: u16) -> String {
self.terminal.backend().row_content(y)
}
pub fn snapshot(&self) -> Snapshot {
Snapshot::new(self.terminal.backend().snapshot(), self.annotations.clone())
}
pub fn cell_at(&self, x: u16, y: u16) -> Option<&crate::backend::EnhancedCell> {
self.terminal.backend().cell(x, y)
}
pub fn backend(&self) -> &CaptureBackend {
self.terminal.backend()
}
pub fn backend_mut(&mut self) -> &mut CaptureBackend {
self.terminal.backend_mut()
}
pub fn events(&self) -> &EventQueue {
&self.events
}
pub fn events_mut(&mut self) -> &mut EventQueue {
&mut self.events
}
pub fn push_event(&mut self, event: Event) {
self.events.push(event);
}
pub fn pop_event(&mut self) -> Option<Event> {
self.events.pop()
}
pub fn type_str(&mut self, s: &str) {
self.events.type_str(s);
}
pub fn enter(&mut self) {
self.events.enter();
}
pub fn escape(&mut self) {
self.events.escape();
}
pub fn tab(&mut self) {
self.events.tab();
}
pub fn ctrl(&mut self, c: char) {
self.events.ctrl(c);
}
pub fn click(&mut self, x: u16, y: u16) {
self.events.click(x, y);
}
pub fn clear_events(&mut self) {
self.events.clear();
}
pub fn annotations(&self) -> &AnnotationRegistry {
&self.annotations
}
pub fn region_at(&self, x: u16, y: u16) -> Option<&RegionInfo> {
self.annotations.region_at(x, y)
}
pub fn find_by_id(&self, id: &str) -> Vec<&RegionInfo> {
self.annotations.find_by_id(id)
}
pub fn get_by_id(&self, id: &str) -> Option<&RegionInfo> {
self.annotations.get_by_id(id)
}
pub fn find_by_type(&self, widget_type: &WidgetType) -> Vec<&RegionInfo> {
self.annotations.find_by_type(widget_type)
}
pub fn focused(&self) -> Option<&RegionInfo> {
self.annotations.focused_region()
}
pub fn interactive(&self) -> Vec<&RegionInfo> {
self.annotations.interactive_regions()
}
pub fn click_on(&mut self, id: &str) -> bool {
if let Some(region) = self.annotations.get_by_id(id) {
let x = region.area.x + region.area.width / 2;
let y = region.area.y + region.area.height / 2;
self.click(x, y);
true
} else {
false
}
}
pub fn contains(&self, needle: &str) -> bool {
self.terminal.backend().contains_text(needle)
}
pub fn find_text(&self, needle: &str) -> Option<(u16, u16)> {
self.terminal
.backend()
.find_text(needle)
.first()
.map(|p| (p.x, p.y))
}
pub fn find_all_text(&self, needle: &str) -> Vec<(u16, u16)> {
self.terminal
.backend()
.find_text(needle)
.iter()
.map(|p| (p.x, p.y))
.collect()
}
pub fn region_content(&self, x: u16, y: u16, width: u16, height: u16) -> String {
let mut lines = Vec::new();
for row in y..y.saturating_add(height) {
let row_content = self.row(row);
let start = x as usize;
let end = (x + width) as usize;
if start < row_content.len() {
let end = end.min(row_content.len());
lines.push(row_content[start..end].to_string());
}
}
lines.join("\n")
}
pub fn assert_contains(&self, needle: &str) {
if !self.contains(needle) {
panic!(
"Expected screen to contain '{}', but it was not found.\n\nScreen:\n{}",
needle,
self.screen()
);
}
}
pub fn assert_not_contains(&self, needle: &str) {
if self.contains(needle) {
panic!(
"Expected screen to NOT contain '{}', but it was found.\n\nScreen:\n{}",
needle,
self.screen()
);
}
}
pub fn assert_widget_exists(&self, id: &str) {
if self.get_by_id(id).is_none() {
panic!(
"Expected widget with id '{}' to exist, but it was not found.\n\nAnnotations:\n{}",
id,
self.annotations.format_tree()
);
}
}
pub fn assert_widget_not_exists(&self, id: &str) {
if self.get_by_id(id).is_some() {
panic!(
"Expected widget with id '{}' to NOT exist, but it was found.\n\nAnnotations:\n{}",
id,
self.annotations.format_tree()
);
}
}
pub fn assert_focused(&self, id: &str) {
match self.get_by_id(id) {
Some(region) if region.annotation.focused => {}
Some(_) => panic!(
"Expected widget '{}' to be focused, but it is not.\n\nAnnotations:\n{}",
id,
self.annotations.format_tree()
),
None => panic!(
"Expected widget '{}' to be focused, but it doesn't exist.\n\nAnnotations:\n{}",
id,
self.annotations.format_tree()
),
}
}
pub fn assert(&self, assertion: Assertion) -> AssertionResult {
assertion.check(self)
}
pub fn assert_all(&self, assertions: Vec<Assertion>) -> Vec<AssertionResult> {
assertions.into_iter().map(|a| self.assert(a)).collect()
}
pub fn assert_all_ok(&self, assertions: Vec<Assertion>) -> Result<(), AssertionError> {
for assertion in assertions {
self.assert(assertion)?;
}
Ok(())
}
}
impl Default for TestHarness {
fn default() -> Self {
Self::new(80, 24)
}
}
#[cfg(test)]
mod tests;