use std::collections::HashMap;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table, Wrap};
use super::common::{ListState, fill_cell, render_error, render_key_hints, truncate};
use super::{FetchRequest, View, ViewAction};
use crate::cli::remote::DiscoveredRoom;
use crate::cli::tui::event::{self, AppEvent, DataEvent, PolicyInfo};
use crate::cli::workspace::RoomConfig;
pub struct MainMenuView {
rooms: Vec<(String, RoomConfig)>,
default_room: Option<String>,
remote_rooms: Vec<DiscoveredRoom>,
remote_policies: Vec<PolicyInfo>,
orchestrator: String,
list_state: ListState,
task_input_active: bool,
task_text: String,
detail_visible: bool,
pub threshold_text: String,
pub threshold_input_active: bool,
}
impl MainMenuView {
pub fn new(
rooms: HashMap<String, RoomConfig>,
default_room: Option<String>,
orchestrator: String,
) -> Self {
let mut sorted: Vec<_> = rooms.into_iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let count = sorted.len();
Self {
rooms: sorted,
default_room,
remote_rooms: Vec::new(),
remote_policies: Vec::new(),
orchestrator,
list_state: ListState::new(count),
task_input_active: false,
task_text: String::new(),
detail_visible: false,
threshold_text: String::from("0.7"),
threshold_input_active: false,
}
}
fn is_empty(&self) -> bool {
self.rooms.is_empty() && self.remote_rooms.is_empty()
}
fn shown_count(&self) -> usize {
self.rooms.len() + self.remote_rooms.len()
}
fn selected_kind(&self) -> Option<Sel> {
let sel = self.list_state.selected;
if sel < self.rooms.len() {
Some(Sel::Local(sel))
} else if sel < self.shown_count() {
Some(Sel::Remote(sel - self.rooms.len()))
} else {
None
}
}
fn parsed_threshold(&self) -> Option<f32> {
let trimmed = self.threshold_text.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse::<f32>().ok().map(|v| v.clamp(0.0, 1.0))
}
fn policy_label(&self, policy: &str) -> String {
self.remote_policies
.iter()
.find(|p| p.policy_id == policy || p.name == policy)
.map(|p| p.name.clone())
.unwrap_or_else(|| policy.to_string())
}
fn selected_room(&self) -> Option<&(String, RoomConfig)> {
match self.selected_kind() {
Some(Sel::Local(i)) => self.rooms.get(i),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Sel {
Local(usize),
Remote(usize),
}
impl View for MainMenuView {
fn captures_input(&self) -> bool {
self.task_input_active
}
fn on_enter(&mut self) -> Vec<ViewAction> {
vec![
ViewAction::Fetch(FetchRequest::Rooms {
orchestrator: self.orchestrator.clone(),
}),
ViewAction::Fetch(FetchRequest::Policies {
orchestrator: self.orchestrator.clone(),
tag: None,
}),
]
}
fn update(&mut self, app_event: &AppEvent) -> Option<ViewAction> {
if let AppEvent::Data(DataEvent::RoomsLoaded {
orchestrator,
rooms,
}) = app_event
&& *orchestrator == self.orchestrator
{
self.remote_rooms = rooms.clone();
self.list_state.set_count(self.shown_count());
return None;
}
if let AppEvent::Data(DataEvent::PoliciesLoaded {
orchestrator,
policies,
}) = app_event
&& *orchestrator == self.orchestrator
{
self.remote_policies = policies.clone();
return None;
}
if let AppEvent::Data(DataEvent::FetchError { context, error }) = app_event {
return Some(ViewAction::SetStatus(
format!("{context} failed: {error}"),
super::StatusLevel::Error,
));
}
let AppEvent::Terminal(event) = app_event else {
return None;
};
if self.task_input_active {
return self.update_task_input(event);
}
if self.detail_visible {
if event::is_escape(event) || event::is_key(event, 'q') {
self.detail_visible = false;
return None;
}
if event::is_up(event) {
self.list_state.up();
}
if event::is_down(event) {
self.list_state.down();
}
if event::is_enter(event) && self.shown_count() > 0 {
self.detail_visible = false;
self.task_input_active = true;
self.task_text.clear();
}
return None;
}
if event::is_key(event, 'q') || event::is_escape(event) {
return Some(ViewAction::Quit);
}
if event::is_key(event, 'n') {
return Some(ViewAction::Push(super::ViewId::Rooms));
}
if event::is_up(event) {
self.list_state.up();
}
if event::is_down(event) {
self.list_state.down();
}
if event::is_enter(event) && self.shown_count() > 0 {
self.task_input_active = true;
self.task_text.clear();
return None;
}
if event::is_key(event, 'd') && self.selected_room().is_some() {
self.detail_visible = true;
return None;
}
None
}
fn draw(&mut self, frame: &mut Frame, area: Rect) {
let input_height = if self.task_input_active {
let inner_width = area.width.saturating_sub(2).max(1) as usize; let char_count = self.task_text.chars().count().max(1);
let wrapped_lines = char_count.div_ceil(inner_width);
let reserved = 2 + 1 + 1; let max_lines = area.height.saturating_sub(reserved + 2) as usize; let max_lines = max_lines.max(1); (wrapped_lines.min(max_lines) as u16) + 2
} else {
0
};
let threshold_height = if self.task_input_active { 3 } else { 0 };
let chunks = Layout::vertical([
Constraint::Length(2), Constraint::Length(input_height), Constraint::Length(threshold_height), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled(
" NSED ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("— Multi-Agent Deliberation"),
]))
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_type(BorderType::Plain),
);
frame.render_widget(title, chunks[0]);
if self.task_input_active {
let room_name = self
.rooms
.get(self.list_state.selected)
.map(|(n, _)| n.as_str())
.unwrap_or("?");
let task_focused = !self.threshold_input_active;
let task_style = if task_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let task_border = if task_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let input = Paragraph::new(self.task_text.as_str())
.style(task_style)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(task_border)
.title(format!(" Task for '{room_name}' (Tab → threshold) ")),
)
.wrap(Wrap { trim: false });
frame.render_widget(input, chunks[1]);
let threshold_focused = self.threshold_input_active;
let threshold_style = if threshold_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let threshold_border = if threshold_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let threshold_paragraph = Paragraph::new(self.threshold_text.as_str())
.style(threshold_style)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(threshold_border)
.title(" Convergence threshold (0.0–1.0, default 0.7) "),
);
frame.render_widget(threshold_paragraph, chunks[2]);
}
if self.is_empty() {
render_error(
frame,
chunks[3],
"No rooms yet — create one in Settings → Rooms (set a policy to submit here)",
);
} else {
let local_sel = match self.selected_kind() {
Some(Sel::Local(i)) => Some(i),
_ => None,
};
let remote_sel = match self.selected_kind() {
Some(Sel::Remote(i)) => Some(i),
_ => None,
};
let (local_area, remote_area) = if self.rooms.is_empty() {
(None, Some(chunks[3]))
} else if self.remote_rooms.is_empty() {
(Some(chunks[3]), None)
} else {
let local_h = ((self.rooms.len() as u16 + 3).min(chunks[3].height / 2)).max(4);
let split = Layout::vertical([Constraint::Length(local_h), Constraint::Min(0)])
.split(chunks[3]);
(Some(split[0]), Some(split[1]))
};
if let Some(la) = local_area {
if self.detail_visible && local_sel.is_some() {
let h = Layout::horizontal([
Constraint::Percentage(45),
Constraint::Percentage(55),
])
.split(la);
self.draw_table(frame, h[0], local_sel);
if let Some((name, config)) = self.selected_room() {
draw_room_detail(frame, h[1], name, config, self.default_room.as_deref());
}
} else {
self.draw_table(frame, la, local_sel);
}
}
if let Some(ra) = remote_area {
self.draw_remote_table(frame, ra, remote_sel);
}
}
let hints = if self.task_input_active {
vec![
("Enter", "Start"),
("Tab", "Task↔Threshold"),
("Esc", "Cancel"),
]
} else if self.detail_visible {
vec![
("↑↓", "Navigate"),
("Enter", "Deliberate"),
("Esc", "Close"),
]
} else {
vec![
("↑↓", "Navigate"),
("Enter", "Deliberate"),
("d", "Detail"),
("1-5/Tab", "Switch tab"),
("q", "Quit"),
]
};
render_key_hints(frame, chunks[4], &hints);
}
}
impl MainMenuView {
fn draw_remote_table(&self, frame: &mut Frame, area: Rect, selected_row: Option<usize>) {
let header = Row::new(vec![
Cell::from("Room"),
Cell::from("Policy"),
Cell::from("Tags"),
Cell::from("Fill"),
])
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let visible = area.height.saturating_sub(3) as usize;
let rows: Vec<Row> = self
.remote_rooms
.iter()
.enumerate()
.take(visible.max(1))
.map(|(i, room)| {
let style = if Some(i) == selected_row {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let policy = match room.policy.as_deref() {
Some(p) => self.policy_label(p),
None => "— (no policy)".to_string(),
};
Row::new(vec![
Cell::from(truncate(&room.id, 24)),
Cell::from(truncate(&policy, 20)),
Cell::from(truncate(&room.tags.join(", "), 26)),
fill_cell(room.eligible_agent_count, room.desired_agents),
])
.style(style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(26),
Constraint::Length(22),
Constraint::Min(18),
Constraint::Length(8),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(format!(
" Remote (orchestrator) ({}) ",
self.remote_rooms.len()
)),
);
frame.render_widget(table, area);
}
fn update_task_input(&mut self, event: &crossterm::event::Event) -> Option<ViewAction> {
if event::is_escape(event) {
self.task_input_active = false;
self.threshold_input_active = false;
return None;
}
if event::is_tab(event) {
self.threshold_input_active = !self.threshold_input_active;
return None;
}
if event::is_enter(event) && !self.task_text.is_empty() {
self.task_input_active = false;
self.threshold_input_active = false;
let effort_override = self.parsed_threshold();
return match self.selected_kind() {
Some(Sel::Remote(i)) => {
let room = &self.remote_rooms[i];
let Some(policy) = room.policy.clone() else {
return Some(ViewAction::SetStatus(
format!(
"Room '{}' has no policy bound — set one when creating it \
(Settings → Rooms → New room → policy)",
room.id
),
super::StatusLevel::Error,
));
};
Some(ViewAction::LaunchJob {
orchestrator: self.orchestrator.clone(),
task: self.task_text.clone(),
room: Some(room.id.clone()),
policy: Some(policy),
effort_override,
})
}
Some(Sel::Local(i)) => {
let (room_name, room_config) = &self.rooms[i];
let orchestrator = match &room_config.orchestrator {
Some(o) => o.clone(),
None => {
return Some(ViewAction::SetStatus(
format!("Room '{room_name}' has no orchestrator configured"),
super::StatusLevel::Error,
));
}
};
Some(ViewAction::LaunchJob {
orchestrator,
task: self.task_text.clone(),
room: Some(room_name.clone()),
policy: None,
effort_override,
})
}
None => Some(ViewAction::SetStatus(
"No rooms available".into(),
super::StatusLevel::Error,
)),
};
}
if let crossterm::event::Event::Key(key) = event
&& key.kind == crossterm::event::KeyEventKind::Press
{
let target: &mut String = if self.threshold_input_active {
&mut self.threshold_text
} else {
&mut self.task_text
};
match key.code {
crossterm::event::KeyCode::Char(c) => {
target.push(c);
return None;
}
crossterm::event::KeyCode::Backspace => {
target.pop();
return None;
}
_ => {}
}
}
None
}
fn draw_table(&self, frame: &mut Frame, area: Rect, selected_row: Option<usize>) {
let header = Row::new(vec![
Cell::from(""),
Cell::from("Room"),
Cell::from("Policy"),
Cell::from("Orchestrator"),
])
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let visible_height = area.height.saturating_sub(3) as usize;
let rows: Vec<Row> = self
.rooms
.iter()
.enumerate()
.take(visible_height.max(1))
.map(|(i, (name, config))| {
let style = if Some(i) == selected_row {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let is_default = self.default_room.as_deref() == Some(name.as_str());
let marker = if is_default { "★" } else { " " };
Row::new(vec![
Cell::from(Span::styled(marker, Style::default().fg(Color::Yellow))),
Cell::from(name.as_str()),
Cell::from(truncate(&self.policy_label(&config.policy), 25)),
Cell::from(config.orchestrator.as_deref().unwrap_or("default")),
])
.style(style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(3),
Constraint::Length(20),
Constraint::Length(27),
Constraint::Min(20),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Local (nsed.yaml) ({}) ", self.rooms.len())),
);
frame.render_widget(table, area);
}
}
fn draw_room_detail(
frame: &mut Frame,
area: Rect,
name: &str,
config: &RoomConfig,
default_room: Option<&str>,
) {
let is_default = default_room == Some(name);
let mut lines = vec![
Line::from(vec![
Span::styled("Policy: ", Style::default().fg(Color::Cyan)),
Span::raw(&config.policy),
]),
Line::from(vec![
Span::styled("Orchestrator: ", Style::default().fg(Color::Cyan)),
Span::raw(config.orchestrator.as_deref().unwrap_or("(default)")),
]),
Line::from(vec![
Span::styled("Default: ", Style::default().fg(Color::Cyan)),
if is_default {
Span::styled("★ Yes", Style::default().fg(Color::Yellow))
} else {
Span::raw("No")
},
]),
];
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press Enter to start a deliberation",
Style::default().fg(Color::DarkGray),
)));
let detail = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Room: {name} ")),
)
.wrap(Wrap { trim: false });
frame.render_widget(detail, area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::tui::views::StatusLevel;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
fn make_key(code: KeyCode) -> AppEvent {
AppEvent::Terminal(Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}))
}
fn sample_rooms() -> HashMap<String, RoomConfig> {
let mut map = HashMap::new();
map.insert(
"local".into(),
RoomConfig {
policy: "review".into(),
orchestrator: Some("local-orch".into()),
},
);
map.insert(
"remote".into(),
RoomConfig {
policy: "brainstorm".into(),
orchestrator: Some("peeramid".into()),
},
);
map
}
#[test]
fn new_sorts_by_name() {
let view = MainMenuView::new(sample_rooms(), Some("local".into()), "orch".into());
assert_eq!(view.rooms[0].0, "local");
assert_eq!(view.rooms[1].0, "remote");
}
#[test]
fn q_quits() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
let action = view.update(&make_key(KeyCode::Char('q')));
assert_eq!(action, Some(ViewAction::Quit));
}
#[test]
fn escape_quits() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
let action = view.update(&make_key(KeyCode::Esc));
assert_eq!(action, Some(ViewAction::Quit));
}
#[test]
fn enter_activates_task_input() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
let action = view.update(&make_key(KeyCode::Enter));
assert!(action.is_none());
assert!(view.task_input_active);
}
#[test]
fn d_opens_detail_panel() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
let action = view.update(&make_key(KeyCode::Char('d')));
assert!(action.is_none());
assert!(view.detail_visible);
}
#[test]
fn tab_in_list_mode_is_ignored_by_view() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
let action = view.update(&make_key(KeyCode::Tab));
assert!(action.is_none());
}
#[test]
fn captures_input_only_while_task_input_active() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
assert!(!view.captures_input());
view.task_input_active = true;
assert!(view.captures_input());
}
#[test]
fn escape_in_detail_closes_detail() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.detail_visible = true;
let action = view.update(&make_key(KeyCode::Esc));
assert!(action.is_none()); assert!(!view.detail_visible);
}
#[test]
fn enter_in_detail_opens_task_input() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.detail_visible = true;
let action = view.update(&make_key(KeyCode::Enter));
assert!(action.is_none());
assert!(view.task_input_active);
assert!(!view.detail_visible);
}
#[test]
fn task_input_enter_launches_job() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
view.task_text = "Review my code".into();
let action = view.update(&make_key(KeyCode::Enter));
assert_eq!(
action,
Some(ViewAction::LaunchJob {
orchestrator: "local-orch".into(),
task: "Review my code".into(),
room: Some("local".into()),
policy: None,
effort_override: Some(0.7),
})
);
assert!(!view.task_input_active);
}
#[test]
fn task_input_threshold_parsed_into_effort_override() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
view.task_text = "spicy debate".into();
view.threshold_text = "0.42".into();
let action = view.update(&make_key(KeyCode::Enter));
match action {
Some(ViewAction::LaunchJob {
effort_override, ..
}) => assert_eq!(effort_override, Some(0.42)),
other => panic!("expected LaunchJob, got {other:?}"),
}
}
#[test]
fn threshold_parse_empty_yields_none() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.threshold_text.clear();
assert!(view.parsed_threshold().is_none());
}
#[test]
fn threshold_parse_clamps_above_one() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.threshold_text = "1.5".into();
assert_eq!(view.parsed_threshold(), Some(1.0));
}
#[test]
fn threshold_parse_clamps_negative() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.threshold_text = "-0.5".into();
assert_eq!(view.parsed_threshold(), Some(0.0));
}
#[test]
fn tab_in_task_input_toggles_threshold_focus() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
assert!(!view.threshold_input_active);
view.update(&make_key(KeyCode::Tab));
assert!(view.threshold_input_active);
view.update(&make_key(KeyCode::Tab));
assert!(!view.threshold_input_active);
}
#[test]
fn keystrokes_in_threshold_focus_edit_threshold_text() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
view.threshold_input_active = true;
view.threshold_text.clear();
view.update(&make_key(KeyCode::Char('0')));
view.update(&make_key(KeyCode::Char('.')));
view.update(&make_key(KeyCode::Char('9')));
assert_eq!(view.threshold_text, "0.9");
assert_eq!(view.task_text, "");
}
#[test]
fn task_input_empty_enter_ignored() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
view.task_text.clear();
let action = view.update(&make_key(KeyCode::Enter));
assert!(action.is_none());
}
#[test]
fn task_input_escape_cancels() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
view.task_text = "some text".into();
let action = view.update(&make_key(KeyCode::Esc));
assert!(action.is_none());
assert!(!view.task_input_active);
}
#[test]
fn task_input_no_orchestrator_returns_error() {
let mut rooms = HashMap::new();
rooms.insert(
"no-orch".into(),
RoomConfig {
policy: "review".into(),
orchestrator: None,
},
);
let mut view = MainMenuView::new(rooms, None, "orch".into());
view.task_input_active = true;
view.task_text = "do something".into();
let action = view.update(&make_key(KeyCode::Enter));
assert!(matches!(
action,
Some(ViewAction::SetStatus(msg, StatusLevel::Error))
if msg.contains("no orchestrator")
));
}
#[test]
fn empty_rooms_no_crash() {
let view = MainMenuView::new(HashMap::new(), None, "orch".into());
assert!(view.rooms.is_empty());
}
fn remote_room(id: &str, policy: Option<&str>) -> DiscoveredRoom {
DiscoveredRoom {
id: id.into(),
tags: vec!["team".into()],
visibility: "public".into(),
eligible_agent_count: 2,
eligible_agent_ids: vec!["a1".into(), "a2".into()],
policy: policy.map(Into::into),
desired_agents: None,
}
}
#[test]
fn is_empty_when_no_local_or_remote_rooms() {
let view = MainMenuView::new(HashMap::new(), None, "orch".into());
assert!(view.is_empty());
let view = MainMenuView::new(sample_rooms(), None, "orch".into());
assert!(!view.is_empty());
}
#[test]
fn on_enter_always_fetches_remote() {
let mut view = MainMenuView::new(HashMap::new(), None, "orch".into());
assert_eq!(
view.on_enter(),
vec![
ViewAction::Fetch(FetchRequest::Rooms {
orchestrator: "orch".into(),
}),
ViewAction::Fetch(FetchRequest::Policies {
orchestrator: "orch".into(),
tag: None,
}),
]
);
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
assert_eq!(view.on_enter().len(), 2);
}
#[test]
fn selection_spans_local_then_remote() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.update(&AppEvent::Data(DataEvent::RoomsLoaded {
orchestrator: "orch".into(),
rooms: vec![remote_room("rmt", Some("p"))],
}));
assert_eq!(view.shown_count(), 3);
assert_eq!(view.selected_kind(), Some(Sel::Local(0)));
view.list_state.selected = 2;
assert_eq!(view.selected_kind(), Some(Sel::Remote(0)));
}
#[test]
fn fetch_error_surfaces_as_status() {
let mut view = MainMenuView::new(HashMap::new(), None, "orch".into());
let action = view.update(&AppEvent::Data(DataEvent::FetchError {
context: "rooms".into(),
error: "boom".into(),
}));
match action {
Some(ViewAction::SetStatus(msg, super::super::StatusLevel::Error)) => {
assert!(msg.contains("rooms") && msg.contains("boom"), "{msg}");
}
other => panic!("expected SetStatus error, got {other:?}"),
}
}
#[test]
fn policy_label_resolves_id_to_name() {
let mut view = MainMenuView::new(HashMap::new(), None, "orch".into());
view.update(&AppEvent::Data(DataEvent::PoliciesLoaded {
orchestrator: "orch".into(),
policies: vec![PolicyInfo {
policy_id: "e032deadbeef".into(),
name: "noosphera:0v1".into(),
tags: vec![],
max_rounds: 3,
effort: 0.7,
is_role_based: false,
}],
}));
assert_eq!(view.policy_label("e032deadbeef"), "noosphera:0v1");
assert_eq!(view.policy_label("noosphera:0v1"), "noosphera:0v1");
assert_eq!(view.policy_label("nope"), "nope");
}
#[test]
fn rooms_loaded_populates_remote_rooms() {
let mut view = MainMenuView::new(HashMap::new(), None, "orch".into());
let action = view.update(&AppEvent::Data(DataEvent::RoomsLoaded {
orchestrator: "orch".into(),
rooms: vec![remote_room("alpha", Some("review"))],
}));
assert!(action.is_none());
assert_eq!(view.remote_rooms.len(), 1);
assert_eq!(view.list_state.count, 1);
}
#[test]
fn rooms_loaded_for_other_orchestrator_ignored() {
let mut view = MainMenuView::new(HashMap::new(), None, "orch".into());
view.update(&AppEvent::Data(DataEvent::RoomsLoaded {
orchestrator: "elsewhere".into(),
rooms: vec![remote_room("alpha", Some("review"))],
}));
assert!(view.remote_rooms.is_empty());
}
#[test]
fn config_free_submit_launches_with_bound_policy() {
let mut view = MainMenuView::new(HashMap::new(), None, "peeramid".into());
view.remote_rooms = vec![remote_room("alpha", Some("review"))];
view.list_state.set_count(1);
view.task_input_active = true;
view.task_text = "ship it".into();
let action = view.update(&make_key(KeyCode::Enter));
assert_eq!(
action,
Some(ViewAction::LaunchJob {
orchestrator: "peeramid".into(),
task: "ship it".into(),
room: Some("alpha".into()),
policy: Some("review".into()),
effort_override: Some(0.7),
})
);
}
#[test]
fn config_free_submit_without_policy_errors() {
let mut view = MainMenuView::new(HashMap::new(), None, "peeramid".into());
view.remote_rooms = vec![remote_room("alpha", None)];
view.list_state.set_count(1);
view.task_input_active = true;
view.task_text = "ship it".into();
let action = view.update(&make_key(KeyCode::Enter));
assert!(matches!(
action,
Some(ViewAction::SetStatus(msg, StatusLevel::Error))
if msg.contains("no policy bound")
));
}
#[test]
fn config_free_submit_no_rooms_errors() {
let mut view = MainMenuView::new(HashMap::new(), None, "peeramid".into());
view.task_input_active = true;
view.task_text = "ship it".into();
let action = view.update(&make_key(KeyCode::Enter));
assert!(matches!(
action,
Some(ViewAction::SetStatus(msg, StatusLevel::Error))
if msg.contains("No rooms available")
));
}
#[test]
fn task_input_typing_appends_characters() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
view.update(&make_key(KeyCode::Char('H')));
view.update(&make_key(KeyCode::Char('i')));
assert_eq!(view.task_text, "Hi");
}
#[test]
fn task_input_backspace_removes_last_char() {
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
view.task_text = "Hello".into();
view.update(&make_key(KeyCode::Backspace));
assert_eq!(view.task_text, "Hell");
}
#[test]
fn task_input_draw_does_not_panic_on_long_text() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut view = MainMenuView::new(sample_rooms(), None, "orch".into());
view.task_input_active = true;
view.task_text = "a".repeat(200);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
view.draw(frame, area);
})
.unwrap();
}
}