use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use rgx::app::{App, OverlayState};
use rgx::engine::{EngineFlags, EngineKind};
use rgx::grex_integration::{generate, GrexOptions};
use rgx::input::{key_to_action, Action};
use rgx::ui;
use rgx::ui::grex_overlay::GrexOverlayState;
fn new_test_app() -> App {
App::new(EngineKind::RustRegex, EngineFlags::default())
}
fn new_test_terminal() -> Terminal<TestBackend> {
Terminal::new(TestBackend::new(80, 24)).unwrap()
}
#[test]
fn ctrl_x_maps_to_open_grex() {
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
assert_eq!(key_to_action(key), Action::OpenGrex);
}
#[test]
fn open_grex_action_opens_overlay() {
let mut app = new_test_app();
assert!(app.overlay.grex.is_none());
app.handle_action(Action::OpenGrex, 10_000);
assert!(app.overlay.grex.is_some());
}
#[test]
fn grex_overlay_renders_empty_state_without_panic() {
let mut terminal = new_test_terminal();
let state = GrexOverlayState::default();
terminal
.draw(|frame| {
let area = frame.area();
ui::grex_overlay::render(frame, area, &state);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = buffer
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<Vec<_>>()
.join("");
assert!(
rendered.contains("Enter one example per line"),
"empty placeholder missing: {rendered}"
);
assert!(
rendered.contains("(none yet)"),
"pattern placeholder missing"
);
assert!(rendered.contains("[D]igit"), "digit flag label missing");
assert!(rendered.contains("[A]nchors"), "anchors flag label missing");
}
#[test]
fn grex_overlay_renders_populated_state() {
let mut terminal = new_test_terminal();
let mut state = GrexOverlayState::default();
state.editor.insert_str("foo\nbar\nbaz");
state.generated_pattern = Some("^(?:foo|bar|baz)$".to_string());
terminal
.draw(|frame| {
let area = frame.area();
ui::grex_overlay::render(frame, area, &state);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = buffer
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<Vec<_>>()
.join("");
assert!(rendered.contains("foo"), "example line missing");
assert!(
rendered.contains("^(?:foo|bar|baz)$"),
"pattern preview missing"
);
assert!(!rendered.contains("(none yet)"), "empty placeholder leaked");
}
fn press(key: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(key, mods)
}
#[test]
fn tab_with_generated_pattern_loads_into_regex_editor_and_closes_overlay() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
if let Some(overlay) = app.overlay.grex.as_mut() {
overlay.generated_pattern = Some("^hello$".to_string());
}
app.dispatch_grex_overlay_key(press(KeyCode::Tab, KeyModifiers::NONE));
assert!(app.overlay.grex.is_none());
assert_eq!(app.regex_editor.content(), "^hello$");
}
#[test]
fn tab_without_pattern_is_noop() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
app.dispatch_grex_overlay_key(press(KeyCode::Tab, KeyModifiers::NONE));
assert!(app.overlay.grex.is_some());
}
#[test]
fn esc_closes_grex_overlay_without_loading() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
if let Some(overlay) = app.overlay.grex.as_mut() {
overlay.generated_pattern = Some("should not be loaded".to_string());
}
let prior_pattern = app.regex_editor.content().to_string();
app.dispatch_grex_overlay_key(press(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.overlay.grex.is_none());
assert_eq!(app.regex_editor.content(), prior_pattern);
}
#[test]
fn alt_d_toggles_digit_flag() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
let before = app.overlay.grex.as_ref().unwrap().options.digit;
app.dispatch_grex_overlay_key(press(KeyCode::Char('d'), KeyModifiers::ALT));
let after = app.overlay.grex.as_ref().unwrap().options.digit;
assert_ne!(before, after);
}
#[test]
fn alt_a_toggles_anchors_flag() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
let before = app.overlay.grex.as_ref().unwrap().options.anchors;
app.dispatch_grex_overlay_key(press(KeyCode::Char('a'), KeyModifiers::ALT));
let after = app.overlay.grex.as_ref().unwrap().options.anchors;
assert_ne!(before, after);
}
#[test]
fn alt_c_toggles_case_insensitive_flag() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
let before = app.overlay.grex.as_ref().unwrap().options.case_insensitive;
app.dispatch_grex_overlay_key(press(KeyCode::Char('c'), KeyModifiers::ALT));
let after = app.overlay.grex.as_ref().unwrap().options.case_insensitive;
assert_ne!(before, after);
}
#[test]
fn plain_characters_append_to_overlay_editor() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
for ch in ['h', 'i'] {
app.dispatch_grex_overlay_key(press(KeyCode::Char(ch), KeyModifiers::NONE));
}
let content = app
.overlay
.grex
.as_ref()
.unwrap()
.editor
.content()
.to_string();
assert_eq!(content, "hi");
}
#[test]
fn enter_inserts_newline_in_overlay_editor() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
for ch in ['a', 'b'] {
app.dispatch_grex_overlay_key(press(KeyCode::Char(ch), KeyModifiers::NONE));
}
app.dispatch_grex_overlay_key(press(KeyCode::Enter, KeyModifiers::NONE));
app.dispatch_grex_overlay_key(press(KeyCode::Char('c'), KeyModifiers::NONE));
let content = app
.overlay
.grex
.as_ref()
.unwrap()
.editor
.content()
.to_string();
assert_eq!(content, "ab\nc");
}
#[test]
fn editing_sets_debounce_deadline() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
assert!(app
.overlay
.grex
.as_ref()
.unwrap()
.debounce_deadline
.is_none());
app.dispatch_grex_overlay_key(press(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(app
.overlay
.grex
.as_ref()
.unwrap()
.debounce_deadline
.is_some());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn grex_roundtrip_full_flow_loads_valid_regex() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
for line in ["foo", "bar", "baz"] {
for ch in line.chars() {
app.dispatch_grex_overlay_key(press(KeyCode::Char(ch), KeyModifiers::NONE));
}
app.dispatch_grex_overlay_key(press(KeyCode::Enter, KeyModifiers::NONE));
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
app.maybe_run_grex_generation();
for _ in 0..20 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
app.drain_grex_results();
if app
.overlay
.grex
.as_ref()
.and_then(|o| o.generated_pattern.as_deref())
.is_some()
{
break;
}
}
app.dispatch_grex_overlay_key(press(KeyCode::Tab, KeyModifiers::NONE));
assert!(app.overlay.grex.is_none(), "overlay should close on Tab");
let content = app.regex_editor.content().to_string();
assert!(!content.is_empty(), "regex editor should be populated");
let re = regex::Regex::new(&content).expect("grex output must compile");
assert!(re.is_match("foo"), "pattern should match 'foo': {content}");
assert!(re.is_match("bar"), "pattern should match 'bar': {content}");
assert!(re.is_match("baz"), "pattern should match 'baz': {content}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn typing_then_tick_produces_generated_pattern() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
for line in ["foo", "bar", "baz"] {
for ch in line.chars() {
app.dispatch_grex_overlay_key(press(KeyCode::Char(ch), KeyModifiers::NONE));
}
app.dispatch_grex_overlay_key(press(KeyCode::Enter, KeyModifiers::NONE));
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
app.maybe_run_grex_generation();
for _ in 0..20 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
app.drain_grex_results();
if app
.overlay
.grex
.as_ref()
.and_then(|o| o.generated_pattern.as_deref())
.is_some()
{
break;
}
}
let overlay = app.overlay.grex.as_ref().unwrap();
let pattern = overlay
.generated_pattern
.as_deref()
.expect("generation should have completed");
assert!(!pattern.is_empty());
}
#[test]
fn stale_generation_results_are_dropped() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
if let Some(overlay) = app.overlay.grex.as_mut() {
overlay.generation_counter = 10;
}
app.grex_result_tx
.send((5, "stale pattern".to_string()))
.unwrap();
app.drain_grex_results();
let overlay = app.overlay.grex.as_ref().unwrap();
assert_ne!(overlay.generated_pattern.as_deref(), Some("stale pattern"));
}
#[test]
fn current_generation_results_are_applied() {
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
if let Some(overlay) = app.overlay.grex.as_mut() {
overlay.generation_counter = 7;
}
app.grex_result_tx
.send((7, "current pattern".to_string()))
.unwrap();
app.drain_grex_results();
let overlay = app.overlay.grex.as_ref().unwrap();
assert_eq!(
overlay.generated_pattern.as_deref(),
Some("current pattern")
);
}
#[test]
fn ui_render_routes_to_grex_overlay_when_open() {
let mut terminal = new_test_terminal();
let mut app = new_test_app();
app.handle_action(Action::OpenGrex, 10_000);
terminal.draw(|frame| ui::render(frame, &app)).unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = buffer
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<Vec<_>>()
.join("");
assert!(
rendered.contains("Generate Regex from Examples"),
"grex overlay not rendered by ui::render"
);
}
#[test]
fn overlay_state_default_has_no_grex_overlay() {
let overlay = OverlayState::default();
assert!(overlay.grex.is_none());
}
#[test]
fn default_options_match_spec_defaults() {
let opts = GrexOptions::default();
assert!(opts.digit);
assert!(opts.anchors);
assert!(!opts.case_insensitive);
}
#[test]
fn empty_input_returns_empty_string() {
let result = generate(&[], GrexOptions::default());
assert_eq!(result, "");
}
#[test]
fn single_example_with_defaults_is_anchored_literal() {
let examples = vec!["hello".to_string()];
let result = generate(&examples, GrexOptions::default());
assert!(result.starts_with('^'), "expected leading ^ in {result}");
assert!(result.ends_with('$'), "expected trailing $ in {result}");
assert!(result.contains("hello"), "expected literal in {result}");
}
#[test]
fn digit_flag_generates_digit_class() {
let examples = vec!["a1".to_string(), "b22".to_string(), "c333".to_string()];
let result = generate(
&examples,
GrexOptions {
digit: true,
anchors: true,
case_insensitive: false,
},
);
assert!(result.contains(r"\d"), "expected \\d in {result}");
}
#[test]
fn anchors_off_produces_unanchored_pattern() {
let examples = vec!["hello".to_string()];
let result = generate(
&examples,
GrexOptions {
digit: false,
anchors: false,
case_insensitive: false,
},
);
assert!(
!result.starts_with('^'),
"expected no leading ^ in {result}"
);
assert!(!result.ends_with('$'), "expected no trailing $ in {result}");
}
#[test]
fn case_insensitive_flag_adds_case_modifier() {
let examples = vec![
"Hello".to_string(),
"HELLO".to_string(),
"hello".to_string(),
];
let result = generate(
&examples,
GrexOptions {
digit: false,
anchors: true,
case_insensitive: true,
},
);
assert!(result.contains("(?i)"), "expected (?i) in {result}");
}