1use std::fmt::Write as _;
31
32use crate::lifecycle::SubjectKind;
33use crate::readouts::buf::ReadoutBuf;
34use crate::readouts::context::ReadoutContext;
35use crate::readouts::format::{braille_bar, format_eta, format_rate, spinner_frame};
36use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
37
38pub struct PhaseStatus;
39
40impl Readout for PhaseStatus {
41 fn name(&self) -> &'static str {
42 "phase_status"
43 }
44 fn accepts(&self) -> &'static [SubjectKind] {
45 &[SubjectKind::Phase]
46 }
47
48 fn render(
49 &self,
50 ctx: &dyn ReadoutContext,
51 lod: Lod,
52 mode: ContentMode,
53 _opts: &ReadoutOptions,
54 out: &mut dyn ReadoutBuf,
55 ) -> usize {
56 match (lod, mode) {
57 (Lod::Compact, ContentMode::Value) => render_compact(ctx, out),
58 (Lod::Labeled, ContentMode::Value) => render_labeled(ctx, out),
59 (Lod::Expanded, ContentMode::Value) => render_expanded(ctx, out),
60 (Lod::Compact, ContentMode::Explanation) => render_compact_explanation(ctx, out),
61 (Lod::Labeled, ContentMode::Explanation) => render_labeled_explanation(ctx, out),
62 (Lod::Expanded, ContentMode::Explanation) => render_expanded_explanation(ctx, out),
63 }
64 }
65}
66
67fn render_compact_explanation(_ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
71 let s = "spin progress% rate/s";
72 let _ = out.write_str(s);
73 s.len()
74}
75
76fn render_labeled_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
80 let coords = if ctx.subject_labels().is_empty() {
81 ""
82 } else {
83 " (scope-coords)"
84 };
85 let mut tmp = String::with_capacity(160);
86 let _ = write!(
87 &mut tmp,
88 "spin (bar) [phase-name]{coords} \
89progress% throughput ok:result-ok% att:attempt-ok% e:errors r:retries c:concurrency \
90(metrics) ETA remaining",
91 );
92 let len = tmp.len();
93 let _ = out.write_str(&tmp);
94 len
95}
96
97fn render_expanded_explanation(_ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
101 let s = "\
102spin [phase-name]\n \
103progress: progress% (bar) ETA remaining\n \
104throughput: throughput ok:result-ok% att:attempt-ok%\n \
105counters: e:errors r:retries c:concurrency\n \
106adapter: adapter-counters\n \
107batch: batch-info\n \
108metrics: workload-emphasised metrics";
109 let _ = out.write_str(s);
110 s.len()
111}
112
113fn meter_slot(_ctx: &dyn ReadoutContext, frac: Option<f64>) -> String {
119 match frac {
124 Some(f) => format!("{:.0}%", f * 100.0),
125 None => String::new(),
126 }
127}
128
129fn render_labeled(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
130 let color = ctx.use_color();
138 let cyan = if color { "\x1b[36m" } else { "" };
139 let dim = if color { "\x1b[2m" } else { "" };
140 let bold = if color { "\x1b[1m" } else { "" };
141 let blue = if color { "\x1b[34m" } else { "" };
142 let yellow = if color { "\x1b[33m" } else { "" };
143 let reset = if color { "\x1b[0m" } else { "" };
144
145 let total_extent = ctx.cycles_total();
146 let started = ctx.ops_started();
147 let finished = ctx.ops_finished();
148 let ops_completed = ctx.cycles_completed();
149 let successes = ctx.ops_ok();
150 let skips = ctx.skips();
151 let errors = ctx.errors();
152 let retries = ctx.retries();
153 let attempt_ok = ctx.attempt_ok();
154 let attempt_failed = ctx.attempt_failed();
155 let elapsed = ctx.elapsed_secs();
156 let concurrency = ctx.concurrency();
157
158 if started == 0 && elapsed < 0.2 {
167 let depth_indent = ctx.depth_indent();
168 let activity_name = ctx.activity_name();
169 let spinner = spinner_frame(ctx.refresh_tick());
170 let mut tmp = String::with_capacity(64);
174 let _ = write!(
175 &mut tmp,
176 "{depth_indent}{cyan}{spinner}{reset} {bold}{blue}[{activity_name}]{reset} {dim}starting…{reset}",
177 );
178 let len = tmp.len();
179 let _ = out.write_str(&tmp);
180 return len;
181 }
182
183 let frac = ctx.progress_fraction();
197 let result_total = ops_completed.saturating_sub(skips).max(successes);
205 let ok_pct: f64 = if result_total > 0 {
206 successes as f64 * 100.0 / result_total as f64
207 } else {
208 100.0
209 };
210 let ok_str: String = if result_total > 0 {
214 format!("{ok_pct:.0}%")
215 } else if skips > 0 {
216 "—".to_string()
217 } else {
218 "100%".to_string()
219 };
220 let attempt_resolved = attempt_ok + attempt_failed;
230 let att_pct: f64 = if attempt_resolved > 0 {
231 attempt_ok as f64 * 100.0 / attempt_resolved as f64
232 } else {
233 100.0
234 };
235 let rate: f64 = if elapsed > 0.0 {
236 finished as f64 / elapsed
237 } else {
238 0.0
239 };
240 let rate_str = format_rate(rate);
241 let skips_chip: String = if skips > 0 {
242 format!(" {dim}skip:{skips}{reset}")
243 } else {
244 String::new()
245 };
246
247 let spinner = "";
253 let _ = spinner_frame(0);
254 let bar = String::new();
268 let eta_chip = |secs: f64| {
283 format!(
284 " {dim}(~{} left of ~{}){reset}",
285 format_eta(secs),
286 format_eta(elapsed + secs)
287 )
288 };
289 let eta = match ctx.eta_secs() {
290 Some(secs) => eta_chip(secs),
291 None if !ctx.open_ended() && ctx.rows_total() == 0 && total_extent > 0 && rate > 0.0 => {
296 let remaining = total_extent.saturating_sub(finished) as f64;
297 eta_chip(remaining / rate)
298 }
299 None => String::new(),
300 };
301
302 let seq_prefix: String = String::new();
304 let depth_indent = ctx.depth_indent();
305 let activity_name = ctx.activity_name();
306 let chips = ctx.status_metric_chips();
307 let adapter_status = ctx.adapter_counters_text();
308 let batch_info = ctx.batch_info_text();
309
310 let err_tone = if errors > 0 || retries > 0 {
315 yellow
316 } else {
317 dim
318 };
319 let ok_tone = if ok_pct >= 100.0 { dim } else { yellow };
320 let att_tone = if att_pct >= 100.0 { dim } else { yellow };
325
326 let memo = ctx.phase_memo();
332 let memo_row = if memo.is_empty() {
333 String::new()
334 } else {
335 let bold_yellow = if color { "\x1b[1;33m" } else { "" };
336 format!("{depth_indent} {bold_yellow}[[ {memo} ]]{reset}\n")
337 };
338
339 let rows_total = ctx.rows_total();
364 let cycles_chip = if ctx.open_ended() {
365 if ops_completed > 0 {
366 format!(" {dim}cycles:{ops_completed}{reset}")
367 } else {
368 String::new()
369 }
370 } else if rows_total > 0 {
371 let rows_consumed = ctx.rows_consumed();
372 let rows_rate = if elapsed > 0.0 {
373 rows_consumed as f64 / elapsed
374 } else {
375 0.0
376 };
377 format!(
378 " {dim}rows:{rows_consumed}/{rows_total} {}{reset}",
379 format_rate(rows_rate)
380 )
381 } else if total_extent > 0 {
382 format!(" {dim}cycles:{ops_completed}/{total_extent}{reset}")
383 } else if ops_completed > 0 {
384 format!(" {dim}cycles:{ops_completed}{reset}")
385 } else {
386 String::new()
387 };
388 let labels = ctx.subject_labels();
402 let bar_visible: usize = 0;
405 let seq_visible: usize = match ctx.subject_seq() {
406 Some((s, t)) => format!("[{s}/{t}] ").chars().count(),
407 None => 0,
408 };
409 let head_consumed: usize =
410 depth_indent.chars().count() + bar_visible + seq_visible + activity_name.chars().count();
411 let continuation_indent = format!("{depth_indent} ");
412 let coords_part = super::phase_outcome::format_coords_block(
413 labels,
414 color,
415 head_consumed,
416 &continuation_indent,
417 false,
418 );
419 let key_metrics = format!("{adapter_status}{batch_info}{chips}");
429 let key_line = if key_metrics.trim().is_empty() {
430 String::new()
431 } else {
432 format!("\n{depth_indent} {}", key_metrics.trim_start())
435 };
436 let mut tmp = String::with_capacity(320);
437 let _ = write!(
438 &mut tmp,
439 "{depth_indent}{cyan}{spinner}{reset}{bar} {seq_prefix}{bold}{blue}{activity_name}{reset}{coords_part} {meter}\n\
440{memo_row}\
441{depth_indent} {dim}{rate_str}{reset} {ok_tone}ok:{ok_str}{reset} \
442{att_tone}att:{att_pct:.0}%{reset}{skips_chip} \
443{err_tone}e:{errors} r:{retries}{reset} {dim}c:{concurrency}{reset}{cycles_chip}{eta}\
444{key_line}",
445 meter = meter_slot(ctx, frac),
446 );
447 let len = tmp.len();
448 let _ = out.write_str(&tmp);
449 len
450}
451
452fn render_expanded(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
458 let color = ctx.use_color();
459 let dim = if color { "\x1b[2m" } else { "" };
460 let cyan = if color { "\x1b[36m" } else { "" };
461 let reset = if color { "\x1b[0m" } else { "" };
462
463 let total_extent = ctx.cycles_total();
464 let _started = ctx.ops_started();
465 let finished = ctx.ops_finished();
466 let ops_completed = ctx.cycles_completed();
467 let successes = ctx.ops_ok();
468 let skips = ctx.skips();
469 let errors = ctx.errors();
470 let retries = ctx.retries();
471 let attempt_ok = ctx.attempt_ok();
472 let attempt_failed = ctx.attempt_failed();
473 let elapsed = ctx.elapsed_secs();
474 let concurrency = ctx.concurrency();
475
476 let frac = ctx.progress_fraction();
483 let pct: f64 = frac.map(|f| f * 100.0).unwrap_or(0.0);
484 let result_total = ops_completed.saturating_sub(skips).max(successes);
492 let ok_pct: f64 = if result_total > 0 {
493 successes as f64 * 100.0 / result_total as f64
494 } else {
495 100.0
496 };
497 let attempt_resolved = attempt_ok + attempt_failed;
500 let att_pct: f64 = if attempt_resolved > 0 {
501 attempt_ok as f64 * 100.0 / attempt_resolved as f64
502 } else {
503 100.0
504 };
505 let rate: f64 = if elapsed > 0.0 {
506 finished as f64 / elapsed
507 } else {
508 0.0
509 };
510 let rate_str = format_rate(rate);
511 let bar = if total_extent > 0 {
512 braille_bar(pct, 20)
513 } else {
514 String::new()
515 };
516 let eta = match ctx.eta_secs() {
519 Some(secs) => format!("ETA {}", format_eta(secs)),
520 None if total_extent > 0 && rate > 0.0 => {
521 let remaining = total_extent.saturating_sub(finished) as f64;
522 format!("ETA {}", format_eta(remaining / rate))
523 }
524 None => String::from("ETA —"),
525 };
526
527 let activity_name = ctx.activity_name();
528 let chips = ctx.status_metric_chips();
529 let adapter_status = ctx.adapter_counters_text();
530 let batch_info = ctx.batch_info_text();
531 let seq_prefix: String = String::new();
533
534 let mut tmp = String::with_capacity(384);
535 let _ = write!(
536 &mut tmp,
537 "{cyan}{spinner}{reset} {seq_prefix}{activity_name}\n \
538 progress: {meter} {dim}{bar}{reset} {eta}\n \
539 throughput: {rate_str} ok:{ok_pct:.0}% att:{att_pct:.0}%\n \
540 counters: e:{errors} r:{retries} c:{concurrency}",
541 meter = meter_slot(ctx, frac),
542 spinner = spinner_frame(ctx.refresh_tick()),
543 );
544 if !adapter_status.is_empty() {
545 let _ = write!(&mut tmp, "\n adapter: {adapter_status}");
546 }
547 if !batch_info.is_empty() {
548 let _ = write!(&mut tmp, "\n batch: {batch_info}");
549 }
550 if !chips.is_empty() {
551 let _ = write!(&mut tmp, "\n metrics: {chips}");
552 }
553 let len = tmp.len();
554 let _ = out.write_str(&tmp);
555 len
556}
557
558fn render_compact(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
563 let finished = ctx.ops_finished();
564 let elapsed = ctx.elapsed_secs();
565 let frac = ctx.progress_fraction();
570 let _pct: f64 = frac.map(|f| f * 100.0).unwrap_or(0.0);
571 let rate: f64 = if elapsed > 0.0 {
572 finished as f64 / elapsed
573 } else {
574 0.0
575 };
576 let mut tmp = String::with_capacity(32);
577 let _ = write!(
578 &mut tmp,
579 "{spin} {meter} {rate}",
580 meter = meter_slot(ctx, frac),
581 spin = spinner_frame(ctx.refresh_tick()),
582 rate = format_rate(rate),
583 );
584 let len = tmp.len();
585 let _ = out.write_str(&tmp);
586 len
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::lifecycle::EventType;
593 use crate::readouts::buf::StringBuf;
594
595 #[derive(Default)]
596 struct TestCtx {
597 phase_name: String,
598 activity_name: String,
599 phase_seq: Option<(usize, usize)>,
600 cycles_completed: u64,
601 cycles_total: u64,
602 ops_started: u64,
603 ops_finished: u64,
604 ops_ok: u64,
605 skips: u64,
606 errors: u64,
607 retries: u64,
608 attempt_ok: u64,
609 attempt_failed: u64,
610 concurrency: usize,
611 elapsed_secs: f64,
612 consumed: u64,
613 rows_consumed: u64,
614 rows_total: u64,
615 chips: String,
616 adapter: String,
617 batch: String,
618 depth_indent: String,
619 refresh_tick: u64,
620 use_color: bool,
621 }
622
623 impl ReadoutContext for TestCtx {
624 fn subject_name(&self) -> &str {
625 &self.phase_name
626 }
627 fn activity_name(&self) -> &str {
628 if self.activity_name.is_empty() {
629 &self.phase_name
630 } else {
631 &self.activity_name
632 }
633 }
634 fn subject_seq(&self) -> Option<(usize, usize)> {
635 self.phase_seq
636 }
637 fn subject_labels(&self) -> &str {
638 ""
639 }
640 fn cycles_completed(&self) -> u64 {
641 self.cycles_completed
642 }
643 fn cycles_total(&self) -> u64 {
644 self.cycles_total
645 }
646 fn ops_started(&self) -> u64 {
647 self.ops_started
648 }
649 fn ops_finished(&self) -> u64 {
650 self.ops_finished
651 }
652 fn ops_ok(&self) -> u64 {
653 self.ops_ok
654 }
655 fn skips(&self) -> u64 {
656 self.skips
657 }
658 fn errors(&self) -> u64 {
659 self.errors
660 }
661 fn retries(&self) -> u64 {
662 self.retries
663 }
664 fn attempt_ok(&self) -> u64 {
665 self.attempt_ok
666 }
667 fn attempt_failed(&self) -> u64 {
668 self.attempt_failed
669 }
670 fn concurrency(&self) -> usize {
671 self.concurrency
672 }
673 fn elapsed_secs(&self) -> f64 {
674 self.elapsed_secs
675 }
676 fn consumed(&self) -> u64 {
677 self.consumed
678 }
679 fn rows_consumed(&self) -> u64 {
680 self.rows_consumed
681 }
682 fn rows_total(&self) -> u64 {
683 self.rows_total
684 }
685 fn status_metric_chips(&self) -> String {
686 self.chips.clone()
687 }
688 fn adapter_counters_text(&self) -> String {
689 self.adapter.clone()
690 }
691 fn batch_info_text(&self) -> String {
692 self.batch.clone()
693 }
694 fn depth_indent(&self) -> &str {
695 &self.depth_indent
696 }
697 fn use_color(&self) -> bool {
698 self.use_color
699 }
700 fn event(&self) -> EventType {
701 EventType::Update
702 }
703 fn refresh_tick(&self) -> u64 {
704 self.refresh_tick
705 }
706 }
707
708 fn render(ctx: &TestCtx, lod: Lod) -> String {
709 let mut s = String::new();
710 let mut buf = StringBuf::new(&mut s);
711 PhaseStatus.render(
712 ctx,
713 lod,
714 ContentMode::Value,
715 &ReadoutOptions::new(),
716 &mut buf,
717 );
718 s
719 }
720
721 #[test]
722 fn labeled_no_color_minimal() {
723 let ctx = TestCtx {
724 phase_name: "run".into(),
725 activity_name: "run".into(),
726 phase_seq: Some((1, 1)),
727 cycles_completed: 50,
728 cycles_total: 100,
729 ops_started: 50,
730 ops_finished: 50,
731 ops_ok: 50,
732 errors: 0,
733 retries: 0,
734 attempt_ok: 50,
735 attempt_failed: 0,
736 concurrency: 1,
737 elapsed_secs: 1.0,
738 consumed: 50,
739 refresh_tick: 0,
740 ..Default::default()
741 };
742 let out = render(&ctx, Lod::Labeled);
743 assert!(
749 !out.contains("⠋"),
750 "spinner MUST NOT appear in phase_status body: {out}"
751 );
752 assert!(
755 out.contains(" 50%\n"),
756 "two-line break after pct missing: {out:?}"
757 );
758 assert!(
759 out.contains("50/s ok:100% att:100% e:0 r:0 c:1"),
760 "labeled body wrong: {out:?}"
761 );
762 assert!(
766 out.contains("(~1s left of ~2s)"),
767 "ETA chip missing for finite-rate phase: {out:?}"
768 );
769 assert!(
770 !out.contains("(1s/1s)"),
771 "elapsed must not be re-emitted in the body: {out:?}"
772 );
773 }
774
775 #[test]
776 fn cursor_phase_renders_rows_chip_not_cycles() {
777 let ctx = TestCtx {
783 phase_name: "ann".into(),
784 activity_name: "ann".into(),
785 phase_seq: Some((1, 1)),
786 cycles_completed: 7,
787 cycles_total: 1000,
788 ops_started: 7,
789 ops_finished: 7,
790 ops_ok: 7,
791 attempt_ok: 7,
792 concurrency: 1,
793 elapsed_secs: 1.0,
794 consumed: 7,
795 rows_consumed: 700,
796 rows_total: 1000,
797 ..Default::default()
798 };
799 let out = render(&ctx, Lod::Labeled);
800 assert!(
801 out.contains("rows:700/1000"),
802 "cursor phase must show row-denominated progress: {out:?}"
803 );
804 assert!(
806 out.contains("rows:700/1000 700/s"),
807 "cursor phase must show a rows/s rate beside the fraction: {out:?}"
808 );
809 assert!(
811 !out.contains("cycles:"),
812 "cursor phase must NOT emit the cycles: chip: {out:?}"
813 );
814 }
815
816 #[test]
817 fn non_cursor_phase_keeps_cycles_chip() {
818 let ctx = TestCtx {
823 phase_name: "run".into(),
824 activity_name: "run".into(),
825 phase_seq: Some((1, 1)),
826 cycles_completed: 50,
827 cycles_total: 100,
828 ops_started: 50,
829 ops_finished: 50,
830 ops_ok: 50,
831 attempt_ok: 50,
832 concurrency: 1,
833 elapsed_secs: 1.0,
834 consumed: 50,
835 ..Default::default()
837 };
838 let out = render(&ctx, Lod::Labeled);
839 assert!(
840 out.contains("cycles:50/100"),
841 "non-cursor phase must keep the cycles: chip: {out:?}"
842 );
843 assert!(
844 !out.contains("rows:"),
845 "non-cursor phase must NOT emit a rows: chip: {out:?}"
846 );
847 }
848
849 #[test]
850 fn skips_excluded_from_ok_rate() {
851 let ctx = TestCtx {
856 phase_name: "run".into(),
857 cycles_completed: 3,
858 cycles_total: 3,
859 ops_started: 3,
860 ops_finished: 3,
861 ops_ok: 2,
862 skips: 1,
863 concurrency: 1,
864 elapsed_secs: 1.0,
865 consumed: 3,
866 ..Default::default()
867 };
868 let out = render(&ctx, Lod::Labeled);
869 assert!(
870 out.contains("ok:100%"),
871 "a skip (0 errors) must not drag ok% below 100%: {out:?}"
872 );
873 assert!(
874 !out.contains("ok:67%"),
875 "ok% wrongly counts the skip in its denominator: {out:?}"
876 );
877 }
878
879 #[test]
880 fn attempt_success_rate_diverges_from_ok_under_retry() {
881 let ctx = TestCtx {
887 phase_name: "run".into(),
888 activity_name: "run".into(),
889 phase_seq: Some((1, 1)),
890 cycles_completed: 100,
891 cycles_total: 100,
892 ops_started: 100,
893 ops_finished: 100,
894 ops_ok: 100,
895 errors: 50,
896 retries: 50,
897 attempt_ok: 100,
898 attempt_failed: 50,
899 concurrency: 4,
900 elapsed_secs: 1.0,
901 consumed: 100,
902 ..Default::default()
903 };
904 let out = render(&ctx, Lod::Labeled);
905 assert!(
906 out.contains("ok:100% att:67%"),
907 "attempt success rate must sit beside ok%, diverging under retry: {out:?}"
908 );
909 let expanded = render(&ctx, Lod::Expanded);
913 assert!(
914 expanded.contains("att:67%"),
915 "expanded throughput row missing attempt rate: {expanded:?}"
916 );
917 }
918
919 #[test]
920 fn memo_header_renders_above_status_when_non_empty() {
921 struct MemoCtx;
927 impl ReadoutContext for MemoCtx {
928 fn subject_name(&self) -> &str {
929 "x"
930 }
931 fn activity_name(&self) -> &str {
932 "x"
933 }
934 fn subject_seq(&self) -> Option<(usize, usize)> {
935 None
936 }
937 fn subject_labels(&self) -> &str {
938 ""
939 }
940 fn cycles_completed(&self) -> u64 {
941 1
942 }
943 fn cycles_total(&self) -> u64 {
944 1
945 }
946 fn ops_started(&self) -> u64 {
947 1
948 }
949 fn ops_finished(&self) -> u64 {
950 1
951 }
952 fn ops_ok(&self) -> u64 {
953 1
954 }
955 fn errors(&self) -> u64 {
956 0
957 }
958 fn retries(&self) -> u64 {
959 0
960 }
961 fn concurrency(&self) -> usize {
962 1
963 }
964 fn elapsed_secs(&self) -> f64 {
965 1.0
966 }
967 fn consumed(&self) -> u64 {
968 1
969 }
970 fn status_metric_chips(&self) -> String {
971 String::new()
972 }
973 fn depth_indent(&self) -> &str {
974 ""
975 }
976 fn use_color(&self) -> bool {
977 false
978 }
979 fn event(&self) -> EventType {
980 EventType::Update
981 }
982 fn refresh_tick(&self) -> u64 {
983 0
984 }
985 fn phase_memo(&self) -> &str {
986 "compacting tableX"
987 }
988 }
989 let ctx = MemoCtx;
990 let mut s = String::new();
991 let mut buf = StringBuf::new(&mut s);
992 PhaseStatus.render(
993 &ctx,
994 Lod::Labeled,
995 ContentMode::Value,
996 &ReadoutOptions::new(),
997 &mut buf,
998 );
999 let lines: Vec<&str> = s.lines().collect();
1002 assert!(
1003 lines[0].contains("x") && lines[0].contains("100%"),
1004 "header row must lead the output, got: {s:?}"
1005 );
1006 assert_eq!(
1007 lines[1].trim_start(),
1008 "[[ compacting tableX ]]",
1009 "memo must be the first detail row under the header: {s:?}"
1010 );
1011 assert!(
1013 lines[2].contains("ok:"),
1014 "regular counters row missing: {s:?}"
1015 );
1016 }
1017
1018 #[test]
1019 fn labeled_no_eta_when_no_extent() {
1020 let ctx = TestCtx {
1027 phase_name: "x".into(),
1028 activity_name: "x".into(),
1029 cycles_total: 0,
1030 ops_started: 1,
1031 elapsed_secs: 0.5,
1032 ..Default::default()
1033 };
1034 let out = render(&ctx, Lod::Labeled);
1035 assert!(
1039 !out.contains(" left)") && !out.contains("(0s") && !out.contains("(1s"),
1040 "no time chip expected when ETA is not computable: {out}"
1041 );
1042 }
1043
1044 #[test]
1045 fn labeled_chips_and_adapter_and_batch() {
1046 let ctx = TestCtx {
1047 phase_name: "run".into(),
1048 activity_name: "run".into(),
1049 phase_seq: Some((1, 1)),
1050 cycles_completed: 100,
1051 cycles_total: 100,
1052 ops_started: 100,
1053 ops_finished: 100,
1054 ops_ok: 100,
1055 concurrency: 4,
1056 elapsed_secs: 1.0,
1057 consumed: 100,
1058 chips: " recall_at_10:80.00%".into(),
1059 adapter: " rows/s=12.5K".into(),
1060 batch: " r/b=12.5".into(),
1061 ..Default::default()
1062 };
1063 let out = render(&ctx, Lod::Labeled);
1064 assert!(
1066 out.contains("rows/s=12.5K r/b=12.5 recall_at_10:80.00%"),
1067 "adapter / batch / chips ordering wrong: {out}"
1068 );
1069 assert!(
1072 out.contains("\n rows/s=12.5K r/b=12.5 recall_at_10:80.00%"),
1073 "key metrics should sit on a dedicated indented line: {out:?}"
1074 );
1075 let counters_line_idx = out.find("c:4").expect("counters row present");
1077 let key_line_idx = out.find("rows/s=12.5K").expect("key row present");
1078 assert!(
1079 counters_line_idx < key_line_idx,
1080 "counters row must precede the key-metrics row: {out:?}"
1081 );
1082 }
1083
1084 #[test]
1085 fn labeled_omits_key_line_when_no_key_metrics() {
1086 let ctx = TestCtx {
1089 phase_name: "run".into(),
1090 activity_name: "run".into(),
1091 cycles_completed: 10,
1092 cycles_total: 10,
1093 ops_started: 10,
1094 ops_finished: 10,
1095 ops_ok: 10,
1096 concurrency: 4,
1097 elapsed_secs: 1.0,
1098 consumed: 10,
1099 ..Default::default()
1100 };
1101 let out = render(&ctx, Lod::Labeled);
1102 assert_eq!(
1104 out.matches('\n').count(),
1105 1,
1106 "plain phase must stay two lines (no empty key row): {out:?}"
1107 );
1108 }
1109
1110 #[test]
1111 fn compact_is_short_and_starts_with_spinner() {
1112 let ctx = TestCtx {
1116 phase_name: "x".into(),
1117 cycles_total: 10,
1118 cycles_completed: 5,
1119 ops_started: 5,
1120 ops_finished: 5,
1121 elapsed_secs: 1.0,
1122 ..Default::default()
1123 };
1124 let out = render(&ctx, Lod::Compact);
1125 assert!(out.starts_with("⠋"), "compact missing spinner: {out}");
1126 assert_eq!(out, "⠋ 50% 5/s");
1127 }
1128
1129 #[test]
1130 fn pct_uses_completed_not_started() {
1131 let ctx = TestCtx {
1136 phase_name: "x".into(),
1137 cycles_total: 1,
1138 cycles_completed: 0,
1139 ops_started: 1,
1140 ops_finished: 0,
1141 elapsed_secs: 5.0,
1142 ..Default::default()
1143 };
1144 let labeled = render(&ctx, Lod::Labeled);
1145 assert!(
1146 labeled.contains(" 0%\n"),
1147 "in-flight op should read 0%, not 100%: {labeled:?}"
1148 );
1149 let compact = render(&ctx, Lod::Compact);
1150 assert!(
1151 compact.contains(" 0% "),
1152 "compact in-flight should also read 0%: {compact:?}"
1153 );
1154 }
1155
1156 #[test]
1157 fn explanation_mode_renders_descriptors_at_every_lod() {
1158 let ctx = TestCtx {
1162 phase_name: "x".into(),
1163 cycles_total: 100,
1164 ops_started: 50,
1165 ops_finished: 50,
1166 elapsed_secs: 1.0,
1167 ..Default::default()
1168 };
1169 for lod in [Lod::Compact, Lod::Labeled, Lod::Expanded] {
1170 let mut s = String::new();
1171 let mut buf = StringBuf::new(&mut s);
1172 let n = PhaseStatus.render(
1173 &ctx,
1174 lod,
1175 ContentMode::Explanation,
1176 &ReadoutOptions::new(),
1177 &mut buf,
1178 );
1179 assert!(n > 0, "{lod:?}/Explanation should render");
1180 assert!(
1181 s.contains("progress"),
1182 "{lod:?}/Explanation missing 'progress' descriptor: {s}"
1183 );
1184 }
1185 }
1186
1187 #[test]
1188 fn expanded_renders_multi_line_block() {
1189 let ctx = TestCtx {
1190 phase_name: "run".into(),
1191 activity_name: "run".into(),
1192 phase_seq: Some((1, 1)),
1193 cycles_completed: 100,
1194 cycles_total: 200,
1195 ops_started: 100,
1196 ops_finished: 100,
1197 ops_ok: 100,
1198 concurrency: 4,
1199 elapsed_secs: 1.0,
1200 consumed: 100,
1201 chips: " recall_at_10:80.00%".into(),
1202 adapter: " rows/s=12.5K".into(),
1203 ..Default::default()
1204 };
1205 let out = render(&ctx, Lod::Expanded);
1206 assert!(
1210 out.contains("progress:"),
1211 "expanded missing 'progress:': {out}"
1212 );
1213 assert!(
1214 out.contains("throughput:"),
1215 "expanded missing 'throughput:': {out}"
1216 );
1217 assert!(
1218 out.contains("counters:"),
1219 "expanded missing 'counters:': {out}"
1220 );
1221 assert!(
1222 out.contains("adapter:"),
1223 "expanded missing 'adapter:' tail: {out}"
1224 );
1225 assert!(
1226 out.contains("metrics:"),
1227 "expanded missing 'metrics:' tail: {out}"
1228 );
1229 assert!(
1230 out.lines().count() >= 5,
1231 "expanded should be multi-line: {out}"
1232 );
1233 }
1234
1235 #[test]
1236 fn refresh_tick_advances_spinner_frame() {
1237 let mut ctx = TestCtx {
1238 phase_name: "x".into(),
1239 cycles_total: 10,
1240 ops_started: 1,
1241 ops_finished: 1,
1242 elapsed_secs: 1.0,
1243 ..Default::default()
1244 };
1245 let mut frames = std::collections::HashSet::new();
1246 for tick in 0..10 {
1247 ctx.refresh_tick = tick;
1248 let out = render(&ctx, Lod::Compact);
1249 let first = out.chars().next().unwrap();
1251 frames.insert(first);
1252 }
1253 assert_eq!(frames.len(), 10, "spinner cycle not 10 distinct frames");
1254 }
1255}