| 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 |