use std::fmt::Write as _;
use crate::lifecycle::SubjectKind;
use crate::readouts::buf::ReadoutBuf;
use crate::readouts::context::ReadoutContext;
use crate::readouts::format::{braille_bar, format_eta, format_rate, spinner_frame};
use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
pub struct PhaseStatus;
impl Readout for PhaseStatus {
fn name(&self) -> &'static str {
"phase_status"
}
fn accepts(&self) -> &'static [SubjectKind] {
&[SubjectKind::Phase]
}
fn render(
&self,
ctx: &dyn ReadoutContext,
lod: Lod,
mode: ContentMode,
_opts: &ReadoutOptions,
out: &mut dyn ReadoutBuf,
) -> usize {
match (lod, mode) {
(Lod::Compact, ContentMode::Value) => render_compact(ctx, out),
(Lod::Labeled, ContentMode::Value) => render_labeled(ctx, out),
(Lod::Expanded, ContentMode::Value) => render_expanded(ctx, out),
(Lod::Compact, ContentMode::Explanation) => render_compact_explanation(ctx, out),
(Lod::Labeled, ContentMode::Explanation) => render_labeled_explanation(ctx, out),
(Lod::Expanded, ContentMode::Explanation) => render_expanded_explanation(ctx, out),
}
}
}
fn render_compact_explanation(_ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
let s = "spin progress% rate/s";
let _ = out.write_str(s);
s.len()
}
fn render_labeled_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
let coords = if ctx.subject_labels().is_empty() {
""
} else {
" (scope-coords)"
};
let mut tmp = String::with_capacity(160);
let _ = write!(
&mut tmp,
"spin (bar) [phase-name]{coords} \
progress% throughput ok:result-ok% att:attempt-ok% e:errors r:retries c:concurrency \
(metrics) ETA remaining",
);
let len = tmp.len();
let _ = out.write_str(&tmp);
len
}
fn render_expanded_explanation(_ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
let s = "\
spin [phase-name]\n \
progress: progress% (bar) ETA remaining\n \
throughput: throughput ok:result-ok% att:attempt-ok%\n \
counters: e:errors r:retries c:concurrency\n \
adapter: adapter-counters\n \
batch: batch-info\n \
metrics: workload-emphasised metrics";
let _ = out.write_str(s);
s.len()
}
fn meter_slot(_ctx: &dyn ReadoutContext, frac: Option<f64>) -> String {
match frac {
Some(f) => format!("{:.0}%", f * 100.0),
None => String::new(),
}
}
fn render_labeled(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
let color = ctx.use_color();
let cyan = if color { "\x1b[36m" } else { "" };
let dim = if color { "\x1b[2m" } else { "" };
let bold = if color { "\x1b[1m" } else { "" };
let blue = if color { "\x1b[34m" } else { "" };
let yellow = if color { "\x1b[33m" } else { "" };
let reset = if color { "\x1b[0m" } else { "" };
let total_extent = ctx.cycles_total();
let started = ctx.ops_started();
let finished = ctx.ops_finished();
let ops_completed = ctx.cycles_completed();
let successes = ctx.ops_ok();
let skips = ctx.skips();
let errors = ctx.errors();
let retries = ctx.retries();
let attempt_ok = ctx.attempt_ok();
let attempt_failed = ctx.attempt_failed();
let elapsed = ctx.elapsed_secs();
let concurrency = ctx.concurrency();
if started == 0 && elapsed < 0.2 {
let depth_indent = ctx.depth_indent();
let activity_name = ctx.activity_name();
let spinner = spinner_frame(ctx.refresh_tick());
let mut tmp = String::with_capacity(64);
let _ = write!(
&mut tmp,
"{depth_indent}{cyan}{spinner}{reset} {bold}{blue}[{activity_name}]{reset} {dim}starting…{reset}",
);
let len = tmp.len();
let _ = out.write_str(&tmp);
return len;
}
let frac = ctx.progress_fraction();
let result_total = ops_completed.saturating_sub(skips).max(successes);
let ok_pct: f64 = if result_total > 0 {
successes as f64 * 100.0 / result_total as f64
} else {
100.0
};
let ok_str: String = if result_total > 0 {
format!("{ok_pct:.0}%")
} else if skips > 0 {
"—".to_string()
} else {
"100%".to_string()
};
let attempt_resolved = attempt_ok + attempt_failed;
let att_pct: f64 = if attempt_resolved > 0 {
attempt_ok as f64 * 100.0 / attempt_resolved as f64
} else {
100.0
};
let rate: f64 = if elapsed > 0.0 {
finished as f64 / elapsed
} else {
0.0
};
let rate_str = format_rate(rate);
let skips_chip: String = if skips > 0 {
format!(" {dim}skip:{skips}{reset}")
} else {
String::new()
};
let spinner = "";
let _ = spinner_frame(0);
let bar = String::new();
let eta_chip = |secs: f64| {
format!(
" {dim}(~{} left of ~{}){reset}",
format_eta(secs),
format_eta(elapsed + secs)
)
};
let eta = match ctx.eta_secs() {
Some(secs) => eta_chip(secs),
None if !ctx.open_ended() && ctx.rows_total() == 0 && total_extent > 0 && rate > 0.0 => {
let remaining = total_extent.saturating_sub(finished) as f64;
eta_chip(remaining / rate)
}
None => String::new(),
};
let seq_prefix: String = String::new();
let depth_indent = ctx.depth_indent();
let activity_name = ctx.activity_name();
let chips = ctx.status_metric_chips();
let adapter_status = ctx.adapter_counters_text();
let batch_info = ctx.batch_info_text();
let err_tone = if errors > 0 || retries > 0 {
yellow
} else {
dim
};
let ok_tone = if ok_pct >= 100.0 { dim } else { yellow };
let att_tone = if att_pct >= 100.0 { dim } else { yellow };
let memo = ctx.phase_memo();
let memo_row = if memo.is_empty() {
String::new()
} else {
let bold_yellow = if color { "\x1b[1;33m" } else { "" };
format!("{depth_indent} {bold_yellow}[[ {memo} ]]{reset}\n")
};
let rows_total = ctx.rows_total();
let cycles_chip = if ctx.open_ended() {
if ops_completed > 0 {
format!(" {dim}cycles:{ops_completed}{reset}")
} else {
String::new()
}
} else if rows_total > 0 {
let rows_consumed = ctx.rows_consumed();
let rows_rate = if elapsed > 0.0 {
rows_consumed as f64 / elapsed
} else {
0.0
};
format!(
" {dim}rows:{rows_consumed}/{rows_total} {}{reset}",
format_rate(rows_rate)
)
} else if total_extent > 0 {
format!(" {dim}cycles:{ops_completed}/{total_extent}{reset}")
} else if ops_completed > 0 {
format!(" {dim}cycles:{ops_completed}{reset}")
} else {
String::new()
};
let labels = ctx.subject_labels();
let bar_visible: usize = 0;
let seq_visible: usize = match ctx.subject_seq() {
Some((s, t)) => format!("[{s}/{t}] ").chars().count(),
None => 0,
};
let head_consumed: usize =
depth_indent.chars().count() + bar_visible + seq_visible + activity_name.chars().count();
let continuation_indent = format!("{depth_indent} ");
let coords_part = super::phase_outcome::format_coords_block(
labels,
color,
head_consumed,
&continuation_indent,
false,
);
let key_metrics = format!("{adapter_status}{batch_info}{chips}");
let key_line = if key_metrics.trim().is_empty() {
String::new()
} else {
format!("\n{depth_indent} {}", key_metrics.trim_start())
};
let mut tmp = String::with_capacity(320);
let _ = write!(
&mut tmp,
"{depth_indent}{cyan}{spinner}{reset}{bar} {seq_prefix}{bold}{blue}{activity_name}{reset}{coords_part} {meter}\n\
{memo_row}\
{depth_indent} {dim}{rate_str}{reset} {ok_tone}ok:{ok_str}{reset} \
{att_tone}att:{att_pct:.0}%{reset}{skips_chip} \
{err_tone}e:{errors} r:{retries}{reset} {dim}c:{concurrency}{reset}{cycles_chip}{eta}\
{key_line}",
meter = meter_slot(ctx, frac),
);
let len = tmp.len();
let _ = out.write_str(&tmp);
len
}
fn render_expanded(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
let color = ctx.use_color();
let dim = if color { "\x1b[2m" } else { "" };
let cyan = if color { "\x1b[36m" } else { "" };
let reset = if color { "\x1b[0m" } else { "" };
let total_extent = ctx.cycles_total();
let _started = ctx.ops_started();
let finished = ctx.ops_finished();
let ops_completed = ctx.cycles_completed();
let successes = ctx.ops_ok();
let skips = ctx.skips();
let errors = ctx.errors();
let retries = ctx.retries();
let attempt_ok = ctx.attempt_ok();
let attempt_failed = ctx.attempt_failed();
let elapsed = ctx.elapsed_secs();
let concurrency = ctx.concurrency();
let frac = ctx.progress_fraction();
let pct: f64 = frac.map(|f| f * 100.0).unwrap_or(0.0);
let result_total = ops_completed.saturating_sub(skips).max(successes);
let ok_pct: f64 = if result_total > 0 {
successes as f64 * 100.0 / result_total as f64
} else {
100.0
};
let attempt_resolved = attempt_ok + attempt_failed;
let att_pct: f64 = if attempt_resolved > 0 {
attempt_ok as f64 * 100.0 / attempt_resolved as f64
} else {
100.0
};
let rate: f64 = if elapsed > 0.0 {
finished as f64 / elapsed
} else {
0.0
};
let rate_str = format_rate(rate);
let bar = if total_extent > 0 {
braille_bar(pct, 20)
} else {
String::new()
};
let eta = match ctx.eta_secs() {
Some(secs) => format!("ETA {}", format_eta(secs)),
None if total_extent > 0 && rate > 0.0 => {
let remaining = total_extent.saturating_sub(finished) as f64;
format!("ETA {}", format_eta(remaining / rate))
}
None => String::from("ETA —"),
};
let activity_name = ctx.activity_name();
let chips = ctx.status_metric_chips();
let adapter_status = ctx.adapter_counters_text();
let batch_info = ctx.batch_info_text();
let seq_prefix: String = String::new();
let mut tmp = String::with_capacity(384);
let _ = write!(
&mut tmp,
"{cyan}{spinner}{reset} {seq_prefix}{activity_name}\n \
progress: {meter} {dim}{bar}{reset} {eta}\n \
throughput: {rate_str} ok:{ok_pct:.0}% att:{att_pct:.0}%\n \
counters: e:{errors} r:{retries} c:{concurrency}",
meter = meter_slot(ctx, frac),
spinner = spinner_frame(ctx.refresh_tick()),
);
if !adapter_status.is_empty() {
let _ = write!(&mut tmp, "\n adapter: {adapter_status}");
}
if !batch_info.is_empty() {
let _ = write!(&mut tmp, "\n batch: {batch_info}");
}
if !chips.is_empty() {
let _ = write!(&mut tmp, "\n metrics: {chips}");
}
let len = tmp.len();
let _ = out.write_str(&tmp);
len
}
fn render_compact(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
let finished = ctx.ops_finished();
let elapsed = ctx.elapsed_secs();
let frac = ctx.progress_fraction();
let _pct: f64 = frac.map(|f| f * 100.0).unwrap_or(0.0);
let rate: f64 = if elapsed > 0.0 {
finished as f64 / elapsed
} else {
0.0
};
let mut tmp = String::with_capacity(32);
let _ = write!(
&mut tmp,
"{spin} {meter} {rate}",
meter = meter_slot(ctx, frac),
spin = spinner_frame(ctx.refresh_tick()),
rate = format_rate(rate),
);
let len = tmp.len();
let _ = out.write_str(&tmp);
len
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lifecycle::EventType;
use crate::readouts::buf::StringBuf;
#[derive(Default)]
struct TestCtx {
phase_name: String,
activity_name: String,
phase_seq: Option<(usize, usize)>,
cycles_completed: u64,
cycles_total: u64,
ops_started: u64,
ops_finished: u64,
ops_ok: u64,
skips: u64,
errors: u64,
retries: u64,
attempt_ok: u64,
attempt_failed: u64,
concurrency: usize,
elapsed_secs: f64,
consumed: u64,
rows_consumed: u64,
rows_total: u64,
chips: String,
adapter: String,
batch: String,
depth_indent: String,
refresh_tick: u64,
use_color: bool,
}
impl ReadoutContext for TestCtx {
fn subject_name(&self) -> &str {
&self.phase_name
}
fn activity_name(&self) -> &str {
if self.activity_name.is_empty() {
&self.phase_name
} else {
&self.activity_name
}
}
fn subject_seq(&self) -> Option<(usize, usize)> {
self.phase_seq
}
fn subject_labels(&self) -> &str {
""
}
fn cycles_completed(&self) -> u64 {
self.cycles_completed
}
fn cycles_total(&self) -> u64 {
self.cycles_total
}
fn ops_started(&self) -> u64 {
self.ops_started
}
fn ops_finished(&self) -> u64 {
self.ops_finished
}
fn ops_ok(&self) -> u64 {
self.ops_ok
}
fn skips(&self) -> u64 {
self.skips
}
fn errors(&self) -> u64 {
self.errors
}
fn retries(&self) -> u64 {
self.retries
}
fn attempt_ok(&self) -> u64 {
self.attempt_ok
}
fn attempt_failed(&self) -> u64 {
self.attempt_failed
}
fn concurrency(&self) -> usize {
self.concurrency
}
fn elapsed_secs(&self) -> f64 {
self.elapsed_secs
}
fn consumed(&self) -> u64 {
self.consumed
}
fn rows_consumed(&self) -> u64 {
self.rows_consumed
}
fn rows_total(&self) -> u64 {
self.rows_total
}
fn status_metric_chips(&self) -> String {
self.chips.clone()
}
fn adapter_counters_text(&self) -> String {
self.adapter.clone()
}
fn batch_info_text(&self) -> String {
self.batch.clone()
}
fn depth_indent(&self) -> &str {
&self.depth_indent
}
fn use_color(&self) -> bool {
self.use_color
}
fn event(&self) -> EventType {
EventType::Update
}
fn refresh_tick(&self) -> u64 {
self.refresh_tick
}
}
fn render(ctx: &TestCtx, lod: Lod) -> String {
let mut s = String::new();
let mut buf = StringBuf::new(&mut s);
PhaseStatus.render(
ctx,
lod,
ContentMode::Value,
&ReadoutOptions::new(),
&mut buf,
);
s
}
#[test]
fn labeled_no_color_minimal() {
let ctx = TestCtx {
phase_name: "run".into(),
activity_name: "run".into(),
phase_seq: Some((1, 1)),
cycles_completed: 50,
cycles_total: 100,
ops_started: 50,
ops_finished: 50,
ops_ok: 50,
errors: 0,
retries: 0,
attempt_ok: 50,
attempt_failed: 0,
concurrency: 1,
elapsed_secs: 1.0,
consumed: 50,
refresh_tick: 0,
..Default::default()
};
let out = render(&ctx, Lod::Labeled);
assert!(
!out.contains("⠋"),
"spinner MUST NOT appear in phase_status body: {out}"
);
assert!(
out.contains(" 50%\n"),
"two-line break after pct missing: {out:?}"
);
assert!(
out.contains("50/s ok:100% att:100% e:0 r:0 c:1"),
"labeled body wrong: {out:?}"
);
assert!(
out.contains("(~1s left of ~2s)"),
"ETA chip missing for finite-rate phase: {out:?}"
);
assert!(
!out.contains("(1s/1s)"),
"elapsed must not be re-emitted in the body: {out:?}"
);
}
#[test]
fn cursor_phase_renders_rows_chip_not_cycles() {
let ctx = TestCtx {
phase_name: "ann".into(),
activity_name: "ann".into(),
phase_seq: Some((1, 1)),
cycles_completed: 7,
cycles_total: 1000,
ops_started: 7,
ops_finished: 7,
ops_ok: 7,
attempt_ok: 7,
concurrency: 1,
elapsed_secs: 1.0,
consumed: 7,
rows_consumed: 700,
rows_total: 1000,
..Default::default()
};
let out = render(&ctx, Lod::Labeled);
assert!(
out.contains("rows:700/1000"),
"cursor phase must show row-denominated progress: {out:?}"
);
assert!(
out.contains("rows:700/1000 700/s"),
"cursor phase must show a rows/s rate beside the fraction: {out:?}"
);
assert!(
!out.contains("cycles:"),
"cursor phase must NOT emit the cycles: chip: {out:?}"
);
}
#[test]
fn non_cursor_phase_keeps_cycles_chip() {
let ctx = TestCtx {
phase_name: "run".into(),
activity_name: "run".into(),
phase_seq: Some((1, 1)),
cycles_completed: 50,
cycles_total: 100,
ops_started: 50,
ops_finished: 50,
ops_ok: 50,
attempt_ok: 50,
concurrency: 1,
elapsed_secs: 1.0,
consumed: 50,
..Default::default()
};
let out = render(&ctx, Lod::Labeled);
assert!(
out.contains("cycles:50/100"),
"non-cursor phase must keep the cycles: chip: {out:?}"
);
assert!(
!out.contains("rows:"),
"non-cursor phase must NOT emit a rows: chip: {out:?}"
);
}
#[test]
fn skips_excluded_from_ok_rate() {
let ctx = TestCtx {
phase_name: "run".into(),
cycles_completed: 3,
cycles_total: 3,
ops_started: 3,
ops_finished: 3,
ops_ok: 2,
skips: 1,
concurrency: 1,
elapsed_secs: 1.0,
consumed: 3,
..Default::default()
};
let out = render(&ctx, Lod::Labeled);
assert!(
out.contains("ok:100%"),
"a skip (0 errors) must not drag ok% below 100%: {out:?}"
);
assert!(
!out.contains("ok:67%"),
"ok% wrongly counts the skip in its denominator: {out:?}"
);
}
#[test]
fn attempt_success_rate_diverges_from_ok_under_retry() {
let ctx = TestCtx {
phase_name: "run".into(),
activity_name: "run".into(),
phase_seq: Some((1, 1)),
cycles_completed: 100,
cycles_total: 100,
ops_started: 100,
ops_finished: 100,
ops_ok: 100,
errors: 50,
retries: 50,
attempt_ok: 100,
attempt_failed: 50,
concurrency: 4,
elapsed_secs: 1.0,
consumed: 100,
..Default::default()
};
let out = render(&ctx, Lod::Labeled);
assert!(
out.contains("ok:100% att:67%"),
"attempt success rate must sit beside ok%, diverging under retry: {out:?}"
);
let expanded = render(&ctx, Lod::Expanded);
assert!(
expanded.contains("att:67%"),
"expanded throughput row missing attempt rate: {expanded:?}"
);
}
#[test]
fn memo_header_renders_above_status_when_non_empty() {
struct MemoCtx;
impl ReadoutContext for MemoCtx {
fn subject_name(&self) -> &str {
"x"
}
fn activity_name(&self) -> &str {
"x"
}
fn subject_seq(&self) -> Option<(usize, usize)> {
None
}
fn subject_labels(&self) -> &str {
""
}
fn cycles_completed(&self) -> u64 {
1
}
fn cycles_total(&self) -> u64 {
1
}
fn ops_started(&self) -> u64 {
1
}
fn ops_finished(&self) -> u64 {
1
}
fn ops_ok(&self) -> u64 {
1
}
fn errors(&self) -> u64 {
0
}
fn retries(&self) -> u64 {
0
}
fn concurrency(&self) -> usize {
1
}
fn elapsed_secs(&self) -> f64 {
1.0
}
fn consumed(&self) -> u64 {
1
}
fn status_metric_chips(&self) -> String {
String::new()
}
fn depth_indent(&self) -> &str {
""
}
fn use_color(&self) -> bool {
false
}
fn event(&self) -> EventType {
EventType::Update
}
fn refresh_tick(&self) -> u64 {
0
}
fn phase_memo(&self) -> &str {
"compacting tableX"
}
}
let ctx = MemoCtx;
let mut s = String::new();
let mut buf = StringBuf::new(&mut s);
PhaseStatus.render(
&ctx,
Lod::Labeled,
ContentMode::Value,
&ReadoutOptions::new(),
&mut buf,
);
let lines: Vec<&str> = s.lines().collect();
assert!(
lines[0].contains("x") && lines[0].contains("100%"),
"header row must lead the output, got: {s:?}"
);
assert_eq!(
lines[1].trim_start(),
"[[ compacting tableX ]]",
"memo must be the first detail row under the header: {s:?}"
);
assert!(
lines[2].contains("ok:"),
"regular counters row missing: {s:?}"
);
}
#[test]
fn labeled_no_eta_when_no_extent() {
let ctx = TestCtx {
phase_name: "x".into(),
activity_name: "x".into(),
cycles_total: 0,
ops_started: 1,
elapsed_secs: 0.5,
..Default::default()
};
let out = render(&ctx, Lod::Labeled);
assert!(
!out.contains(" left)") && !out.contains("(0s") && !out.contains("(1s"),
"no time chip expected when ETA is not computable: {out}"
);
}
#[test]
fn labeled_chips_and_adapter_and_batch() {
let ctx = TestCtx {
phase_name: "run".into(),
activity_name: "run".into(),
phase_seq: Some((1, 1)),
cycles_completed: 100,
cycles_total: 100,
ops_started: 100,
ops_finished: 100,
ops_ok: 100,
concurrency: 4,
elapsed_secs: 1.0,
consumed: 100,
chips: " recall_at_10:80.00%".into(),
adapter: " rows/s=12.5K".into(),
batch: " r/b=12.5".into(),
..Default::default()
};
let out = render(&ctx, Lod::Labeled);
assert!(
out.contains("rows/s=12.5K r/b=12.5 recall_at_10:80.00%"),
"adapter / batch / chips ordering wrong: {out}"
);
assert!(
out.contains("\n rows/s=12.5K r/b=12.5 recall_at_10:80.00%"),
"key metrics should sit on a dedicated indented line: {out:?}"
);
let counters_line_idx = out.find("c:4").expect("counters row present");
let key_line_idx = out.find("rows/s=12.5K").expect("key row present");
assert!(
counters_line_idx < key_line_idx,
"counters row must precede the key-metrics row: {out:?}"
);
}
#[test]
fn labeled_omits_key_line_when_no_key_metrics() {
let ctx = TestCtx {
phase_name: "run".into(),
activity_name: "run".into(),
cycles_completed: 10,
cycles_total: 10,
ops_started: 10,
ops_finished: 10,
ops_ok: 10,
concurrency: 4,
elapsed_secs: 1.0,
consumed: 10,
..Default::default()
};
let out = render(&ctx, Lod::Labeled);
assert_eq!(
out.matches('\n').count(),
1,
"plain phase must stay two lines (no empty key row): {out:?}"
);
}
#[test]
fn compact_is_short_and_starts_with_spinner() {
let ctx = TestCtx {
phase_name: "x".into(),
cycles_total: 10,
cycles_completed: 5,
ops_started: 5,
ops_finished: 5,
elapsed_secs: 1.0,
..Default::default()
};
let out = render(&ctx, Lod::Compact);
assert!(out.starts_with("⠋"), "compact missing spinner: {out}");
assert_eq!(out, "⠋ 50% 5/s");
}
#[test]
fn pct_uses_completed_not_started() {
let ctx = TestCtx {
phase_name: "x".into(),
cycles_total: 1,
cycles_completed: 0,
ops_started: 1,
ops_finished: 0,
elapsed_secs: 5.0,
..Default::default()
};
let labeled = render(&ctx, Lod::Labeled);
assert!(
labeled.contains(" 0%\n"),
"in-flight op should read 0%, not 100%: {labeled:?}"
);
let compact = render(&ctx, Lod::Compact);
assert!(
compact.contains(" 0% "),
"compact in-flight should also read 0%: {compact:?}"
);
}
#[test]
fn explanation_mode_renders_descriptors_at_every_lod() {
let ctx = TestCtx {
phase_name: "x".into(),
cycles_total: 100,
ops_started: 50,
ops_finished: 50,
elapsed_secs: 1.0,
..Default::default()
};
for lod in [Lod::Compact, Lod::Labeled, Lod::Expanded] {
let mut s = String::new();
let mut buf = StringBuf::new(&mut s);
let n = PhaseStatus.render(
&ctx,
lod,
ContentMode::Explanation,
&ReadoutOptions::new(),
&mut buf,
);
assert!(n > 0, "{lod:?}/Explanation should render");
assert!(
s.contains("progress"),
"{lod:?}/Explanation missing 'progress' descriptor: {s}"
);
}
}
#[test]
fn expanded_renders_multi_line_block() {
let ctx = TestCtx {
phase_name: "run".into(),
activity_name: "run".into(),
phase_seq: Some((1, 1)),
cycles_completed: 100,
cycles_total: 200,
ops_started: 100,
ops_finished: 100,
ops_ok: 100,
concurrency: 4,
elapsed_secs: 1.0,
consumed: 100,
chips: " recall_at_10:80.00%".into(),
adapter: " rows/s=12.5K".into(),
..Default::default()
};
let out = render(&ctx, Lod::Expanded);
assert!(
out.contains("progress:"),
"expanded missing 'progress:': {out}"
);
assert!(
out.contains("throughput:"),
"expanded missing 'throughput:': {out}"
);
assert!(
out.contains("counters:"),
"expanded missing 'counters:': {out}"
);
assert!(
out.contains("adapter:"),
"expanded missing 'adapter:' tail: {out}"
);
assert!(
out.contains("metrics:"),
"expanded missing 'metrics:' tail: {out}"
);
assert!(
out.lines().count() >= 5,
"expanded should be multi-line: {out}"
);
}
#[test]
fn refresh_tick_advances_spinner_frame() {
let mut ctx = TestCtx {
phase_name: "x".into(),
cycles_total: 10,
ops_started: 1,
ops_finished: 1,
elapsed_secs: 1.0,
..Default::default()
};
let mut frames = std::collections::HashSet::new();
for tick in 0..10 {
ctx.refresh_tick = tick;
let out = render(&ctx, Lod::Compact);
let first = out.chars().next().unwrap();
frames.insert(first);
}
assert_eq!(frames.len(), 10, "spinner cycle not 10 distinct frames");
}
}