use super::terminal_capture::{TerminalCapture, TerminalFrame};
use crate::model::app::load_app_from_yaml;
use crate::{App, AppContext};
use std::path::Path;
use std::time::{Duration, Instant};
pub struct BoxMuxTester {
capture: TerminalCapture,
app: Option<App>,
app_context: Option<AppContext>,
pending_events: Vec<SimulatedEvent>,
dimensions: (u16, u16),
config: TestConfig,
}
#[derive(Debug, Clone)]
enum SimulatedEvent {
KeyPress(crossterm::event::KeyEvent),
MouseEvent(crossterm::event::MouseEvent),
}
#[derive(Debug, Clone)]
pub struct TestConfig {
pub terminal_size: (u16, u16),
pub operation_timeout: Duration,
pub capture_history: bool,
pub max_frames: usize,
pub frame_interval: Duration,
}
impl Default for TestConfig {
fn default() -> Self {
Self {
terminal_size: (80, 24),
operation_timeout: Duration::from_secs(5),
capture_history: true,
max_frames: 100,
frame_interval: Duration::from_millis(16), }
}
}
impl BoxMuxTester {
pub fn new() -> Self {
let config = TestConfig::default();
let dimensions = config.terminal_size;
Self {
capture: TerminalCapture::new(dimensions.0, dimensions.1),
app: None,
app_context: None,
pending_events: Vec::new(),
dimensions,
config,
}
}
pub fn with_config(config: TestConfig) -> Self {
let dimensions = config.terminal_size;
Self {
capture: TerminalCapture::new(dimensions.0, dimensions.1),
app: None,
app_context: None,
pending_events: Vec::new(),
dimensions,
config,
}
}
pub fn load_config<P: AsRef<Path>>(&mut self, yaml_path: P) -> Result<&mut Self, TestError> {
let yaml_content = std::fs::read_to_string(yaml_path.as_ref())
.map_err(|e| TestError::ConfigLoad(format!("Failed to read YAML: {}", e)))?;
self.load_config_from_string(&yaml_content)
}
pub fn load_config_from_string(&mut self, yaml_content: &str) -> Result<&mut Self, TestError> {
use std::io::Write;
use tempfile::NamedTempFile;
let mut temp_file = NamedTempFile::new()
.map_err(|e| TestError::ConfigLoad(format!("Failed to create temp file: {}", e)))?;
temp_file
.write_all(yaml_content.as_bytes())
.map_err(|e| TestError::ConfigLoad(format!("Failed to write temp file: {}", e)))?;
let app = load_app_from_yaml(temp_file.path().to_str().unwrap())
.map_err(|e| TestError::ConfigLoad(format!("Failed to parse YAML: {}", e)))?;
let config = crate::Config::default();
let app_context = AppContext::new(app.clone(), config);
self.app = Some(app);
self.app_context = Some(app_context);
Ok(self)
}
pub fn send_key(&mut self, key: crossterm::event::KeyCode) -> Result<&mut Self, TestError> {
self.send_key_with_modifiers(key, crossterm::event::KeyModifiers::empty())
}
pub fn send_key_with_modifiers(
&mut self,
key: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Result<&mut Self, TestError> {
let key_event = crossterm::event::KeyEvent {
code: key,
modifiers,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::empty(),
};
self.pending_events
.push(SimulatedEvent::KeyPress(key_event));
self.process_pending_events()?;
Ok(self)
}
pub fn click_at(&mut self, x: u16, y: u16) -> Result<&mut Self, TestError> {
let mouse_event = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::empty(),
};
self.pending_events
.push(SimulatedEvent::MouseEvent(mouse_event));
self.process_pending_events()?;
Ok(self)
}
pub fn type_text(&mut self, text: &str) -> Result<&mut Self, TestError> {
for ch in text.chars() {
let key = match ch {
'\n' => crossterm::event::KeyCode::Enter,
'\t' => crossterm::event::KeyCode::Tab,
' ' => crossterm::event::KeyCode::Char(' '),
c => crossterm::event::KeyCode::Char(c),
};
self.send_key(key)?;
}
Ok(self)
}
pub fn drag_from_to(
&mut self,
from_x: u16,
from_y: u16,
to_x: u16,
to_y: u16,
) -> Result<&mut Self, TestError> {
self.send_mouse_event(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
from_x,
from_y,
)?;
self.send_mouse_event(
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
to_x,
to_y,
)?;
self.send_mouse_event(
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
to_x,
to_y,
)?;
Ok(self)
}
pub fn send_mouse_event(
&mut self,
kind: crossterm::event::MouseEventKind,
x: u16,
y: u16,
) -> Result<&mut Self, TestError> {
let mouse_event = crossterm::event::MouseEvent {
kind,
column: x,
row: y,
modifiers: crossterm::event::KeyModifiers::empty(),
};
self.pending_events
.push(SimulatedEvent::MouseEvent(mouse_event));
self.process_pending_events()?;
Ok(self)
}
pub fn wait_until<F>(
&mut self,
mut condition: F,
timeout: Duration,
) -> Result<&mut Self, TestError>
where
F: FnMut(&TerminalFrame) -> bool,
{
let start_time = Instant::now();
while start_time.elapsed() < timeout {
if let Ok(frame) = self.wait_for_frame_with_timeout(Duration::from_millis(100)) {
if condition(frame) {
return Ok(self);
}
}
std::thread::sleep(Duration::from_millis(50));
}
Err(TestError::Timeout(format!(
"Condition not met within {:?}",
timeout
)))
}
pub fn assert_interaction_result<F>(
&mut self,
interaction: F,
assertion: Box<dyn Fn(&TerminalFrame) -> Result<(), String>>,
) -> Result<&mut Self, TestError>
where
F: FnOnce(&mut Self) -> Result<&mut Self, TestError>,
{
interaction(self)?;
let frame = self.wait_for_frame()?;
assertion(frame).map_err(|e| TestError::MessageSend(format!("Assertion failed: {}", e)))?;
Ok(self)
}
fn process_pending_events(&mut self) -> Result<(), TestError> {
let events = std::mem::take(&mut self.pending_events);
for event in &events {
match event {
SimulatedEvent::KeyPress(key_event) => {
log::debug!("Simulated key press: {:?}", key_event.code);
self.simulate_key_effect(key_event)?;
}
SimulatedEvent::MouseEvent(mouse_event) => {
log::debug!(
"Simulated mouse event: {:?} at ({}, {})",
mouse_event.kind,
mouse_event.column,
mouse_event.row
);
self.simulate_mouse_effect(mouse_event)?;
}
}
}
Ok(())
}
fn simulate_key_effect(
&mut self,
_key_event: &crossterm::event::KeyEvent,
) -> Result<(), TestError> {
std::thread::sleep(Duration::from_millis(10));
Ok(())
}
fn simulate_mouse_effect(
&mut self,
_mouse_event: &crossterm::event::MouseEvent,
) -> Result<(), TestError> {
std::thread::sleep(Duration::from_millis(10));
Ok(())
}
pub fn wait_for_frame(&mut self) -> Result<&TerminalFrame, TestError> {
self.wait_for_frame_with_timeout(self.config.operation_timeout)
}
pub fn wait_for_frame_with_timeout(
&mut self,
timeout: Duration,
) -> Result<&TerminalFrame, TestError> {
let _start_time = Instant::now();
self.process_pending_events()?;
if let Some(app) = &self.app {
let frame = self.capture.capture_frame(app);
return Ok(frame);
}
Err(TestError::NoApp(
"No app loaded for frame capture".to_string(),
))
}
pub fn capture_animation_sequence(
&mut self,
duration: Duration,
frame_interval: Option<Duration>,
) -> Result<Vec<TerminalFrame>, TestError> {
let interval = frame_interval.unwrap_or(self.config.frame_interval);
let mut frames = Vec::new();
let start_time = Instant::now();
while start_time.elapsed() < duration {
if let Ok(frame) = self.wait_for_frame_with_timeout(interval) {
frames.push(frame.clone());
}
std::thread::sleep(interval);
}
Ok(frames)
}
pub fn current_frame(&self) -> Option<&TerminalFrame> {
self.capture.current_frame()
}
pub fn get_frame_diff_count(
&self,
frame1_index: isize,
frame2_index: isize,
) -> Result<usize, TestError> {
let frame1 = self
.capture
.get_frame(frame1_index)
.ok_or_else(|| TestError::FrameAccess(format!("Frame {} not found", frame1_index)))?;
let frame2 = self
.capture
.get_frame(frame2_index)
.ok_or_else(|| TestError::FrameAccess(format!("Frame {} not found", frame2_index)))?;
let mut diff_count = 0;
for (y, (row1, row2)) in frame1.buffer.iter().zip(frame2.buffer.iter()).enumerate() {
for (x, (cell1, cell2)) in row1.iter().zip(row2.iter()).enumerate() {
if cell1 != cell2 {
diff_count += 1;
}
}
}
Ok(diff_count)
}
pub fn save_snapshot(&self, name: &str) -> Result<(), TestError> {
if let Some(frame) = self.current_frame() {
let snapshot_data = self.serialize_frame(frame);
let filename = format!("snapshots/{}.snapshot", name);
if let Some(parent) = Path::new(&filename).parent() {
std::fs::create_dir_all(parent).map_err(|e| {
TestError::SnapshotSave(format!("Failed to create directory: {}", e))
})?;
}
std::fs::write(&filename, snapshot_data)
.map_err(|e| TestError::SnapshotSave(format!("Failed to save snapshot: {}", e)))?;
Ok(())
} else {
Err(TestError::SnapshotSave(
"No frame available for snapshot".to_string(),
))
}
}
fn serialize_frame(&self, frame: &TerminalFrame) -> String {
let mut output = Vec::new();
output.push(format!("# BoxMux Visual Snapshot"));
output.push(format!(
"# Dimensions: {}x{}",
frame.dimensions.0, frame.dimensions.1
));
output.push(format!(
"# Cursor: ({}, {}) visible={}",
frame.cursor.0, frame.cursor.1, frame.cursor_visible
));
output.push(format!("# Timestamp: {:?}", frame.timestamp));
output.push("".to_string());
for (y, row) in frame.buffer.iter().enumerate() {
let line: String = row.iter().map(|cell| cell.ch).collect();
output.push(format!("{:2}|{}", y, line));
}
output.join("\n")
}
pub fn frame_count(&self) -> usize {
self.capture.frame_count()
}
pub fn clear_frames(&mut self) {
self.capture.clear_frames();
}
pub fn set_dimensions(&mut self, width: u16, height: u16) {
self.dimensions = (width, height);
self.capture.set_dimensions(width, height);
self.config.terminal_size = (width, height);
}
pub fn get_dimensions(&self) -> (u16, u16) {
self.dimensions
}
}
impl std::fmt::Debug for BoxMuxTester {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BoxMuxTester")
.field("dimensions", &self.dimensions)
.field("config", &self.config)
.field("app_loaded", &self.app.is_some())
.field("app_context_loaded", &self.app_context.is_some())
.field("pending_events_count", &self.pending_events.len())
.field("frame_count", &self.capture.frame_count())
.finish()
}
}
#[derive(Debug, Clone)]
pub enum TestError {
ConfigLoad(String),
MessageSend(String),
FrameAccess(String),
SnapshotSave(String),
NoApp(String),
Timeout(String),
}
impl std::fmt::Display for TestError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
TestError::ConfigLoad(msg) => write!(f, "Config load error: {}", msg),
TestError::MessageSend(msg) => write!(f, "Message send error: {}", msg),
TestError::FrameAccess(msg) => write!(f, "Frame access error: {}", msg),
TestError::SnapshotSave(msg) => write!(f, "Snapshot save error: {}", msg),
TestError::NoApp(msg) => write!(f, "No app error: {}", msg),
TestError::Timeout(msg) => write!(f, "Timeout error: {}", msg),
}
}
}
impl std::error::Error for TestError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_boxmux_tester_creation() {
let tester = BoxMuxTester::new();
assert_eq!(tester.dimensions, (80, 24));
assert_eq!(tester.frame_count(), 0);
}
#[test]
fn test_test_config_default() {
let config = TestConfig::default();
assert_eq!(config.terminal_size, (80, 24));
assert_eq!(config.max_frames, 100);
assert!(config.capture_history);
}
}