返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / views / fleet_roster / tests.rs
1 use super::*;
2 use crate::tui::views::ViewStack;
3 use crossterm::event::KeyModifiers;
4 use std::collections::BTreeMap;
5 use std::path::PathBuf;
6 use unicode_width::UnicodeWidthStr;
7
8 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
9
10 fn key(code: KeyCode) -> KeyEvent {
11 KeyEvent::new(code, KeyModifiers::NONE)
12 }
13
14 fn operator() -> OperatorInfo {
15 OperatorInfo {
16 provider: "DeepSeek".to_string(),
17 provider_id: "deepseek".to_string(),
18 model: "deepseek-v4-pro".to_string(),
19 reasoning: "Auto".to_string(),
20 }
21 }
22
23 fn built_in_view() -> FleetRosterView {
24 FleetRosterView::from_parts(operator(), FleetRoster::built_ins_only(), None)
25 }
26
27 fn view_with_overrides() -> FleetRosterView {
28 let mut members = FleetRoster::built_ins_only()
29 .members()
30 .iter()
31 .filter(|m| !m.id.trim().eq_ignore_ascii_case("operator"))
32 .cloned()
33 .collect::<Vec<_>>();
34 // A project override of the built-in reviewer with a pinned model and
35 // an instruction overlay.
36 if let Some(reviewer) = members.iter_mut().find(|m| m.id == "reviewer") {
37 reviewer.origin = ProfileOrigin::Workspace;
38 reviewer.source = PathBuf::from(".codewhale/agents/reviewer.toml");
39 reviewer.profile.model = Some("glm-5.2".to_string());
40 reviewer.profile.role.instructions = Some("Review hard.".to_string());
41 reviewer.profile.delegation.max_spawn_depth = Some(1);
42 }
43 FleetRosterView {
44 operator: operator(),
45 members,
46 shadowed: Vec::new(),
47 selected_fleet: None,
48 selected: 0,
49 detail_scroll: 0,
50 locale: Locale::En,
51 }
52 }
53
54 fn render_through_stack(make: impl Fn() -> FleetRosterView, w: u16, h: u16) -> Vec<String> {
55 let area = Rect::new(0, 0, w, h);
56 let mut buf = Buffer::empty(area);
57 for y in 0..h {
58 for x in 0..w {
59 buf[(x, y)].set_symbol("X");
60 }
61 }
62 let mut stack = ViewStack::new();
63 stack.push(make());
64 stack.render(area, &mut buf);
65 (0..h)
66 .map(|y| {
67 (0..w)
68 .map(|x| buf[(x, y)].symbol().to_string())
69 .collect::<String>()
70 })
71 .collect()
72 }
73
74 /// #4208: every role mark and control glyph on the roster — operator,
75 /// role shapes, selection arrows, scroll rails — must narrow to an
76 /// ASCII-safe alternative.
77 #[test]
78 fn fleet_roster_glyphs_all_have_ascii_alternatives() {
79 let rows = render_through_stack(view_with_overrides, 100, 30);
80 for ch in rows.join("\n").chars().filter(|ch| !ch.is_ascii()) {
81 let mut cell = ratatui::buffer::Cell::default();
82 cell.set_symbol(&ch.to_string());
83 crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
84 assert!(
85 cell.symbol().is_ascii(),
86 "fleet glyph {ch:?} (U+{:04X}) lacks an ASCII-safe alternative",
87 ch as u32
88 );
89 }
90 }
91
92 #[test]
93 fn operator_row_is_pinned_first_with_the_session_model() {
94 let rows = render_through_stack(built_in_view, 100, 30);
95 let text = rows.join("\n");
96 // The operator row leads the list and the detail pane (row 0 is
97 // selected on open) shows the live session route.
98 let operator_row = rows
99 .iter()
100 .position(|row| row.contains("operator"))
101 .expect("operator row rendered");
102 let first_member_row = rows
103 .iter()
104 .position(|row| row.contains("manager"))
105 .expect("first member rendered");
106 assert!(
107 operator_row < first_member_row,
108 "operator must render above the first member"
109 );
110 assert!(text.contains("▸ @ operator"), "operator selected on open");
111 assert!(text.contains("deepseek-v4-pro"), "session model shown");
112 assert!(text.contains("full session authority"), "{text}");
113 }
114
115 #[test]
116 fn arrows_move_selection_and_wrap() {
117 let mut view = built_in_view();
118 let last = view.members.len();
119 assert_eq!(view.selected, 0);
120
121 view.handle_key(key(KeyCode::Up));
122 assert_eq!(
123 view.selected, last,
124 "up from the operator wraps to the last member (#4755)"
125 );
126
127 view.handle_key(key(KeyCode::Down));
128 assert_eq!(
129 view.selected, 0,
130 "down from the last member wraps to the operator"
131 );
132
133 view.handle_key(key(KeyCode::Down));
134 assert_eq!(view.selected, 1, "first member follows the operator");
135
136 // A full cycle of the roster returns to where it started.
137 for _ in 0..=last {
138 view.handle_key(key(KeyCode::Down));
139 }
140 assert_eq!(view.selected, 1, "one full cycle is the identity");
141 }
142
143 #[test]
144 fn selection_change_resets_detail_scroll() {
145 let mut view = built_in_view();
146 view.handle_key(key(KeyCode::PageDown));
147 assert_eq!(view.detail_scroll, 8);
148 view.handle_key(key(KeyCode::Down));
149 assert_eq!(view.detail_scroll, 0);
150 }
151
152 #[test]
153 fn enter_and_s_open_the_setup_wizard_for_members_only() {
154 for code in [KeyCode::Enter, KeyCode::Char('s')] {
155 // Operator row: display-only, no wizard hand-off.
156 let mut view = built_in_view();
157 assert!(view.operator_selected());
158 assert!(
159 matches!(view.handle_key(key(code)), ViewAction::None),
160 "{code:?} must be inert on the operator row"
161 );
162
163 // Member row: hands off to the setup wizard.
164 view.handle_key(key(KeyCode::Down));
165 let action = view.handle_key(key(code));
166 let ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { role }) = action
167 else {
168 panic!("{code:?} should hand off to the setup wizard");
169 };
170 assert_eq!(role, "manager");
171 }
172 }
173
174 #[test]
175 fn w_opens_the_live_workers_tab() {
176 let mut view = built_in_view();
177 assert!(matches!(
178 view.handle_key(key(KeyCode::Char('w'))),
179 ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested)
180 ));
181 }
182
183 #[test]
184 fn esc_closes() {
185 let mut view = built_in_view();
186 assert!(matches!(
187 view.handle_key(key(KeyCode::Esc)),
188 ViewAction::Close
189 ));
190 }
191
192 #[test]
193 fn built_in_party_lists_all_members_in_canonical_order() {
194 let view = built_in_view();
195 let ids: Vec<&str> = view.members.iter().map(|m| m.id.as_str()).collect();
196 // The operator is rendered as the pinned session row, not a member
197 // (#dogfood 0.8.67), so it is intentionally absent from this list.
198 assert_eq!(
199 ids,
200 [
201 "manager",
202 "scout",
203 "builder",
204 "reviewer",
205 "verifier",
206 "consultant",
207 "synthesizer",
208 "general"
209 ]
210 );
211 }
212
213 #[test]
214 fn detail_shows_posture_routing_and_origin() {
215 // Built-in reviewer: read-only review worker with the recon posture
216 // (network + bounded verification surface; raw shell still requires
217 // write), inherits the session route.
218 let reviewer = FleetRoster::built_ins_only()
219 .get("reviewer")
220 .unwrap()
221 .clone();
222 assert_eq!(
223 member_posture(&reviewer),
224 "reviewer worker · read-only · shell full"
225 );
226 assert_eq!(member_routing(&reviewer), "inherit session route");
227
228 // Built-in scout: no setup means the session route, just like every
229 // other built-in role; recon posture reports full shell authority
230 // (bounded verification surface, raw shell still write-gated).
231 let scout = FleetRoster::built_ins_only().get("scout").unwrap().clone();
232 assert_eq!(
233 member_posture(&scout),
234 "scout worker · read-only · shell full"
235 );
236 assert_eq!(member_routing(&scout), "inherit session route");
237
238 // Builder writes with full shell.
239 let builder = FleetRoster::built_ins_only()
240 .get("builder")
241 .unwrap()
242 .clone();
243 assert_eq!(
244 member_posture(&builder),
245 "builder worker · write · shell full"
246 );
247
248 // A pinned model beats the route preset label.
249 let mut pinned = reviewer.clone();
250 pinned.profile.model = Some("glm-5.2".to_string());
251 assert_eq!(member_routing(&pinned), "model glm-5.2 (pinned)");
252 }
253
254 include!("../fleet_roster_capability_tests.rs");
255
256 #[test]
257 fn detail_lines_carry_overlay_source_for_project_members() {
258 let view = view_with_overrides();
259 let reviewer = view.members.iter().find(|m| m.id == "reviewer").unwrap();
260 let text = member_detail_lines_with_session(reviewer, None, &view.shadowed)
261 .iter()
262 .map(|line| {
263 line.spans
264 .iter()
265 .map(|span| span.content.clone().into_owned())
266 .collect::<String>()
267 })
268 .collect::<Vec<_>>()
269 .join("\n");
270 assert!(text.contains("project"), "{text}");
271 assert!(
272 text.contains("custom overlay (.codewhale/agents/reviewer.toml)"),
273 "{text}"
274 );
275 assert!(text.contains("model glm-5.2 (pinned)"), "{text}");
276 assert!(text.contains("spawn depth 1"), "{text}");
277 }
278
279 #[test]
280 fn roster_loads_config_members_through_the_shared_merge() {
281 let tmp = tempfile::TempDir::new().unwrap();
282 let mut profiles = BTreeMap::new();
283 profiles.insert(
284 "docs-writer".to_string(),
285 codewhale_config::FleetProfile {
286 slot: codewhale_config::FleetSlot::from_name("scout"),
287 role: codewhale_config::FleetRole {
288 name: "scout".to_string(),
289 description: Some("Writes docs.".to_string()),
290 instructions: None,
291 },
292 loadout: codewhale_config::FleetLoadout::Fast,
293 model: None,
294 provider: None,
295 reasoning_effort: None,
296 permissions: codewhale_config::FleetProfilePermissions::default(),
297 delegation: codewhale_config::FleetDelegationHints::default(),
298 },
299 );
300 let config = codewhale_config::FleetConfigToml {
301 profiles,
302 ..codewhale_config::FleetConfigToml::default()
303 };
304 let view =
305 FleetRosterView::from_parts(operator(), FleetRoster::load(&config, tmp.path()), None);
306 let extra = view.members.iter().find(|m| m.id == "docs-writer").unwrap();
307 assert_eq!(extra.origin, ProfileOrigin::Config);
308 assert_eq!(
309 member_routing(extra),
310 "route preset fast (resolved at launch)"
311 );
312 }
313
314 #[test]
315 fn detail_pane_reports_shadowed_lower_layers() {
316 // #5098: a member whose winning layer ignores a personal file must
317 // say so in the detail pane — the shadowed edit is no longer dropped
318 // from every surface.
319 let mut view = view_with_overrides();
320 view.shadowed.push(crate::fleet::roster::ShadowedProfile {
321 id: "reviewer".to_string(),
322 shadowed_origin: ProfileOrigin::Personal,
323 shadowed_source: PathBuf::from("/home/op/.codewhale/agents/reviewer.toml"),
324 winner_origin: ProfileOrigin::Workspace,
325 winner_source: PathBuf::from(".codewhale/agents/reviewer.toml"),
326 });
327 let reviewer = view.members.iter().find(|m| m.id == "reviewer").unwrap();
328 let text = member_detail_lines_with_session(reviewer, None, &view.shadowed)
329 .iter()
330 .map(|line| {
331 line.spans
332 .iter()
333 .map(|span| span.content.clone().into_owned())
334 .collect::<String>()
335 })
336 .collect::<Vec<_>>()
337 .join("\n");
338 assert!(text.contains("Shadows"), "detail names the shadow: {text}");
339 assert!(
340 text.contains("personal copy at /home/op/.codewhale/agents/reviewer.toml (ignored)"),
341 "detail names the ignored file: {text}"
342 );
343 }
344
345 #[test]
346 fn fleet_roster_is_usable_and_opaque_at_blocker_sizes() {
347 type Builder = (&'static str, fn() -> FleetRosterView);
348 let builders: [Builder; 3] = [
349 ("built-ins", built_in_view),
350 ("overrides", view_with_overrides),
351 ("last-selected", || {
352 let mut v = built_in_view();
353 v.selected = v.row_count() - 1;
354 v
355 }),
356 ];
357
358 for (label, make) in builders {
359 for (w, h) in BLOCKER_SIZES {
360 let rows = render_through_stack(make, w, h);
361 let text = rows.join("\n");
362
363 // No bleed-through anywhere in the composited frame.
364 assert!(
365 !text.contains('X'),
366 "{label} {w}x{h}: background bleed-through"
367 );
368 // Some action label is always visible.
369 assert!(text.contains("close"), "{label} {w}x{h}: missing footer");
370 // The first impression names Fleet as the worker/orchestration surface.
371 assert!(
372 text.contains("fleet") && text.contains("workers"),
373 "{label} {w}x{h}: missing framing"
374 );
375 // The selected row's detail is on screen.
376 assert!(
377 text.contains("Posture"),
378 "{label} {w}x{h}: missing detail pane"
379 );
380 // No row overflows the frame width.
381 for (y, row) in rows.iter().enumerate() {
382 assert!(
383 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
384 "{label} {w}x{h}: row {y} overflows: {row:?}"
385 );
386 }
387 }
388 }
389 }
390
391 #[test]
392 fn selection_stays_visible_when_list_scrolls() {
393 // Select the last member and render short: the pointer row must be
394 // in the frame.
395 let rows = render_through_stack(
396 || {
397 let mut v = built_in_view();
398 v.selected = v.row_count() - 1;
399 v
400 },
401 80,
402 24,
403 );
404 let text = rows.join("\n");
405 assert!(text.contains("▸ · general"), "{text}");
406 }
407
407 lines RUST