Skip to main content

lilyco_tui/
lib.rs

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// ── 测试 ──────────────────────────────────────────────────
10
11#[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    /// 构建测试用 schema:两个 flag + enum + number
20    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    // ─── 测试 1:所有参数出现在渲染输出里 ───────────────
89
90    #[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    // ─── 测试 2:切换 Flag 后 CLI 预览更新 ──────────────
103
104    #[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        // 焦点移到 verbose (index 0),按空格切换
113        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    // ─── 测试 3:左右键在 enum 值间循环 ─────────────────
121
122    #[test]
123    fn enum_cycles_with_arrow_keys() {
124        let schema = test_schema();
125        let mut app = TuiApp::new(&schema);
126
127        // codec 在 index 2,默认选中索引 0 (h264)
128        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        // 右键 → index 1 (h265)
134        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        // 右键 → index 2 (av1)
140        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        // 右键 → 不能越界,仍为 index 2
146        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        // 左键 → index 1
152        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    // ─── 测试 4:超出范围的输入被拒绝 ───────────────────
159
160    #[test]
161    fn number_respects_range() {
162        let schema = test_schema();
163        let mut app = TuiApp::new(&schema);
164
165        // quality 在 index 3,默认 23
166        app.form.focus_index = 3;
167
168        // 直接修改值为边界值测试
169        if let FieldValue::Number(n) = &mut app.fields_mut()[3].value {
170            *n = 51.0; // 上限
171        }
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; // 下限
176        }
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; // quality, default 23
185
186        // Up → 24
187        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        // Down twice → 22
193        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    // ─── 测试 5:默认值参数不出现在预览命令里 ───────────
201
202    #[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        // quality 默认 23,当前值即 23,不应出现
209        assert!(!preview.contains("23"), "default quality should be omitted: {preview}");
210        // codec 默认 h264,当前值即 h264,不应出现
211        assert!(!preview.contains("h264"), "default codec should be omitted: {preview}");
212        // auto 默认 true,不应出现
213        assert!(!preview.contains("auto"), "default auto=true should be omitted: {preview}");
214    }
215
216    // ─── 测试 6:false 的 flag 不出现在预览里 ────────────
217
218    #[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        // verbose 默认 false,不应出现
225        assert!(!preview.contains("verbose"), "false flag should be omitted: {preview}");
226    }
227
228    // ─── 附加:测试代码片段 ─────────────────────────────
229
230    #[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        // 空必填字段不出现在预览中(用户需要先填写)
237        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        // 填写必填字段 output
246        app.form.focus_index = 4; // output field
247        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        // 按 Enter → Confirm
257        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}