1pub mod app;
2pub mod renderer;
3pub mod widgets;
4
5pub use app::TuiApp;
6pub use renderer::{AppState, FormRenderer, TuiRenderer};
7pub use widgets::{FieldValue, FormField};
8
9#[cfg(test)]
12mod tests {
13 use super::*;
14 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15 use ratatui::buffer::Buffer;
16 use ratatui::layout::Rect;
17 use lilyco_core::schema::{ArgKind, ArgSchema, CommandSchema};
18
19 fn test_schema() -> CommandSchema {
21 CommandSchema {
22 name: "demo".into(),
23 about: "测试命令".into(),
24 args: vec![
25 ArgSchema {
26 name: "verbose".into(),
27 about: "详细输出".into(),
28 kind: ArgKind::Flag,
29 required: false,
30 default: None,
31 },
32 ArgSchema {
33 name: "auto".into(),
34 about: "自动模式".into(),
35 kind: ArgKind::Flag,
36 required: false,
37 default: Some(serde_json::json!(true)),
38 },
39 ArgSchema {
40 name: "codec".into(),
41 about: "编码格式".into(),
42 kind: ArgKind::Enum {
43 values: vec!["h264".into(), "h265".into(), "av1".into()],
44 },
45 required: true,
46 default: Some(serde_json::json!("h264")),
47 },
48 ArgSchema {
49 name: "quality".into(),
50 about: "质量 0-51".into(),
51 kind: ArgKind::Number {
52 min: Some(0.0),
53 max: Some(51.0),
54 },
55 required: false,
56 default: Some(serde_json::json!(23)),
57 },
58 ArgSchema {
59 name: "output".into(),
60 about: "输出文件".into(),
61 kind: ArgKind::Path { must_exist: false },
62 required: true,
63 default: None,
64 },
65 ],
66 subcommands: vec![],
67 }
68 }
69
70 fn key(code: KeyCode) -> KeyEvent {
71 KeyEvent::new(code, KeyModifiers::NONE)
72 }
73
74 fn render_to_string(app: &TuiApp, width: u16, height: u16) -> String {
75 let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
76 app.render(Rect::new(0, 0, width, height), &mut buf);
77 let mut s = String::new();
78 for y in 0..height {
79 for x in 0..width {
80 let cell = buf.cell((x, y)).unwrap();
81 s.push(cell.symbol().chars().next().unwrap_or(' '));
82 }
83 s.push('\n');
84 }
85 s
86 }
87
88 #[test]
91 fn render_form_shows_all_args() {
92 let schema = test_schema();
93 let app = TuiApp::new(&schema);
94 let out = render_to_string(&app, 80, 20);
95 assert!(out.contains("verbose"), "should show verbose: {out}");
96 assert!(out.contains("auto"), "should show auto: {out}");
97 assert!(out.contains("codec"), "should show codec: {out}");
98 assert!(out.contains("quality"), "should show quality: {out}");
99 assert!(out.contains("output"), "should show output: {out}");
100 }
101
102 #[test]
105 fn toggle_flag_updates_preview() {
106 let schema = test_schema();
107 let mut app = TuiApp::new(&schema);
108
109 let preview_before = app.form.cli_preview();
110 assert!(!preview_before.contains("--verbose"), "verbose should be off by default");
111
112 app.form.focus_index = 0;
114 app.handle_event(key(KeyCode::Char(' ')));
115
116 let preview_after = app.form.cli_preview();
117 assert!(preview_after.contains("--verbose"), "verbose should be on after toggle: {preview_after}");
118 }
119
120 #[test]
123 fn enum_cycles_with_arrow_keys() {
124 let schema = test_schema();
125 let mut app = TuiApp::new(&schema);
126
127 app.form.focus_index = 2;
129 if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
130 assert_eq!(*selected, 0, "default should be index 0 (h264)");
131 }
132
133 app.handle_event(key(KeyCode::Right));
135 if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
136 assert_eq!(*selected, 1, "should cycle to h265");
137 }
138
139 app.handle_event(key(KeyCode::Right));
141 if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
142 assert_eq!(*selected, 2, "should cycle to av1");
143 }
144
145 app.handle_event(key(KeyCode::Right));
147 if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
148 assert_eq!(*selected, 2, "should stay at av1");
149 }
150
151 app.handle_event(key(KeyCode::Left));
153 if let FieldValue::Enum { selected, .. } = &app.fields()[2].value {
154 assert_eq!(*selected, 1, "should go back to h265");
155 }
156 }
157
158 #[test]
161 fn number_respects_range() {
162 let schema = test_schema();
163 let mut app = TuiApp::new(&schema);
164
165 app.form.focus_index = 3;
167
168 if let FieldValue::Number(n) = &mut app.fields_mut()[3].value {
170 *n = 51.0; }
172 assert_eq!(app.form.cli_preview().contains("51"), true, "should show 51");
173
174 if let FieldValue::Number(n) = &mut app.fields_mut()[3].value {
175 *n = 0.0; }
177 assert_eq!(app.form.cli_preview().contains("0"), true, "should show 0");
178 }
179
180 #[test]
181 fn number_widget_up_down_keys() {
182 let schema = test_schema();
183 let mut app = TuiApp::new(&schema);
184 app.form.focus_index = 3; app.handle_event(key(KeyCode::Up));
188 if let FieldValue::Number(n) = &app.fields()[3].value {
189 assert_eq!(*n, 24.0, "up should increment 23 to 24");
190 }
191
192 app.handle_event(key(KeyCode::Down));
194 app.handle_event(key(KeyCode::Down));
195 if let FieldValue::Number(n) = &app.fields()[3].value {
196 assert_eq!(*n, 22.0, "down twice should give 22");
197 }
198 }
199
200 #[test]
203 fn cli_preview_omits_defaults() {
204 let schema = test_schema();
205 let app = TuiApp::new(&schema);
206 let preview = app.form.cli_preview();
207
208 assert!(!preview.contains("23"), "default quality should be omitted: {preview}");
210 assert!(!preview.contains("h264"), "default codec should be omitted: {preview}");
212 assert!(!preview.contains("auto"), "default auto=true should be omitted: {preview}");
214 }
215
216 #[test]
219 fn cli_preview_omits_false_flags() {
220 let schema = test_schema();
221 let app = TuiApp::new(&schema);
222 let preview = app.form.cli_preview();
223
224 assert!(!preview.contains("verbose"), "false flag should be omitted: {preview}");
226 }
227
228 #[test]
231 fn cli_preview_is_non_empty() {
232 let schema = test_schema();
233 let app = TuiApp::new(&schema);
234 let preview = app.form.cli_preview();
235 assert!(preview.starts_with("demo"), "preview should start with command name: {preview}");
236 assert_eq!(preview.trim(), "demo", "only command name when all defaults/empty");
238 }
239
240 #[test]
241 fn enter_moves_to_confirm() {
242 let schema = test_schema();
243 let mut app = TuiApp::new(&schema);
244
245 app.form.focus_index = 4; app.handle_event(key(KeyCode::Char('f')));
248 app.handle_event(key(KeyCode::Char('i')));
249 app.handle_event(key(KeyCode::Char('l')));
250 app.handle_event(key(KeyCode::Char('e')));
251 app.handle_event(key(KeyCode::Char('.')));
252 app.handle_event(key(KeyCode::Char('m')));
253 app.handle_event(key(KeyCode::Char('p')));
254 app.handle_event(key(KeyCode::Char('4')));
255
256 app.handle_event(key(KeyCode::Enter));
258 assert_eq!(*app.state(), AppState::Confirm);
259 }
260
261 #[test]
262 fn esc_quits_app() {
263 let schema = test_schema();
264 let mut app = TuiApp::new(&schema);
265 let cont = app.handle_event(key(KeyCode::Esc));
266 assert!(!cont, "Esc should quit");
267 assert!(app.should_quit);
268 }
269
270 #[test]
271 fn tab_cycles_focus() {
272 let schema = test_schema();
273 let mut app = TuiApp::new(&schema);
274 assert_eq!(app.form.focus_index, 0);
275 app.handle_event(key(KeyCode::Tab));
276 assert_eq!(app.form.focus_index, 1);
277 app.handle_event(key(KeyCode::Tab));
278 assert_eq!(app.form.focus_index, 2);
279 }
280}