use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use crate::engine::types::ExecutionEvent;
use crate::parser::ast::{Statement, WorkflowDecl};
use crate::state::types::HarnessEvent;
use super::qa_protocol::{AnswerValue, QOutFile, QuestionType};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepVisualStatus {
Pending,
InProgress,
Completed,
Failed,
Paused,
}
impl StepVisualStatus {
pub fn color(&self) -> ratatui::style::Color {
use ratatui::style::Color;
match self {
StepVisualStatus::Pending => Color::DarkGray,
StepVisualStatus::InProgress => Color::Yellow,
StepVisualStatus::Completed => Color::Green,
StepVisualStatus::Failed => Color::Red,
StepVisualStatus::Paused => Color::Yellow,
}
}
pub fn icon(&self, spinner_frame: usize) -> &'static str {
match self {
StepVisualStatus::Pending => "\u{25CB}", StepVisualStatus::InProgress => SPINNER_FRAMES[spinner_frame % SPINNER_FRAMES.len()],
StepVisualStatus::Completed => "\u{2713}", StepVisualStatus::Failed => "\u{2717}", StepVisualStatus::Paused => "\u{2016}", }
}
}
#[derive(Debug, Clone)]
pub struct VisualStep {
pub name: String,
pub path: Vec<String>,
pub status: StepVisualStatus,
pub depth: usize,
pub is_par_and_group: bool,
pub is_join: bool,
}
impl VisualStep {
fn new(name: String, parent_path: &[String], segment: String, depth: usize) -> Self {
let mut path = parent_path.to_vec();
path.push(segment);
VisualStep {
name,
path,
status: StepVisualStatus::Pending,
depth,
is_par_and_group: false,
is_join: false,
}
}
}
#[derive(Default)]
pub struct QAState {
pub current_qout: Option<QOutFile>,
pub current_seq: u32,
pub pending_answers: HashMap<String, AnswerValue>,
pub waiting: bool,
pub selected_tab: usize,
pub show_review: bool,
pub widget_cursor: usize,
pub text_buffer: String,
pub text_cursor: usize,
pub toggled: HashSet<usize>,
}
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(byte_pos, _)| byte_pos)
.unwrap_or(s.len())
}
fn word_start_left(text: &str, pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if pos == 0 {
return 0;
}
let mut index = pos.min(chars.len());
while index > 0 && chars[index - 1].is_whitespace() {
index -= 1;
}
while index > 0 && !chars[index - 1].is_whitespace() {
index -= 1;
}
index
}
fn word_end_right(text: &str, pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if pos >= len {
return len;
}
let mut index = pos;
while index < len && !chars[index].is_whitespace() {
index += 1;
}
while index < len && chars[index].is_whitespace() {
index += 1;
}
index
}
impl QAState {
pub fn reset_widget_for_tab(&mut self) {
self.widget_cursor = 0;
self.text_buffer.clear();
self.text_cursor = 0;
self.toggled.clear();
if let Some(ref qout) = self.current_qout
&& let Some(question) = qout.questions.get(self.selected_tab)
&& let Some(answer) = self.pending_answers.get(&question.id)
{
match answer {
AnswerValue::Text(text) => {
self.text_buffer = text.clone();
self.text_cursor = self.text_buffer.chars().count();
}
AnswerValue::Boolean(val) => {
self.widget_cursor = if *val { 0 } else { 1 };
}
AnswerValue::MultiSelect(selected) => {
if let Some(ref options) = question.options {
for (i, opt) in options.iter().enumerate() {
if selected.contains(opt) {
self.toggled.insert(i);
}
}
}
}
}
if question.question_type == QuestionType::ChooseOne
&& let AnswerValue::Text(text) = answer
&& let Some(options) = &question.options
&& let Some(idx) = options.iter().position(|o| o == text)
{
self.widget_cursor = idx;
}
}
}
pub fn save_widget_state_for_tab(&mut self) {
let question = self
.current_qout
.as_ref()
.and_then(|q| q.questions.get(self.selected_tab))
.cloned();
if let Some(question) = question {
match question.question_type {
QuestionType::FreeWrite if !self.text_buffer.is_empty() => {
self.pending_answers
.insert(question.id, AnswerValue::Text(self.text_buffer.clone()));
}
QuestionType::ChooseMany => {
if let Some(ref options) = question.options {
let mut indices: Vec<usize> = self.toggled.iter().copied().collect();
indices.sort_unstable();
let selected: Vec<String> = indices
.iter()
.filter_map(|&i| options.get(i).cloned())
.collect();
if !selected.is_empty() || self.pending_answers.contains_key(&question.id) {
self.pending_answers
.insert(question.id, AnswerValue::MultiSelect(selected));
}
}
}
_ => {}
}
}
}
pub fn all_answered(&self) -> bool {
if let Some(ref qout) = self.current_qout {
qout.questions
.iter()
.all(|q| self.pending_answers.contains_key(&q.id))
} else {
false
}
}
}
#[derive(Debug)]
pub enum AppMessage {
EngineEvent(ExecutionEvent),
HarnessEvent(HarnessEvent),
KeyPress(crossterm::event::KeyEvent),
Tick,
EngineFinished,
QuestionsDetected(u32, QOutFile),
QAAnswer {
question_id: String,
answer: AnswerValue,
},
QASelectTab(usize),
QAShowReview,
QASubmit,
QABackToEdit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppMode {
Running,
Paused,
Completed,
Failed,
Inspecting,
CommandPalette,
QuestionInput,
}
pub struct App {
pub mode: AppMode,
pub previous_mode: AppMode,
pub steps: Vec<VisualStep>,
pub selected_index: usize,
pub workflow_name: String,
pub start_time: Instant,
pub elapsed_secs: u64,
pub spinner_frame: usize,
pub should_quit: bool,
pub quit_requested: bool,
pub events: Vec<ExecutionEvent>,
pub harness_events: Vec<HarnessEvent>,
path_index: HashMap<String, usize>,
pub palette: super::command_palette::CommandPaletteState,
pub pause_flag: Arc<AtomicBool>,
pub pause_notify: Arc<tokio::sync::Notify>,
pub qa_state: QAState,
pub qa_submit_pending: bool,
qa_submit_data: Option<(u32, super::qa_protocol::QAnswersFile)>,
}
pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
impl App {
pub fn new(
workflows: &[WorkflowDecl],
workflow_name: &str,
pause_flag: Arc<AtomicBool>,
pause_notify: Arc<tokio::sync::Notify>,
) -> Self {
let steps = build_display_steps(workflows, workflow_name);
let mut path_index = HashMap::new();
for (i, step) in steps.iter().enumerate() {
path_index.insert(step.path.join("/"), i);
}
App {
mode: AppMode::Running,
previous_mode: AppMode::Running,
steps,
selected_index: 0,
workflow_name: workflow_name.to_string(),
start_time: Instant::now(),
elapsed_secs: 0,
spinner_frame: 0,
should_quit: false,
quit_requested: false,
events: Vec::new(),
harness_events: Vec::new(),
path_index,
palette: super::command_palette::CommandPaletteState::new(),
pause_flag,
pause_notify,
qa_state: QAState::default(),
qa_submit_pending: false,
qa_submit_data: None,
}
}
pub fn update(&mut self, msg: AppMessage) {
match msg {
AppMessage::EngineEvent(event) => self.handle_engine_event(event),
AppMessage::HarnessEvent(event) => self.harness_events.push(event),
AppMessage::KeyPress(key) => self.handle_key(key),
AppMessage::Tick => self.handle_tick(),
AppMessage::EngineFinished => {
if self.mode == AppMode::Running {
self.mode = AppMode::Completed;
}
}
AppMessage::QuestionsDetected(seq, qout) => {
self.qa_state.current_qout = Some(qout);
self.qa_state.current_seq = seq;
self.qa_state.pending_answers.clear();
self.qa_state.selected_tab = 0;
self.qa_state.show_review = false;
self.qa_state.waiting = false;
self.qa_state.reset_widget_for_tab();
self.previous_mode = self.mode.clone();
self.mode = AppMode::QuestionInput;
}
AppMessage::QAAnswer {
question_id,
answer,
} => {
self.qa_state.pending_answers.insert(question_id, answer);
}
AppMessage::QASelectTab(idx) => {
if let Some(ref qout) = self.qa_state.current_qout
&& idx < qout.questions.len()
{
self.qa_state.save_widget_state_for_tab();
self.qa_state.selected_tab = idx;
self.qa_state.show_review = false;
self.qa_state.reset_widget_for_tab();
}
}
AppMessage::QAShowReview => {
self.qa_state.show_review = true;
}
AppMessage::QASubmit => {
if let Some(ref qout) = self.qa_state.current_qout {
let answers: Vec<super::qa_protocol::Answer> = qout
.questions
.iter()
.filter_map(|q| {
self.qa_state
.pending_answers
.get(&q.id)
.map(|a| super::qa_protocol::question_to_answer(q, a.clone()))
})
.collect();
let answers_file = super::qa_protocol::QAnswersFile {
seq: self.qa_state.current_seq,
answers,
questions: qout.questions.clone(),
};
self.qa_submit_data = Some((self.qa_state.current_seq, answers_file));
}
self.qa_submit_pending = true;
self.qa_state.current_qout = None;
self.qa_state.pending_answers.clear();
self.qa_state.show_review = false;
self.qa_state.waiting = true;
self.mode = self.previous_mode.clone();
}
AppMessage::QABackToEdit => {
self.qa_state.show_review = false;
self.qa_state.reset_widget_for_tab();
}
}
}
fn handle_engine_event(&mut self, event: ExecutionEvent) {
match &event {
ExecutionEvent::StepStarted { stepPath, .. } => {
self.update_step_status(stepPath, StepVisualStatus::InProgress);
}
ExecutionEvent::StepCompleted { stepPath, .. } => {
self.update_step_status(stepPath, StepVisualStatus::Completed);
}
ExecutionEvent::StepFailed { stepPath, .. } => {
self.update_step_status(stepPath, StepVisualStatus::Failed);
}
ExecutionEvent::BranchStarted { branchPath, .. } => {
self.update_step_status(branchPath, StepVisualStatus::InProgress);
}
ExecutionEvent::BranchCompleted { branchPath, .. } => {
self.update_step_status(branchPath, StepVisualStatus::Completed);
}
ExecutionEvent::BranchFailed { branchPath, .. } => {
self.update_step_status(branchPath, StepVisualStatus::Failed);
}
ExecutionEvent::JoinStarted { joinWorkflow, .. } => {
for step in &mut self.steps {
if step.is_join
&& step.path.last().map(|s| s.as_str())
== Some(&format!("join:{}", joinWorkflow))
{
step.status = StepVisualStatus::InProgress;
break;
}
}
}
ExecutionEvent::RunCompleted { .. } => {
self.mode = AppMode::Completed;
self.qa_state.waiting = false;
if self.quit_requested {
self.should_quit = true;
}
}
ExecutionEvent::RunFailed { .. } => {
self.mode = AppMode::Failed;
self.qa_state.waiting = false;
if self.quit_requested {
self.should_quit = true;
}
}
ExecutionEvent::RunPaused { .. } => {
self.mode = AppMode::Paused;
self.qa_state.waiting = false;
if self.quit_requested {
self.should_quit = true;
}
}
ExecutionEvent::CheckEvaluated { .. }
| ExecutionEvent::MatchEvaluated { .. }
| ExecutionEvent::SafeBoundary { .. } => {
}
}
self.events.push(event);
}
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers};
if self.mode == AppMode::CommandPalette {
match key.code {
KeyCode::Esc => {
self.mode = self.previous_mode.clone();
self.palette.close();
}
KeyCode::Up => {
self.palette.move_up();
}
KeyCode::Down => {
self.palette.move_down();
}
KeyCode::Enter => {
if let Some(action) = self.palette.selected_action() {
self.execute_palette_action(action);
}
}
KeyCode::Char(c) => {
self.palette.push_filter(c);
}
KeyCode::Backspace => {
self.palette.pop_filter();
}
_ => {}
}
return;
}
if self.mode == AppMode::QuestionInput {
self.handle_qa_key(key);
return;
}
if self.mode == AppMode::Inspecting {
match key.code {
KeyCode::Esc | KeyCode::Char('v') => {
self.mode = self.previous_mode.clone();
}
KeyCode::Char('q') => {
self.request_quit();
}
_ => {}
}
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('p') {
self.previous_mode = self.mode.clone();
self.mode = AppMode::CommandPalette;
self.palette.open(&self.previous_mode);
return;
}
match key.code {
KeyCode::Char('q') => {
self.request_quit();
}
KeyCode::Char('p') => {
if self.mode == AppMode::Running {
self.mode = AppMode::Paused;
self.pause_flag.store(true, Ordering::SeqCst);
} else if self.mode == AppMode::Paused {
self.mode = AppMode::Running;
self.pause_flag.store(false, Ordering::SeqCst);
self.pause_notify.notify_one();
}
}
KeyCode::Char('v') => {
self.previous_mode = self.mode.clone();
self.mode = AppMode::Inspecting;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_index + 1 < self.steps.len() {
self.selected_index += 1;
}
}
_ => {}
}
}
fn handle_tick(&mut self) {
if self.mode == AppMode::Running {
self.elapsed_secs = self.start_time.elapsed().as_secs();
}
self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len();
}
fn update_step_status(&mut self, event_path: &[String], status: StepVisualStatus) {
let key = event_path.join("/");
if let Some(&idx) = self.path_index.get(&key) {
self.steps[idx].status = status;
} else {
for step in &mut self.steps {
if step.path == event_path {
step.status = status;
break;
}
}
}
}
fn execute_palette_action(&mut self, action: super::command_palette::PaletteAction) {
use super::command_palette::PaletteAction;
match action {
PaletteAction::Resume => {
self.mode = AppMode::Running;
self.pause_flag
.store(false, std::sync::atomic::Ordering::SeqCst);
self.pause_notify.notify_one();
}
PaletteAction::Quit => {
self.request_quit();
}
PaletteAction::Inspect => {
self.mode = AppMode::Inspecting;
}
PaletteAction::Reset => {
for step in &mut self.steps {
step.status = StepVisualStatus::Pending;
}
self.mode = AppMode::Running;
self.start_time = Instant::now();
self.elapsed_secs = 0;
}
}
self.palette.close();
}
fn request_quit(&mut self) {
self.quit_requested = true;
let base_mode = match self.mode {
AppMode::CommandPalette | AppMode::Inspecting | AppMode::QuestionInput => {
self.previous_mode.clone()
}
_ => self.mode.clone(),
};
self.mode = base_mode.clone();
match base_mode {
AppMode::Running => {
self.pause_flag.store(true, Ordering::SeqCst);
}
AppMode::Paused | AppMode::Completed | AppMode::Failed => {
self.should_quit = true;
}
_ => {
self.should_quit = true;
}
}
}
fn handle_qa_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers};
let current_question_type = self.qa_state.current_qout.as_ref().and_then(|qout| {
qout.questions
.get(self.qa_state.selected_tab)
.map(|q| q.question_type.clone())
});
let is_free_write = current_question_type.as_ref() == Some(&QuestionType::FreeWrite);
let is_review = self.qa_state.show_review;
match key.code {
KeyCode::Esc => {
self.mode = self.previous_mode.clone();
}
KeyCode::Tab => {
if !self.qa_state.show_review {
if let Some(ref qout) = self.qa_state.current_qout {
if self.qa_state.selected_tab + 1 < qout.questions.len() {
self.qa_state.save_widget_state_for_tab();
self.qa_state.selected_tab += 1;
self.qa_state.reset_widget_for_tab();
} else if self.qa_state.all_answered() {
self.qa_state.save_widget_state_for_tab();
self.qa_state.show_review = true;
}
}
}
}
KeyCode::BackTab => {
if self.qa_state.show_review {
self.qa_state.save_widget_state_for_tab();
self.qa_state.show_review = false;
self.qa_state.reset_widget_for_tab();
} else if self.qa_state.selected_tab > 0 {
self.qa_state.save_widget_state_for_tab();
self.qa_state.selected_tab -= 1;
self.qa_state.reset_widget_for_tab();
}
}
KeyCode::Left
if is_free_write
&& !is_review
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)) =>
{
self.qa_state.text_cursor =
word_start_left(&self.qa_state.text_buffer, self.qa_state.text_cursor);
}
KeyCode::Right
if is_free_write
&& !is_review
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)) =>
{
self.qa_state.text_cursor =
word_end_right(&self.qa_state.text_buffer, self.qa_state.text_cursor);
}
KeyCode::Backspace
if is_free_write
&& !is_review
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)) =>
{
let next_cursor =
word_start_left(&self.qa_state.text_buffer, self.qa_state.text_cursor);
if next_cursor < self.qa_state.text_cursor {
let byte_start = char_to_byte_index(&self.qa_state.text_buffer, next_cursor);
let byte_end =
char_to_byte_index(&self.qa_state.text_buffer, self.qa_state.text_cursor);
self.qa_state.text_buffer.drain(byte_start..byte_end);
self.qa_state.text_cursor = next_cursor;
}
}
KeyCode::Delete
if is_free_write
&& !is_review
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)) =>
{
let end_pos = word_end_right(&self.qa_state.text_buffer, self.qa_state.text_cursor);
if end_pos > self.qa_state.text_cursor {
let byte_start =
char_to_byte_index(&self.qa_state.text_buffer, self.qa_state.text_cursor);
let byte_end = char_to_byte_index(&self.qa_state.text_buffer, end_pos);
self.qa_state.text_buffer.drain(byte_start..byte_end);
}
}
KeyCode::Enter
if is_free_write && !is_review && key.modifiers.contains(KeyModifiers::SHIFT) =>
{
let byte_pos =
char_to_byte_index(&self.qa_state.text_buffer, self.qa_state.text_cursor);
self.qa_state.text_buffer.insert(byte_pos, '\n');
self.qa_state.text_cursor += 1;
}
KeyCode::Left if !is_free_write || is_review => {
if self.qa_state.show_review {
self.qa_state.save_widget_state_for_tab();
self.qa_state.show_review = false;
self.qa_state.reset_widget_for_tab();
} else if self.qa_state.selected_tab > 0 {
self.qa_state.save_widget_state_for_tab();
self.qa_state.selected_tab -= 1;
self.qa_state.reset_widget_for_tab();
}
}
KeyCode::Right if !is_free_write || is_review => {
if let Some(ref qout) = self.qa_state.current_qout {
if self.qa_state.show_review {
} else if self.qa_state.selected_tab + 1 < qout.questions.len() {
self.qa_state.save_widget_state_for_tab();
self.qa_state.selected_tab += 1;
self.qa_state.reset_widget_for_tab();
} else {
if self.qa_state.all_answered() {
self.qa_state.save_widget_state_for_tab();
self.qa_state.show_review = true;
}
}
}
}
KeyCode::Char(c @ '1'..='9') if !is_free_write => {
let idx = (c as usize) - ('1' as usize);
if let Some(ref qout) = self.qa_state.current_qout
&& idx < qout.questions.len()
{
self.qa_state.save_widget_state_for_tab();
self.qa_state.selected_tab = idx;
self.qa_state.show_review = false;
self.qa_state.reset_widget_for_tab();
}
}
_ if is_review => match key.code {
KeyCode::Enter => {
if self.qa_state.all_answered() {
self.update(AppMessage::QASubmit);
}
}
KeyCode::Char('e') | KeyCode::Backspace => {
self.update(AppMessage::QABackToEdit);
}
_ => {}
},
_ => match current_question_type {
Some(QuestionType::ChooseOne) => {
self.handle_choose_one_key(key.code);
}
Some(QuestionType::ChooseMany) => {
self.handle_choose_many_key(key.code);
}
Some(QuestionType::FreeWrite) => {
self.handle_free_write_key(key.code);
}
Some(QuestionType::YesNo) => {
self.handle_yes_no_key(key.code);
}
None => {}
},
}
}
fn handle_choose_one_key(&mut self, code: crossterm::event::KeyCode) {
use crossterm::event::KeyCode;
let option_count = self
.qa_state
.current_qout
.as_ref()
.and_then(|q| q.questions.get(self.qa_state.selected_tab))
.and_then(|q| q.options.as_ref())
.map(|o| o.len())
.unwrap_or(0);
match code {
KeyCode::Up | KeyCode::Char('k') => {
if self.qa_state.widget_cursor > 0 {
self.qa_state.widget_cursor -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.qa_state.widget_cursor + 1 < option_count {
self.qa_state.widget_cursor += 1;
}
}
KeyCode::Enter => {
if let Some(ref qout) = self.qa_state.current_qout
&& let Some(question) = qout.questions.get(self.qa_state.selected_tab)
&& let Some(ref options) = question.options
&& let Some(selected) = options.get(self.qa_state.widget_cursor)
{
let qid = question.id.clone();
let answer = AnswerValue::Text(selected.clone());
self.update(AppMessage::QAAnswer {
question_id: qid,
answer,
});
self.advance_to_next_unanswered();
}
}
_ => {}
}
}
fn handle_choose_many_key(&mut self, code: crossterm::event::KeyCode) {
use crossterm::event::KeyCode;
let option_count = self
.qa_state
.current_qout
.as_ref()
.and_then(|q| q.questions.get(self.qa_state.selected_tab))
.and_then(|q| q.options.as_ref())
.map(|o| o.len())
.unwrap_or(0);
match code {
KeyCode::Up | KeyCode::Char('k') => {
if self.qa_state.widget_cursor > 0 {
self.qa_state.widget_cursor -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.qa_state.widget_cursor + 1 < option_count {
self.qa_state.widget_cursor += 1;
}
}
KeyCode::Char(' ') => {
if self.qa_state.toggled.contains(&self.qa_state.widget_cursor) {
self.qa_state.toggled.remove(&self.qa_state.widget_cursor);
} else {
self.qa_state.toggled.insert(self.qa_state.widget_cursor);
}
}
KeyCode::Enter => {
if let Some(ref qout) = self.qa_state.current_qout
&& let Some(question) = qout.questions.get(self.qa_state.selected_tab)
&& let Some(ref options) = question.options
{
let selected: Vec<String> = self
.qa_state
.toggled
.iter()
.filter_map(|&i| options.get(i).cloned())
.collect();
let qid = question.id.clone();
let answer = AnswerValue::MultiSelect(selected);
self.update(AppMessage::QAAnswer {
question_id: qid,
answer,
});
self.advance_to_next_unanswered();
}
}
_ => {}
}
}
fn handle_free_write_key(&mut self, code: crossterm::event::KeyCode) {
use crossterm::event::KeyCode;
match code {
KeyCode::Left => {
if self.qa_state.text_cursor > 0 {
self.qa_state.text_cursor -= 1;
}
}
KeyCode::Right => {
let char_len = self.qa_state.text_buffer.chars().count();
if self.qa_state.text_cursor < char_len {
self.qa_state.text_cursor += 1;
}
}
KeyCode::Char(c) => {
let byte_pos =
char_to_byte_index(&self.qa_state.text_buffer, self.qa_state.text_cursor);
self.qa_state.text_buffer.insert(byte_pos, c);
self.qa_state.text_cursor += 1;
}
KeyCode::Backspace => {
if self.qa_state.text_cursor > 0 {
let cursor = self.qa_state.text_cursor;
let byte_end = char_to_byte_index(&self.qa_state.text_buffer, cursor);
let byte_start = char_to_byte_index(&self.qa_state.text_buffer, cursor - 1);
self.qa_state.text_buffer.drain(byte_start..byte_end);
self.qa_state.text_cursor -= 1;
}
}
KeyCode::Enter => {
if !self.qa_state.text_buffer.is_empty()
&& let Some(ref qout) = self.qa_state.current_qout
&& let Some(question) = qout.questions.get(self.qa_state.selected_tab)
{
let qid = question.id.clone();
let answer = AnswerValue::Text(self.qa_state.text_buffer.clone());
self.update(AppMessage::QAAnswer {
question_id: qid,
answer,
});
self.advance_to_next_unanswered();
}
}
_ => {}
}
}
fn handle_yes_no_key(&mut self, code: crossterm::event::KeyCode) {
use crossterm::event::KeyCode;
match code {
KeyCode::Up | KeyCode::Char('k') => {
self.qa_state.widget_cursor = 0; }
KeyCode::Down | KeyCode::Char('j') => {
self.qa_state.widget_cursor = 1; }
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.qa_state.widget_cursor = 0;
self.confirm_yes_no(true);
}
KeyCode::Char('n') | KeyCode::Char('N') => {
self.qa_state.widget_cursor = 1;
self.confirm_yes_no(false);
}
KeyCode::Enter => {
let value = self.qa_state.widget_cursor == 0;
self.confirm_yes_no(value);
}
_ => {}
}
}
fn confirm_yes_no(&mut self, value: bool) {
if let Some(ref qout) = self.qa_state.current_qout
&& let Some(question) = qout.questions.get(self.qa_state.selected_tab)
{
let qid = question.id.clone();
let answer = AnswerValue::Boolean(value);
self.update(AppMessage::QAAnswer {
question_id: qid,
answer,
});
self.advance_to_next_unanswered();
}
}
fn advance_to_next_unanswered(&mut self) {
self.qa_state.save_widget_state_for_tab();
if let Some(ref qout) = self.qa_state.current_qout {
let count = qout.questions.len();
for offset in 1..=count {
let idx = (self.qa_state.selected_tab + offset) % count;
if !self
.qa_state
.pending_answers
.contains_key(&qout.questions[idx].id)
{
self.qa_state.selected_tab = idx;
self.qa_state.reset_widget_for_tab();
return;
}
}
self.qa_state.show_review = true;
}
}
pub fn spinner_char(&self) -> &'static str {
SPINNER_FRAMES[self.spinner_frame]
}
pub fn status_text(&self) -> &'static str {
match self.mode {
AppMode::Running => "Running",
AppMode::Paused => "Paused",
AppMode::Completed => "Completed",
AppMode::Failed => "Failed",
AppMode::Inspecting => "Inspecting",
AppMode::CommandPalette => "Command Palette",
AppMode::QuestionInput => "Questions",
}
}
pub fn take_qa_submit_data(&mut self) -> Option<(u32, super::qa_protocol::QAnswersFile)> {
self.qa_submit_data.take()
}
pub fn qa_panel_visible(&self) -> bool {
self.mode == AppMode::QuestionInput
|| self.qa_state.current_qout.is_some()
|| self.qa_state.waiting
}
pub fn harness_log_visible(&self) -> bool {
!self.harness_events.is_empty() && self.mode != AppMode::Inspecting
}
}
pub fn build_display_steps(workflows: &[WorkflowDecl], root_workflow: &str) -> Vec<VisualStep> {
let wf_map: HashMap<&str, &WorkflowDecl> =
workflows.iter().map(|w| (w.name.as_str(), w)).collect();
let mut steps = Vec::new();
let mut visited = std::collections::HashSet::new();
flatten_workflow(
root_workflow,
0,
&[root_workflow.to_string()],
&wf_map,
&mut steps,
&mut visited,
);
steps
}
fn flatten_workflow(
wf_name: &str,
depth: usize,
parent_path: &[String],
wf_map: &HashMap<&str, &WorkflowDecl>,
steps: &mut Vec<VisualStep>,
visited: &mut std::collections::HashSet<String>,
) {
if visited.contains(wf_name) {
return;
}
visited.insert(wf_name.to_string());
if let Some(wf) = wf_map.get(wf_name) {
for stmt in &wf.body {
flatten_statement(stmt, depth, parent_path, wf_map, steps, visited);
}
}
visited.remove(wf_name);
}
fn flatten_statement(
stmt: &Statement,
depth: usize,
parent_path: &[String],
wf_map: &HashMap<&str, &WorkflowDecl>,
steps: &mut Vec<VisualStep>,
visited: &mut std::collections::HashSet<String>,
) {
match stmt {
Statement::Run(run) => {
let step = VisualStep::new(
format!("run: {}", run.workflow_name),
parent_path,
run.workflow_name.clone(),
depth,
);
let step_path = step.path.clone();
steps.push(step);
flatten_workflow(
&run.workflow_name,
depth + 1,
&step_path,
wf_map,
steps,
visited,
);
}
Statement::Exec(exec) => {
steps.push(VisualStep::new(
format!("exec: {}", exec.harness),
parent_path,
format!("exec:{}", exec.harness),
depth,
));
}
Statement::ParAnd(par) => {
let mut group_step = VisualStep::new(
format!("par-and \u{2192} {}", par.join_workflow_name),
parent_path,
format!("par-and:{}", par.join_workflow_name),
depth,
);
group_step.is_par_and_group = true;
steps.push(group_step);
for branch in &par.branches {
steps.push(VisualStep::new(
format!("run: {}", branch.workflow_name),
parent_path,
format!("par-and:{}", branch.workflow_name),
depth + 1,
));
}
let mut join_step = VisualStep::new(
format!("join: {}", par.join_workflow_name),
parent_path,
format!("join:{}", par.join_workflow_name),
depth + 1,
);
join_step.is_join = true;
steps.push(join_step);
}
Statement::If(s) => {
flatten_control_flow(
"if",
&s.check_name,
&s.body,
depth,
parent_path,
wf_map,
steps,
visited,
);
}
Statement::IfNot(s) => {
flatten_control_flow(
"if-not",
&s.check_name,
&s.body,
depth,
parent_path,
wf_map,
steps,
visited,
);
}
Statement::While(s) => {
flatten_control_flow(
"while",
&s.check_name,
&s.body,
depth,
parent_path,
wf_map,
steps,
visited,
);
}
Statement::WhileNot(s) => {
flatten_control_flow(
"while-not",
&s.check_name,
&s.body,
depth,
parent_path,
wf_map,
steps,
visited,
);
}
Statement::Match(s) => {
steps.push(VisualStep::new(
format!("match {}", s.check_name),
parent_path,
format!("match:{}", s.check_name),
depth,
));
for arm in &s.arms {
for inner_stmt in &arm.body {
flatten_statement(inner_stmt, depth + 1, parent_path, wf_map, steps, visited);
}
}
if let Some(else_body) = &s.else_body {
for inner_stmt in else_body {
flatten_statement(inner_stmt, depth + 1, parent_path, wf_map, steps, visited);
}
}
}
}
}
fn flatten_control_flow(
keyword: &str,
check_name: &str,
body: &[Statement],
depth: usize,
parent_path: &[String],
wf_map: &HashMap<&str, &WorkflowDecl>,
steps: &mut Vec<VisualStep>,
visited: &mut std::collections::HashSet<String>,
) {
let step = VisualStep::new(
format!("{} {}", keyword, check_name),
parent_path,
format!("{}:{}", keyword, check_name),
depth,
);
let step_path = step.path.clone();
steps.push(step);
for child in body {
flatten_statement(child, depth + 1, &step_path, wf_map, steps, visited);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::ast::*;
fn make_workflow(name: &str, body: Vec<Statement>) -> WorkflowDecl {
WorkflowDecl {
name: name.to_string(),
body,
file: "test.7".to_string(),
line: 1,
column: 1,
}
}
fn test_app(workflows: &[WorkflowDecl]) -> App {
App::new(
workflows,
"main",
Arc::new(AtomicBool::new(false)),
Arc::new(tokio::sync::Notify::new()),
)
}
fn key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent {
code,
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}
}
fn key_with_modifiers(
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent {
code,
modifiers,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}
}
#[test]
fn test_build_display_steps_simple() {
let workflows = vec![make_workflow(
"main",
vec![Statement::Exec(ExecBlock {
harness: "sh".to_string(),
prompt: Some("echo hello".to_string()),
prompt_file: None,
args: None,
line: 2,
column: 3,
})],
)];
let steps = build_display_steps(&workflows, "main");
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].name, "exec: sh");
assert_eq!(steps[0].depth, 0);
assert_eq!(steps[0].status, StepVisualStatus::Pending);
}
#[test]
fn test_build_display_steps_with_run() {
let workflows = vec![
make_workflow(
"main",
vec![Statement::Run(RunStatement {
workflow_name: "deploy".to_string(),
line: 2,
column: 3,
})],
),
make_workflow(
"deploy",
vec![Statement::Exec(ExecBlock {
harness: "sh".to_string(),
prompt: Some("deploy it".to_string()),
prompt_file: None,
args: None,
line: 2,
column: 3,
})],
),
];
let steps = build_display_steps(&workflows, "main");
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].name, "run: deploy");
assert_eq!(steps[0].depth, 0);
assert_eq!(steps[1].name, "exec: sh");
assert_eq!(steps[1].depth, 1);
}
#[test]
fn test_app_mode_transitions() {
let workflows = vec![make_workflow("main", vec![])];
let mut app = test_app(&workflows);
assert_eq!(app.mode, AppMode::Running);
app.update(AppMessage::EngineEvent(ExecutionEvent::RunCompleted {
runId: "r1".to_string(),
}));
assert_eq!(app.mode, AppMode::Completed);
}
#[test]
fn test_app_step_status_update() {
let workflows = vec![make_workflow(
"main",
vec![Statement::Exec(ExecBlock {
harness: "sh".to_string(),
prompt: Some("hello".to_string()),
prompt_file: None,
args: None,
line: 2,
column: 3,
})],
)];
let mut app = test_app(&workflows);
assert_eq!(app.steps[0].status, StepVisualStatus::Pending);
app.update(AppMessage::EngineEvent(ExecutionEvent::StepStarted {
runId: "r1".to_string(),
stepPath: vec!["main".to_string(), "exec:sh".to_string()],
}));
assert_eq!(app.steps[0].status, StepVisualStatus::InProgress);
app.update(AppMessage::EngineEvent(ExecutionEvent::StepCompleted {
runId: "r1".to_string(),
stepPath: vec!["main".to_string(), "exec:sh".to_string()],
}));
assert_eq!(app.steps[0].status, StepVisualStatus::Completed);
}
#[test]
fn test_app_navigation() {
let workflows = vec![make_workflow(
"main",
vec![
Statement::Exec(ExecBlock {
harness: "a".to_string(),
prompt: None,
prompt_file: None,
args: None,
line: 1,
column: 1,
}),
Statement::Exec(ExecBlock {
harness: "b".to_string(),
prompt: None,
prompt_file: None,
args: None,
line: 2,
column: 1,
}),
],
)];
let mut app = test_app(&workflows);
assert_eq!(app.selected_index, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Down));
assert_eq!(app.selected_index, 1);
app.handle_key(key(KeyCode::Up));
assert_eq!(app.selected_index, 0);
app.handle_key(key(KeyCode::Up));
assert_eq!(app.selected_index, 0);
}
use crate::tui::qa_protocol::{AnswerValue, QOutFile, Question, QuestionType};
fn make_qout(seq: u32, question_types: &[QuestionType]) -> QOutFile {
let questions = question_types
.iter()
.enumerate()
.map(|(i, qt)| Question {
id: format!("q{}", i + 1),
question_type: qt.clone(),
prompt: format!("Question {}", i + 1),
options: match qt {
QuestionType::ChooseOne | QuestionType::ChooseMany => Some(vec![
"opt-a".to_string(),
"opt-b".to_string(),
"opt-c".to_string(),
]),
_ => None,
},
preview: None,
})
.collect();
QOutFile { seq, questions }
}
fn empty_app() -> App {
let workflows = vec![make_workflow("main", vec![])];
test_app(&workflows)
}
#[test]
fn test_questions_detected_switches_mode() {
let mut app = empty_app();
assert_eq!(app.mode, AppMode::Running);
let qout = make_qout(1, &[QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.mode, AppMode::QuestionInput);
assert_eq!(app.qa_state.current_seq, 1);
assert!(app.qa_state.current_qout.is_some());
assert_eq!(app.qa_state.selected_tab, 0);
assert!(!app.qa_state.show_review);
assert!(!app.qa_state.waiting);
assert!(app.qa_state.pending_answers.is_empty());
}
#[test]
fn test_quit_from_running_requests_pause_before_exit() {
let mut app = empty_app();
assert_eq!(app.mode, AppMode::Running);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('q')));
assert!(app.quit_requested);
assert!(app.pause_flag.load(Ordering::SeqCst));
assert!(!app.should_quit);
}
#[test]
fn test_run_paused_completes_pending_quit() {
let mut app = empty_app();
app.request_quit();
assert!(!app.should_quit);
app.update(AppMessage::EngineEvent(ExecutionEvent::RunPaused {
runId: "r1".to_string(),
position: vec!["main".to_string()],
}));
assert_eq!(app.mode, AppMode::Paused);
assert!(app.should_quit);
}
#[test]
fn test_quit_from_paused_exits_immediately() {
let mut app = empty_app();
app.mode = AppMode::Paused;
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('q')));
assert!(app.quit_requested);
assert!(app.should_quit);
}
#[test]
fn test_questions_detected_clears_previous_answers() {
let mut app = empty_app();
app.qa_state
.pending_answers
.insert("old-q".to_string(), AnswerValue::Boolean(true));
app.qa_state.show_review = true;
app.qa_state.selected_tab = 5;
let qout = make_qout(2, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(2, qout));
assert!(app.qa_state.pending_answers.is_empty());
assert!(!app.qa_state.show_review);
assert_eq!(app.qa_state.selected_tab, 0);
}
#[test]
fn test_qa_answer_message_stores_answer() {
let mut app = empty_app();
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Text("hello".to_string()),
});
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::Text(s)) => assert_eq!(s, "hello"),
_ => panic!("Expected Text answer"),
}
}
#[test]
fn test_qa_select_tab_valid_index() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseOne, QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.selected_tab, 0);
app.update(AppMessage::QASelectTab(1));
assert_eq!(app.qa_state.selected_tab, 1);
assert!(!app.qa_state.show_review);
}
#[test]
fn test_qa_select_tab_out_of_range_ignored() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QASelectTab(5));
assert_eq!(app.qa_state.selected_tab, 0); }
#[test]
fn test_qa_show_review() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert!(!app.qa_state.show_review);
app.update(AppMessage::QAShowReview);
assert!(app.qa_state.show_review);
}
#[test]
fn test_qa_back_to_edit() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAShowReview);
assert!(app.qa_state.show_review);
app.update(AppMessage::QABackToEdit);
assert!(!app.qa_state.show_review);
}
#[test]
fn test_qa_submit_clears_state_and_sets_waiting() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
app.update(AppMessage::QASubmit);
assert!(app.qa_state.current_qout.is_none());
assert!(app.qa_state.pending_answers.is_empty());
assert!(!app.qa_state.show_review);
assert!(app.qa_state.waiting);
assert!(app.qa_submit_pending);
let data = app.take_qa_submit_data();
assert!(data.is_some());
let (seq, answers_file) = data.unwrap();
assert_eq!(seq, 1);
assert_eq!(answers_file.answers.len(), 1);
assert_eq!(answers_file.seq, 1);
}
#[test]
fn test_qa_waiting_cleared_on_run_completed() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
app.update(AppMessage::QASubmit);
assert!(app.qa_state.waiting);
assert!(app.qa_panel_visible());
app.update(AppMessage::EngineEvent(ExecutionEvent::RunCompleted {
runId: "r1".to_string(),
}));
assert!(!app.qa_state.waiting);
assert!(!app.qa_panel_visible());
}
#[test]
fn test_qa_waiting_cleared_on_run_failed() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
app.update(AppMessage::QASubmit);
assert!(app.qa_state.waiting);
app.update(AppMessage::EngineEvent(ExecutionEvent::RunFailed {
runId: "r1".to_string(),
position: vec!["main".to_string()],
error: "test error".to_string(),
}));
assert!(!app.qa_state.waiting);
assert!(!app.qa_panel_visible());
}
#[test]
fn test_qa_waiting_cleared_on_run_paused() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
app.update(AppMessage::QASubmit);
assert!(app.qa_state.waiting);
app.update(AppMessage::EngineEvent(ExecutionEvent::RunPaused {
runId: "r1".to_string(),
position: vec!["main".to_string()],
}));
assert!(!app.qa_state.waiting);
assert!(!app.qa_panel_visible());
}
#[test]
fn test_qa_submit_restores_previous_mode() {
let mut app = empty_app();
assert_eq!(app.mode, AppMode::Running);
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.mode, AppMode::QuestionInput);
assert_eq!(app.previous_mode, AppMode::Running);
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(false),
});
app.update(AppMessage::QASubmit);
assert_eq!(app.mode, AppMode::Running);
}
#[test]
fn test_all_answered_false_when_some_missing() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
assert!(!app.qa_state.all_answered());
}
#[test]
fn test_all_answered_true_when_all_present() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
app.update(AppMessage::QAAnswer {
question_id: "q2".to_string(),
answer: AnswerValue::Text("text".to_string()),
});
assert!(app.qa_state.all_answered());
}
#[test]
fn test_all_answered_false_when_no_qout() {
let app = empty_app();
assert!(!app.qa_state.all_answered());
}
#[test]
fn test_qa_panel_visible_when_question_input() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert!(app.qa_panel_visible());
}
#[test]
fn test_qa_panel_visible_when_waiting() {
let mut app = empty_app();
app.qa_state.waiting = true;
assert!(app.qa_panel_visible());
}
#[test]
fn test_qa_panel_not_visible_by_default() {
let app = empty_app();
assert!(!app.qa_panel_visible());
}
#[test]
fn test_handle_qa_key_esc_returns_to_previous_mode() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.mode, AppMode::QuestionInput);
assert_eq!(app.previous_mode, AppMode::Running);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, AppMode::Running);
}
#[test]
fn test_handle_qa_key_yes_no_y() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('y')));
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::Boolean(true)) => {}
other => panic!("Expected Boolean(true), got {:?}", other),
}
}
#[test]
fn test_handle_qa_key_yes_no_n() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('n')));
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::Boolean(false)) => {}
other => panic!("Expected Boolean(false), got {:?}", other),
}
}
#[test]
fn test_handle_qa_key_yes_no_enter_selects_cursor() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Down));
assert_eq!(app.qa_state.widget_cursor, 1);
app.handle_key(key(KeyCode::Enter));
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::Boolean(false)) => {}
other => panic!("Expected Boolean(false), got {:?}", other),
}
}
#[test]
fn test_handle_qa_key_choose_one_navigation() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.widget_cursor, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Down));
assert_eq!(app.qa_state.widget_cursor, 1);
app.handle_key(key(KeyCode::Down));
assert_eq!(app.qa_state.widget_cursor, 2);
app.handle_key(key(KeyCode::Down));
assert_eq!(app.qa_state.widget_cursor, 2);
app.handle_key(key(KeyCode::Up));
assert_eq!(app.qa_state.widget_cursor, 1);
app.handle_key(key(KeyCode::Up));
app.handle_key(key(KeyCode::Up));
assert_eq!(app.qa_state.widget_cursor, 0);
}
#[test]
fn test_handle_qa_key_choose_one_enter() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Down));
app.handle_key(key(KeyCode::Enter));
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::Text(s)) => assert_eq!(s, "opt-b"),
other => panic!("Expected Text(opt-b), got {:?}", other),
}
}
#[test]
fn test_handle_qa_key_choose_many_space_toggle() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseMany]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char(' ')));
assert!(app.qa_state.toggled.contains(&0));
app.handle_key(key(KeyCode::Char(' ')));
assert!(!app.qa_state.toggled.contains(&0));
app.handle_key(key(KeyCode::Down));
app.handle_key(key(KeyCode::Char(' ')));
assert!(app.qa_state.toggled.contains(&1));
}
#[test]
fn test_handle_qa_key_choose_many_enter_confirms() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseMany]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char(' '))); app.handle_key(key(KeyCode::Down));
app.handle_key(key(KeyCode::Down));
app.handle_key(key(KeyCode::Char(' '))); app.handle_key(key(KeyCode::Enter));
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::MultiSelect(v)) => {
assert!(v.contains(&"opt-a".to_string()));
assert!(v.contains(&"opt-c".to_string()));
assert_eq!(v.len(), 2);
}
other => panic!("Expected MultiSelect, got {:?}", other),
}
}
#[test]
fn test_handle_qa_key_free_write_typing() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert!(app.qa_state.text_buffer.is_empty());
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('h')));
app.handle_key(key(KeyCode::Char('i')));
assert_eq!(app.qa_state.text_buffer, "hi");
app.handle_key(key(KeyCode::Backspace));
assert_eq!(app.qa_state.text_buffer, "h");
app.handle_key(key(KeyCode::Backspace));
assert_eq!(app.qa_state.text_buffer, "");
app.handle_key(key(KeyCode::Backspace));
assert_eq!(app.qa_state.text_buffer, "");
}
#[test]
fn test_handle_qa_key_free_write_shift_enter_inserts_newline() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::{KeyCode, KeyModifiers};
app.handle_key(key(KeyCode::Char('a')));
app.handle_key(key_with_modifiers(KeyCode::Enter, KeyModifiers::SHIFT));
app.handle_key(key(KeyCode::Char('b')));
assert_eq!(app.qa_state.text_buffer, "a\nb");
assert_eq!(app.qa_state.text_cursor, 3);
}
#[test]
fn test_handle_qa_key_free_write_enter_confirms() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('m')));
app.handle_key(key(KeyCode::Char('y')));
app.handle_key(key(KeyCode::Char(' ')));
app.handle_key(key(KeyCode::Char('a')));
app.handle_key(key(KeyCode::Char('n')));
app.handle_key(key(KeyCode::Char('s')));
app.handle_key(key(KeyCode::Enter));
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::Text(s)) => assert_eq!(s, "my ans"),
other => panic!("Expected Text, got {:?}", other),
}
}
#[test]
fn test_handle_qa_key_free_write_enter_with_empty_is_noop() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert!(app.qa_state.text_buffer.is_empty());
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Enter));
assert!(!app.qa_state.pending_answers.contains_key("q1"));
}
#[test]
fn test_handle_qa_key_free_write_does_not_navigate_tabs_with_left_right() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite, QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.selected_tab, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Right));
assert_eq!(app.qa_state.selected_tab, 0); }
#[test]
fn test_handle_qa_key_number_keys_jump_tabs() {
let mut app = empty_app();
let qout = make_qout(
1,
&[
QuestionType::YesNo,
QuestionType::FreeWrite,
QuestionType::ChooseOne,
],
);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.selected_tab, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('3')));
assert_eq!(app.qa_state.selected_tab, 2);
app.handle_key(key(KeyCode::Char('1')));
assert_eq!(app.qa_state.selected_tab, 0);
}
#[test]
fn test_handle_qa_key_number_key_out_of_range_ignored() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('2')));
assert_eq!(app.qa_state.selected_tab, 0); }
#[test]
fn test_handle_qa_key_review_enter_submits_when_all_answered() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
app.update(AppMessage::QAShowReview);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Enter));
assert!(app.qa_submit_pending);
assert!(app.qa_state.waiting);
}
#[test]
fn test_handle_qa_key_review_enter_does_not_submit_when_not_all_answered() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
app.update(AppMessage::QAShowReview);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Enter));
assert!(!app.qa_submit_pending);
}
#[test]
fn test_handle_qa_key_review_e_goes_back_to_edit() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAShowReview);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('e')));
assert!(!app.qa_state.show_review);
}
#[test]
fn test_handle_qa_key_review_backspace_goes_back_to_edit() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAShowReview);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Backspace));
assert!(!app.qa_state.show_review);
}
#[test]
fn test_advance_to_next_unanswered_after_choose_one() {
let mut app = empty_app();
let qout = make_qout(
1,
&[
QuestionType::ChooseOne,
QuestionType::YesNo,
QuestionType::FreeWrite,
],
);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.selected_tab, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.qa_state.selected_tab, 1);
assert!(!app.qa_state.show_review);
}
#[test]
fn test_advance_shows_review_when_all_answered() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('y'))); assert!(app.qa_state.show_review);
}
#[test]
fn test_reset_widget_for_tab_clears_state() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseMany]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state.widget_cursor = 2;
app.qa_state.text_buffer = "some text".to_string();
app.qa_state.toggled.insert(0);
app.qa_state.toggled.insert(1);
app.qa_state.reset_widget_for_tab();
assert_eq!(app.qa_state.widget_cursor, 0);
assert!(app.qa_state.text_buffer.is_empty());
assert!(app.qa_state.toggled.is_empty());
}
#[test]
fn test_reset_widget_for_tab_pre_populates_existing_answer_text() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state
.pending_answers
.insert("q1".to_string(), AnswerValue::Text("existing".to_string()));
app.qa_state.reset_widget_for_tab();
assert_eq!(app.qa_state.text_buffer, "existing");
}
#[test]
fn test_reset_widget_for_tab_pre_populates_boolean_answer() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state
.pending_answers
.insert("q1".to_string(), AnswerValue::Boolean(false));
app.qa_state.reset_widget_for_tab();
assert_eq!(app.qa_state.widget_cursor, 1);
}
#[test]
fn test_reset_widget_for_tab_pre_populates_multiselect() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseMany]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state.pending_answers.insert(
"q1".to_string(),
AnswerValue::MultiSelect(vec!["opt-a".to_string(), "opt-c".to_string()]),
);
app.qa_state.reset_widget_for_tab();
assert!(app.qa_state.toggled.contains(&0)); assert!(!app.qa_state.toggled.contains(&1)); assert!(app.qa_state.toggled.contains(&2)); }
#[test]
fn test_reset_widget_for_tab_pre_populates_choose_one_cursor() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state
.pending_answers
.insert("q1".to_string(), AnswerValue::Text("opt-c".to_string()));
app.qa_state.reset_widget_for_tab();
assert_eq!(app.qa_state.widget_cursor, 2);
}
#[test]
fn test_take_qa_submit_data_returns_none_when_empty() {
let mut app = empty_app();
assert!(app.take_qa_submit_data().is_none());
}
#[test]
fn test_harness_event_message_appends_to_log() {
let mut app = empty_app();
app.update(AppMessage::HarnessEvent(HarnessEvent {
sequence: 0,
exec_ordinal: 0,
stream: crate::state::types::HarnessEventStream::Stdout,
kind: crate::state::types::HarnessEventKind::Json,
raw: "{\"type\":\"message\"}".to_string(),
parsed: Some(serde_json::json!({"type": "message"})),
step_path: Some(vec!["main".to_string(), "exec:test".to_string()]),
boundary_index: Some(0),
timestamp: None,
}));
assert_eq!(app.harness_events.len(), 1);
assert!(app.harness_log_visible());
}
#[test]
fn test_qa_submit_with_partial_answers_only_includes_answered() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
app.update(AppMessage::QASubmit);
let (_, answers_file) = app.take_qa_submit_data().unwrap();
assert_eq!(answers_file.answers.len(), 1);
assert_eq!(answers_file.answers[0].id, "q1");
assert_eq!(answers_file.questions.len(), 2);
}
#[test]
fn test_handle_qa_key_left_navigates_tab() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QASelectTab(1));
assert_eq!(app.qa_state.selected_tab, 1);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Left));
assert_eq!(app.qa_state.selected_tab, 0);
app.handle_key(key(KeyCode::Left));
assert_eq!(app.qa_state.selected_tab, 0);
}
#[test]
fn test_handle_qa_key_right_navigates_tab() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.selected_tab, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Right));
assert_eq!(app.qa_state.selected_tab, 1);
}
#[test]
fn test_handle_qa_key_right_past_last_shows_review_only_if_all_answered() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Right));
assert!(app.qa_state.show_review);
}
#[test]
fn test_handle_qa_key_right_past_last_does_not_show_review_if_not_all_answered() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Right));
assert!(!app.qa_state.show_review);
}
#[test]
fn test_handle_qa_key_left_from_review_returns_to_last_tab() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QASelectTab(1));
app.update(AppMessage::QAShowReview);
assert!(app.qa_state.show_review);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Left));
assert!(!app.qa_state.show_review);
}
#[test]
fn test_reset_widget_for_tab_resets_text_cursor_to_zero() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state.text_buffer = "hello".to_string();
app.qa_state.text_cursor = 3;
app.qa_state.reset_widget_for_tab();
assert_eq!(app.qa_state.text_cursor, 0);
assert!(app.qa_state.text_buffer.is_empty()); }
#[test]
fn test_reset_widget_for_tab_pre_populates_cursor_to_end_of_text() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Text("hello".to_string()),
});
app.qa_state.reset_widget_for_tab();
assert_eq!(app.qa_state.text_buffer, "hello");
assert_eq!(app.qa_state.text_cursor, 5);
}
#[test]
fn test_save_widget_state_saves_free_write_text_without_enter() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state.text_buffer = "typed but no enter".to_string();
assert!(!app.qa_state.pending_answers.contains_key("q1"));
app.qa_state.save_widget_state_for_tab();
assert_eq!(
app.qa_state.pending_answers.get("q1"),
Some(&AnswerValue::Text("typed but no enter".to_string()))
);
}
#[test]
fn test_save_widget_state_empty_free_write_does_not_save() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state.save_widget_state_for_tab();
assert!(!app.qa_state.pending_answers.contains_key("q1"));
}
#[test]
fn test_save_widget_state_saves_choose_many_toggles_without_enter() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseMany]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.qa_state.toggled.insert(0);
app.qa_state.toggled.insert(2);
assert!(!app.qa_state.pending_answers.contains_key("q1"));
app.qa_state.save_widget_state_for_tab();
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::MultiSelect(v)) => {
assert!(v.contains(&"opt-a".to_string()));
assert!(v.contains(&"opt-c".to_string()));
assert!(!v.contains(&"opt-b".to_string()));
}
other => panic!("expected MultiSelect, got {:?}", other),
}
}
#[test]
fn test_save_widget_state_choose_many_deselect_all_with_prior_answer_saves_empty() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::ChooseMany]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::MultiSelect(vec!["opt-a".to_string()]),
});
assert!(app.qa_state.pending_answers.contains_key("q1"));
app.qa_state.toggled.clear();
app.qa_state.save_widget_state_for_tab();
match app.qa_state.pending_answers.get("q1") {
Some(AnswerValue::MultiSelect(v)) => assert!(v.is_empty()),
other => panic!("expected MultiSelect([]), got {:?}", other),
}
}
#[test]
fn test_char_to_byte_index_ascii() {
assert_eq!(char_to_byte_index("hello", 0), 0);
assert_eq!(char_to_byte_index("hello", 2), 2);
assert_eq!(char_to_byte_index("hello", 4), 4);
}
#[test]
fn test_char_to_byte_index_multibyte() {
let s = "héllo";
assert_eq!(char_to_byte_index(s, 0), 0); assert_eq!(char_to_byte_index(s, 1), 1); assert_eq!(char_to_byte_index(s, 2), 3); assert_eq!(char_to_byte_index(s, 3), 4); assert_eq!(char_to_byte_index(s, 4), 5); }
#[test]
fn test_char_to_byte_index_one_past_end_returns_len() {
assert_eq!(char_to_byte_index("abc", 3), 3); assert_eq!(char_to_byte_index("héllo", 5), 6); assert_eq!(char_to_byte_index("", 0), 0); }
#[test]
fn test_char_to_byte_index_beyond_end_clamps_to_len() {
assert_eq!(char_to_byte_index("abc", 99), 3); }
#[test]
fn test_word_start_left_basic() {
assert_eq!(word_start_left("hello world", 11), 6);
assert_eq!(word_start_left("hello world", 6), 0);
assert_eq!(word_start_left("hello world", 0), 0);
}
#[test]
fn test_word_start_left_multiple_spaces() {
assert_eq!(word_start_left("one two three", 15), 10);
assert_eq!(word_start_left("one two three", 10), 5);
}
#[test]
fn test_word_end_right_basic() {
assert_eq!(word_end_right("hello world", 0), 6);
assert_eq!(word_end_right("hello world", 6), 11);
assert_eq!(word_end_right("hello world", 11), 11);
}
#[test]
fn test_word_end_right_multiple_spaces() {
assert_eq!(word_end_right("one two three", 0), 5);
assert_eq!(word_end_right("one two three", 5), 10);
}
#[test]
fn test_free_write_left_moves_cursor_left() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('a')));
app.handle_key(key(KeyCode::Char('b')));
app.handle_key(key(KeyCode::Char('c')));
assert_eq!(app.qa_state.text_buffer, "abc");
assert_eq!(app.qa_state.text_cursor, 3);
app.handle_key(key(KeyCode::Left));
assert_eq!(app.qa_state.text_cursor, 2);
app.handle_key(key(KeyCode::Left));
assert_eq!(app.qa_state.text_cursor, 1);
}
#[test]
fn test_free_write_right_moves_cursor_right() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('a')));
app.handle_key(key(KeyCode::Char('b')));
app.handle_key(key(KeyCode::Left));
app.handle_key(key(KeyCode::Left));
assert_eq!(app.qa_state.text_cursor, 0);
app.handle_key(key(KeyCode::Right));
assert_eq!(app.qa_state.text_cursor, 1);
}
#[test]
fn test_free_write_left_at_start_is_noop() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.text_cursor, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Left)); assert_eq!(app.qa_state.text_cursor, 0);
}
#[test]
fn test_free_write_right_at_end_is_noop() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('x')));
assert_eq!(app.qa_state.text_cursor, 1);
app.handle_key(key(KeyCode::Right)); assert_eq!(app.qa_state.text_cursor, 1);
}
#[test]
fn test_ctrl_left_moves_cursor_by_word() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::{KeyCode, KeyModifiers};
for c in "hello world".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(app.qa_state.text_cursor, 11);
app.handle_key(key_with_modifiers(KeyCode::Left, KeyModifiers::CONTROL));
assert_eq!(app.qa_state.text_cursor, 6);
app.handle_key(key_with_modifiers(KeyCode::Left, KeyModifiers::CONTROL));
assert_eq!(app.qa_state.text_cursor, 0);
}
#[test]
fn test_ctrl_right_moves_cursor_by_word() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::{KeyCode, KeyModifiers};
for c in "hello world".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
app.qa_state.text_cursor = 0;
app.handle_key(key_with_modifiers(KeyCode::Right, KeyModifiers::CONTROL));
assert_eq!(app.qa_state.text_cursor, 6);
app.handle_key(key_with_modifiers(KeyCode::Right, KeyModifiers::CONTROL));
assert_eq!(app.qa_state.text_cursor, 11);
}
#[test]
fn test_ctrl_backspace_deletes_word() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::{KeyCode, KeyModifiers};
for c in "hello world".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
app.handle_key(key_with_modifiers(
KeyCode::Backspace,
KeyModifiers::CONTROL,
));
assert_eq!(app.qa_state.text_buffer, "hello ");
assert_eq!(app.qa_state.text_cursor, 6);
}
#[test]
fn test_ctrl_delete_deletes_word_ahead() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::{KeyCode, KeyModifiers};
for c in "hello world again".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
app.qa_state.text_cursor = 6;
app.handle_key(key_with_modifiers(KeyCode::Delete, KeyModifiers::CONTROL));
assert_eq!(app.qa_state.text_buffer, "hello again");
assert_eq!(app.qa_state.text_cursor, 6);
}
#[test]
fn test_free_write_char_inserts_at_cursor_position() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('a')));
app.handle_key(key(KeyCode::Char('c')));
app.handle_key(key(KeyCode::Left));
app.handle_key(key(KeyCode::Char('b')));
assert_eq!(app.qa_state.text_buffer, "abc");
assert_eq!(app.qa_state.text_cursor, 2);
}
#[test]
fn test_free_write_backspace_deletes_before_cursor() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('a')));
app.handle_key(key(KeyCode::Char('b')));
app.handle_key(key(KeyCode::Char('c')));
app.handle_key(key(KeyCode::Left));
app.handle_key(key(KeyCode::Backspace));
assert_eq!(app.qa_state.text_buffer, "ac");
assert_eq!(app.qa_state.text_cursor, 1);
}
#[test]
fn test_free_write_backspace_at_start_is_noop() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('x')));
app.handle_key(key(KeyCode::Left)); app.handle_key(key(KeyCode::Backspace)); assert_eq!(app.qa_state.text_buffer, "x");
assert_eq!(app.qa_state.text_cursor, 0);
}
#[test]
fn test_free_write_does_not_navigate_tabs_with_left_right() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite, QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.selected_tab, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('h')));
app.handle_key(key(KeyCode::Right));
assert_eq!(app.qa_state.selected_tab, 0);
}
#[test]
fn test_tab_key_advances_tab_in_free_write_mode() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite, QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
assert_eq!(app.qa_state.selected_tab, 0);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Tab));
assert_eq!(app.qa_state.selected_tab, 1);
}
#[test]
fn test_backtab_key_goes_back_from_free_write_mode() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::FreeWrite]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QASelectTab(1));
assert_eq!(app.qa_state.selected_tab, 1);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::BackTab));
assert_eq!(app.qa_state.selected_tab, 0);
}
#[test]
fn test_tab_key_auto_saves_free_write_text_before_switch() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::FreeWrite, QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Char('h')));
app.handle_key(key(KeyCode::Char('i')));
assert_eq!(app.qa_state.text_buffer, "hi");
assert!(!app.qa_state.pending_answers.contains_key("q1"));
app.handle_key(key(KeyCode::Tab));
assert_eq!(app.qa_state.selected_tab, 1);
assert_eq!(
app.qa_state.pending_answers.get("q1"),
Some(&AnswerValue::Text("hi".to_string()))
);
}
#[test]
fn test_tab_key_to_review_when_all_answered() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QAAnswer {
question_id: "q1".to_string(),
answer: AnswerValue::Boolean(true),
});
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Tab));
assert!(app.qa_state.show_review);
}
#[test]
fn test_backtab_from_review_returns_to_last_tab() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo, QuestionType::ChooseOne]);
app.update(AppMessage::QuestionsDetected(1, qout));
app.update(AppMessage::QASelectTab(1));
assert_eq!(app.qa_state.selected_tab, 1);
app.update(AppMessage::QAShowReview);
assert!(app.qa_state.show_review);
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::BackTab));
assert!(!app.qa_state.show_review);
assert_eq!(app.qa_state.selected_tab, 1);
}
#[test]
fn test_tab_key_at_last_not_all_answered_does_nothing() {
let mut app = empty_app();
let qout = make_qout(1, &[QuestionType::YesNo]);
app.update(AppMessage::QuestionsDetected(1, qout));
use crossterm::event::KeyCode;
app.handle_key(key(KeyCode::Tab));
assert!(!app.qa_state.show_review);
assert_eq!(app.qa_state.selected_tab, 0);
}
}