use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use shep_core::config::ApplyGroup;
use super::super::app::{App, PaneMenu};
use super::super::pane::{
ConfigPane, EnvPane, EnvRow, ListPane, ListRow, Lock, PanePending, PaneRow, PaneTarget,
};
use super::super::theme::Palette;
use super::flock::{fit, mark};
use super::scroll::Attempt;
const GUTTER: u16 = 2;
const KEY_W: u16 = 26;
const KEY_MIN: u16 = 8;
const VALUE_MIN: u16 = 8;
const POSITION_W: u16 = 3;
const COST_W: u16 = 10;
const FULL_WIDTH: u16 = KEY_W + 2 + VALUE_MIN + 2 + COST_W;
const VALUE_WIDTH: u16 = KEY_MIN + 2 + VALUE_MIN;
const fn body_width(width: u16) -> u16 {
width.saturating_sub(GUTTER)
}
const fn cost_label(group: ApplyGroup) -> &'static str {
match group {
ApplyGroup::Live => "now",
ApplyGroup::NextSpawn => "next start",
ApplyGroup::Structural => "read-only",
ApplyGroup::NeedsRespawn | _ => "respawn",
}
}
fn widths(width: u16) -> (u16, u16, u16) {
if width >= FULL_WIDTH {
let rest = width - COST_W - 2;
let key = KEY_W.min(rest - VALUE_MIN - 2);
(key, rest - key - 2, COST_W)
} else if width >= VALUE_WIDTH {
let key = KEY_W.min(width - VALUE_MIN - 2);
(key, width - key - 2, 0)
} else {
(width, 0, 0)
}
}
fn section_header(label: &str, palette: Palette) -> Line<'static> {
Line::from(Span::styled(format!(" {label}"), palette.muted()))
}
fn title_line(pane: &ConfigPane, palette: Palette, width: u16) -> Line<'static> {
let kind = match pane.target() {
PaneTarget::Sheep { .. } => "sheep config",
PaneTarget::Dog { .. } => "dog config",
};
Line::from(Span::styled(
format!(
" {}",
fit(
&format!("{} ({kind})", pane.target().name()),
body_width(width)
)
),
palette.muted(),
))
}
fn field_line(
pane: &ConfigPane,
index: usize,
selected: bool,
width: u16,
palette: Palette,
) -> Line<'static> {
let Some(field) = pane.fields().fields().get(index) else {
return Line::default();
};
let (key_w, value_w, cost_w) = widths(body_width(width));
let flag = match (pane.is_pending(&field.key), pane.is_overridden(&field.key)) {
(true, _) => '!',
(false, true) => '*',
(false, false) => ' ',
};
let raw = pane.display_value(&field.key);
let typing = match pane.pending_edit() {
Some(PanePending::Typing { key, buffer }) if *key == field.key => Some(buffer),
_ => None,
};
let value = match typing {
Some(buffer) => format!("{buffer}\u{258f}"),
None if field.secret && raw != "(unset)" => "<set>".to_owned(),
None => raw,
};
let lock = match pane.lock(&field.key) {
Some(Lock::Refused) => '=',
Some(Lock::NoWidget) => '~',
None => ' ',
};
let mut text = format!("{}{lock}", mark(selected));
text.push_str(&fit(&format!("{flag}{}", field.key), key_w));
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit(&value, value_w));
}
if cost_w > 0 {
text.push_str(" ");
let cost = pane.cost(&field.key).map_or("", cost_label);
text.push_str(&fit(cost, cost_w));
}
if field.editable {
Line::from(Span::raw(text))
} else {
Line::from(Span::styled(text, palette.muted()))
}
}
fn confirm_text(pane: &ConfigPane) -> Option<&str> {
match pane.pending_edit()? {
PanePending::Armed { text, .. } | PanePending::Sent { text, .. } => Some(text),
PanePending::Typing { .. } => None,
}
}
fn menu_text(menu: PaneMenu) -> String {
let reload = menu.reload().label();
match menu.parked() {
1 => format!(
"1 saved field waits on the running sheep: L reload ({reload}), R restart, esc leave it parked"
),
parked => format!(
"{parked} saved fields wait on the running sheep: L reload ({reload}), R restart, esc leave them parked"
),
}
}
fn top_line(
pane: &ConfigPane,
menu: Option<&PaneMenu>,
palette: Palette,
) -> Option<(String, Style)> {
if let Some(menu) = menu {
return Some((menu_text(*menu), palette.attention()));
}
if let Some(text) = confirm_text(pane) {
return Some((text.to_owned(), palette.attention()));
}
if pane.help_open()
&& let Some(PaneRow::Field(index)) = pane.cursor()
&& let Some(field) = pane.fields().fields().get(index)
{
return Some((field.help.clone(), palette.muted()));
}
None
}
fn env_lines(
pane: &ConfigPane,
env: &EnvPane,
palette: Palette,
width: u16,
budget: usize,
) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(Span::styled(
format!(
" {}",
fit(
&format!("{} env (write-only)", pane.target().name()),
body_width(width)
)
),
palette.muted(),
))];
let mut body_budget = budget - 1;
if let Some(text) = confirm_text(pane)
&& body_budget > 0
{
lines.push(Line::from(Span::styled(
format!(" {}", fit(text, body_width(width))),
palette.attention(),
)));
body_budget -= 1;
}
if body_budget == 0 {
return lines;
}
let rows = env.rows();
let cursor_row = env.view().cursor().min(rows.len().saturating_sub(1));
lines.extend(super::scroll::to_cursor(
cursor_row,
env.view().offset(),
|offset| env_body_from(env, palette, width, body_budget, offset),
|| vec![env_line(env, cursor_row, true, width, palette)],
));
lines
}
fn env_line(
env: &EnvPane,
index: usize,
selected: bool,
width: u16,
palette: Palette,
) -> Line<'static> {
let (key_w, value_w, _) = widths(body_width(width));
let typed = selected
.then(|| env.typing())
.flatten()
.map(|(_, buffer)| buffer);
let (key, value) = match env.rows().get(index).copied() {
Some(EnvRow::Key(key_index)) => (
env.keys().get(key_index).cloned().unwrap_or_default(),
typed.map_or_else(|| "<set>".to_owned(), |buffer| format!("{buffer}\u{258f}")),
),
Some(EnvRow::New) => match typed {
Some(buffer) => (format!("{buffer}\u{258f}"), String::new()),
None => ("+ new".to_owned(), String::new()),
},
None => return Line::default(),
};
let mut text = format!("{} ", mark(selected));
text.push_str(&fit(&key, key_w));
if value_w > 0 && !value.is_empty() {
text.push_str(" ");
text.push_str(&fit(&value, value_w));
}
if matches!(env.rows().get(index), Some(EnvRow::New)) {
return Line::from(Span::styled(text, palette.muted()));
}
Line::from(Span::raw(text))
}
fn env_body_from(
env: &EnvPane,
palette: Palette,
width: u16,
budget: usize,
offset: usize,
) -> Attempt {
let rows = env.rows();
let total = rows.len();
let cursor_row = env.view().cursor().min(total.saturating_sub(1));
let above = usize::from(offset > 0);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut drawn = 0usize;
for index in offset..total {
if lines.len() + 1 + above + usize::from(index + 1 < total) > budget {
break;
}
lines.push(env_line(env, index, index == cursor_row, width, palette));
drawn += 1;
}
let hidden_below = total.saturating_sub(offset + drawn);
if hidden_below > 0 {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
if offset > 0 {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {offset} above"),
palette.muted(),
)),
);
}
Attempt {
cursor_drawn: drawn > 0 && (offset..offset + drawn).contains(&cursor_row),
lines,
}
}
fn list_lines(
pane: &ConfigPane,
list: &ListPane,
palette: Palette,
width: u16,
budget: usize,
) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(Span::styled(
format!(
" {}",
fit(
&format!("{} {} (list)", pane.target().name(), list.key()),
body_width(width)
)
),
palette.muted(),
))];
let mut body_budget = budget - 1;
if let Some(text) = confirm_text(pane)
&& body_budget > 0
{
lines.push(Line::from(Span::styled(
format!(" {}", fit(text, body_width(width))),
palette.attention(),
)));
body_budget -= 1;
}
if body_budget == 0 {
return lines;
}
let secret = pane.fields().by_key(list.key()).is_some_and(|f| f.secret);
let rows = list.rows();
let cursor_row = list.view().cursor().min(rows.len().saturating_sub(1));
lines.extend(super::scroll::to_cursor(
cursor_row,
list.view().offset(),
|offset| list_body_from(list, palette, width, body_budget, offset, secret),
|| vec![list_line(list, cursor_row, true, width, palette, secret)],
));
lines
}
fn list_line(
list: &ListPane,
index: usize,
selected: bool,
width: u16,
palette: Palette,
secret: bool,
) -> Line<'static> {
let body = body_width(width);
let position_w = POSITION_W.min(body);
let value_w = body.saturating_sub(position_w + 2);
let typed = selected
.then(|| list.typing())
.flatten()
.map(|(_, buffer)| buffer);
let (position, value) = match list.rows().get(index).copied() {
Some(ListRow::Item(item)) => (
format!("{item}"),
typed.map_or_else(
|| {
let raw = list
.elements()
.get(item)
.cloned()
.unwrap_or_else(|| "(unset)".to_owned());
if secret && raw != "(unset)" {
"<set>".to_owned()
} else {
raw
}
},
|buffer| format!("{buffer}\u{258f}"),
),
),
Some(ListRow::New) => match typed {
Some(buffer) => (String::new(), format!("{buffer}\u{258f}")),
None => (String::new(), "+ new".to_owned()),
},
None => return Line::default(),
};
let mut text = format!("{} ", mark(selected));
text.push_str(&fit(&position, position_w));
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit(&value, value_w));
}
if matches!(list.rows().get(index), Some(ListRow::New)) {
return Line::from(Span::styled(text, palette.muted()));
}
Line::from(Span::raw(text))
}
fn list_body_from(
list: &ListPane,
palette: Palette,
width: u16,
budget: usize,
offset: usize,
secret: bool,
) -> Attempt {
let rows = list.rows();
let total = rows.len();
let cursor_row = list.view().cursor().min(total.saturating_sub(1));
let above = usize::from(offset > 0);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut drawn = 0usize;
for index in offset..total {
if lines.len() + 1 + above + usize::from(index + 1 < total) > budget {
break;
}
lines.push(list_line(
list,
index,
index == cursor_row,
width,
palette,
secret,
));
drawn += 1;
}
let hidden_below = total.saturating_sub(offset + drawn);
if hidden_below > 0 {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
if offset > 0 {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {offset} above"),
palette.muted(),
)),
);
}
Attempt {
cursor_drawn: drawn > 0 && (offset..offset + drawn).contains(&cursor_row),
lines,
}
}
#[must_use]
pub fn pane_lines(
pane: &ConfigPane,
menu: Option<&PaneMenu>,
palette: Palette,
width: u16,
height: u16,
) -> Vec<Line<'static>> {
let budget = if height == 0 {
usize::MAX
} else {
usize::from(height)
};
if budget == 0 {
return Vec::new();
}
if let Some(list) = pane.list() {
return list_lines(pane, list, palette, width, budget);
}
if let Some(env) = pane.env() {
return env_lines(pane, env, palette, width, budget);
}
let mut lines = vec![title_line(pane, palette, width)];
let mut body_budget = budget - 1;
if let Some((text, style)) = top_line(pane, menu, palette)
&& body_budget > 0
{
lines.push(Line::from(Span::styled(
format!(" {}", fit(&text, body_width(width))),
style,
)));
body_budget -= 1;
}
let footer = match pane.target() {
PaneTarget::Sheep { .. } => None,
PaneTarget::Dog { name, .. } => (body_budget > 0)
.then(|| format!("shep publishes the change; {name} decides what to reload")),
};
if footer.is_some() {
body_budget -= 1;
}
if !pane.fields().is_empty() && body_budget > 0 {
let total = pane.rows().len();
let cursor_row = pane.view().cursor().min(total - 1);
lines.extend(super::scroll::to_cursor(
cursor_row,
pane.view().offset(),
|offset| body_from(pane, palette, width, body_budget, offset),
|| cursor_only(pane, palette, width, body_budget, cursor_row),
));
}
if let Some(text) = footer {
lines.push(Line::from(Span::styled(
format!(" {}", fit(&text, body_width(width))),
palette.muted(),
)));
}
lines
}
fn body_from(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
offset: usize,
) -> Attempt {
let rows = pane.rows();
let total = rows.len();
let cursor_row = pane.view().cursor().min(total.saturating_sub(1));
let above = usize::from(offset > 0);
let room = |taken: usize, need: usize, index: usize| {
taken + need + above + usize::from(index + 1 < total) <= budget
};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_group: Option<&str> = None;
let mut pending_header: Vec<Line<'static>> = Vec::new();
let mut drawn = 0usize;
for (index, row) in rows.iter().enumerate() {
let PaneRow::Field(field_index) = *row;
let group = pane
.fields()
.fields()
.get(field_index)
.and_then(|field| field.group.as_deref());
if current_group != group {
let mut header = Vec::new();
if current_group.is_some() {
header.push(Line::default());
}
if let Some(group) = group {
header.push(section_header(group, palette));
}
pending_header = header;
current_group = group;
}
if index < offset {
continue;
}
if !room(lines.len(), pending_header.len() + 1, index) {
break;
}
lines.append(&mut pending_header);
lines.push(field_line(
pane,
field_index,
pane.cursor() == Some(*row),
width,
palette,
));
drawn += 1;
}
let hidden_below = total.saturating_sub(offset + drawn);
if hidden_below > 0 {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
if offset > 0 {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {offset} above"),
palette.muted(),
)),
);
}
Attempt {
cursor_drawn: drawn > 0 && (offset..offset + drawn).contains(&cursor_row),
lines,
}
}
fn cursor_only(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
cursor_row: usize,
) -> Vec<Line<'static>> {
let rows = pane.rows();
let mut lines = Vec::new();
if let Some(PaneRow::Field(index)) = rows.get(cursor_row).copied() {
lines.push(field_line(pane, index, true, width, palette));
}
let hidden_below = rows.len().saturating_sub(cursor_row + 1);
if cursor_row > 0 && lines.len() < budget {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {cursor_row} above"),
palette.muted(),
)),
);
}
if hidden_below > 0 && lines.len() < budget {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
lines
}
pub fn draw_pane(app: &App, pane: &ConfigPane, area: Rect, buffer: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let lines = pane_lines(
pane,
app.pane_menu().as_ref(),
app.palette(),
area.width,
area.height,
);
for (offset, line) in lines.iter().enumerate().take(usize::from(area.height)) {
let offset = u16::try_from(offset).unwrap_or(0);
buffer.set_line(area.x, area.y + offset, line, area.width);
}
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::super::fixtures;
use super::*;
use crate::lookout::app::{Effect, KeyPress, Msg};
use crate::lookout::frames::render_text;
use crate::lookout::pane::ReloadKind;
use crate::output::width::visible_width;
fn web_pane() -> ConfigPane {
ConfigPane::sheep(fixtures::sheep_config_view())
}
fn text_of(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect()
}
#[test]
fn a_sheep_pane_at_a_comfortable_width() {
let lines = pane_lines(&web_pane(), None, fixtures::plain(), 120, 0);
insta::assert_snapshot!("sheep_pane_wide", text_of(&lines).join("\n"));
}
#[test]
fn a_sheep_pane_scrolled_to_the_cron_section_labels_it() {
let mut pane = web_pane();
pane.set_rows(8);
pane.move_to_last();
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 9));
assert!(text.len() <= 9, "{text:?}");
assert!(text.iter().any(|line| line.contains("above")), "{text:?}");
assert!(
text.iter().any(|line| line.trim() == "cron"),
"the visible section is labelled: {text:?}"
);
assert!(!text.iter().any(|line| line.contains("below")), "{text:?}");
}
#[test]
fn every_pane_line_fits_the_width_it_was_drawn_for() {
let pane = web_pane();
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, None, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn a_structural_field_renders_muted_and_the_cost_column_says_why() {
let pane = web_pane();
let lines = pane_lines(&pane, None, fixtures::coloured(), 120, 0);
let instances = lines
.iter()
.find(|line| text_of(core::slice::from_ref(line))[0].contains("instances"))
.expect("every field is drawn at 120 columns");
assert!(
text_of(core::slice::from_ref(instances))[0].contains("read-only"),
"{instances:?}"
);
assert_eq!(
instances.spans[0].style,
fixtures::coloured().muted(),
"a refused row is muted"
);
let probe = lines
.iter()
.find(|line| text_of(core::slice::from_ref(line))[0].contains("~ liveness_probe"))
.expect("a field with no widget is drawn too");
let rendered = text_of(core::slice::from_ref(probe))[0].clone();
assert!(
rendered.contains("now") && !rendered.contains("read-only"),
"shep writes `liveness_probe`, so its cost is a real cost: {rendered:?}"
);
assert_eq!(
probe.spans[0].style,
fixtures::coloured().muted(),
"muting says `not from here`, which is true of both kinds"
);
}
fn parts(line: &str) -> Option<(char, char, char, String)> {
let mut chars = line.chars();
let mark = chars.next()?;
let lock = chars.next()?;
let flag = chars.next()?;
let key = chars.as_str().split_whitespace().next()?.to_string();
(mark == '>' || mark == ' ').then_some((mark, lock, flag, key))
}
fn rows_of(text: &[String]) -> Vec<(char, char, char, String)> {
let keys: Vec<String> = web_pane()
.fields()
.fields()
.iter()
.map(|field| field.key.clone())
.collect();
text.iter()
.filter_map(|line| parts(line))
.filter(|(_, _, _, key)| keys.contains(key))
.collect()
}
#[test]
fn the_flags_mark_exactly_the_overridden_and_pending_fields() {
let text = text_of(&pane_lines(&web_pane(), None, fixtures::plain(), 120, 0));
let flagged = |wanted: char| -> Vec<String> {
rows_of(&text)
.into_iter()
.filter(|(_, _, flag, _)| *flag == wanted)
.map(|(_, _, _, key)| key)
.collect()
};
assert_eq!(flagged('*'), ["reuse_port", "max_restarts"]);
assert_eq!(flagged('!'), ["kill_signal"]);
assert_eq!(rows_of(&text).len(), 39, "every field is drawn at 120");
}
#[test]
fn a_refused_field_and_one_the_pane_has_no_widget_for_get_different_glyphs() {
let text = text_of(&pane_lines(&web_pane(), None, fixtures::plain(), 120, 0));
let glyphed = |wanted: char| -> Vec<String> {
rows_of(&text)
.into_iter()
.filter(|(_, lock, _, _)| *lock == wanted)
.map(|(_, _, _, key)| key)
.collect()
};
assert_eq!(glyphed('='), ["instances", "name"]);
assert_eq!(glyphed('~'), ["liveness_probe", "readiness_probe"]);
assert_eq!(glyphed(' ').len(), 39 - 2 - 2);
}
#[test]
fn a_bare_duration_or_mem_size_shows_its_resolved_unit_in_the_row() {
let text = text_of(&pane_lines(&web_pane(), None, fixtures::plain(), 120, 0));
let row = |key: &str| {
text.iter()
.find(|line| line.contains(key))
.unwrap_or_else(|| panic!("{key} is drawn at 120 columns: {text:?}"))
};
assert!(
row("kill_timeout").contains("1600ms"),
"{:?}",
row("kill_timeout")
);
assert!(
row("exp_backoff_restart_delay").contains("100ms"),
"{:?}",
row("exp_backoff_restart_delay")
);
assert!(
row("listen_timeout").contains("3s"),
"{:?}",
row("listen_timeout")
);
}
#[test]
fn the_two_glyphs_survive_the_narrowest_width_and_a_palette_with_no_colour() {
let mut pane = web_pane();
pane.move_to_last();
let width = super::super::MIN_TERM_WIDTH;
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), width, 0));
let rows = rows_of(&text);
assert!(
!text[1..]
.iter()
.any(|line| parts(line).is_some() && line.contains("read-only")),
"no field row can afford the cost cell at {width}: {text:?}"
);
let glyph = |key: &str| rows.iter().find(|(_, _, _, k)| k == key).map(|r| r.1);
assert_eq!(glyph("instances"), Some('='));
assert_eq!(glyph("liveness_probe"), Some('~'));
assert_eq!(glyph("watch"), Some(' '));
}
fn screen_at(app: &mut crate::lookout::app::App, height: u16) -> String {
let area = Rect::new(0, 0, 120, height);
app.note_body_rows(super::super::body_rows(area));
let mut terminal = Terminal::new(TestBackend::new(120, height)).unwrap();
terminal
.draw(|frame| super::super::draw(app, frame))
.unwrap();
render_text(terminal.backend().buffer())
}
fn marked(text: &str) -> usize {
text.lines().filter(|line| line.starts_with('>')).count()
}
#[test]
fn the_cursor_survives_every_step_of_a_walk_down_and_back_up() {
for height in [6u16, 7, 8, 10, 14, 20, 45] {
let mut app = fixtures::app_in_sheep_pane();
let total = app.config_pane().unwrap().rows().len();
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows, {step} down:\n{text}");
app.update(Msg::Key(KeyPress::SelectDown));
}
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows, {step} up:\n{text}");
app.update(Msg::Key(KeyPress::SelectUp));
}
}
}
#[test]
fn the_body_never_outgrows_the_height_it_was_given() {
let mut pane = web_pane();
for height in 1..=60u16 {
pane.set_rows(usize::from(height.saturating_sub(1)));
for cursor in [0usize, 7, 20, 38] {
pane.move_to_first();
pane.move_by(isize::try_from(cursor).unwrap());
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}, cursor {cursor}: {text:?}"
);
}
}
}
#[test]
fn an_armed_edit_is_echoed_under_the_title() {
let mut pane = web_pane();
pane.move_to_key("autorestart");
pane.cycle(Instant::now());
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 0));
assert!(text[1].contains("set autorestart = false"), "{:?}", text[1]);
pane.take_armed(0);
let sent = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 0));
assert_eq!(
sent[1], text[1],
"the wording must not change between the question and its answer"
);
}
#[test]
fn help_open_draws_the_selected_fields_own_text_under_the_title() {
let mut pane = web_pane();
pane.move_to_key("max_memory");
pane.toggle_help();
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 0));
assert!(
text.iter()
.any(|line| line.contains("Restart the app if it climbs above this much memory")),
"{text:?}"
);
}
#[test]
fn toggling_help_again_dismisses_it() {
let mut pane = web_pane();
pane.move_to_key("max_memory");
pane.toggle_help();
pane.toggle_help();
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 0));
assert!(
!text.iter().any(|line| line.contains("Restart the app")),
"{text:?}"
);
}
#[test]
fn an_armed_edit_outranks_open_help_for_the_shared_slot() {
let mut pane = web_pane();
pane.move_to_key("autorestart");
pane.toggle_help();
pane.cycle(Instant::now());
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 0));
assert!(text[1].contains("set autorestart"), "{:?}", text[1]);
}
#[test]
fn help_text_still_respects_the_width_and_height_budgets() {
let mut pane = web_pane();
pane.move_to_key("max_memory");
pane.toggle_help();
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, None, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width}: {line:?}"
);
}
}
for height in 1..=30u16 {
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}: {text:?}"
);
}
}
fn app_at_the_menu() -> crate::lookout::app::App {
let mut app = fixtures::app_in_sheep_pane_with_two_parked_fields();
app.update(Msg::Key(KeyPress::Escape));
assert!(app.pane_menu().is_some(), "esc offered the menu");
app
}
#[test]
fn the_menu_takes_the_line_under_the_title_and_names_all_three_keys() {
let mut app = app_at_the_menu();
let text = screen_at(&mut app, 30);
let line = text
.lines()
.find(|line| line.contains("saved fields"))
.expect("the menu is drawn");
assert!(line.contains("2 saved fields wait"), "{line:?}");
for clause in [
"L reload (overlapping)",
"R restart",
"esc leave them parked",
] {
assert!(line.contains(clause), "{clause} missing from {line:?}");
}
}
#[test]
fn one_parked_field_reads_in_the_singular() {
let mut app = fixtures::app_in_sheep_pane_with_a_parked_field();
app.update(Msg::Key(KeyPress::Escape));
let text = screen_at(&mut app, 30);
assert!(text.contains("1 saved field waits"), "{text}");
assert!(text.contains("esc leave it parked"), "{text}");
}
#[test]
fn the_menu_never_says_anything_is_at_risk() {
let mut app = app_at_the_menu();
let screen = screen_at(&mut app, 30);
let line = screen
.lines()
.find(|line| line.contains("saved fields"))
.expect("the menu is drawn")
.to_lowercase();
for word in ["discard", "unsaved", "are you sure", "lose"] {
assert!(!line.contains(word), "{word} has no business in {line:?}");
}
}
#[test]
fn the_menu_costs_the_field_list_no_line() {
let mut open = fixtures::app_in_sheep_pane_with_two_parked_fields();
let mut closed = fixtures::app_in_sheep_pane_with_two_parked_fields();
open.update(Msg::Key(KeyPress::Escape));
for height in [super::super::flock::MIN_HEIGHT, 7, 8, 12, 20, 30] {
let with = screen_at(&mut open, height);
let without = screen_at(&mut closed, height);
assert_eq!(
with.lines().count(),
without.lines().count(),
"height {height}"
);
assert_eq!(marked(&with), 1, "height {height}:\n{with}");
}
}
#[test]
fn the_menu_line_fits_every_width_the_pane_claims_to_support() {
let mut app = app_at_the_menu();
for width in super::super::MIN_TERM_WIDTH..=200 {
let area = Rect::new(0, 0, width, 30);
app.note_body_rows(super::super::body_rows(area));
let mut terminal = Terminal::new(TestBackend::new(width, 30)).unwrap();
terminal
.draw(|frame| super::super::draw(&app, frame))
.unwrap();
for line in render_text(terminal.backend().buffer()).lines() {
assert!(
visible_width(line) <= usize::from(width),
"width {width}: {line:?}"
);
}
}
}
#[test]
fn a_sheep_pane_with_the_apply_menu_open() {
let menu = PaneMenu::new(2, ReloadKind::Serial, Instant::now());
let lines = pane_lines(&web_pane(), Some(&menu), fixtures::plain(), 120, 0);
insta::assert_snapshot!("sheep_pane_apply_menu", text_of(&lines).join("\n"));
}
fn pane_to(app: &mut crate::lookout::app::App, key: &str) {
let index = app
.config_pane()
.expect("the pane is open")
.fields()
.fields()
.iter()
.position(|field| field.key == key)
.unwrap_or_else(|| panic!("no field named {key}"));
app.update(Msg::Key(KeyPress::SelectFirst));
for _ in 0..index {
app.update(Msg::Key(KeyPress::SelectDown));
}
}
#[test]
fn the_cost_column_predicts_and_the_status_bar_reports() {
for (key, column, pending, sentence) in [
("watch", "now", true, "waits for `shep reload web`"),
("autostart", "next start", false, "is set"),
] {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, key);
let text = text_of(&pane_lines(
app.config_pane().unwrap(),
None,
fixtures::plain(),
120,
0,
));
let row = text
.iter()
.find(|line| {
let mut words = line.split_whitespace();
let mut rest = line.get(3..).map(str::split_whitespace);
(words.next() == Some(key) && words.next().is_some())
|| rest
.as_mut()
.is_some_and(|w| w.next() == Some(key) && w.next().is_some())
})
.unwrap_or_else(|| panic!("{key} is drawn at 120 columns: {text:?}"));
assert!(row.contains(column), "{key}: {row:?}");
app.update(Msg::Key(KeyPress::Cycle));
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("{key}: the confirm sends");
};
app.update(Msg::Replied {
sent,
result: Ok(shep_core::protocol::Response::SheepFieldSet {
name: "web".to_string(),
key: key.to_string(),
pending,
}),
});
let bar = crate::lookout::view::status::status_line(&app, 200).to_string();
assert!(bar.contains(sentence), "{key}: {bar:?}");
}
}
#[test]
fn an_open_editor_draws_its_buffer_in_the_fields_own_row() {
let mut pane = web_pane();
pane.move_to_key("cwd");
pane.begin_typing();
for typed in "/srv".chars() {
pane.type_char(typed);
}
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 0));
let row = text
.iter()
.find(|line| line.contains(" cwd"))
.expect("cwd is drawn at 120 columns");
assert!(row.contains("/srv\u{258f}"), "{row:?}");
assert!(
!text.iter().any(|line| line.contains("set cwd")),
"an editor is not a confirm: {text:?}"
);
}
fn bark_pane() -> ConfigPane {
let schema = crate::dog::builtin_schema("bark").expect("bark is a built-in");
ConfigPane::dog(
"bark".into(),
None,
schema,
"poll = \"60s\"\nhistory_bytes = 4096\n\n[sinks.ops]\nkind = \"slack\"\nurl = \"https://hooks.example/x\"\n"
.into(),
)
}
#[test]
fn a_dog_pane_at_a_comfortable_width() {
let text = text_of(&pane_lines(&bark_pane(), None, fixtures::plain(), 120, 0));
assert!(
!text.iter().any(|line| line.contains("hooks.example")),
"a secret is never rendered: {text:?}"
);
assert!(text.iter().any(|line| line.contains("<set>")), "{text:?}");
assert!(
text.iter()
.any(|line| line.contains("decides what to reload")),
"{text:?}"
);
insta::assert_snapshot!("dog_pane_wide", text.join("\n"));
}
#[test]
fn a_dog_panes_footer_is_paid_for_out_of_the_height_it_was_given() {
let mut pane = bark_pane();
for height in 1..=20u16 {
pane.set_rows(usize::from(height.saturating_sub(1)));
for cursor in [0usize, 2, 4] {
pane.move_to_first();
pane.move_by(isize::try_from(cursor).unwrap());
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}, cursor {cursor}: {text:?}"
);
}
}
}
#[test]
fn every_dog_pane_line_fits_the_width_it_was_drawn_for() {
let pane = bark_pane();
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, None, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn the_env_sub_screen_at_a_comfortable_width() {
let mut pane = web_pane();
pane.open_env();
let lines = pane_lines(&pane, None, fixtures::plain(), 120, 0);
insta::assert_snapshot!("env_sub_screen", text_of(&lines).join("\n"));
}
#[test]
fn an_armed_env_write_is_echoed_and_never_quotes_the_value() {
let mut pane = web_pane();
pane.open_env();
pane.env_mut().unwrap().begin_typing();
for typed in "hunter2".chars() {
pane.env_mut().unwrap().type_char(typed);
}
let (key, value) = pane.env_mut().unwrap().apply_typing().unwrap();
pane.arm_env(key, value.map(Into::into), Instant::now());
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 0));
assert!(text[1].contains("set env DB_HOST"), "{:?}", text[1]);
assert!(!text.join("\n").contains("hunter2"), "{text:?}");
}
#[test]
fn the_env_sub_screen_never_renders_a_value() {
let mut pane = ConfigPane::sheep({
let mut config = shep_core::config::AppConfig {
name: "web".to_string(),
..Default::default()
};
config
.env
.insert("DB_PASSWORD".to_string(), "hunter2".to_string());
shep_core::protocol::SheepConfigView::new(config, Vec::new(), Vec::new())
});
pane.open_env();
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, 0)).join("\n");
assert!(text.contains("DB_PASSWORD"), "{text}");
assert!(!text.contains("hunter2"), "{text}");
assert!(text.contains("<set>"), "{text}");
}
#[test]
fn the_env_cursor_survives_every_step_of_a_walk_down_and_back_up() {
let mut pane = ConfigPane::sheep({
let mut config = shep_core::config::AppConfig {
name: "web".to_string(),
..Default::default()
};
for index in 0..20 {
config
.env
.insert(format!("KEY_{index:02}"), "x".to_string());
}
shep_core::protocol::SheepConfigView::new(config, Vec::new(), Vec::new())
});
pane.open_env();
for height in [1u16, 2, 3, 6, 8, 14, 30] {
pane.env_mut().unwrap().move_to_first();
let body = usize::from(height.saturating_sub(1));
pane.env_mut().unwrap().set_rows(body);
let total = pane.env().unwrap().rows().len();
for step in 0..=total {
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}, step {step}: {text:?}"
);
if height > 1 {
assert_eq!(
text.iter().filter(|line| line.starts_with('>')).count(),
1,
"height {height}, step {step}: {text:?}"
);
}
pane.env_mut().unwrap().move_by(1);
}
for step in 0..=total {
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, height));
if height > 1 {
assert_eq!(
text.iter().filter(|line| line.starts_with('>')).count(),
1,
"height {height}, step {step} up: {text:?}"
);
}
pane.env_mut().unwrap().move_by(-1);
}
}
}
#[test]
fn every_env_line_fits_the_width_it_was_drawn_for() {
let mut pane = web_pane();
pane.open_env();
pane.env_mut().unwrap().begin_typing();
for typed in "A_VERY_LONG_ENV_KEY_NAME=and a value longer still".chars() {
pane.env_mut().unwrap().type_char(typed);
}
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, None, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn the_title_names_the_target_and_no_longer_calls_it_read_only() {
let text = text_of(&pane_lines(&web_pane(), None, fixtures::plain(), 120, 0));
assert!(text[0].contains("web"), "{:?}", text[0]);
assert!(text[0].contains("(sheep config)"), "{:?}", text[0]);
assert!(!text[0].contains("read-only"), "{:?}", text[0]);
}
fn web_with_args(args: &[&str]) -> shep_core::protocol::SheepConfigView {
let config = shep_core::config::AppConfig {
name: "web".to_string(),
args: args.iter().map(|arg| (*arg).to_string()).collect(),
..Default::default()
};
shep_core::protocol::SheepConfigView::new(config, Vec::new(), Vec::new())
}
fn rendered_list(pane: &ConfigPane, width: u16, height: u16) -> Vec<String> {
let mut pane = pane.clone();
pane.move_to_key("args");
pane.open_list();
text_of(&pane_lines(&pane, None, fixtures::plain(), width, height))
}
fn app_on_the_list_screen(args: &[&str]) -> crate::lookout::app::App {
let mut app = fixtures::with_selection(
shep_core::protocol::ProcessInfo::builder(
9,
"web",
shep_core::status::ProcStatus::Online,
)
.pid(Some(48_000))
.build(),
);
app.set_control_for_tests(crate::lookout::app::Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: crate::lookout::app::Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(shep_core::protocol::Response::SheepConfig(Box::new(
web_with_args(args),
))),
});
pane_to(&mut app, "args");
let _ = app.update(Msg::Key(KeyPress::Confirm));
app
}
#[test]
fn the_list_screen_shows_its_values_unlike_env() {
let pane = ConfigPane::sheep(web_with_args(&["--port"]));
let lines = rendered_list(&pane, 120, 20);
assert!(lines.iter().any(|l| l.contains("--port")), "{lines:?}");
assert!(
!lines.iter().any(|l| l.contains(r#"["--port"]"#)),
"the element is a row of its own, not the field list's JSON cell: {lines:?}"
);
}
fn secret_list_dog_pane() -> ConfigPane {
let schema = serde_json::json!({
"properties": {
"tokens": {
"type": "array",
"items": { "type": "string" },
"x-shep-secret": true,
}
}
});
let mut pane = ConfigPane::dog(
"watch".into(),
None,
schema,
"tokens = [\"ab12cd34\", \"ef56gh78\"]\n".into(),
);
pane.move_to_key("tokens");
pane.open_list();
pane
}
#[test]
fn the_list_screen_masks_a_secret_arrays_elements() {
let text = text_of(&pane_lines(
&secret_list_dog_pane(),
None,
fixtures::plain(),
120,
0,
));
assert!(
!text
.iter()
.any(|line| line.contains("ab12cd34") || line.contains("ef56gh78")),
"a secret element is never rendered: {text:?}"
);
assert!(
text.iter().filter(|line| line.contains("<set>")).count() == 2,
"both elements mask to <set>: {text:?}"
);
}
#[test]
fn the_list_sub_screen_at_a_comfortable_width() {
let lines = rendered_list(&web_pane(), 120, 0);
insta::assert_snapshot!("list_sub_screen", lines.join("\n"));
}
#[test]
fn the_list_sub_screen_never_outgrows_the_height_or_the_width_it_was_given() {
let mut pane = ConfigPane::sheep(web_with_args(&[
"--port",
"8080",
"--host",
"0.0.0.0",
"--verbose",
"--log",
"debug",
]));
pane.move_to_key("args");
pane.open_list();
pane.arm_list_removal(Instant::now());
for height in 1..=20u16 {
pane.list_mut().unwrap().move_to_first();
let total = pane.list().unwrap().rows().len();
pane.list_mut()
.unwrap()
.set_rows(usize::from(height.saturating_sub(1)));
for step in 0..=total {
let text = text_of(&pane_lines(&pane, None, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}, step {step}: {text:?}"
);
pane.list_mut().unwrap().move_by(1);
}
}
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, None, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn the_list_cursor_survives_every_step_at_the_minimum_height() {
let args = [
"--port",
"8080",
"--host",
"0.0.0.0",
"--verbose",
"--log",
"debug",
"--quiet",
];
for height in [super::super::flock::MIN_HEIGHT, 7, 8, 12] {
let mut app = app_on_the_list_screen(&args);
let total = app.config_pane().unwrap().list().unwrap().rows().len();
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "height {height}, step {step}:\n{text}");
app.update(Msg::Key(KeyPress::SelectDown));
}
}
}
}