pub mod app;
pub mod renderer;
pub mod widgets;
pub use app::TuiApp;
pub use renderer::{AppState, FormRenderer, TuiRenderer};
pub use widgets::{FieldValue, FormField};
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use lilyco_core::schema::{ArgKind, ArgSchema, CommandSchema};
fn test_schema() -> CommandSchema {
CommandSchema {
name: "demo".into(),
about: "测试命令".into(),
args: vec![
ArgSchema {
name: "verbose".into(),
about: "详细输出".into(),
kind: ArgKind::Flag,
required: false,
default: None,
},
ArgSchema {
name: "auto".into(),
about: "自动模式".into(),
kind: ArgKind::Flag,
required: false,
default: Some(serde_json::json!(true)),
},
ArgSchema {
name: "codec".into(),
about: "编码格式".into(),
kind: ArgKind::Enum {
values: vec!["h264".into(), "h265".into(), "av1".into()],
},
required: true,
default: Some(serde_json::json!("h264")),
},
ArgSchema {
name: "quality".into(),
about: "质量 0-51".into(),
kind: ArgKind::Number {
min: Some(0.0),
max: Some(51.0),
},
required: false,
default: Some(serde_json::json!(23)),
},
ArgSchema {
name: "output".into(),
about: "输出文件".into(),
kind: ArgKind::Path { must_exist: false },
required: true,
default: None,
},
],
subcommands: vec![],
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn render_to_string(app: &TuiApp, width: u16, height: u16) -> String {
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
app.render(Rect::new(0, 0, width, height), &mut buf);
let mut s = String::new();
for y in 0..height {
for x in 0..width {
let cell = buf.cell((x, y)).unwrap();
s.push(cell.symbol().chars().next().unwrap_or(' '));
}
s.push('\n');
}
s
}
#[test]
fn render_form_shows_all_args() {
let schema = test_schema();
let app = TuiApp::new(&schema);
let out = render_to_string(&app, 80, 20);
assert!(out.contains("verbose"), "should show verbose: {out}");
assert!(out.contains("auto"), "should show auto: {out}");
assert!(out.contains("codec"), "should show codec: {out}");
assert!(out.contains("quality"), "should show quality: {out}");
assert!(out.contains("output"), "should show output: {out}");
}
#[test]
fn toggle_flag_updates_preview() {
let schema = test_schema();
let mut app = TuiApp::new(&schema);
let preview_before = app.form.cli_preview();
assert!(!preview_before.contains("--verbose"), "verbose should be off by default");
app.form.focus_index = 0;
app.handle_event(key(KeyCode::Char(' ')));
let preview_after = app.form.cli_preview();
assert!(preview_after.contains("--verbose"), "verbose should be on after toggle: {preview_after}");
}
#[test]
fn enum_cycles_with_arrow_keys() {
let schema = test_schema();
let mut app = TuiApp::new(&schema);
app.form.focus_index = 2;
if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
assert_eq!(*selected, 0, "default should be index 0 (h264)");
}
app.handle_event(key(KeyCode::Right));
if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
assert_eq!(*selected, 1, "should cycle to h265");
}
app.handle_event(key(KeyCode::Right));
if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
assert_eq!(*selected, 2, "should cycle to av1");
}
app.handle_event(key(KeyCode::Right));
if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
assert_eq!(*selected, 2, "should stay at av1");
}
app.handle_event(key(KeyCode::Left));
if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
assert_eq!(*selected, 1, "should go back to h265");
}
}
#[test]
fn number_respects_range() {
let schema = test_schema();
let mut app = TuiApp::new(&schema);
app.form.focus_index = 3;
if let FieldValue::Number(n) = &mut app.fields_mut()[3].value {
*n = 51.0; }
assert_eq!(app.form.cli_preview().contains("51"), true, "should show 51");
if let FieldValue::Number(n) = &mut app.fields_mut()[3].value {
*n = 0.0; }
assert_eq!(app.form.cli_preview().contains("0"), true, "should show 0");
}
#[test]
fn number_widget_up_down_keys() {
let schema = test_schema();
let mut app = TuiApp::new(&schema);
app.form.focus_index = 3;
app.handle_event(key(KeyCode::Up));
if let FieldValue::Number(n) = &app.fields()[3].value {
assert_eq!(*n, 24.0, "up should increment 23 to 24");
}
app.handle_event(key(KeyCode::Down));
app.handle_event(key(KeyCode::Down));
if let FieldValue::Number(n) = &app.fields()[3].value {
assert_eq!(*n, 22.0, "down twice should give 22");
}
}
#[test]
fn cli_preview_omits_defaults() {
let schema = test_schema();
let app = TuiApp::new(&schema);
let preview = app.form.cli_preview();
assert!(!preview.contains("23"), "default quality should be omitted: {preview}");
assert!(!preview.contains("h264"), "default codec should be omitted: {preview}");
assert!(!preview.contains("auto"), "default auto=true should be omitted: {preview}");
}
#[test]
fn cli_preview_omits_false_flags() {
let schema = test_schema();
let app = TuiApp::new(&schema);
let preview = app.form.cli_preview();
assert!(!preview.contains("verbose"), "false flag should be omitted: {preview}");
}
#[test]
fn cli_preview_is_non_empty() {
let schema = test_schema();
let app = TuiApp::new(&schema);
let preview = app.form.cli_preview();
assert!(preview.starts_with("demo"), "preview should start with command name: {preview}");
assert_eq!(preview.trim(), "demo", "only command name when all defaults/empty");
}
#[test]
fn enter_moves_to_confirm() {
let schema = test_schema();
let mut app = TuiApp::new(&schema);
app.form.focus_index = 4; app.handle_event(key(KeyCode::Char('f')));
app.handle_event(key(KeyCode::Char('i')));
app.handle_event(key(KeyCode::Char('l')));
app.handle_event(key(KeyCode::Char('e')));
app.handle_event(key(KeyCode::Char('.')));
app.handle_event(key(KeyCode::Char('m')));
app.handle_event(key(KeyCode::Char('p')));
app.handle_event(key(KeyCode::Char('4')));
app.handle_event(key(KeyCode::Enter));
assert_eq!(*app.state(), AppState::Confirm);
}
#[test]
fn esc_quits_app() {
let schema = test_schema();
let mut app = TuiApp::new(&schema);
let cont = app.handle_event(key(KeyCode::Esc));
assert!(!cont, "Esc should quit");
assert!(app.should_quit);
}
#[test]
fn tab_cycles_focus() {
let schema = test_schema();
let mut app = TuiApp::new(&schema);
assert_eq!(app.form.focus_index, 0);
app.handle_event(key(KeyCode::Tab));
assert_eq!(app.form.focus_index, 1);
app.handle_event(key(KeyCode::Tab));
assert_eq!(app.form.focus_index, 2);
}
}