use std::fs;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use shep_core::protocol::DogSource;
use super::super::app::{App, LambWalk, RowKey};
use super::super::theme::Palette;
use super::cell;
use super::flock::fit;
use crate::output::width::char_columns;
use crate::output::{human_bytes, human_duration};
use crate::vocabulary::Role;
#[must_use]
pub fn detail_lines(app: &App, width: u16) -> Vec<Line<'static>> {
let palette = app.palette();
match app.selected() {
None => empty_lines(app, width, palette),
Some(RowKey::Group(name)) => group_lines(app, &name, width, palette),
Some(RowKey::Sheep(_)) => sheep_lines(app, width, palette),
Some(RowKey::Fold(name)) => fold_lines(app, &name, width, palette),
Some(RowKey::Section(_)) => unreachable!("a header is never selectable"),
}
}
fn fold_lines(app: &App, name: &str, width: u16, palette: Palette) -> Vec<Line<'static>> {
let totals = app.fold_totals(name);
let head = format!("fold {name} \u{d7}{} ", totals.count);
let status = app.fold_status_text(name);
let rest = format!(
" restarts {} uptime {} cpu {} mem {}",
totals.restarts,
totals
.uptime_ms
.map_or_else(|| "-".to_string(), human_duration),
totals
.cpu
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
totals.memory.map_or_else(|| "-".to_string(), human_bytes),
);
let status = fit(
&status,
width.min(u16::try_from(columns(&status)).unwrap_or(width)),
);
let head_budget = width.saturating_sub(u16::try_from(columns(&status)).unwrap_or(0));
let head = fit(
&head,
head_budget.min(u16::try_from(columns(&head)).unwrap_or(head_budget)),
);
let used = columns(&head) + columns(&status);
let status_style = app
.fold_uniform_status(name)
.map_or(Style::default(), |status| palette.status(status));
vec![
Line::from(vec![
Span::raw(head),
Span::styled(status, status_style),
Span::raw(fit(
&rest,
width.saturating_sub(u16::try_from(used).unwrap_or(width)),
)),
]),
Line::from(Span::styled(
fit(
"lambs a fold has no single process to walk; select one sheep",
width,
),
palette.muted(),
)),
Line::from(Span::raw(String::new())),
Line::from(Span::raw(String::new())),
]
}
fn empty_lines(app: &App, width: u16, palette: Palette) -> Vec<Line<'static>> {
let why = if app.flock_len() == 0 {
"no sheep selected: the flock is empty".to_string()
} else {
format!("no sheep selected: no name contains \"{}\"", app.filter())
};
vec![
Line::from(Span::styled(fit(&why, width), palette.muted())),
Line::from(Span::raw(String::new())),
Line::from(Span::raw(String::new())),
Line::from(Span::raw(String::new())),
]
}
fn group_lines(app: &App, name: &str, width: u16, palette: Palette) -> Vec<Line<'static>> {
let totals = app.group_totals(name);
let head = format!("app {name} \u{d7}{} ", totals.count);
let status = app.group_status_text(name);
let rest = format!(
" restarts {} uptime {} cpu {} mem {}",
totals.restarts,
totals
.uptime_ms
.map_or_else(|| "-".to_string(), human_duration),
totals
.cpu
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
totals.memory.map_or_else(|| "-".to_string(), human_bytes),
);
let status = fit(
&status,
width.min(u16::try_from(columns(&status)).unwrap_or(width)),
);
let head_budget = width.saturating_sub(u16::try_from(columns(&status)).unwrap_or(0));
let head = fit(
&head,
head_budget.min(u16::try_from(columns(&head)).unwrap_or(head_budget)),
);
let used = columns(&head) + columns(&status);
let status_style = app
.group_uniform_status(name)
.map_or(Style::default(), |status| palette.status(status));
vec![
Line::from(vec![
Span::raw(head),
Span::styled(status, status_style),
Span::raw(fit(
&rest,
width.saturating_sub(u16::try_from(used).unwrap_or(width)),
)),
]),
Line::from(Span::styled(
fit("lambs not shown for a group; select one instance", width),
palette.muted(),
)),
Line::from(Span::raw(String::new())),
Line::from(Span::raw(String::new())),
]
}
fn sheep_lines(app: &App, width: u16, palette: Palette) -> Vec<Line<'static>> {
let Some(row) = app.selected_row() else {
return empty_lines(app, width, palette);
};
let info = &row.info;
let chip = chip_text(&format!("SHEEP {}", info.id));
let facts = format!(" {} ", info.name);
let status = row.reported().word();
let cfg = crate::output::cfg_cell(info.pending.as_deref(), None);
let cfg_text = if cfg.starts_with('!') {
format!(" cfg {cfg} pending")
} else {
String::new()
};
let rest = format!(
" pid {} restarts {} uptime {} cpu {} mem {} fold {}{}{}",
info.pid
.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
info.restarts,
app.uptime_ms(info.id)
.map_or_else(|| "-".to_string(), human_duration),
info.cpu_percent
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
info.memory_bytes
.map_or_else(|| "-".to_string(), human_bytes),
info.fold.as_deref().unwrap_or("-"),
match &info.dog {
None => String::new(),
Some(DogSource::BuiltIn) => " dog built-in".to_string(),
Some(DogSource::Adopted { path }) => format!(" dog adopted {path}"),
_ => " dog (unrecognised source)".to_string(),
},
cfg_text,
);
let used = columns(&chip) + columns(&facts) + columns(&status);
vec![
Line::from(vec![
Span::styled(chip, palette.band(Role::Meadow)),
Span::raw(facts),
Span::styled(status, palette.reported(row.reported())),
Span::raw(fit(
&rest,
width.saturating_sub(u16::try_from(used).unwrap_or(width)),
)),
]),
lamb_line(app, info.id, width, palette),
log_row(app, width),
Line::from(Span::raw(String::new())),
]
}
fn lamb_line(
app: &App,
id: u32,
width: u16,
palette: super::super::theme::Palette,
) -> Line<'static> {
let text = match app.lambs_for(id) {
None => "lambs not read yet".to_string(),
Some((LambWalk::Failed, _)) => {
"lambs the shepherd did not answer that request".to_string()
}
Some((LambWalk::NotWalked, _)) => {
"lambs this sheep is not running, so there is no tree to walk".to_string()
}
Some((LambWalk::Walked(lambs), age)) if lambs.is_empty() => {
format!("lambs none found, read {} ago", human_duration(age))
}
Some((LambWalk::Walked(lambs), age)) => {
let noun = if lambs.len() == 1 {
"descendant"
} else {
"descendants"
};
let list = lambs
.iter()
.map(|lamb| format!("{} {}", lamb.pid, lamb.name))
.collect::<Vec<_>>()
.join(" ");
format!(
"lambs {} parent-pid {noun}, read {} ago {list}",
lambs.len(),
human_duration(age)
)
}
};
Line::from(Span::styled(fit(&text, width), palette.muted()))
}
pub(crate) fn chip_text(label: &str) -> String {
let natural = format!(" \u{2588}\u{2588} {label}");
let cells: usize = natural.chars().map(char_columns).sum();
cell::band(label, cells)
}
fn log_row(app: &App, width: u16) -> Line<'static> {
const OUT_LABEL: &str = "out ";
const ERR_LABEL: &str = "err ";
const DIVIDER: &str = " \u{2502} ";
let palette = app.palette();
let info = &app
.selected_row()
.expect("a selected sheep is in the flock")
.info;
let out_path = info.out_file.as_deref();
let err_path = info.err_file.as_deref();
let out_full = out_path.unwrap_or("not reported");
let err_full = err_path.unwrap_or("not reported");
let size_text = match (out_path, err_path) {
(Some(out), Some(err)) => match (fs::metadata(out), fs::metadata(err)) {
(Ok(out_meta), Ok(err_meta)) => Some(format!(
" {} on disk",
human_bytes(out_meta.len() + err_meta.len())
)),
_ => None,
},
_ => None,
};
let overhead = columns(OUT_LABEL) + columns(DIVIDER) + columns(ERR_LABEL);
let width_usize = usize::from(width);
let paths_len = overhead + columns(out_full) + columns(err_full);
let (out_val, err_val, size_val) =
if paths_len + size_text.as_deref().map_or(0, columns) <= width_usize {
(out_full.to_string(), err_full.to_string(), size_text)
} else if paths_len <= width_usize {
(out_full.to_string(), err_full.to_string(), None)
} else {
let budget = width_usize.saturating_sub(overhead);
let out_budget = budget / 2;
let err_budget = budget - out_budget;
(
truncate_from_left(out_full, out_budget),
truncate_from_left(err_full, err_budget),
None,
)
};
let mut spans = vec![
Span::styled(format!("{OUT_LABEL}{out_val}"), palette.muted()),
Span::styled(DIVIDER, palette.line()),
Span::styled(
format!("{ERR_LABEL}{err_val}{}", size_val.unwrap_or_default()),
palette.muted(),
),
];
let used: usize = spans
.iter()
.map(|span| columns(span.content.as_ref()))
.sum();
if used < width_usize {
spans.push(Span::styled(
" ".repeat(width_usize - used),
palette.muted(),
));
}
Line::from(spans)
}
fn truncate_from_left(path: &str, budget: usize) -> String {
if columns(path) <= budget {
return path.to_string();
}
if budget == 0 {
return String::new();
}
let keep = budget - 1;
let mut kept: Vec<char> = Vec::new();
let mut used = 0;
for c in path.chars().rev() {
let width = char_columns(c);
if used + width > keep {
break;
}
used += width;
kept.push(c);
}
kept.reverse();
format!("\u{2026}{}", kept.into_iter().collect::<String>())
}
fn columns(text: &str) -> usize {
text.chars().map(char_columns).sum()
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use shep_core::protocol::{Lamb, ProcessInfo};
use shep_core::status::ProcStatus;
use super::super::fixtures::{
app_fixture, app_with, app_with_lamb_reading_at, coloured, lamb_line_of, plain, render_all,
rendered, sheep_in_fold, sheep_in_fold_with_status, sheep_with_lambs, with_lamb_reading,
with_lamb_reading_for, with_selection, with_selection_and_palette,
};
use super::*;
use crate::lookout::app::{App, Control, KeyPress, LambWalk, Msg, RowKey};
use crate::lookout::theme::Palette;
#[test]
fn the_pane_says_which_lamb_state_it_is_in() {
let cases: [(LambWalk, &str); 3] = [
(
LambWalk::Walked(vec![Lamb::new(48_220, "node"), Lamb::new(48_221, "node")]),
"lambs 2 parent-pid descendants, read ",
),
(LambWalk::Walked(Vec::new()), "lambs none found, read "),
(
LambWalk::NotWalked,
"lambs this sheep is not running, so there is no tree to walk",
),
];
for (walk, expected) in cases {
let app = with_lamb_reading(walk);
let rendered = render_all(&detail_lines(&app, 200));
assert!(
rendered.contains(expected),
"expected {expected:?} in {rendered:?}"
);
}
let failed = with_lamb_reading(LambWalk::Failed);
assert!(
render_all(&detail_lines(&failed, 200))
.contains("lambs the shepherd did not answer that request")
);
let unread = with_selection(sheep_with_lambs());
assert!(render_all(&detail_lines(&unread, 200)).contains("lambs not read yet"));
}
#[test]
fn one_lamb_is_a_descendant_and_not_descendants() {
let app = with_lamb_reading(LambWalk::Walked(vec![Lamb::new(48_220, "node")]));
let rendered = render_all(&detail_lines(&app, 200));
assert!(
rendered.contains("1 parent-pid descendant, read "),
"got {rendered:?}"
);
}
#[test]
fn the_lamb_line_carries_its_age_before_its_list() {
let app = with_lamb_reading(LambWalk::Walked(vec![Lamb::new(48_220, "node")]));
let line = rendered(&detail_lines(&app, 200)[1]);
let stamp = line.find("read ").expect("a stamp");
let list = line.find("48220").expect("a list");
assert!(stamp < list, "the caveat must survive truncation: {line:?}");
}
#[test]
fn a_reading_for_another_sheep_is_not_drawn_here() {
let app = with_lamb_reading_for(11, LambWalk::Walked(vec![Lamb::new(48_220, "node")]));
assert!(render_all(&detail_lines(&app, 200)).contains("lambs not read yet"));
}
#[test]
fn the_stamp_ages_on_a_live_dashboard_and_stops_on_a_frozen_one() {
let (mut app, t0) =
app_with_lamb_reading_at(LambWalk::Walked(vec![Lamb::new(48_220, "node")]));
app.update(Msg::Tick {
now: t0 + Duration::from_secs(120),
});
let live = lamb_line_of(&app);
assert!(live.contains("read 2m ago"), "the stamp aged: {live:?}");
app.update(Msg::Frozen {
at_local: "2026-08-16 09:00:00".to_string(),
});
app.update(Msg::Tick {
now: t0 + Duration::from_secs(3_600),
});
assert_eq!(
lamb_line_of(&app),
live,
"a frozen dashboard's reading must not age"
);
}
#[test]
fn the_pane_adds_the_full_name_and_both_log_paths() {
let app = with_selection(
ProcessInfo::builder(7, "payments-reconciliation-worker", ProcStatus::Errored)
.out_file(Some("/home/ada/.shep/logs/payments-out.log".to_string()))
.err_file(Some("/home/ada/.shep/logs/payments-err.log".to_string()))
.build(),
);
let rendered = render_all(&detail_lines(&app, 200));
assert!(
rendered.contains("payments-reconciliation-worker"),
"the whole name"
);
assert!(rendered.contains("out /home/ada/.shep/logs/payments-out.log"));
assert!(rendered.contains("err /home/ada/.shep/logs/payments-err.log"));
}
#[test]
fn the_header_names_the_pending_count() {
let app = with_selection(
ProcessInfo::builder(2, "api", ProcStatus::Online)
.pending(Some(vec!["cwd".to_string(), "env".to_string()]))
.build(),
);
let rendered = render_all(&detail_lines(&app, 200));
assert!(rendered.contains("cfg !2 pending"), "got {rendered:?}");
}
#[test]
fn a_narrow_width_truncates_the_cfg_cell_before_the_universal_fields() {
let app = with_selection(
ProcessInfo::builder(2, "api", ProcStatus::Online)
.pid(Some(4_242))
.restarts(3)
.pending(Some(vec!["cwd".to_string()]))
.build(),
);
let wide = render_all(&detail_lines(&app, 200));
assert!(wide.contains("cfg !1 pending"), "got {wide:?}");
let narrow = render_all(&detail_lines(&app, 40));
assert!(
!narrow.contains("cfg"),
"the rarest field truncates first: {narrow:?}"
);
assert!(
narrow.contains("pid"),
"a universal field must survive over the cfg cell: {narrow:?}"
);
}
#[test]
fn a_sheep_with_nothing_pending_shows_no_cfg_cell() {
let app = with_selection(ProcessInfo::builder(2, "api", ProcStatus::Online).build());
let rendered = render_all(&detail_lines(&app, 200));
assert!(!rendered.contains("cfg"), "got {rendered:?}");
}
#[test]
fn only_the_status_word_is_coloured() {
let palette = coloured();
let app = with_selection_and_palette(
ProcessInfo::builder(2, "api", ProcStatus::Errored).build(),
palette,
);
let lines = detail_lines(&app, 200);
let coloured: Vec<&str> = lines
.iter()
.flat_map(|line| &line.spans)
.filter(|span| span.style.fg == palette.alarm().fg)
.map(|span| span.content.as_ref())
.collect();
assert_eq!(coloured, vec!["errored"], "got {coloured:?}");
}
#[test]
fn an_empty_flock_says_why_the_pane_has_nothing_to_describe() {
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
std::time::Instant::now(),
);
let rendered = render_all(&detail_lines(&app, 200));
assert!(
rendered.contains("no sheep selected: the flock is empty"),
"got {rendered:?}"
);
}
#[test]
fn a_selected_group_row_shows_the_apps_rollup_and_no_lambs_or_paths() {
let app = app_with(
vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.instance(Some(0))
.memory_bytes(Some(100 << 20))
.uptime_ms(120_000)
.out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
.build(),
ProcessInfo::builder(2, "web", ProcStatus::Online)
.instance(Some(1))
.memory_bytes(Some(150 << 20))
.uptime_ms(30_000)
.build(),
],
plain(),
);
assert!(
matches!(app.selected(), Some(RowKey::Group(ref name)) if name == "web"),
"sanity: the group is selected by default, got {:?}",
app.selected()
);
let rendered = render_all(&detail_lines(&app, 200));
assert!(rendered.contains("app web \u{d7}2"), "got {rendered:?}");
assert!(
rendered.contains("uptime 30s"),
"the MINIMUM uptime (30s), not the first instance's 120s: {rendered:?}"
);
assert!(
rendered.contains("mem 250.0M"),
"memory summed (100 + 150 MiB): {rendered:?}"
);
assert!(
rendered.contains("lambs not shown for a group; select one instance"),
"got {rendered:?}"
);
assert!(
!rendered.contains("web-0-out.log"),
"no arbitrarily-chosen instance's log path: {rendered:?}"
);
}
#[test]
fn a_selected_fold_shows_the_rollup_and_no_log_paths() {
let mut app = app_with(
vec![
sheep_in_fold(1, "api", Some("edge")),
sheep_in_fold(2, "cdn", Some("edge")),
],
plain(),
);
let _ = app.update(Msg::Key(KeyPress::FoldView));
app.select_fold_for_tests("edge");
let text = render_all(&detail_lines(&app, 200));
assert!(text.contains("fold edge \u{d7}2"), "got {text}");
assert!(
text.contains("restarts") && text.contains("uptime") && text.contains("cpu"),
"the rollup a fold shows in place of one sheep's fields: {text}"
);
}
#[test]
fn a_silent_dogs_status_word_and_colour_agree_with_the_flock_pane() {
use shep_core::protocol::DogSource;
use shep_core::status::ProcStatus;
use super::super::flock::{columns_for, row_line};
let dog = ProcessInfo::builder(9, "log-rotate", ProcStatus::Online)
.pid(Some(4_242))
.dog(Some(DogSource::Adopted {
path: "/usr/local/bin/shep-log-rotate".to_string(),
}))
.handshook(Some(false))
.build();
let app = with_selection_and_palette(dog, coloured());
let palette = app.palette();
let detail_rendered = render_all(&detail_lines(&app, 200));
assert!(
detail_rendered.contains("silent"),
"detail pane must say silent: {detail_rendered:?}"
);
assert!(!detail_rendered.contains("online"), "{detail_rendered:?}");
let row = app.row(9).unwrap();
let flock_line = row_line(&app, row, columns_for(200), 200, false, false);
let flock_rendered: String = flock_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(flock_rendered.contains("silent"), "{flock_rendered:?}");
let detail_colour = detail_lines(&app, 200)[0]
.spans
.iter()
.find(|span| span.content.as_ref() == "silent")
.map(|span| span.style.fg);
assert_eq!(detail_colour, Some(palette.attention().fg));
}
fn log_row_thresholds(app: &App) -> (u16, u16, u16) {
let info = &app.selected_row().expect("a selected sheep").info;
let out = info.out_file.as_deref().unwrap_or("not reported");
let err = info.err_file.as_deref().unwrap_or("not reported");
let full = "out ".len() + out.len() + " \u{2502} ".len() + "err ".len() + err.len();
let wide = u16::try_from(full + 40).expect("a test fixture's width fits u16");
let medium = u16::try_from(full + 5).expect("a test fixture's width fits u16");
let narrow = 39;
(wide, medium, narrow)
}
#[test]
fn the_log_row_carries_both_paths_and_the_size() {
let (wide, ..) = log_row_thresholds(&app_fixture());
let line = log_row(&app_fixture(), wide);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("out"));
assert!(text.contains("err"));
assert!(text.contains('\u{2502}'), "a divider between the two");
}
#[test]
fn a_narrow_log_row_drops_the_size_before_it_truncates_a_path() {
let app = app_fixture();
let (wide, medium, _) = log_row_thresholds(&app);
let wide_text: String = log_row(&app, wide)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let medium_text: String = log_row(&app, medium)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(wide_text.contains("on disk"));
assert!(!medium_text.contains("on disk"));
}
#[test]
fn a_path_truncates_from_the_left_so_its_filename_survives() {
let app = app_fixture();
let (_, _, narrow) = log_row_thresholds(&app);
let narrow_text: String = log_row(&app, narrow)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
narrow_text.contains("-out.log"),
"the tail identifies the file"
);
}
#[test]
fn a_missing_log_drops_the_size_rather_than_report_one_file() {
let dir = tempfile::Builder::new()
.prefix("shep-fx-")
.tempdir()
.expect("a tempdir for the fixture's logs");
let out_path = dir.path().join("only-out.log");
std::fs::write(&out_path, b"listening on :8080\n").expect("write the out log");
let missing_err = dir.path().join("rotated-away-err.log");
let info = ProcessInfo::builder(11, "half-rotated", ProcStatus::Online)
.pid(Some(48_111))
.out_file(Some(out_path.display().to_string()))
.err_file(Some(missing_err.display().to_string()))
.build();
let app = with_selection(info);
let text: String = log_row(&app, 200)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
!text.contains("on disk"),
"one file's metadata failed, so no size is honest: {text:?}"
);
}
#[test]
fn a_wide_character_name_does_not_push_the_first_line_past_width() {
let info = ProcessInfo::builder(3, "羊羊羊羊羊", ProcStatus::Online)
.pid(Some(48_103))
.build();
let app = with_selection(info);
let width = 60;
let lines = detail_lines(&app, width);
let first = rendered(&lines[0]);
assert!(
columns(&first) <= usize::from(width),
"first line is {} columns wide, wanted at most {width}: {first:?}",
columns(&first)
);
}
#[test]
fn a_wide_character_group_name_does_not_push_the_first_line_past_width() {
let app = app_with(
vec![
ProcessInfo::builder(
1,
"\u{7f8a}\u{7f8a}\u{7f8a}\u{7f8a}\u{7f8a}",
ProcStatus::Online,
)
.instance(Some(0))
.build(),
ProcessInfo::builder(
2,
"\u{7f8a}\u{7f8a}\u{7f8a}\u{7f8a}\u{7f8a}",
ProcStatus::Online,
)
.instance(Some(1))
.build(),
],
plain(),
);
let width = 60;
let lines = detail_lines(&app, width);
let first = rendered(&lines[0]);
assert!(
columns(&first) <= usize::from(width),
"first line is {} columns wide, wanted at most {width}: {first:?}",
columns(&first)
);
}
#[test]
fn a_mixed_status_does_not_overflow_a_narrow_pane() {
for selected in ["fold", "group"] {
let mut app = app_with(
vec![
sheep_in_fold_with_status(1, "api", Some("edge"), ProcStatus::Errored),
sheep_in_fold_with_status(2, "cdn", Some("edge"), ProcStatus::Online),
sheep_in_fold_with_status(3, "img", Some("edge"), ProcStatus::Starting),
sheep_in_fold_with_status(4, "web", Some("edge"), ProcStatus::Stopped),
],
plain(),
);
if selected == "fold" {
let _ = app.update(Msg::Key(KeyPress::FoldView));
app.select_fold_for_tests("edge");
} else {
app.select_fold_for_tests("edge");
let _ = app.update(Msg::Key(KeyPress::FoldView));
}
for line in &detail_lines(&app, 33) {
let drawn: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(
columns(&drawn) <= 33,
"{selected} row overflowed 33 columns: {drawn:?}"
);
}
}
}
#[test]
fn a_long_fold_name_does_not_push_the_status_off_a_narrow_pane() {
let mut app = app_with(
vec![
sheep_in_fold(1, "api", Some("edge-services-and-more")),
sheep_in_fold(2, "cdn", Some("edge-services-and-more")),
],
plain(),
);
let _ = app.update(Msg::Key(KeyPress::FoldView));
app.select_fold_for_tests("edge-services-and-more");
let lines = detail_lines(&app, 33);
for line in &lines {
let drawn: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(
columns(&drawn) <= 33,
"a detail row overflowed 33 columns: {drawn:?}"
);
}
let head = render_all(&lines);
assert!(head.contains("online"), "the status survives: {head}");
}
}