use std::fmt::Write as _;
use std::time::{Duration, Instant};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::style::Color;
use shep_client::RequestError;
use shep_core::protocol::{ExitInfo, Lamb, ProcessInfo, Response, RpcError, RpcErrorCode};
use shep_core::status::ProcStatus;
use super::app::{ActionVerb, App, Control, KeyPress, Msg, Sent};
use super::source::HostSample;
use super::tail::{Stream, Tail, TailLine};
use super::theme::Palette;
use super::view::draw;
#[must_use]
pub fn render_text(buffer: &Buffer) -> String {
let area = buffer.area;
(0..area.height)
.map(|row| {
(0..area.width)
.map(|col| buffer[(area.x + col, area.y + row)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
pub fn render_ansi(buffer: &Buffer) -> String {
let area = buffer.area;
let mut out = String::new();
for row in 0..area.height {
let mut current = String::new();
for col in 0..area.width {
let cell = &buffer[(area.x + col, area.y + row)];
let wanted = sgr(cell.fg);
if wanted != current {
out.push_str("\u{1b}[0m");
out.push_str(&wanted);
current = wanted;
}
out.push_str(cell.symbol());
}
out.push_str("\u{1b}[0m");
out.push('\n');
}
out
}
fn sgr(fg: Color) -> String {
let mut out = String::new();
match fg {
Color::Reset => {}
Color::Indexed(index) => {
let _ = write!(out, "\u{1b}[38;5;{index}m");
}
Color::Red => out.push_str("\u{1b}[31m"),
Color::Green => out.push_str("\u{1b}[32m"),
Color::Yellow => out.push_str("\u{1b}[33m"),
Color::DarkGray => out.push_str("\u{1b}[90m"),
_ => {}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scene {
HealthyWide,
Errored,
Empty,
Narrow,
TooNarrow,
Retrying,
Frozen,
Refused,
FilterEditing,
FilterActive,
FilterNoMatch,
NoDetail,
TableOnly,
FeedGap,
FeedMissing,
Cramped,
HostUnknown,
Lambs,
LambsUnknown,
Confirm,
Acting,
ActionRefused,
ActionAccepted,
ActionRefusedOffline,
}
impl Scene {
pub const ALL: &'static [Self] = &[
Self::HealthyWide,
Self::Errored,
Self::Empty,
Self::Narrow,
Self::TooNarrow,
Self::Retrying,
Self::Frozen,
Self::Refused,
Self::FilterEditing,
Self::FilterActive,
Self::FilterNoMatch,
Self::NoDetail,
Self::TableOnly,
Self::FeedGap,
Self::FeedMissing,
Self::Cramped,
Self::HostUnknown,
Self::Lambs,
Self::LambsUnknown,
Self::Confirm,
Self::Acting,
Self::ActionRefused,
Self::ActionAccepted,
Self::ActionRefusedOffline,
];
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::HealthyWide => "healthy_wide",
Self::Errored => "errored",
Self::Empty => "empty",
Self::Narrow => "narrow",
Self::TooNarrow => "too_narrow",
Self::Retrying => "retrying",
Self::Frozen => "frozen",
Self::Refused => "refused",
Self::FilterEditing => "filter_editing",
Self::FilterActive => "filter_active",
Self::FilterNoMatch => "filter_no_match",
Self::NoDetail => "no_detail",
Self::TableOnly => "table_only",
Self::FeedGap => "feed_gap",
Self::FeedMissing => "feed_missing",
Self::Cramped => "cramped",
Self::HostUnknown => "host_unknown",
Self::Lambs => "lambs",
Self::LambsUnknown => "lambs_unknown",
Self::Confirm => "confirm",
Self::Acting => "acting",
Self::ActionRefused => "action_refused",
Self::ActionAccepted => "action_accepted",
Self::ActionRefusedOffline => "action_refused_offline",
}
}
#[must_use]
pub const fn caption(self) -> &'static str {
match self {
Self::HealthyWide => {
"All three panes at 120x30: the host strip under the title, the detail pane and the bleats feed under the table. `>` marks the selected sheep, and every pane below the table describes it."
}
Self::Errored => {
"One errored, one waiting to restart, one stopped, with the selection parked on the errored sheep. Each row's own STATUS cell is the only coloured cell in that row, and EXIT carries why each of the three stopped: a code for the two that crashed, a signal name for the one shep stopped itself."
}
Self::Empty => {
"No sheep registered. Each of the three panes says why it is empty, and the three sentences are different because the three reasons are."
}
Self::Narrow => {
"51 columns: FOLD, EXIT, RESTARTS, PID and MEM are gone, in that order. CPU and UPTIME survive because they explain WHY a RUNNING sheep is behaving badly, a question EXIT cannot even ask. The host strip fits; the detail pane and the feed do not, at 14 rows."
}
Self::TooNarrow => {
"28 columns: below the floor, the pane refuses rather than drawing overlapping garbage. Two short lines, so the refusal still fits the terminal it is refusing about."
}
Self::Retrying => {
"The shepherd stopped answering. Five attempts over about eight seconds before this becomes the next frame. Every pane below the table keeps describing the selected sheep from the last listing."
}
Self::Frozen => {
"The ladder ran out. Last known values stay, the uptime clock has stopped, and so has the host strip — one line ticking over on a frozen screen is a contradiction on the same frame."
}
Self::Refused => {
"`x` with actions gated off. The refusal is literal, nothing about damage gets charming, and the panes below carry on."
}
Self::FilterEditing => {
"Mid-type at 100x14. The table has already narrowed to the two sheep whose names contain the query, the title counts the narrowed set and the whole flock, and the status bar carries the query, a cursor, and the three keys that mean anything while the box is open."
}
Self::FilterActive => {
"The same query applied. The box is closed, the table is still narrowed, and the bar has changed to name the two keys that now touch the filter."
}
Self::FilterNoMatch => {
"A query nothing matches. The table names the query rather than claiming the flock is empty, and the title keeps the flock's real size on screen."
}
Self::NoDetail => {
"20 rows: the detail pane is the first to go, because every number on it but the log paths is already in the row above it."
}
Self::TableOnly => {
"12 rows: no optional panes at all. This is 12a's frame, and the only thing that changed is the two-column gutter the marker sits in."
}
Self::FeedGap => {
"The feed under a burst: 3.8 megabytes were never read and some hundreds of lines were read and dropped. The pane counts both, and counts them separately, because it knows the second exactly and cannot know how many lines are in the first."
}
Self::FeedMissing => {
"The selected sheep has never written a log in this $SHEP_HOME. The feed names that cause rather than sitting blank."
}
Self::Cramped => {
"33 columns: the narrowest terminal that draws. 26 rows — a couple more than the 24-row floor for all three panes being up, so this frame has a little breathing room rather than sitting exactly on the edge. Everything truncates with an ellipsis; nothing overlaps."
}
Self::HostUnknown => {
"`sysinfo` reports this platform unsupported. The strip says so and keeps the flock's own totals, which lookout can always compute."
}
Self::Lambs => {
"The detail pane with a lamb list: how many descendants the shepherd's walk found, how old that reading is, and each lamb's pid and executable name. The stamp sits before the list so a narrow terminal truncates lambs rather than the caveat."
}
Self::LambsUnknown => {
"The same pane on a stopped sheep. The shepherd had no pid to walk from and left the field unset rather than empty, and the line says which of the two it is looking at rather than reporting none found."
}
Self::Confirm => {
"`R` pressed with the gate open. Nothing has been sent: the bar asks a question naming the verb and the exact sheep, and `api` is still online in the table behind it."
}
Self::Acting => {
"Enter pressed. The request is out and nothing on the table has changed, because nothing the shepherd has said has changed: `api` is still online and the cursor has not moved."
}
Self::ActionAccepted => {
"The shepherd answered. The bar says what it did, in the non-grave style a refusal does not get, and the table shows the row the reply carried rather than waiting for the next poll."
}
Self::ActionRefused => {
"The shepherd refused while the request was out, and its own sentence is forwarded rather than rewritten. The sheep has left the flock in the listing behind it, so the table is one row shorter and the cursor has moved to the row below."
}
Self::ActionRefusedOffline => {
"An action key pressed while the link is coming back. The refusal names the same reconnect attempt the banner above it does, rather than the exhausted-ladder sentence — Phase 16 review Minor #8 caught the two disagreeing on one frame."
}
}
}
#[must_use]
pub const fn control(self) -> Control {
match self {
Self::Confirm
| Self::Acting
| Self::ActionRefused
| Self::ActionAccepted
| Self::ActionRefusedOffline
| Self::Lambs => Control::Allowed,
_ => Control::ReadOnly,
}
}
#[must_use]
pub const fn size(self) -> (u16, u16) {
match self {
Self::Empty => (100, 28),
Self::Narrow => (51, 14),
Self::TooNarrow => (28, 8),
Self::FilterEditing | Self::FilterActive | Self::FilterNoMatch => (100, 14),
Self::NoDetail => (120, 20),
Self::TableOnly => (120, 12),
Self::Cramped => (33, 26),
Self::Confirm
| Self::Acting
| Self::ActionRefused
| Self::ActionAccepted
| Self::ActionRefusedOffline => (100, 14),
_ => (120, 30),
}
}
}
#[must_use]
pub fn scene(which: Scene) -> (&'static str, Buffer) {
(which.label(), scene_with(which, Duration::from_secs(600)))
}
#[track_caller]
fn select_id(app: &mut App, id: u32) {
for _ in 0..=app.flock_len() {
if app.selected() == Some(id) {
return;
}
app.update(Msg::Key(KeyPress::SelectDown));
}
panic!("the gallery cannot park its cursor on id {id}");
}
#[must_use]
fn scene_with(which: Scene, age: Duration) -> Buffer {
use std::ffi::OsStr;
let palette = Palette::detect(None, Some(OsStr::new("xterm-256color")), None);
let t0 = Instant::now();
let mut app = App::new(palette, which.control(), "/home/rin/.shep".to_string(), t0);
let flock = match which {
Scene::Empty => Vec::new(),
Scene::Errored | Scene::Frozen | Scene::LambsUnknown => vec![
sheep(
0,
"web",
ProcStatus::Online,
Some(48_211),
0,
Some(3.4),
Some(182 << 20),
Some("edge"),
),
sheep(
1,
"web",
ProcStatus::Online,
Some(48_212),
0,
Some(2.9),
Some(178 << 20),
Some("edge"),
),
sheep(
2,
"api",
ProcStatus::Errored,
None,
14,
None,
None,
Some("edge"),
),
sheep(
3,
"billing-reconciliation-worker",
ProcStatus::WaitingRestart,
None,
3,
None,
None,
None,
),
sheep(4, "cron", ProcStatus::Stopped, None, 0, None, None, None),
sheep(
5,
"metrics",
ProcStatus::Online,
Some(48_240),
0,
Some(0.4),
Some(11 << 20),
None,
),
],
_ => vec![
sheep(
0,
"web",
ProcStatus::Online,
Some(48_211),
0,
Some(3.4),
Some(182 << 20),
Some("edge"),
),
sheep(
1,
"web",
ProcStatus::Online,
Some(48_212),
0,
Some(2.9),
Some(178 << 20),
Some("edge"),
),
sheep(
2,
"api",
ProcStatus::Online,
Some(48_219),
1,
Some(7.1),
Some(241 << 20),
Some("edge"),
),
sheep(
3,
"billing-reconciliation-worker",
ProcStatus::Online,
Some(48_230),
0,
Some(0.8),
Some(96 << 20),
None,
),
sheep(
4,
"cron",
ProcStatus::Online,
Some(48_233),
0,
Some(0.1),
Some(8 << 20),
None,
),
sheep(
5,
"metrics",
ProcStatus::Online,
Some(48_240),
0,
Some(0.4),
Some(11 << 20),
None,
),
],
};
app.update(Msg::Snapshot {
rows: flock,
at: t0,
});
app.update(Msg::Tick {
now: t0 + Duration::from_secs(7),
});
if !matches!(
which,
Scene::Empty | Scene::Narrow | Scene::TooNarrow | Scene::TableOnly
) {
select_id(&mut app, 2);
}
if which == Scene::LambsUnknown {
select_id(&mut app, 4);
}
match which {
Scene::FilterEditing | Scene::FilterNoMatch | Scene::FilterActive => {
app.update(Msg::Key(KeyPress::FilterStart));
let query = if which == Scene::FilterNoMatch {
"zzz"
} else {
"web"
};
for typed in query.chars() {
app.update(Msg::Key(KeyPress::FilterChar(typed)));
}
if which == Scene::FilterActive {
app.update(Msg::Key(KeyPress::FilterApply));
}
}
_ => {}
}
if which == Scene::HostUnknown {
app.update(Msg::Host { sample: None });
} else {
app.update(Msg::Host {
sample: Some(HostSample {
load: (2.31, 4.10, 3.88),
cores: Some(10),
memory_total_bytes: 32 << 30,
memory_used_bytes: 12 * (1 << 30) + (410 << 20),
uptime_seconds: 6 * 86_400 + 3 * 3_600,
}),
});
}
app.update(Msg::Bleats {
tail: feed_for(which),
});
if matches!(which, Scene::Lambs | Scene::Frozen) {
app.update(Msg::Replied {
sent: Sent::Lambs { id: 2 },
result: Ok(Response::Described(vec![
ProcessInfo::builder(2, "api", ProcStatus::Online)
.pid(Some(48_219))
.lambs(Some(vec![
Lamb::new(48_220, "node"),
Lamb::new(48_221, "node"),
Lamb::new(48_222, "node"),
]))
.build(),
])),
});
}
if which == Scene::LambsUnknown {
app.update(Msg::Replied {
sent: Sent::Lambs { id: 4 },
result: Ok(Response::Described(vec![
ProcessInfo::builder(4, "cron", ProcStatus::Stopped)
.lambs(None)
.build(),
])),
});
}
match which {
Scene::Retrying => {
app.update(Msg::Retrying { attempt: 3 });
}
Scene::Frozen => {
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
app.update(Msg::Host {
sample: Some(HostSample {
load: (2.31 + age.as_secs_f64(), 4.10, 3.88),
cores: Some(10),
memory_total_bytes: 32 << 30,
memory_used_bytes: 12 * (1 << 30) + (410 << 20),
uptime_seconds: 6 * 86_400 + 3 * 3_600,
}),
});
}
Scene::Refused => {
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
}
_ => {}
}
app.update(Msg::Tick { now: t0 + age });
match which {
Scene::ActionRefusedOffline => {
app.update(Msg::Retrying { attempt: 3 });
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
}
Scene::Confirm | Scene::Acting | Scene::ActionRefused | Scene::ActionAccepted => {
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
if which != Scene::Confirm {
app.update(Msg::Key(KeyPress::Confirm));
}
if which == Scene::ActionAccepted {
app.update(Msg::Replied {
sent: Sent::Action {
verb: ActionVerb::Restart,
id: 2,
name: "api".to_string(),
},
result: Ok(Response::Restarted(vec![restarted_api()])),
});
}
if which == Scene::ActionRefused {
app.update(Msg::Snapshot {
rows: flock_without_api(),
at: t0,
});
app.update(Msg::Replied {
sent: Sent::Action {
verb: ActionVerb::Restart,
id: 2,
name: "api".to_string(),
},
result: Err(RequestError::Rpc(RpcError {
code: RpcErrorCode::NotFound,
message: "selector matched no registered sheep".to_string(),
})),
});
}
}
_ => {}
}
let (width, height) = which.size();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| draw(&app, frame)).unwrap();
terminal.backend().buffer().clone()
}
fn feed_for(which: Scene) -> Tail {
match which {
Scene::Empty => Tail::default(),
Scene::FeedGap => Tail {
lines: (0..30)
.map(|n| TailLine {
stream: Stream::Out,
text: format!("GET /v1/orders/{n} 200 {}ms", 8 + n % 40),
})
.collect(),
missed_lines: 500,
missed_bytes: 4_012_000,
read_bytes: 65_536,
note: None,
},
Scene::FeedMissing => Tail {
lines: Vec::new(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: Some("this sheep has not written a log in this $SHEP_HOME".to_string()),
},
_ => Tail {
lines: [
"listening on 0.0.0.0:8080",
"GET /healthz 200 3ms",
"GET /v1/orders 200 44ms",
"POST /v1/orders 201 88ms",
"GET /v1/orders/8821 200 9ms",
"connection pool: 14/50 in use",
]
.into_iter()
.map(|text| TailLine {
stream: Stream::Out,
text: text.to_string(),
})
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 512,
note: None,
},
}
}
#[allow(clippy::too_many_arguments)]
fn sheep(
id: u32,
name: &str,
status: ProcStatus,
pid: Option<u32>,
restarts: u32,
cpu: Option<f32>,
memory: Option<u64>,
fold: Option<&str>,
) -> ProcessInfo {
ProcessInfo::builder(id, name, status)
.pid(pid)
.restarts(restarts)
.uptime_ms(4_512_000 + u64::from(id) * 91_000)
.cpu_percent(cpu)
.memory_bytes(memory)
.fold(fold.map(str::to_string))
.out_file(Some(format!("/home/rin/.shep/logs/{name}-{id}-out.log")))
.err_file(Some(format!("/home/rin/.shep/logs/{name}-{id}-err.log")))
.last_exit(match status {
ProcStatus::Errored | ProcStatus::WaitingRestart => Some(ExitInfo {
code: Some(1),
signal: None,
}),
ProcStatus::Stopped => Some(ExitInfo {
code: None,
signal: Some(15),
}),
ProcStatus::Online | ProcStatus::Starting | ProcStatus::Stopping => None,
})
.build()
}
fn restarted_api() -> ProcessInfo {
sheep(
2,
"api",
ProcStatus::Online,
Some(48_299),
2,
Some(7.1),
Some(241 << 20),
Some("edge"),
)
}
fn flock_without_api() -> Vec<ProcessInfo> {
vec![
sheep(
0,
"web",
ProcStatus::Online,
Some(48_211),
0,
Some(3.4),
Some(182 << 20),
Some("edge"),
),
sheep(
1,
"web",
ProcStatus::Online,
Some(48_212),
0,
Some(2.9),
Some(178 << 20),
Some("edge"),
),
sheep(
3,
"billing-reconciliation-worker",
ProcStatus::Online,
Some(48_230),
0,
Some(0.8),
Some(96 << 20),
None,
),
sheep(
4,
"cron",
ProcStatus::Online,
Some(48_233),
0,
Some(0.1),
Some(8 << 20),
None,
),
sheep(
5,
"metrics",
ProcStatus::Online,
Some(48_240),
0,
Some(0.4),
Some(11 << 20),
None,
),
]
}
const GALLERY_PREAMBLE: &str = "shep lookout — Phase 16 frames
================================
These are real frames, rendered headlessly through ratatui's TestBackend by
cargo test -p shep --lib --all-features -- --ignored write_the_gallery
Nothing here is a mockup.
frames.ansi is the same twenty-four frames with colour; read it with `less -R`.
All four panes are here: the flock table (the spine), the host-usage strip,
the sheep detail pane and the bleats feed. `>` marks the selected sheep, and
every pane below the table describes that one sheep.
The feed reads the selected sheep's log files from disk and re-reads them with
each flock listing. It is not a live subscription, and it says so on its own
header line: `out then err` because the two files are shown end to end with no
interleaving, and `re-read with each listing` because a two-second gap in this
pane is the refresh, not the sheep.
When the pane cannot show everything, the header says what went instead. Lines
it read and dropped are counted exactly; bytes below its 64 KiB window were
never read at all, so those are reported in bytes, because nothing counted the
lines in them and guessing would be worse than saying so.
";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_plain_renderer_is_one_line_per_row_and_no_escapes() {
let text = render_text(&scene(Scene::HealthyWide).1);
assert_eq!(text.lines().count(), 30);
assert!(!text.contains('\u{1b}'), "plain means plain");
for line in text.lines() {
assert_eq!(line.chars().count(), 120, "every row is the full width");
}
}
#[test]
fn the_ansi_renderer_colours_the_errored_row_and_always_resets() {
let ansi = render_ansi(&scene(Scene::Errored).1);
assert!(
ansi.contains("\u{1b}[38;5;166m"),
"bark, on the errored status"
);
for line in ansi.lines() {
assert!(
line.is_empty() || line.ends_with("\u{1b}[0m"),
"every line resets before its newline"
);
}
}
fn row_for<'a>(frame: &'a str, name: &str) -> Option<&'a str> {
frame.lines().find(|line| {
let mut tokens = line.trim_start_matches('>').split_whitespace();
tokens.next().is_some_and(|id| id.parse::<u32>().is_ok()) && tokens.next() == Some(name)
})
}
fn marked_row_name_starts_with(frame: &str, prefix: &str) -> bool {
frame.lines().any(|line| {
line.starts_with('>')
&& line
.trim_start_matches('>')
.split_whitespace()
.nth(1)
.is_some_and(|name| name.starts_with(prefix))
})
}
#[test]
#[allow(clippy::too_many_lines)] fn every_scene_shows_the_thing_it_is_named_for() {
let wide = render_text(&scene(Scene::HealthyWide).1);
assert!(
wide.contains("FOLD") && wide.contains("EXIT"),
"every column fits at 120 columns"
);
assert!(
wide.contains("host load 2.31 4.10 3.88 / 10 cores"),
"the host strip"
);
assert!(
wide.contains("sheep 2 api"),
"the detail pane, on the selected sheep"
);
assert!(
wide.contains("bleats api"),
"and the feed, on the same one"
);
assert_eq!(
wide.lines().filter(|line| line.starts_with('>')).count(),
1,
"exactly one selection marker"
);
let empty = render_text(&scene(Scene::Empty).1);
assert!(
empty.contains("the flock is empty"),
"the table's own sentence"
);
assert!(
empty.contains("no sheep selected: the flock is empty"),
"the detail pane's"
);
assert!(empty.contains("bleats no sheep is selected"), "the feed's");
assert!(
empty.contains("flock cpu -"),
"and the strip shows no reading, not zero"
);
let narrow = render_text(&scene(Scene::Narrow).1);
assert!(narrow.contains("CPU") && narrow.contains("UPTIME"));
for gone in ["FOLD", "EXIT", "RESTARTS", "PID", "MEM"] {
assert!(!narrow.contains(gone), "the narrow tier dropped {gone}");
}
assert!(narrow.contains("host load"), "the strip is up at 14 rows");
assert!(!narrow.contains("bleats "), "the feed is not");
assert!(
!narrow.contains("sheep 0 "),
"and neither is the detail pane"
);
let too_narrow = render_text(&scene(Scene::TooNarrow).1);
let mut lines = too_narrow.lines();
assert_eq!(lines.next().unwrap().trim_end(), "too small");
assert_eq!(lines.next().unwrap().trim_end(), "need 33x6");
let gap = render_text(&scene(Scene::FeedGap).1);
assert!(
gap.contains("earlier lines not shown"),
"the lines it dropped"
);
assert!(gap.contains("3.8M"), "the exact figure, not a vague one");
assert!(gap.contains("never read"), "and what it never looked at");
assert!(
!gap.contains("re-read with each listing"),
"the gap replaces the header"
);
let missing = render_text(&scene(Scene::FeedMissing).1);
assert!(missing.contains("has not written a log in this $SHEP_HOME"));
let no_detail = render_text(&scene(Scene::NoDetail).1);
assert!(
no_detail.contains("bleats api"),
"the feed stayed, on the selection"
);
assert!(no_detail.contains("host load"), "and so did the strip");
assert!(
!no_detail.contains("out /home/rin/.shep/logs/"),
"the detail pane went"
);
let table_only = render_text(&scene(Scene::TableOnly).1);
assert!(!table_only.contains("host load"));
assert!(!table_only.contains("bleats "));
assert!(table_only.contains("STATUS"), "the table is still there");
let cramped = render_text(&scene(Scene::Cramped).1);
assert!(cramped.contains('…'), "something truncated, visibly");
for marker in ["host ", "bleats ", "out /home/rin/.shep/logs/"] {
assert_eq!(
cramped
.lines()
.filter(|line| line.starts_with(marker))
.count(),
1,
"{marker:?} appears once at 33 columns"
);
}
assert!(
cramped.lines().last().unwrap().contains("read-only"),
"and the status bar is still the last row"
);
let retrying = render_text(&scene(Scene::Retrying).1);
assert!(retrying.contains("reconnecting"));
assert!(
retrying.contains("sheep 2 api"),
"the detail pane is still up"
);
assert!(retrying.contains("host load"), "and so is the strip");
let frozen = render_text(&scene(Scene::Frozen).1);
assert!(frozen.contains("the shepherd has died"));
assert!(
frozen.contains("host load 2.31 4.10 3.88 / 10 cores"),
"the strip kept its LAST values rather than blanking"
);
let errored = render_text(&scene(Scene::Errored).1);
assert!(errored.contains("errored"));
assert!(
errored.contains("sheep 2 api"),
"the selection is on the errored sheep"
);
assert_eq!(
errored.lines().filter(|line| line.starts_with('>')).count(),
1,
"exactly one marker, on that row"
);
let row_of = |name: &str| {
errored
.lines()
.find(|line| line.contains(name))
.unwrap_or_else(|| panic!("no row for {name}:\n{errored}"))
.to_string()
};
for (name, want) in [
("api", "1"),
("billing-r", "1"),
("cron", "SIGTERM"),
] {
let row = row_of(name);
assert!(
row.contains(want),
"{name}'s EXIT cell must read {want}, not a dash: {row}"
);
}
assert!(
row_of("metrics").contains(" - "),
"a running sheep has no exit to report: {}",
row_of("metrics")
);
let errored_ansi = render_ansi(&scene(Scene::Errored).1);
assert!(
errored_ansi.contains("\u{1b}[38;5;29monline"),
"online's STATUS cell gets meadow"
);
assert!(
errored_ansi.contains("\u{1b}[38;5;166merrored"),
"errored's STATUS cell gets bark"
);
assert!(
errored_ansi.contains("\u{1b}[38;5;221mwaiting-restart"),
"waiting-restart's STATUS cell gets butter"
);
assert!(
errored_ansi.contains("\u{1b}[38;5;245mID"),
"the header row is muted grey, the same token stopped's STATUS uses"
);
assert!(
errored_ansi.contains("\u{1b}[38;5;245mstopped"),
"stopped's STATUS cell shares the chrome's muted grey rather than standing out"
);
let refused = render_text(&scene(Scene::Refused).1);
assert!(refused.contains("--allow-control"));
assert!(
refused.contains("bleats api"),
"a refusal does not blank the screen"
);
let editing = render_text(&scene(Scene::FilterEditing).1);
assert_eq!(
editing
.lines()
.filter(|line| line.contains(" web "))
.count(),
2,
"two rows survived the query"
);
assert!(!editing.contains("billing"), "and the rest did not");
assert!(editing.contains("2 of 6 in the flock"), "got {editing:?}");
assert!(
editing.contains("filter web\u{258f}"),
"the query and the cursor"
);
for named in ["enter applies", "esc cancels", "ctrl-c quits"] {
assert!(editing.contains(named), "the box names {named}");
}
let active = render_text(&scene(Scene::FilterActive).1);
assert!(active.contains("filter \"web\""), "the box is closed");
assert!(!active.contains("enter applies"), "and its keys are gone");
assert!(active.contains("2 of 6 in the flock"), "still narrowed");
assert!(active.contains("/ edit") && active.contains("esc clear"));
let none = render_text(&scene(Scene::FilterNoMatch).1);
assert!(none.contains("no sheep's name contains \"zzz\""));
assert!(!none.contains("the flock is empty"));
assert!(none.contains("0 of 6 in the flock"));
let unknown = render_text(&scene(Scene::HostUnknown).1);
assert!(unknown.contains("host usage is not available on this platform"));
assert!(
unknown.contains("flock cpu"),
"the half lookout can compute survives"
);
let lambs = render_text(&scene(Scene::Lambs).1);
assert!(lambs.contains("lambs 3 parent-pid descendants, read "));
assert!(lambs.contains("48220 node"), "each lamb's pid and name");
let line = lambs
.lines()
.find(|line| line.starts_with("lambs "))
.expect("the lamb line");
assert!(
line.find("read ").unwrap() < line.find("48220").unwrap(),
"the stamp comes before the list"
);
let unknown = render_text(&scene(Scene::LambsUnknown).1);
assert!(unknown.contains("lambs this sheep is not running, so there is no tree to walk"));
assert!(
!unknown.contains("none found"),
"which is the other sentence"
);
assert!(unknown.contains("sheep 4 cron"), "on the stopped sheep");
let confirm = render_text(&scene(Scene::Confirm).1);
assert!(confirm.contains("restart api (id 2)? enter confirms, any other key cancels"));
assert!(confirm.contains("control enabled"), "the gate is open");
assert!(
row_for(&confirm, "api").is_some_and(|row| row.contains("online")),
"nothing was sent, so api is still online: {confirm:?}"
);
let acting = render_text(&scene(Scene::Acting).1);
assert!(acting.contains("restart api (id 2): sent, waiting for the shepherd"));
assert!(
row_for(&acting, "api").is_some_and(|row| row.starts_with('>')),
"the table is untouched: the marker is still on api"
);
assert!(
row_for(&acting, "api").is_some_and(|row| row.contains("online")),
"and the row still says what the shepherd last said"
);
let accepted = render_text(&scene(Scene::ActionAccepted).1);
assert!(accepted.contains("restart api (id 2): the shepherd restarted it"));
assert!(
row_for(&accepted, "api").is_some_and(|row| row.contains("48299")),
"the reply's own row reached the table without waiting for a poll"
);
let refused = render_text(&scene(Scene::ActionRefused).1);
assert!(refused.contains("restart api (id 2): selector matched no registered sheep"));
assert!(
!refused.contains("NotFound"),
"no Rust identifiers on the bar"
);
assert!(refused.contains("5 in the flock"), "one row shorter");
assert!(
row_for(&refused, "api").is_none(),
"api is the row that went"
);
assert!(
marked_row_name_starts_with(&refused, "billing"),
"and the cursor has moved to the row below: {refused:?}"
);
let offline = render_text(&scene(Scene::ActionRefusedOffline).1);
assert_eq!(
offline.matches("reconnecting (attempt 3)").count(),
2,
"the banner and the refusal under it agree, rather than one \
saying reconnecting and the other saying gone: {offline:?}"
);
assert!(
!offline.contains("nothing left to ask"),
"the ladder has not run out yet, so the refusal must not claim it has: {offline:?}"
);
let lambs_bar = render_text(&scene(Scene::Lambs).1);
for key in ["x stop", "R restart", "L reload"] {
assert!(lambs_bar.contains(key), "the control hint names {key}");
}
}
#[test]
fn every_scene_has_a_caption_and_a_distinct_label() {
let mut labels = std::collections::BTreeSet::new();
for which in Scene::ALL {
assert!(
labels.insert(which.label()),
"two scenes share {}",
which.label()
);
let caption = which.caption();
assert!(caption.len() > 30, "{} has a stub caption", which.label());
assert!(
caption.ends_with('.'),
"{}'s caption is not a sentence",
which.label()
);
}
assert_eq!(Scene::ALL.len(), 24);
}
#[test]
fn no_scene_uses_a_modifier_the_ansi_renderer_would_drop() {
for which in Scene::ALL {
let buffer = scene(*which).1;
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
let cell = &buffer[(buffer.area.x + x, buffer.area.y + y)];
assert!(
cell.modifier.is_empty(),
"{} has a modifier at {x},{y}",
which.label()
);
}
}
}
}
#[test]
fn the_frozen_frame_does_not_move_however_long_the_link_stays_gone() {
let ten_minutes = render_text(&scene_with(Scene::Frozen, Duration::from_secs(600)));
let sixteen_hours = render_text(&scene_with(Scene::Frozen, Duration::from_secs(60_000)));
assert_eq!(
ten_minutes, sixteen_hours,
"the frozen frame's uptime column advanced after the link was lost"
);
assert!(
ten_minutes.lines().any(|line| line.starts_with("lambs ")),
"the frozen frame has a lamb line for the comparison above to cover"
);
let live_ten = render_text(&scene_with(Scene::HealthyWide, Duration::from_secs(600)));
let live_sixteen =
render_text(&scene_with(Scene::HealthyWide, Duration::from_secs(60_000)));
assert_ne!(
live_ten, live_sixteen,
"a LIVE frame's uptime column must advance, or the assertion above passes for the wrong reason"
);
}
#[test]
fn frames_are_pinned() {
for which in Scene::ALL {
let (label, buffer) = scene(*which);
insta::assert_snapshot!(label, render_text(&buffer));
}
}
#[test]
#[ignore = "writes into docs/lookout; run it deliberately"]
fn write_the_gallery() {
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/lookout"));
std::fs::create_dir_all(dir).unwrap();
let mut plain = String::from(GALLERY_PREAMBLE);
let mut ansi = String::from(GALLERY_PREAMBLE);
for which in Scene::ALL {
let (label, buffer) = scene(*which);
let (width, height) = which.size();
let heading = format!(
"\n\n=== {label} ({width}x{height}) ===\n{}\n\n",
which.caption()
);
plain.push_str(&heading);
plain.push_str(&render_text(&buffer));
ansi.push_str(&heading);
ansi.push_str(&render_ansi(&buffer));
}
std::fs::write(dir.join("frames.txt"), &plain).unwrap();
std::fs::write(dir.join("frames.ansi"), &ansi).unwrap();
assert!(
plain.lines().count() > 100,
"eight frames is more than a hundred lines"
);
assert_eq!(plain.matches("=== ").count(), Scene::ALL.len());
}
}