返回 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 mouse(kind: MouseEventKind, area: Rect) -> MouseEvent {
15 MouseEvent {
16 kind,
17 column: area.x,
18 row: area.y,
19 modifiers: KeyModifiers::NONE,
20 }
21 }
22
23 fn setup_member_id(action: ViewAction) -> Option<String> {
24 match action {
25 ViewAction::Emit(ViewEvent::FleetRosterOpenSetupRequested { member_id }) => Some(member_id),
26 _ => None,
27 }
28 }
29
30 fn operator() -> OperatorInfo {
31 OperatorInfo {
32 provider: "DeepSeek".to_string(),
33 provider_id: "deepseek".to_string(),
34 model: "deepseek-v4-pro".to_string(),
35 reasoning: "Auto".to_string(),
36 }
37 }
38
39 fn built_in_view() -> FleetRosterView {
40 FleetRosterView::from_parts(operator(), FleetRoster::built_ins_only(), None)
41 }
42
43 fn view_with_overrides() -> FleetRosterView {
44 let mut members = FleetRoster::built_ins_only()
45 .members()
46 .iter()
47 .filter(|m| !m.id.trim().eq_ignore_ascii_case("operator"))
48 .cloned()
49 .collect::<Vec<_>>();
50 // A project override of the built-in reviewer with a pinned model and
51 // an instruction overlay.
52 if let Some(reviewer) = members.iter_mut().find(|m| m.id == "reviewer") {
53 reviewer.origin = ProfileOrigin::Workspace;
54 reviewer.source = PathBuf::from(".codewhale/agents/reviewer.toml");
55 reviewer.profile.model = Some("glm-5.2".to_string());
56 reviewer.profile.role.instructions = Some("Review hard.".to_string());
57 reviewer.profile.delegation.max_spawn_depth = Some(1);
58 }
59 FleetRosterView {
60 operator: operator(),
61 members,
62 shadowed: Vec::new(),
63 selected_fleet: None,
64 load_error: None,
65 selected: 0,
66 detail_scroll: 0,
67 row_hitboxes: RefCell::new(Vec::new()),
68 last_mouse_selected: None,
69 hovered_row: Cell::new(None),
70 workers_hitbox: Cell::new(None),
71 hovered_workers: Cell::new(false),
72 surface_bg: palette::UI_THEME.surface_bg,
73 locale: Locale::En,
74 }
75 }
76
77 fn selected_fleet_view() -> FleetRosterView {
78 let mut view = view_with_overrides();
79 view.selected_fleet = Some(SelectedFleetSummary {
80 name: "Default".to_string(),
81 scope: crate::fleet::store::FleetScope::Personal,
82 });
83 view
84 }
85
86 #[test]
87 fn session_subagent_tab_is_named_workers_not_durable_runs() {
88 assert_eq!(
89 tr(Locale::En, MessageId::FleetRosterWorkers),
90 "workers",
91 "the w tab opens current-session sub-agents; durable runs belong to /fleet runs"
92 );
93 }
94
95 fn render_through_stack(make: impl Fn() -> FleetRosterView, w: u16, h: u16) -> Vec<String> {
96 let area = Rect::new(0, 0, w, h);
97 let mut buf = Buffer::empty(area);
98 for y in 0..h {
99 for x in 0..w {
100 buf[(x, y)].set_symbol("X");
101 }
102 }
103 let mut stack = ViewStack::new();
104 stack.push(make());
105 stack.render(area, &mut buf);
106 (0..h)
107 .map(|y| {
108 (0..w)
109 .map(|x| buf[(x, y)].symbol().to_string())
110 .collect::<String>()
111 })
112 .collect()
113 }
114
115 /// #4208: every role mark and control glyph on the roster — operator,
116 /// role shapes, selection arrows, scroll rails — must narrow to an
117 /// ASCII-safe alternative.
118 #[test]
119 fn fleet_roster_glyphs_all_have_ascii_alternatives() {
120 let rows = render_through_stack(view_with_overrides, 100, 30);
121 for ch in rows.join("\n").chars().filter(|ch| !ch.is_ascii()) {
122 let mut cell = ratatui::buffer::Cell::default();
123 cell.set_symbol(&ch.to_string());
124 crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
125 assert!(
126 cell.symbol().is_ascii(),
127 "fleet glyph {ch:?} (U+{:04X}) lacks an ASCII-safe alternative",
128 ch as u32
129 );
130 }
131 }
132
133 #[test]
134 fn operator_row_is_pinned_first_with_the_session_model() {
135 let rows = render_through_stack(built_in_view, 100, 30);
136 let text = rows.join("\n");
137 // The operator row leads the list and the detail pane (row 0 is
138 // selected on open) shows the live session route.
139 let operator_row = rows
140 .iter()
141 .position(|row| row.contains("Coordinator"))
142 .expect("operator row rendered");
143 let first_member_row = rows
144 .iter()
145 .position(|row| row.contains("manager"))
146 .expect("first member rendered");
147 assert!(
148 operator_row < first_member_row,
149 "operator must render above the first member"
150 );
151 assert!(
152 text.contains("▸ @ Coordinator"),
153 "operator selected on open"
154 );
155 assert!(text.contains("deepseek-v4-pro"), "session model shown");
156 assert!(text.contains("full session access"), "{text}");
157 // Inline field labels can wrap the role at this width. Read just the
158 // inspector columns, excluding the independently positioned member list.
159 let detail_start = rows
160 .iter()
161 .find_map(|row| row.find("Role ").map(|index| row[..index].chars().count()))
162 .expect("role field rendered");
163 let detail = rows
164 .iter()
165 .map(|row| {
166 row.chars()
167 .skip(detail_start)
168 .collect::<String>()
169 .trim()
170 .to_owned()
171 })
172 .collect::<Vec<_>>()
173 .join(" ");
174 assert!(detail.contains("leads the Fleet"), "{text}");
175 }
176
177 #[test]
178 fn arrows_move_selection_and_wrap() {
179 let mut view = built_in_view();
180 let last = view.members.len();
181 assert_eq!(view.selected, 0);
182
183 view.handle_key(key(KeyCode::Up));
184 assert_eq!(
185 view.selected, last,
186 "up from the operator wraps to the last member (#4755)"
187 );
188
189 view.handle_key(key(KeyCode::Down));
190 assert_eq!(
191 view.selected, 0,
192 "down from the last member wraps to the operator"
193 );
194
195 view.handle_key(key(KeyCode::Down));
196 assert_eq!(view.selected, 1, "first member follows the operator");
197
198 // A full cycle of the roster returns to where it started.
199 for _ in 0..=last {
200 view.handle_key(key(KeyCode::Down));
201 }
202 assert_eq!(view.selected, 1, "one full cycle is the identity");
203 }
204
205 #[test]
206 fn selection_change_resets_detail_scroll() {
207 let mut view = built_in_view();
208 // Bare paging drives the row list; Shift-modified paging scrolls the
209 // detail pane (#6290, #6014-style split).
210 view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT));
211 assert_eq!(view.detail_scroll, 8);
212 view.handle_key(key(KeyCode::Down));
213 assert_eq!(view.detail_scroll, 0);
214 }
215
216 #[test]
217 fn bare_paging_drives_rows_not_the_detail_pane() {
218 let mut view = built_in_view();
219 let last = view.members.len();
220 view.handle_key(key(KeyCode::PageDown));
221 assert_eq!(view.detail_scroll, 0);
222 assert_eq!(view.selected, 10.min(last));
223 view.handle_key(key(KeyCode::Home));
224 assert_eq!(view.selected, 0);
225 }
226
227 #[test]
228 fn enter_opens_role_assignment_and_keeps_the_roster_underneath() {
229 // Coordinator changes the current session through the shared picker.
230 let mut view = built_in_view();
231 assert!(view.operator_selected());
232 assert!(
233 matches!(
234 view.handle_key(key(KeyCode::Enter)),
235 ViewAction::Emit(ViewEvent::FleetRosterOpenCoordinatorRequested)
236 ),
237 "Enter opens the Coordinator model picker"
238 );
239
240 // Member assignment retains this roster on the view stack.
241 view.handle_key(key(KeyCode::Down));
242 let action = view.handle_key(key(KeyCode::Enter));
243 let ViewAction::Emit(ViewEvent::FleetRosterOpenSetupRequested { member_id }) = action else {
244 panic!("Enter should hand off to the setup wizard");
245 };
246 assert_eq!(member_id, "manager");
247 }
248
249 #[test]
250 fn mouse_selection_reveals_details_then_activates_the_same_member_as_enter() {
251 let area = Rect::new(0, 0, 100, 30);
252 let mut view = built_in_view();
253 let mut buf = Buffer::empty(area);
254 view.render(area, &mut buf);
255 let manager_row = view
256 .row_hitboxes
257 .borrow()
258 .iter()
259 .find_map(|(rect, action)| (action.row() == 1).then_some(*rect))
260 .expect("manager row hitbox");
261 let click = mouse(MouseEventKind::Down(MouseButton::Left), manager_row);
262
263 view.detail_scroll = 8;
264 assert!(matches!(view.handle_mouse(click), ViewAction::None));
265 assert_eq!(view.selected, 1);
266 assert_eq!(view.detail_scroll, 0);
267
268 // Selection owns the existing detail pane; clicking does not invent a
269 // second details route.
270 let mut selected_buf = Buffer::empty(area);
271 view.render(area, &mut selected_buf);
272 let selected_text = (0..area.height)
273 .flat_map(|y| (0..area.width).map(move |x| (x, y)))
274 .map(|(x, y)| selected_buf[(x, y)].symbol())
275 .collect::<String>();
276 assert!(selected_text.contains("Role"), "{selected_text}");
277 assert!(selected_text.contains("manager"), "{selected_text}");
278
279 let mouse_member = setup_member_id(view.handle_mouse(click)).expect("second click activates");
280 let mut keyboard = built_in_view();
281 keyboard.handle_key(key(KeyCode::Down));
282 let keyboard_member =
283 setup_member_id(keyboard.handle_key(key(KeyCode::Enter))).expect("Enter activates");
284 assert_eq!(mouse_member, keyboard_member);
285 }
286
287 #[test]
288 fn hover_tints_roster_row_without_moving_selection() {
289 let area = Rect::new(0, 0, 100, 30);
290 let mut view = built_in_view();
291 let mut buf = Buffer::empty(area);
292 view.render(area, &mut buf);
293 assert_eq!(view.selected, 0);
294 let manager_row = view
295 .row_hitboxes
296 .borrow()
297 .iter()
298 .find_map(|(rect, action)| (action.row() == 1).then_some(*rect))
299 .expect("manager row hitbox");
300
301 assert!(matches!(
302 view.handle_mouse(mouse(MouseEventKind::Moved, manager_row)),
303 ViewAction::None
304 ));
305 assert_eq!(view.hovered_row.get(), Some(1));
306 assert_eq!(view.selected, 0);
307
308 // Repaint: the hovered row wears the shared hover band on its text.
309 let mut hovered_buf = Buffer::empty(area);
310 view.render(area, &mut hovered_buf);
311 assert_eq!(
312 hovered_buf[(manager_row.x, manager_row.y)].bg,
313 codewhale_palette::SURFACE_ELEVATED,
314 "hovered roster row must show the shared hover band"
315 );
316
317 // Keyboard motion clears the tint so a stale row never glows.
318 view.handle_key(key(KeyCode::Down));
319 assert_eq!(view.hovered_row.get(), None);
320 }
321
322 #[test]
323 fn mouse_wheel_and_arrow_keys_share_roster_selection_semantics() {
324 let mut mouse_view = built_in_view();
325 let mut keyboard_view = built_in_view();
326 let anywhere = Rect::new(0, 0, 1, 1);
327
328 mouse_view.handle_mouse(mouse(MouseEventKind::ScrollDown, anywhere));
329 keyboard_view.handle_key(key(KeyCode::Down));
330 assert_eq!(mouse_view.selected, keyboard_view.selected);
331 assert_eq!(mouse_view.detail_scroll, keyboard_view.detail_scroll);
332
333 mouse_view.handle_mouse(mouse(MouseEventKind::ScrollUp, anywhere));
334 keyboard_view.handle_key(key(KeyCode::Up));
335 assert_eq!(mouse_view.selected, keyboard_view.selected);
336 assert_eq!(mouse_view.detail_scroll, keyboard_view.detail_scroll);
337 }
338
339 #[test]
340 fn saved_profile_rows_keep_view_owned_typed_actions() {
341 let area = Rect::new(0, 0, 160, 40);
342 let view = built_in_view();
343 let mut buf = Buffer::empty(area);
344 view.render(area, &mut buf);
345
346 let hitboxes = view.row_hitboxes.borrow();
347 assert_eq!(hitboxes.len(), view.row_count());
348 for (_, action) in hitboxes.iter() {
349 assert!(matches!(
350 action,
351 FleetRosterRowAction::SelectOrActivate { .. }
352 ));
353 }
354 }
355
356 #[test]
357 fn match_terminal_roster_surface_uses_reset_background() {
358 let area = Rect::new(0, 0, 100, 30);
359 let mut view = built_in_view();
360 view.surface_bg = palette::TERMINAL_UI_THEME.surface_bg;
361 let mut buf = Buffer::empty(area);
362 for cell in &mut buf.content {
363 cell.set_bg(Color::Red);
364 }
365
366 view.render(area, &mut buf);
367
368 assert_eq!(view.surface_bg, Color::Reset);
369 assert_eq!(buf[(0, 0)].bg, Color::Reset);
370 }
371
372 #[test]
373 fn selected_named_fleet_member_shows_edit_affordance() {
374 let rows = render_through_stack(
375 || {
376 let mut view = selected_fleet_view();
377 view.selected = 1;
378 view
379 },
380 100,
381 30,
382 );
383 let text = rows.join("\n");
384 assert!(text.contains("Thinking"), "{text}");
385 assert!(text.contains("Team `Default`"), "{text}");
386 assert!(text.contains("Enter model & thinking"), "{text}");
387 assert!(text.contains("Tab workers"), "{text}");
388 assert!(text.contains("saved teams"), "{text}");
389 // The letter-key wall is gone: one grammar, no `s`, `m`, `w`, PgUp hints.
390 assert!(!text.contains("m model"), "{text}");
391 assert!(!text.contains("PgUp"), "{text}");
392 }
393
394 /// #5954: the forward jump `Emit`s and keeps the roster on the stack, so the
395 /// view it opens is pushed on top and `Esc` there pops back here.
396 #[test]
397 fn tab_and_w_open_the_live_workers_tab() {
398 for code in [KeyCode::Tab, KeyCode::BackTab, KeyCode::Char('w')] {
399 let mut view = built_in_view();
400 assert!(
401 matches!(
402 view.handle_key(key(code)),
403 ViewAction::Emit(ViewEvent::FleetRosterOpenWorkersRequested)
404 ),
405 "{code:?}"
406 );
407 }
408 }
409
410 /// #5954: same for the saved-teams jump.
411 #[test]
412 fn f_opens_saved_teams_without_closing_the_roster() {
413 let mut view = built_in_view();
414 assert!(matches!(
415 view.handle_key(key(KeyCode::Char('f'))),
416 ViewAction::Emit(ViewEvent::FleetRosterOpenFleetsRequested)
417 ));
418 }
419
420 #[test]
421 fn esc_closes() {
422 let mut view = built_in_view();
423 assert!(matches!(
424 view.handle_key(key(KeyCode::Esc)),
425 ViewAction::Close
426 ));
427 }
428
429 /// #5954, end to end on the real stack: `Tab` leaves the roster in place,
430 /// the host pushes workers on top, and `Esc` there returns to the roster
431 /// rather than closing the window.
432 #[test]
433 fn roster_tab_workers_esc_returns_to_the_roster() {
434 let mut stack = ViewStack::new();
435 stack.push(built_in_view());
436
437 let events = stack.handle_key(key(KeyCode::Tab));
438 assert!(
439 matches!(
440 events.as_slice(),
441 [ViewEvent::FleetRosterOpenWorkersRequested]
442 ),
443 "{events:?}"
444 );
445 assert_eq!(
446 stack.top_kind(),
447 Some(ModalKind::FleetRoster),
448 "the roster must survive the forward jump so it can be popped back to"
449 );
450
451 // What ui/handlers.rs does with that event.
452 stack.push(crate::tui::views::SubAgentsView::new(Vec::new()).over_fleet_roster());
453 assert_eq!(stack.top_kind(), Some(ModalKind::SubAgents));
454
455 stack.handle_key(key(KeyCode::Esc));
456 assert_eq!(
457 stack.top_kind(),
458 Some(ModalKind::FleetRoster),
459 "Esc in workers must return to the roster, not close the window"
460 );
461 }
462
463 /// #5954: `Esc` at the root of the Fleet stack still closes the window.
464 #[test]
465 fn esc_at_the_roster_root_closes_the_window() {
466 let mut stack = ViewStack::new();
467 stack.push(built_in_view());
468 stack.handle_key(key(KeyCode::Esc));
469 assert!(stack.is_empty(), "Esc at the roster must close the window");
470 }
471
472 #[test]
473 fn built_in_party_lists_all_members_in_canonical_order() {
474 let view = built_in_view();
475 let ids: Vec<&str> = view.members.iter().map(|m| m.id.as_str()).collect();
476 // The operator is rendered as the pinned session row, not a member
477 // (#dogfood 0.8.67), so it is intentionally absent from this list. There is
478 // no built-in `general` member any more (#6244); `worker` is the posture
479 // and the selector resolves the legacy name onto it.
480 assert_eq!(
481 ids,
482 [
483 "manager",
484 "scout",
485 "builder",
486 "reviewer",
487 "verifier",
488 "consultant",
489 "synthesizer",
490 "worker",
491 "planner",
492 "custom"
493 ]
494 );
495 }
496
497 /// #5888 folded the legacy built-in `general` alias out of presentation while
498 /// keeping it in the roster. #6244 removed the member instead: two built-ins
499 /// that canonicalize to the same role make `role:general` permanently
500 /// `Ambiguous`. The lineup is unchanged — `general` was never presented — and
501 /// the name still resolves, through the identity selector rather than a second
502 /// member (proven in `fleet::roster`'s
503 /// `general_still_resolves_to_the_worker_member_without_its_own_built_in`).
504 #[test]
505 fn the_legacy_general_alias_has_no_built_in_member() {
506 let view = built_in_view();
507 let ids: Vec<&str> = view.members.iter().map(|m| m.id.as_str()).collect();
508 assert!(
509 !ids.contains(&"general"),
510 "the alias must not be presented: {ids:?}"
511 );
512 assert!(ids.contains(&"worker"), "the posture's primary name stays");
513 assert_eq!(view.row_count(), 11, "operator plus ten members");
514
515 let roster = FleetRoster::built_ins_only();
516 assert!(
517 roster.get("general").is_none(),
518 "the duplicate built-in is gone"
519 );
520 assert!(roster.get("worker").is_some());
521 }
522
523 /// A `general` the user actually defined — a saved-team member carries
524 /// Personal/Workspace origin, like `roster_from_fleet` produces — is the
525 /// user's own member and stays visible. Since #6244 there is no built-in
526 /// `general` to harvest, so the fixture derives one from `worker`, which is
527 /// exactly what a migrated fleet file looks like.
528 #[test]
529 fn user_defined_general_member_stays_visible() {
530 let worker: AgentProfile = FleetRoster::built_ins_only()
531 .members()
532 .iter()
533 .find(|m| m.id == "worker")
534 .cloned()
535 .expect("worker built-in");
536 let mut user_general = worker.clone();
537 user_general.id = "general".to_string();
538 let mut members = vec![worker, user_general];
539 for member in members.iter_mut().filter(|m| m.id == "general") {
540 member.origin = ProfileOrigin::Personal;
541 member.source = PathBuf::from("fleets/Default.toml");
542 }
543 let view = FleetRosterView::from_parts(operator(), FleetRoster::from_members(members), None);
544 let ids: Vec<&str> = view.members.iter().map(|m| m.id.as_str()).collect();
545 assert!(
546 ids.contains(&"general") && ids.contains(&"worker"),
547 "a user-defined general is presented: {ids:?}"
548 );
549 assert_eq!(view.row_count(), 3, "operator plus both members");
550 }
551
552 #[test]
553 fn detail_shows_access_model_and_saved_for() {
554 // Built-in reviewer: read-only files, full shell for its bounded
555 // verification surface, network on. Inherits the session model.
556 let reviewer = FleetRoster::built_ins_only()
557 .get("reviewer")
558 .unwrap()
559 .clone();
560 assert!(member_access_summary(&reviewer).contains("read-only files"));
561 assert_eq!(
562 member_routing_with_session(&reviewer, None),
563 "same model as this session"
564 );
565
566 // Built-in scout: same Access shape as reviewer.
567 let scout = FleetRoster::built_ins_only().get("scout").unwrap().clone();
568 assert!(member_access_summary(&scout).contains("read-only files"));
569
570 // Builder can edit files and run commands.
571 let builder = FleetRoster::built_ins_only()
572 .get("builder")
573 .unwrap()
574 .clone();
575 assert!(member_access_summary(&builder).contains("can edit files"));
576
577 // An explicit model beats the saved-set label, with no "(pinned)" jargon.
578 let mut pinned = reviewer.clone();
579 pinned.profile.model = Some("glm-5.2".to_string());
580 assert_eq!(member_routing_with_session(&pinned, None), "model glm-5.2");
581 }
582
583 include!("../fleet_roster_capability_tests.rs");
584
585 #[test]
586 fn detail_lines_carry_overlay_source_for_project_members() {
587 let view = view_with_overrides();
588 let reviewer = view.members.iter().find(|m| m.id == "reviewer").unwrap();
589 let text = member_detail_lines_with_session(reviewer, None, &view.shadowed, view.locale)
590 .iter()
591 .map(|line| {
592 line.spans
593 .iter()
594 .map(|span| span.content.clone().into_owned())
595 .collect::<String>()
596 })
597 .collect::<Vec<_>>()
598 .join("\n");
599 assert!(text.contains("project"), "{text}");
600 assert!(
601 text.contains("custom overlay (.codewhale/agents/reviewer.toml)"),
602 "{text}"
603 );
604 assert!(text.contains("model glm-5.2"), "{text}");
605 assert!(text.contains("spawn depth 1"), "{text}");
606 }
607
608 #[test]
609 fn roster_loads_config_members_through_the_shared_merge() {
610 let tmp = tempfile::TempDir::new().unwrap();
611 let mut profiles = BTreeMap::new();
612 profiles.insert(
613 "docs-writer".to_string(),
614 codewhale_config::FleetProfile {
615 slot: codewhale_config::FleetSlot::from_name("scout"),
616 role: codewhale_config::FleetRole {
617 name: "scout".to_string(),
618 description: Some("Writes docs.".to_string()),
619 instructions: None,
620 },
621 loadout: codewhale_config::FleetLoadout::Fast,
622 model: None,
623 provider: None,
624 reasoning_effort: None,
625 permissions: codewhale_config::FleetProfilePermissions::default(),
626 delegation: codewhale_config::FleetDelegationHints::default(),
627 },
628 );
629 let config = codewhale_config::FleetConfigToml {
630 profiles,
631 ..codewhale_config::FleetConfigToml::default()
632 };
633 let view =
634 FleetRosterView::from_parts(operator(), FleetRoster::load(&config, tmp.path()), None);
635 let extra = view.members.iter().find(|m| m.id == "docs-writer").unwrap();
636 assert_eq!(extra.origin, ProfileOrigin::Config);
637 assert_eq!(
638 member_routing_with_session(extra, None),
639 "fast model, picked at launch"
640 );
641 }
642
643 #[test]
644 fn detail_pane_reports_shadowed_lower_layers() {
645 // #5098: a member whose winning layer ignores a personal file must
646 // say so in the detail pane — the shadowed edit is no longer dropped
647 // from every surface.
648 let mut view = view_with_overrides();
649 view.shadowed.push(crate::fleet::roster::ShadowedProfile {
650 id: "reviewer".to_string(),
651 shadowed_origin: ProfileOrigin::Personal,
652 shadowed_source: PathBuf::from("/home/op/.codewhale/agents/reviewer.toml"),
653 winner_origin: ProfileOrigin::Workspace,
654 winner_source: PathBuf::from(".codewhale/agents/reviewer.toml"),
655 });
656 let reviewer = view.members.iter().find(|m| m.id == "reviewer").unwrap();
657 let text = member_detail_lines_with_session(reviewer, None, &view.shadowed, view.locale)
658 .iter()
659 .map(|line| {
660 line.spans
661 .iter()
662 .map(|span| span.content.clone().into_owned())
663 .collect::<String>()
664 })
665 .collect::<Vec<_>>()
666 .join("\n");
667 assert!(
668 text.contains("Saved for"),
669 "detail lists every layer for the id: {text}"
670 );
671 assert!(
672 text.contains("project · .codewhale/agents/reviewer.toml (active)"),
673 "detail names the winning layer: {text}"
674 );
675 assert!(
676 text.contains("personal · /home/op/.codewhale/agents/reviewer.toml (ignored copy)"),
677 "detail names the ignored file: {text}"
678 );
679 }
680
681 #[test]
682 fn roster_row_badges_personal_copy_ignored() {
683 // #5098: the list row itself must say the personal file is ignored,
684 // not only the detail pane.
685 let rows = render_through_stack(
686 || {
687 let mut view = view_with_overrides();
688 view.shadowed.push(crate::fleet::roster::ShadowedProfile {
689 id: "reviewer".to_string(),
690 shadowed_origin: ProfileOrigin::Personal,
691 shadowed_source: PathBuf::from("/home/op/.codewhale/agents/reviewer.toml"),
692 winner_origin: ProfileOrigin::Workspace,
693 winner_source: PathBuf::from(".codewhale/agents/reviewer.toml"),
694 });
695 view
696 },
697 120,
698 32,
699 );
700 let text = rows.join("\n");
701 assert!(
702 text.contains("saved copy ignored"),
703 "shadowed reviewer row is badged: {text}"
704 );
705 }
706
707 #[test]
708 fn fleet_roster_is_usable_and_opaque_at_blocker_sizes() {
709 type Builder = (&'static str, fn() -> FleetRosterView);
710 let builders: [Builder; 3] = [
711 ("built-ins", built_in_view),
712 ("overrides", view_with_overrides),
713 ("last-selected", || {
714 let mut v = built_in_view();
715 v.selected = v.row_count() - 1;
716 v
717 }),
718 ];
719
720 for (label, make) in builders {
721 for (w, h) in BLOCKER_SIZES {
722 let rows = render_through_stack(make, w, h);
723 let text = rows.join("\n");
724
725 // No bleed-through anywhere in the composited frame.
726 assert!(
727 !text.contains('X'),
728 "{label} {w}x{h}: background bleed-through"
729 );
730 // Some action label is always visible.
731 assert!(text.contains("close"), "{label} {w}x{h}: missing footer");
732 // The first impression names the team as the worker/orchestration surface.
733 assert!(
734 text.contains("team") && text.contains("workers"),
735 "{label} {w}x{h}: missing framing"
736 );
737 // The selected row's detail is on screen.
738 assert!(
739 text.contains("Access"),
740 "{label} {w}x{h}: missing detail pane"
741 );
742 // No row overflows the frame width.
743 for (y, row) in rows.iter().enumerate() {
744 assert!(
745 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
746 "{label} {w}x{h}: row {y} overflows: {row:?}"
747 );
748 }
749 }
750 }
751 }
752
753 /// Whale Teams: the selected member's detail pane carries the identity
754 /// block (badge plus `Name · species · job`) with no
755 /// caption labels and no state claim. The hand-drawn portrait art was
756 /// deleted per the 2026-08-29 founder directive, so no tier ever draws it.
757 #[test]
758 fn roster_detail_carries_whale_identity_without_claiming_state() {
759 let wide = render_through_stack(
760 || {
761 let mut v = built_in_view();
762 v.selected = 2; // scout
763 v
764 },
765 120,
766 32,
767 )
768 .join("\n");
769 assert!(wide.contains("◂▰ Scout"), "{wide}");
770 assert!(wide.contains("Scout · beaked whale · research"), "{wide}");
771 assert!(
772 !wide.contains("Whale identity"),
773 "no caption labels: {wide}"
774 );
775 assert!(!wide.contains("identity only"), "no caption labels: {wide}");
776 for word in ["Working", "Waiting for you", "Blocked", "Offline"] {
777 assert!(!wide.contains(word), "roster must not claim {word}: {wide}");
778 }
779
780 let narrow = render_through_stack(
781 || {
782 let mut v = built_in_view();
783 v.selected = 2;
784 v
785 },
786 56,
787 20,
788 )
789 .join("\n");
790 assert!(narrow.contains("◂▰ Scout · beaked whale"), "{narrow}");
791 assert!(
792 !narrow.contains("▚△▞"),
793 "the deleted portrait fluke must stay deleted, even at wide tiers: {narrow}"
794 );
795 assert!(
796 !wide.contains("▚△▞"),
797 "the deleted portrait fluke must stay deleted: {wide}"
798 );
799 }
800
801 #[test]
802 fn selection_stays_visible_when_list_scrolls() {
803 // Select the last member and render short: the pointer row must be
804 // in the frame.
805 let rows = render_through_stack(
806 || {
807 let mut v = built_in_view();
808 v.selected = v.row_count() - 1;
809 v
810 },
811 80,
812 24,
813 );
814 let text = rows.join("\n");
815 assert!(text.contains("▸ · custom"), "{text}");
816 }
817
818 #[test]
819 fn workbench_workers_header_is_clickable_without_moving_selection() {
820 for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
821 let mut view = built_in_view();
822 let area = Rect::new(0, 0, width, height);
823 let mut buf = Buffer::empty(area);
824 view.render(area, &mut buf);
825 let hit = view
826 .workers_hitbox
827 .get()
828 .expect("visible Workers destination");
829 assert!(area.contains((hit.x, hit.y).into()));
830 view.handle_mouse(mouse(MouseEventKind::Moved, hit));
831 assert!(view.hovered_workers.get());
832 assert_eq!(view.selected, 0);
833 assert!(matches!(
834 view.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), hit)),
835 ViewAction::Emit(ViewEvent::FleetRosterOpenWorkersRequested)
836 ));
837 }
838 }
839
839 lines RUST