use super::super::super::app::{App, Body, Control, InputMode, Link, Settings, retrying_sentence};
use super::super::super::pane_bleats::BleatsPane;
use super::super::super::textbox::TextBox;
use super::super::cell;
use super::super::flock::fit;
use super::action_gate::{CONTROL_ENABLED_LABEL, READ_ONLY_LABEL, confirm_prompt, in_flight_text};
use super::key_hint::{
BLEATS_HINT, CLOSE_DIALOG_HINT, FROZEN_HINT, env_armed, hint_for, pane_editor, pane_hint,
pane_screen, secrets_armed, secrets_hint, secrets_typing, sheep_pane_hint,
};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
pub(in crate::lookout::view) const TEXT_ENTRY_HINT: &str = "enter applies esc cancels";
fn entry_line(buffer: &TextBox, secret: bool, label: &str, keys: &str, width: u16) -> String {
buffer.line(secret, &format!("{label} "), &format!(" {keys}"), width)
}
#[must_use]
pub fn banner_line(app: &App, width: u16) -> Option<Line<'static>> {
let palette = app.palette();
match app.link() {
Link::Live => None,
Link::Retrying { attempt } => Some(Line::from(Span::styled(
retrying_sentence(*attempt),
palette.attention(),
))),
Link::Lost { .. } => Some(Line::from(Span::styled(
fit(
&format!(
" shep lookout {} · the dashboard stays up so you can read what it had · it will not exit on its own",
app.place()
),
width,
),
palette.muted(),
))),
}
}
#[must_use]
pub fn status_line(app: &App, width: u16) -> Line<'static> {
let palette = app.palette();
let right = if matches!(app.link(), Link::Lost { .. }) {
"\u{2588} frozen"
} else if app.bleats_pane().is_some_and(BleatsPane::following) {
"\u{2588} following"
} else {
match app.control() {
Control::ReadOnly => READ_ONLY_LABEL,
Control::Allowed => CONTROL_ENABLED_LABEL,
}
};
let right_len = u16::try_from(right.chars().count()).unwrap_or(0);
let left_width = width.saturating_sub(right_len).saturating_sub(1);
let (left, left_style) = if let Some(prompt) = app.settings().and_then(Settings::pending) {
let text = if prompt.sent {
format!("{} sent, waiting for the shepherd", prompt.text)
} else {
format!("{} enter confirms, any other key cancels", prompt.text)
};
(text, palette.attention())
} else if let Some(buffer) = app.bleats_pane().and_then(BleatsPane::match_editing) {
(
entry_line(buffer, false, "match", TEXT_ENTRY_HINT, left_width),
palette.attention(),
)
} else if let Some(buffer) = app
.sheep_pane()
.and_then(|pane| pane.feed().match_editing())
{
(
entry_line(buffer, false, "match", TEXT_ENTRY_HINT, left_width),
palette.attention(),
)
} else if let Some(editor) = app.config_pane().and_then(pane_editor) {
(
entry_line(
editor.buffer,
editor.secret,
&editor.label,
editor.keys,
left_width,
),
palette.attention(),
)
} else if let Some(action) = app.action().filter(|a| !a.sent) {
(confirm_prompt(&action), palette.attention())
} else if let Some((settings, label, buffer)) = app.settings().and_then(|settings| {
settings
.typing()
.map(|(label, buffer)| (settings, label, buffer))
}) {
(
entry_line(
buffer,
settings.typing_is_secret(),
&format!("editing {label}"),
TEXT_ENTRY_HINT,
left_width,
),
palette.attention(),
)
} else if let Some(key) = app.config_pane().and_then(env_armed) {
(
format!("delete {key}? enter confirms, any other key cancels"),
palette.attention(),
)
} else if let Some(editor) = secrets_typing(app) {
(
entry_line(
editor.buffer,
editor.secret,
&editor.label,
editor.keys,
left_width,
),
palette.attention(),
)
} else if let Some(key) = secrets_armed(app) {
(
format!("delete {key}? enter confirms, any other key cancels"),
palette.attention(),
)
} else if app.mode() == InputMode::Text {
(
entry_line(
app.filter_box(),
false,
"filter",
&format!("{TEXT_ENTRY_HINT} ctrl-c quits"),
left_width,
),
palette.attention(),
)
} else if let Some(notice) = app.notice() {
(
notice.to_string(),
if notice.is_grave() {
palette.refusal()
} else {
palette.attention()
},
)
} else if let Some(action) = app.action() {
let text = in_flight_text(&action);
(text, palette.attention())
} else if app.close_dialog().is_some() {
(CLOSE_DIALOG_HINT.to_string(), palette.muted())
} else if let Some(pane) = app.config_pane() {
(
pane_hint(app.control(), pane_screen(pane)).to_string(),
palette.attention(),
)
} else if app.bleats_pane().is_some() {
(BLEATS_HINT.to_string(), palette.attention())
} else if matches!(app.body(), Body::Secrets(_)) {
(secrets_hint(app.control()), palette.attention())
} else if app.sheep_pane().is_some() {
(
sheep_pane_hint(app.control()).to_string(),
palette.attention(),
)
} else if app.settings().is_none() && !app.filter().is_empty() {
(
format!("filter \"{}\" / edit esc clear", app.filter()),
palette.muted(),
)
} else if matches!(app.link(), Link::Lost { .. }) {
(FROZEN_HINT.to_string(), palette.attention())
} else {
(
hint_for(app.control(), app.settings().is_some(), app.grouping()),
palette.attention(),
)
};
let ground = palette.ground();
Line::from(vec![
Span::styled(fit(&left, left_width), left_style.patch(ground)),
Span::styled(format!(" {right}"), palette.muted().patch(ground)),
])
}
#[must_use]
pub fn rule_line(style: Style, width: u16) -> Line<'static> {
Line::from(Span::styled(cell::rule(usize::from(width)), style))
}
#[cfg(test)]
mod tests {
use super::super::super::super::app::{App, Control};
use super::super::super::fixtures::{
acting_app, allowed_app, app_armed_to_delete_a_secret, app_in_settings, app_in_settings_on,
app_in_settings_with_control, armed_app, armed_app_with_a_filter_and_a_notice, editing_app,
filtered_app, rendered, with_selection,
};
use super::super::testing::*;
use super::*;
use crate::lookout::app::{ActionVerb, KeyPress, Msg};
use crate::lookout::theme::Palette;
use shep_core::protocol::BusEvent;
use std::ffi::OsStr;
use std::time::Instant;
#[test]
fn a_truncated_hint_still_leaves_a_gap_before_the_control_label() {
let palette = Palette::detect(None, Some(OsStr::new("xterm-256color")), None);
let app = App::new(
palette,
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let line = status_line(&app, 49);
let rendered: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(rendered.chars().count(), 49, "must fill the full width");
assert!(
rendered.ends_with(" read-only"),
"expected a space before the label, got: {rendered:?}"
);
assert!(
!rendered.contains("…read-only"),
"the ellipsis must not butt straight against the label: {rendered:?}"
);
}
#[test]
fn the_key_hint_says_what_the_keys_now_do() {
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let hint: String = status_line(&app, 200)
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(hint.contains("j/k select"), "got {hint:?}");
assert!(hint.contains("g/G first/last"), "got {hint:?}");
assert!(
!hint.contains("scroll"),
"the pane no longer scrolls: {hint:?}"
);
}
#[test]
fn a_wide_status_line_still_pads_out_to_the_full_width() {
let palette = Palette::detect(None, Some(OsStr::new("xterm-256color")), None);
let app = App::new(
palette,
Control::Allowed,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let line = status_line(&app, 120);
let rendered: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(rendered.chars().count(), 120);
assert!(rendered.ends_with(" control enabled"));
}
#[test]
fn the_bar_names_the_filter_keys_while_a_filter_is_applied() {
let app = filtered_app("web");
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("filter \"web\""), "the query, quoted: {bar:?}");
assert!(bar.contains("/ edit"), "got {bar:?}");
assert!(bar.contains("esc clear"), "got {bar:?}");
}
#[test]
fn the_bar_carries_the_query_and_a_cursor_while_editing() {
let app = editing_app("we");
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("filter we\u{258f}"),
"query then cursor: {bar:?}"
);
assert!(bar.contains("enter applies"), "got {bar:?}");
assert!(bar.contains("esc cancels"), "got {bar:?}");
assert!(bar.contains("ctrl-c quits"), "got {bar:?}");
}
#[test]
fn the_bar_shows_the_settings_editor_rather_than_the_filter_box() {
let mut app = app_in_settings_on("daemon.socket");
let _ = app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("editing daemon.socket "),
"names the field being typed: {bar:?}"
);
assert!(
bar.contains("/home/ada/.shep/run/shep.sock\u{258f}"),
"shows the buffer and the cursor, not the dashboard's own filter: {bar:?}"
);
assert!(
!bar.contains("filter "),
"must not read as the filter box: {bar:?}"
);
}
#[test]
fn a_notice_raised_while_typing_does_not_cover_the_box() {
let mut app = editing_app("we");
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("filter we\u{258f}"),
"the box is still there: {bar:?}"
);
assert!(!bar.contains("dropped 3 events"), "got {bar:?}");
}
#[test]
fn closing_the_box_shows_the_notice_that_was_waiting() {
let mut app = editing_app("we");
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
app.update(Msg::Key(KeyPress::TextApply));
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("dropped 3 events"), "got {bar:?}");
}
#[test]
fn the_read_only_hint_advertises_the_filter_key() {
let app = filtered_app("");
let hint = rendered(&status_line(&app, 200));
assert!(hint.contains("/ filter"), "got {hint:?}");
}
#[test]
fn an_armed_confirm_names_the_verb_the_sheep_and_the_answer() {
let app = armed_app(ActionVerb::Restart);
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("restart api (id 2)?"), "got {bar:?}");
assert!(
bar.contains("enter confirms, any other key cancels"),
"got {bar:?}"
);
}
#[test]
fn an_armed_secret_delete_names_the_key_and_the_answer() {
let app = app_armed_to_delete_a_secret();
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("delete DB_PASSWORD"), "got {bar:?}");
assert!(
bar.contains("enter confirms, any other key cancels"),
"got {bar:?}"
);
}
#[test]
fn an_armed_secret_delete_stops_advertising_set_a_value() {
let app = app_armed_to_delete_a_secret();
let bar = rendered(&status_line(&app, 120));
assert!(
!bar.contains("set a value"),
"the arm changes what enter does: {bar:?}"
);
}
#[test]
fn an_in_flight_action_says_it_is_waiting() {
let app = acting_app(ActionVerb::Stop);
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("stop api (id 2): sent, waiting for the shepherd"),
"got {bar:?}"
);
}
#[test]
fn the_confirm_outranks_a_notice_and_the_filter_line() {
let app = armed_app_with_a_filter_and_a_notice();
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("stop api (id 2)?"), "got {bar:?}");
assert!(
!bar.contains("filter \""),
"the filter line is below it: {bar:?}"
);
}
#[test]
fn a_refusal_while_an_action_is_in_flight_reaches_the_bar() {
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("one action is already in flight"),
"the refusal is on the bar, not only in the reducer: {bar:?}"
);
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Event(BusEvent::DaemonShutdown));
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("the shepherd is shutting down"), "got {bar:?}");
}
#[test]
fn the_in_flight_line_comes_back_when_the_notice_clears() {
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
app.update(Msg::Key(KeyPress::SelectDown));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("stop api (id 2): sent, waiting for the shepherd"),
"got {bar:?}"
);
}
#[test]
fn the_action_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(&filtered_app(""), 200));
for key in ["x stop", "R restart", "L reload"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
}
let open = rendered(&status_line(&allowed_app(), 200));
for key in ["x stop", "R restart", "L reload"] {
assert!(
open.contains(key),
"{key} missing when the gate is open: {open:?}"
);
}
assert!(
open.contains("/ filter"),
"and the filter key survives both forms"
);
}
#[test]
fn the_settings_edit_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(&app_in_settings(), 200));
for key in ["space cycle", "enter apply"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
}
let open = rendered(&status_line(&app_in_settings_with_control(), 200));
for key in ["space cycle", "enter apply"] {
assert!(
open.contains(key),
"{key} missing when the gate is open: {open:?}"
);
}
for both in [&closed, &open] {
assert!(both.contains("esc/s close"), "got {both:?}");
assert!(both.contains("r refresh"), "got {both:?}");
}
}
#[test]
fn q_quit_is_named_on_the_settings_screen_in_both_control_states() {
let closed = rendered(&status_line(&app_in_settings(), 200));
let open = rendered(&status_line(&app_in_settings_with_control(), 200));
for hint in [&closed, &open] {
assert!(hint.contains("q quit"), "got {hint:?}");
}
}
#[test]
fn the_panes_edit_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(
&super::super::super::fixtures::app_in_sheep_pane(),
200,
));
let open = rendered(&status_line(
&super::super::super::fixtures::app_in_sheep_pane_with_control(),
200,
));
for key in ["space cycle", "e edit"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
assert!(
open.contains(key),
"{key} missing with the gate open: {open:?}"
);
}
assert!(closed.contains("esc close"), "got {closed:?}");
assert!(
open.contains("esc close"),
"the gate is open, but esc only asks or closes now: {open:?}"
);
for both in [&closed, &open] {
assert!(both.contains("h help"), "got {both:?}");
assert!(both.contains("* yours"), "got {both:?}");
assert!(both.contains("! parked"), "got {both:?}");
assert!(both.contains("q quit"), "got {both:?}");
assert!(!both.contains("x stop"), "got {both:?}");
}
}
#[test]
fn an_env_rows_status_bar_hint_is_the_field_lists_own() {
let mut app = super::super::super::fixtures::app_in_sheep_pane_with_control();
app.update(Msg::Key(KeyPress::SelectLast));
assert!(matches!(
app.config_pane().unwrap().cursor(),
Some(crate::lookout::pane::PaneRow::Env(_) | crate::lookout::pane::PaneRow::AddEnv)
));
let bar = rendered(&status_line(&app, 200));
assert!(bar.contains("esc close"), "got {bar:?}");
assert!(bar.contains("e edit"), "got {bar:?}");
}
#[test]
fn a_filed_pane_edit_leaves_the_status_bar_on_its_key_hint() {
let mut app = super::super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "autorestart");
app.update(Msg::Key(KeyPress::Cycle));
let bar = rendered(&status_line(&app, 200));
assert!(!bar.contains("enter confirms"), "got {bar:?}");
assert!(!bar.contains("sent, waiting"), "got {bar:?}");
assert!(bar.contains("esc close"), "got {bar:?}");
}
#[test]
fn the_panes_editors_get_their_own_status_line_rather_than_the_filters() {
let mut app = super::super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "cwd");
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::TextChar('x')));
let field = rendered(&status_line(&app, 200));
assert!(field.contains("editing cwd"), "got {field:?}");
assert!(!field.contains("filter"), "got {field:?}");
app.update(Msg::Key(KeyPress::TextAbandon));
app.update(Msg::Key(KeyPress::SelectLast));
app.update(Msg::Key(KeyPress::SelectUp));
app.update(Msg::Key(KeyPress::SelectUp));
assert_eq!(
app.config_pane().unwrap().cursor(),
Some(crate::lookout::pane::PaneRow::Env(0))
);
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::TextChar('y')));
let env = rendered(&status_line(&app, 200));
assert!(env.contains("env DB_HOST VALUE"), "got {env:?}");
assert!(env.contains("\u{2588}\u{258f}"), "got {env:?}");
assert!(!env.contains(" y"), "the typed value is drawn: {env:?}");
assert!(!env.contains("filter"), "got {env:?}");
}
#[test]
fn a_secrets_value_is_masked_on_the_bar_while_it_is_typed() {
use crate::lookout::textbox::TextEdit;
let mut app = super::super::super::fixtures::app_with_secrets_and_control();
app.update(Msg::Key(KeyPress::SelectLast));
app.update(Msg::Key(KeyPress::Confirm));
for c in "DB_NEW".chars() {
app.update(Msg::Key(KeyPress::TextChar(c)));
}
let key = rendered(&status_line(&app, 200));
assert!(
key.contains("DB_NEW\u{258f}"),
"KEY is not a secret: {key:?}"
);
app.update(Msg::Key(KeyPress::NextField));
for c in "hunter2".chars() {
app.update(Msg::Key(KeyPress::TextChar(c)));
}
let masked = rendered(&status_line(&app, 200));
assert!(!masked.contains("hunter2"), "got {masked:?}");
assert!(
masked.contains("\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{258f}"),
"got {masked:?}"
);
app.update(Msg::Key(KeyPress::TextEdit(TextEdit::Reveal)));
let shown = rendered(&status_line(&app, 200));
assert!(shown.contains("hunter2\u{258f}"), "got {shown:?}");
}
#[test]
fn an_env_value_is_masked_on_the_bar_while_it_is_typed() {
let mut app = super::super::super::fixtures::app_in_sheep_pane_with_control();
app.update(Msg::Key(KeyPress::SelectLast));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::TextChar('K')));
app.update(Msg::Key(KeyPress::NextField));
for c in "hunter2".chars() {
app.update(Msg::Key(KeyPress::TextChar(c)));
}
let bar = rendered(&status_line(&app, 200));
assert!(!bar.contains("hunter2"), "got {bar:?}");
assert!(bar.contains("\u{2588}\u{258f}"), "got {bar:?}");
}
#[test]
fn the_key_hint_for_each_field_reads_the_same_in_both_panes() {
let mut env_app = super::super::super::fixtures::app_in_sheep_pane_with_control();
env_app.update(Msg::Key(KeyPress::SelectLast));
env_app.update(Msg::Key(KeyPress::Confirm));
let env_key = rendered(&status_line(&env_app, 200));
let mut secret_app = super::super::super::fixtures::app_with_secrets_and_control();
secret_app.update(Msg::Key(KeyPress::SelectLast));
secret_app.update(Msg::Key(KeyPress::Confirm));
let secret_key = rendered(&status_line(&secret_app, 200));
let on_key = "tab value enter next esc cancels";
assert!(env_key.contains(on_key), "env KEY focus: {env_key:?}");
assert!(
secret_key.contains(on_key),
"secrets KEY focus: {secret_key:?}"
);
env_app.update(Msg::Key(KeyPress::TextChar('A')));
secret_app.update(Msg::Key(KeyPress::TextChar('A')));
env_app.update(Msg::Key(KeyPress::NextField));
secret_app.update(Msg::Key(KeyPress::NextField));
let env_value = rendered(&status_line(&env_app, 200));
let secret_value = rendered(&status_line(&secret_app, 200));
let on_value = "tab key enter writes esc cancels";
assert!(
env_value.contains(on_value),
"env VALUE focus: {env_value:?}"
);
assert!(
secret_value.contains(on_value),
"secrets VALUE focus: {secret_value:?}"
);
}
#[test]
fn an_armed_env_delete_owns_the_status_line() {
let mut app = super::super::super::fixtures::app_in_sheep_pane_with_control();
app.update(Msg::Key(KeyPress::SelectLast));
app.update(Msg::Key(KeyPress::SelectUp));
app.update(Msg::Key(KeyPress::SelectUp));
app.update(Msg::Key(KeyPress::ArmDelete));
let bar = rendered(&status_line(&app, 200));
assert!(
bar.contains("delete DB_HOST? enter confirms, any other key cancels"),
"{bar:?}"
);
assert!(
!bar.contains("e edit"),
"the arm outranks the pane's own hint: {bar:?}"
);
}
#[test]
fn the_config_pane_gets_its_own_key_hint() {
let app = super::super::super::fixtures::app_in_sheep_pane();
let bar = status_line(&app, 120).to_string();
assert!(bar.contains("esc close"), "got {bar:?}");
assert!(bar.contains("r refresh"), "got {bar:?}");
assert!(!bar.contains("x stop"), "got {bar:?}");
assert!(!bar.contains("s settings"), "got {bar:?}");
}
#[test]
fn the_list_sub_screens_own_keys_do_what_its_hint_says() {
let mut app = super::super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "args");
app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 200));
assert!(bar.contains("esc back"), "got {bar:?}");
assert!(bar.contains("d remove"), "got {bar:?}");
assert!(bar.contains("K/J move"), "got {bar:?}");
app.update(Msg::Key(KeyPress::StepDown));
assert_eq!(
filed_args(&app),
Some(serde_json::json!(["8080", "--port"])),
"J files the array with the element moved down"
);
app.update(Msg::Key(KeyPress::Remove));
assert_eq!(
filed_args(&app),
Some(serde_json::json!(["--port"])),
"d files the array without the element under the cursor"
);
}
#[test]
fn the_bleats_match_box_owns_the_status_bar_while_it_is_open() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let _ = app.update(Msg::Key(KeyPress::Bleats));
let _ = app.update(Msg::Key(KeyPress::FilterStart));
for typed in "pool".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(typed)));
}
let bar = rendered(&status_line(&app, 160));
assert!(bar.contains("match pool"), "got {bar}");
assert!(!bar.contains("filter"), "not the dashboard's box: {bar}");
}
#[test]
fn the_bleats_pane_gets_its_own_status_line() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let _ = app.update(Msg::Key(KeyPress::Bleats));
let bar = rendered(&status_line(&app, 200));
for key in [
"esc back",
"j/k line",
"ctrl-d/u page",
"G end",
"/ search",
"n/N match",
"f follow",
"w wrap",
"o out/err/both",
"m level",
] {
assert!(bar.contains(key), "missing {key:?}: got {bar}");
}
}
#[test]
fn the_following_indicator_replaces_control_state_while_pinned_to_the_tail() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let _ = app.update(Msg::Key(KeyPress::Bleats));
let bar = rendered(&status_line(&app, 160));
assert!(bar.contains("\u{2588} following"), "got {bar}");
assert!(!bar.contains("read-only"), "got {bar}");
let _ = app.update(Msg::Key(KeyPress::SelectUp));
let scrolled = rendered(&status_line(&app, 160));
assert!(
!scrolled.contains("\u{2588} following"),
"scrolled back, no longer following: {scrolled}"
);
assert!(scrolled.contains("read-only"), "got {scrolled}");
}
#[test]
fn the_sheep_panes_hint_names_the_write_keys_and_the_feeds_own() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.set_control_for_tests(Control::Allowed);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 200));
for key in [
"esc flock",
"e edit",
"J/K next sheep",
"x stop",
"R restart",
"L reload",
"b full log",
"/ filter",
] {
assert!(bar.contains(key), "missing {key:?}: got {bar}");
}
}
}