返回 CodeWhale
model_picker_actions.rs
根目录 / crates / tui / src / tui / ui / tests / model_picker_actions.rs
1 //! #6500: the model picker's ⇧P / ⇧F / ⇧D after a search, driven through the
2 //! open view stack, the event handler, disk, and the rendered picker.
3
4 use super::*;
5 use crate::tui::model_picker::ModelPickerView;
6 use crate::tui::ui::handlers::{toggle_model_picker_fleet, toggle_model_picker_pin};
7 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8 use ratatui::{buffer::Buffer, layout::Rect};
9
10 /// An app on a keyed DeepSeek route with `/model` open, under a temp home.
11 #[cfg(test)]
12 fn app_with_open_picker(root: &std::path::Path) -> (App, Config) {
13 let workspace = root.join("workspace");
14 std::fs::create_dir_all(&workspace).unwrap();
15 let mut config = Config::default();
16 config
17 .set_provider_api_key_override(
18 &config.test_identity_for_kind(ProviderKind::Deepseek),
19 Some("fixture-key".into()),
20 )
21 .unwrap();
22 let mut app = App::new(crate::test_support::test_tui_options(&workspace), &config);
23 app.workspace = workspace;
24 let picker = ModelPickerView::new(&app, &config);
25 app.view_stack.push(picker);
26 (app, config)
27 }
28
29 fn type_query(app: &mut App, query: &str) {
30 for ch in query.chars() {
31 let events = app
32 .view_stack
33 .handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
34 assert!(events.is_empty(), "typing {ch:?} emitted {events:?}");
35 }
36 }
37
38 fn shifted(ch: char) -> KeyEvent {
39 KeyEvent::new(KeyCode::Char(ch), KeyModifiers::SHIFT)
40 }
41
42 fn render_picker(app: &mut App) -> String {
43 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker));
44 let view = app.view_stack.pop().unwrap();
45 let area = Rect::new(0, 0, 140, 40);
46 let mut buf = Buffer::empty(area);
47 view.render(area, &mut buf);
48 app.view_stack.push_boxed(view);
49 (0..area.height)
50 .map(|y| {
51 (0..area.width)
52 .map(|x| buf[(x, y)].symbol())
53 .collect::<String>()
54 })
55 .collect::<Vec<_>>()
56 .join("\n")
57 }
58
59 /// The picker line that lists `model` as a row (not the query title or the
60 /// receipt line).
61 fn row_line<'a>(screen: &'a str, model: &str) -> &'a str {
62 screen
63 .lines()
64 .find(|line| {
65 line.contains(model) && !line.contains("Model:") && !line.contains(&format!("/{model}"))
66 })
67 .unwrap_or_else(|| panic!("no row for {model}:\n{screen}"))
68 }
69
70 #[test]
71 fn shift_p_after_a_search_pins_the_highlighted_row_and_says_so_in_the_picker() {
72 let _lock = crate::test_support::lock_test_env();
73 let root = tempfile::tempdir().unwrap();
74 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
75 let (mut app, config) = app_with_open_picker(root.path());
76 let model = app.model.clone();
77 type_query(&mut app, &model);
78
79 let events = app.view_stack.handle_key(shifted('P'));
80 let [
81 ViewEvent::ModelPickerTogglePin {
82 provider,
83 provider_id,
84 model: picked,
85 },
86 ] = events.as_slice()
87 else {
88 panic!("⇧P while searching must pin the highlighted row, got {events:?}");
89 };
90 assert_eq!(picked, &model);
91 let provider_key = provider_id
92 .clone()
93 .unwrap_or_else(|| provider.as_str().to_string());
94 toggle_model_picker_pin(&mut app, &config, &provider_key, picked);
95
96 let saved = crate::settings::Settings::load_persisted().unwrap();
97 assert!(
98 saved
99 .pinned_models
100 .iter()
101 .any(|pin| pin.provider == provider_key && pin.model == model),
102 "pin not persisted: {:?}",
103 saved.pinned_models
104 );
105 let screen = render_picker(&mut app);
106 assert!(
107 screen.contains(&format!("Pinned {provider_key}/{model}")),
108 "receipt must be visible inside the picker:\n{screen}"
109 );
110 assert!(
111 row_line(&screen, &model).contains("pinned"),
112 "row must carry its pin:\n{screen}"
113 );
114 assert!(
115 screen.contains(&format!("Model: {model}")) && !screen.contains(&format!("{model}P")),
116 "⇧P must not become query text:\n{screen}"
117 );
118
119 // The same chord on the same row unpins it, on disk and on screen.
120 let events = app.view_stack.handle_key(shifted('P'));
121 assert!(matches!(
122 events.as_slice(),
123 [ViewEvent::ModelPickerTogglePin { model: again, .. }] if again == &model
124 ));
125 toggle_model_picker_pin(&mut app, &config, &provider_key, &model);
126 let saved = crate::settings::Settings::load_persisted().unwrap();
127 assert!(saved.pinned_models.is_empty(), "{:?}", saved.pinned_models);
128 let screen = render_picker(&mut app);
129 assert!(
130 screen.contains(&format!("Unpinned {provider_key}/{model}")),
131 "{screen}"
132 );
133 assert!(!row_line(&screen, &model).contains("pinned"), "{screen}");
134 }
135
136 #[test]
137 fn shift_f_after_a_search_adds_the_highlighted_row_to_fleet_and_says_so_in_the_picker() {
138 let _lock = crate::test_support::lock_test_env();
139 let root = tempfile::tempdir().unwrap();
140 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
141 let (mut app, config) = app_with_open_picker(root.path());
142 let model = app.model.clone();
143 type_query(&mut app, &model);
144
145 let events = app.view_stack.handle_key(shifted('F'));
146 let [
147 ViewEvent::ModelPickerToggleFleet {
148 provider,
149 provider_id,
150 model: picked,
151 },
152 ] = events.as_slice()
153 else {
154 panic!("⇧F while searching must toggle Fleet for the highlighted row, got {events:?}");
155 };
156 assert_eq!(picked, &model);
157 let provider_key = provider_id
158 .clone()
159 .unwrap_or_else(|| provider.as_str().to_string());
160 toggle_model_picker_fleet(&mut app, &config, &provider_key, picked);
161
162 let fleet = crate::fleet::members::fleet_models(&app.workspace).unwrap();
163 assert!(
164 fleet
165 .iter()
166 .any(|member| member.matches(&provider_key, &model)),
167 "route not in the selected fleet: {fleet:?}"
168 );
169 assert!(app.fleet_roster_stale, "the engine roster must be resynced");
170 let screen = render_picker(&mut app);
171 assert!(
172 screen.contains(&format!("{provider_key}/{model}")),
173 "receipt must be visible inside the picker:\n{screen}"
174 );
175 assert!(
176 row_line(&screen, &model).contains("fleet · "),
177 "row must carry its Fleet membership:\n{screen}"
178 );
179
180 // Toggling again removes the shortlist row it added.
181 let events = app.view_stack.handle_key(shifted('F'));
182 assert!(matches!(
183 events.as_slice(),
184 [ViewEvent::ModelPickerToggleFleet { model: again, .. }] if again == &model
185 ));
186 toggle_model_picker_fleet(&mut app, &config, &provider_key, &model);
187 let fleet = crate::fleet::members::fleet_models(&app.workspace).unwrap();
188 assert!(
189 !fleet
190 .iter()
191 .any(|member| member.matches(&provider_key, &model)),
192 "{fleet:?}"
193 );
194 let screen = render_picker(&mut app);
195 assert!(!row_line(&screen, &model).contains("fleet · "), "{screen}");
196 }
197
198 #[test]
199 fn shift_d_while_searching_is_query_text_not_a_provider_auth_detour() {
200 let _lock = crate::test_support::lock_test_env();
201 let root = tempfile::tempdir().unwrap();
202 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
203 let (mut app, _config) = app_with_open_picker(root.path());
204 let model = app.model.clone();
205 type_query(&mut app, &model);
206 assert!(
207 !render_picker(&mut app).contains("⇧D"),
208 "⇧D is not advertised while it would be query text"
209 );
210
211 let events = app.view_stack.handle_key(shifted('D'));
212 assert!(
213 events.is_empty(),
214 "⇧D mid-search used to emit a lock explanation / auth hand-off: {events:?}"
215 );
216 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker));
217 assert!(render_picker(&mut app).contains(&format!("Model: {model}D")));
218 }
219
220 /// #6523 review: a failed mutation renders in the theme's failure slot, not
221 /// warning ink, and success in the outcome slot — the typed toast level is
222 /// carried into the picker's receipt.
223 #[test]
224 fn picker_receipt_renders_its_semantic_level_through_the_palette() {
225 let _lock = crate::test_support::lock_test_env();
226 let root = tempfile::tempdir().unwrap();
227 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
228 let (mut app, config) = app_with_open_picker(root.path());
229 let theme = app.ui_theme;
230
231 let receipt_fg = |app: &mut App, text: &str| {
232 let view = app.view_stack.pop().unwrap();
233 let area = Rect::new(0, 0, 140, 40);
234 let mut buf = Buffer::empty(area);
235 view.render(area, &mut buf);
236 app.view_stack.push_boxed(view);
237 let first = text.chars().next().unwrap().to_string();
238 (0..area.height)
239 .find_map(|y| {
240 let line: String = (0..area.width).map(|x| buf[(x, y)].symbol()).collect();
241 let col = line.find(text)?;
242 let x = line[..col].chars().count() as u16;
243 assert_eq!(buf[(x, y)].symbol(), first);
244 Some(buf[(x, y)].fg)
245 })
246 .unwrap_or_else(|| panic!("receipt {text:?} not rendered"))
247 };
248
249 let failure = "reorder refused";
250 super::super::handlers::refresh_open_model_picker(
251 &mut app,
252 &config,
253 Some((
254 failure.to_string(),
255 crate::tui::app::StatusToastLevel::Error,
256 )),
257 );
258 assert_eq!(receipt_fg(&mut app, failure), theme.error_fg);
259
260 let success = "order saved";
261 super::super::handlers::refresh_open_model_picker(
262 &mut app,
263 &config,
264 Some((
265 success.to_string(),
266 crate::tui::app::StatusToastLevel::Success,
267 )),
268 );
269 assert_eq!(receipt_fg(&mut app, success), theme.status_working);
270 }
271
272 /// #6523 review: while a custom model id is typed there is no highlighted
273 /// catalog route, ⇧P / ⇧F are query text, so the footer must not offer them.
274 #[test]
275 fn pin_and_fleet_hints_hide_when_no_catalog_route_is_highlighted() {
276 let _lock = crate::test_support::lock_test_env();
277 let root = tempfile::tempdir().unwrap();
278 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
279 let (mut app, _config) = app_with_open_picker(root.path());
280 let model = app.model.clone();
281 type_query(&mut app, &model);
282 let screen = render_picker(&mut app);
283 assert!(
284 screen.contains("⇧P") && screen.contains("⇧F"),
285 "a highlighted route offers its verbs:\n{screen}"
286 );
287
288 let mut app_custom = {
289 let (app, _config) = app_with_open_picker(root.path());
290 app
291 };
292 type_query(&mut app_custom, "zz-no-such-model-xyz");
293 let screen = render_picker(&mut app_custom);
294 assert!(
295 !screen.contains("⇧P") && !screen.contains("⇧F"),
296 "no highlighted route, no Pin/Fleet hints:\n{screen}"
297 );
298 let events = app_custom.view_stack.handle_key(shifted('P'));
299 assert!(events.is_empty(), "{events:?}");
300 }
301
301 lines RUST