use shep_core::config::GROUP_ORDER;
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Focus {
pub sheep: String,
pub tab: Option<&'static str>,
pub dog: Option<String>,
}
impl Focus {
#[must_use]
pub(crate) fn new(sheep: String, tab: Option<&str>, dog: Option<String>) -> Self {
Self {
sheep,
tab: tab.map(|name| {
GROUP_ORDER
.iter()
.copied()
.find(|group| *group == name)
.unwrap_or(GROUP_ORDER[0])
}),
dog,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct FocusAsk {
pub sheep: String,
pub tab: Option<&'static str>,
pub dog: Option<String>,
}
impl App {
pub(crate) fn set_focus(&mut self, focus: Focus) {
self.focus = Some(focus);
}
pub(super) fn resolve_focus(&mut self) -> Option<Effect> {
let focus = self.focus.take()?;
let Some((id, name)) = self
.flock
.iter()
.find(|(_, row)| row.info.dog.is_none() && row.info.name == focus.sheep)
.map(|(id, row)| (*id, row.info.name.clone()))
else {
self.notice = Some(Notice::grave(format!("no sheep named {}", focus.sheep)));
return None;
};
self.selected = Some(RowKey::Sheep(id));
let Some(tab) = focus.tab else {
return Some(self.open_sheep_pane());
};
self.focus_ask = Some(FocusAsk {
sheep: name.clone(),
tab: Some(tab),
dog: focus.dog,
});
Some(self.ask_for_sheep_config(name, ConfigFor::Editor))
}
pub(super) fn take_focus_tab(&mut self) -> Option<&'static str> {
self.focus_ask.as_mut()?.tab.take()
}
pub(super) fn take_focus_dog(&mut self) -> Option<String> {
self.focus_ask.as_mut()?.dog.take()
}
}
#[cfg(test)]
mod tests {
use super::super::testing::*;
use super::*;
use crate::lookout::view::fixtures;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use shep_core::config::AppConfig;
use shep_core::protocol::{RpcError, RpcErrorCode};
fn awaiting(focus: Focus) -> App {
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
app.set_focus(focus);
app
}
fn flock() -> Vec<ProcessInfo> {
vec![
sheep(1, "web", ProcStatus::Online),
sheep(2, "api", ProcStatus::Online),
sheep(3, "worker", ProcStatus::Online),
]
}
fn first_frame(app: &mut App) -> String {
let area = Rect::new(0, 0, 160, 48);
app.note_body_rows(crate::lookout::view::body_rows(area));
app.note_body_width(area.width);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| crate::lookout::view::draw(app, frame))
.unwrap();
crate::lookout::frames::render_text(terminal.backend().buffer())
}
fn jobs_row(app: &App) -> DogRow {
app.config_pane()
.expect("a pane")
.dogs_tab()
.rows()
.iter()
.find(|row| row.name() == "jobs")
.expect("jobs is listed")
.clone()
}
#[test]
fn a_named_sheep_opens_its_status_page_on_the_first_answer() {
let mut app = awaiting(Focus::new("web".into(), None, None));
let effect = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
assert_eq!(
effect,
Effect::Send(Sent::SheepConfig {
name: "web".to_string()
}),
"the pane's read goes out with the answer that found the sheep"
);
assert!(matches!(app.body(), Body::Sheep(_)));
assert_eq!(app.selected(), Some(RowKey::Sheep(1)));
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(
fixtures::sheep_config_view(),
))),
});
let frame = first_frame(&mut app);
assert!(
frame.contains("██ SHEEP 1 web online"),
"the first frame is the sheep pane:\n{frame}"
);
assert!(!frame.contains("STATUS"), "and not the flock table");
}
#[test]
fn a_named_sheep_and_a_tab_open_the_config_pane_on_that_tab() {
let mut app = awaiting(Focus::new("web".into(), Some("watch"), None));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(
fixtures::sheep_config_view(),
))),
});
assert_eq!(
app.config_pane().expect("the pane is open").group(),
"watch"
);
let frame = first_frame(&mut app);
assert!(
frame.contains("██ web (sheep config)"),
"the first frame is the config pane:\n{frame}"
);
assert!(
frame.contains("> ignore_watch") && frame.contains("watch_delay"),
"and it is drawn on the watch tab's own fields:\n{frame}"
);
assert!(
!frame.contains("> cwd"),
"the cursor is not on the process tab's first field:\n{frame}"
);
}
#[test]
fn a_named_dog_opens_its_table_on_the_dogs_tab() {
let mut app = awaiting(Focus::new("web".into(), Some("dogs"), Some("jobs".into())));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let effect = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(web_view(true)))),
});
let Effect::ListSheepDogs { sheep, ask } = effect else {
panic!("the dogs tab asks for its listing: {effect:?}");
};
assert_eq!(sheep, "web");
let effect = app.update(Msg::SheepDogNames {
sheep: "web".into(),
ask,
dogs: listing(),
});
assert_eq!(
effect,
Effect::LoadSheepDog {
sheep: "web".into(),
dog: "jobs".into(),
adopted_path: Some("/opt/jobs".into()),
},
"the listing's answer opens the asked-for row"
);
let _ = app.update(Msg::SheepDog {
sheep: "web".into(),
entry: jobs_entry(),
});
let frame = first_frame(&mut app);
assert!(
frame.contains("██ web (sheep config)"),
"the first frame is the config pane:\n{frame}"
);
assert!(frame.contains("â–¾ jobs"), "with jobs's table open");
assert!(frame.contains("concurrency"), "and its rows drawn");
}
#[test]
fn an_unknown_sheep_lands_on_the_dashboard_with_a_notice() {
let mut app = awaiting(Focus::new("ghost".into(), None, None));
let effect = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
assert_eq!(effect, Effect::RefreshFeed, "the dashboard's own read");
assert!(matches!(app.body(), Body::FlockTable));
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("no sheep named ghost")
);
let frame = first_frame(&mut app);
assert!(
frame.contains("STATUS"),
"the flock table is drawn:\n{frame}"
);
assert!(
frame.contains("no sheep named ghost"),
"and the notice names what was not found:\n{frame}"
);
}
#[test]
fn an_unknown_dog_lands_on_the_dogs_tab_with_a_notice() {
let mut app = awaiting(Focus::new("web".into(), Some("dogs"), Some("nope".into())));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let Effect::ListSheepDogs { ask, .. } = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(web_view(true)))),
}) else {
panic!("the dogs tab asks for its listing");
};
let effect = app.update(Msg::SheepDogNames {
sheep: "web".into(),
ask,
dogs: listing(),
});
assert_eq!(effect, Effect::None);
let notice = app.notice().expect("a refusal").to_string();
assert_eq!(notice, "web: no dog named nope", "{notice}");
assert_eq!(
app.config_pane().expect("the tab stays open").group(),
"dogs"
);
}
#[test]
fn a_dog_ask_over_a_host_stops_at_the_tab() {
let mut app = awaiting(Focus::new("web".into(), Some("dogs"), Some("jobs".into())));
app.set_remote_host("build-01");
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let effect = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(web_view(true)))),
});
assert_eq!(effect, Effect::None, "no listing ask over a host");
assert_eq!(
app.config_pane().expect("the tab still opens").group(),
"dogs"
);
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some(NOT_OVER_HOST),
"and the tab says why it has no rows"
);
}
fn api_view() -> SheepConfigView {
SheepConfigView::new(
AppConfig {
name: "api".into(),
..AppConfig::default()
},
Vec::new(),
Vec::new(),
)
}
#[test]
fn a_walk_to_another_sheep_retires_the_tab_it_was_asked_for() {
let mut app = awaiting(Focus::new("web".into(), Some("watch"), None));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let _ = app.update(Msg::Key(KeyPress::SelectUp));
let effect = app.update(Msg::Key(KeyPress::Edit));
assert_eq!(
effect,
Effect::Send(Sent::SheepConfig {
name: "api".to_string()
}),
"the keystroke asks for the sheep it moved to"
);
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(web_view(false)))),
});
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "api".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(api_view()))),
});
let pane = app.config_pane().expect("api's pane opens");
assert_eq!(pane.target().name(), "api");
assert_eq!(pane.group(), "process", "not the tab web was named for");
}
#[test]
fn a_walk_to_another_sheep_retires_the_dog_it_was_asked_for() {
let mut app = awaiting(Focus::new("web".into(), Some("dogs"), Some("jobs".into())));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let _ = app.update(Msg::Key(KeyPress::SelectUp));
let _ = app.update(Msg::Key(KeyPress::Edit));
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "api".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(api_view()))),
});
let Effect::ListSheepDogs { sheep, ask } = app.update(Msg::Key(KeyPress::Group(9))) else {
panic!(
"the tab asks for its own listing: {:?}",
app.config_pane().map(|p| p.group())
);
};
assert_eq!(sheep, "api");
let effect = app.update(Msg::SheepDogNames {
sheep,
ask,
dogs: listing(),
});
assert_eq!(
effect,
Effect::None,
"no table opens on a sheep that was not named"
);
assert!(
app.notice().is_none(),
"{:?}",
app.notice().map(ToString::to_string)
);
assert!(!jobs_row(&app).is_open(), "jobs is a row like any other");
}
#[test]
fn the_ask_is_spent_once() {
let mut app = awaiting(Focus::new("web".into(), None, None));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let _ = app.update(Msg::Key(KeyPress::Escape));
assert!(matches!(app.body(), Body::FlockTable));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
assert!(matches!(app.body(), Body::FlockTable), "no reopening");
}
#[test]
fn closing_a_pane_drops_the_rest_of_the_ask() {
let mut app = awaiting(Focus::new("web".into(), Some("dogs"), Some("jobs".into())));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(web_view(true)))),
});
let _ = app.update(Msg::Key(KeyPress::Escape));
assert!(app.config_pane().is_none(), "the pane closed");
let _ = app.update(Msg::Key(KeyPress::Edit));
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(web_view(true)))),
});
let Effect::ListSheepDogs { ask, .. } = app.update(Msg::Key(KeyPress::Group(9))) else {
panic!("the tab asks for its listing again");
};
let _ = app.update(Msg::SheepDogNames {
sheep: "web".into(),
ask,
dogs: listing(),
});
assert!(
app.notice().is_none(),
"{:?}",
app.notice().map(ToString::to_string)
);
assert!(!jobs_row(&app).is_open(), "jobs stays closed");
}
#[test]
fn a_refused_config_read_drops_the_ask_it_was_waiting_on() {
let mut app = awaiting(Focus::new("web".into(), Some("dogs"), Some("jobs".into())));
let _ = app.update(Msg::Snapshot {
rows: flock(),
at: Instant::now(),
});
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Err(RequestError::Rpc(RpcError {
code: RpcErrorCode::NotFound,
message: "no sheep named web".to_string(),
daemon_version: None,
})),
});
assert!(app.config_pane().is_none());
let _ = app.update(Msg::Key(KeyPress::Edit));
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(web_view(true)))),
});
assert_eq!(
app.config_pane().expect("the pane opens").group(),
"process",
"the pane opens on its own first tab"
);
}
#[test]
fn a_dog_is_not_a_sheep() {
let mut app = awaiting(Focus::new("otel".into(), None, None));
let _ = app.update(Msg::Snapshot {
rows: vec![
ProcessInfo::builder(90, "otel", ProcStatus::Online)
.dog(Some(DogSource::BuiltIn {
grants: None,
events: Vec::new(),
}))
.build(),
],
at: Instant::now(),
});
assert!(matches!(app.body(), Body::FlockTable));
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("no sheep named otel")
);
}
}