1use std::fmt::Write as _;
54
55use crate::lifecycle::SubjectKind;
56use crate::readouts::buf::ReadoutBuf;
57use crate::readouts::context::ReadoutContext;
58use crate::readouts::format::ballot_bar;
59use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
60
61static LAST_RENDERED_COORDS: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
74
75struct Stratum {
81 pairs: Vec<(String, String)>,
82}
83
84fn parse_strata(labels: &str) -> Vec<Stratum> {
96 if labels.is_empty() {
97 return Vec::new();
98 }
99 let mut strata = Vec::new();
103 for raw in labels.split("), (") {
104 let inner = raw.trim_start_matches('(').trim_end_matches(')');
105 let mut pairs = Vec::new();
106 for kv in inner.split(", ") {
107 match kv.split_once('=') {
108 Some((k, v)) => pairs.push((k.to_string(), v.to_string())),
109 None => pairs.push(("_".into(), kv.to_string())),
110 }
111 }
112 strata.push(Stratum { pairs });
113 }
114 strata
115}
116
117fn render_strata(
139 strata: &[Stratum],
140 prev_strata: &[Stratum],
141 color: bool,
142 head_consumed: usize,
143 available_width: usize,
144 continuation_indent: &str,
145) -> String {
146 if strata.is_empty() {
147 return String::new();
148 }
149 let bold = if color { "\x1b[1m" } else { "" };
150 let dim = if color { "\x1b[2m" } else { "" };
151 let yellow = if color { "\x1b[33m" } else { "" };
152 let magenta = if color { "\x1b[35m" } else { "" };
153 let reset = if color { "\x1b[0m" } else { "" };
154
155 let mut rendered: Vec<(String, usize)> = Vec::with_capacity(strata.len());
160 for (idx, s) in strata.iter().enumerate() {
161 let prior_pairs: Option<&Vec<(String, String)>> = prev_strata.get(idx).map(|p| &p.pairs);
162 let mut styled = String::with_capacity(64);
163 let mut plain = String::with_capacity(64);
164 styled.push_str(bold);
165 styled.push_str(yellow);
166 styled.push('(');
167 plain.push('(');
168 for (pi, (k, v)) in s.pairs.iter().enumerate() {
169 if pi > 0 {
170 styled.push_str(", ");
171 plain.push_str(", ");
172 }
173 let changed = match prior_pairs {
179 Some(prior) => prior
180 .iter()
181 .find(|(pk, _)| pk == k)
182 .map(|(_, pv)| pv != v)
183 .unwrap_or(true),
184 None => true,
185 };
186 write!(styled, "{k}=").ok();
187 plain.push_str(k);
188 plain.push('=');
189 if changed {
190 write!(styled, "{reset}{bold}{magenta}{v}{reset}{bold}{yellow}").ok();
193 } else {
194 styled.push_str(v);
195 }
196 plain.push_str(v);
197 }
198 styled.push(')');
199 styled.push_str(reset);
200 plain.push(')');
201 rendered.push((styled, plain.chars().count()));
202 }
203
204 let mut out = String::new();
232 let sep_plain_len = 2; let cont_indent_width = continuation_indent.chars().count();
234 let mut current_visible = head_consumed;
235 let mut wrote_any = false;
236 for (styled, plain_len) in rendered.iter() {
237 let needs_sep = wrote_any;
238 let next_width = if needs_sep {
239 current_visible + sep_plain_len + plain_len
240 } else {
241 current_visible + plain_len
242 };
243 let first_stratum_overflows = !wrote_any
247 && current_visible + plain_len > available_width
248 && cont_indent_width + plain_len < available_width;
254 if first_stratum_overflows {
255 out.push('\n');
256 out.push_str(continuation_indent);
257 out.push_str(styled);
258 current_visible = cont_indent_width + plain_len;
259 } else if wrote_any && next_width > available_width {
260 out.push(',');
264 out.push('\n');
265 out.push_str(continuation_indent);
266 out.push_str(styled);
267 current_visible = cont_indent_width + plain_len;
268 } else {
269 if needs_sep {
270 out.push_str(dim);
271 out.push_str(", ");
272 out.push_str(reset);
273 current_visible += sep_plain_len;
274 }
275 out.push_str(styled);
276 current_visible += plain_len;
277 }
278 wrote_any = true;
279 }
280 out
281}
282
283fn current_terminal_cols() -> usize {
290 crate::activity::terminal_cols().unwrap_or(120).max(40)
291}
292
293fn strata_diff(strata: &[Stratum], prev_strata: &[Stratum]) -> Vec<Stratum> {
305 if prev_strata.is_empty() {
306 return strata.to_vec();
310 }
311 let mut out: Vec<Stratum> = Vec::with_capacity(strata.len());
312 for (idx, s) in strata.iter().enumerate() {
313 let prior = prev_strata.get(idx);
314 let mut changed_pairs: Vec<(String, String)> = Vec::new();
315 for (k, v) in &s.pairs {
316 let unchanged = match prior {
317 Some(p) => p
318 .pairs
319 .iter()
320 .find(|(pk, _)| pk == k)
321 .map(|(_, pv)| pv == v)
322 .unwrap_or(false),
323 None => false,
324 };
325 if !unchanged {
326 changed_pairs.push((k.clone(), v.clone()));
327 }
328 }
329 if !changed_pairs.is_empty() {
330 out.push(Stratum {
331 pairs: changed_pairs,
332 });
333 }
334 }
335 out
336}
337
338impl Clone for Stratum {
339 fn clone(&self) -> Self {
340 Self {
341 pairs: self.pairs.clone(),
342 }
343 }
344}
345
346pub(crate) fn format_coords_block(
369 labels: &str,
370 color: bool,
371 head_consumed: usize,
372 continuation_indent: &str,
373 summarize_changed_only: bool,
374) -> String {
375 if labels.is_empty() {
376 return String::new();
377 }
378 let strata = parse_strata(labels);
379 let prev = LAST_RENDERED_COORDS
380 .lock()
381 .ok()
382 .map(|g| g.clone())
383 .unwrap_or_default();
384 let prev_strata = parse_strata(&prev);
385 let display_strata: Vec<Stratum> = if summarize_changed_only {
386 strata_diff(&strata, &prev_strata)
387 } else {
388 strata.clone()
389 };
390 if summarize_changed_only && let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
403 *g = labels.to_string();
404 }
405 if display_strata.is_empty() {
406 return String::new();
413 }
414 let width = current_terminal_cols();
415 let available = width.saturating_sub(1);
416 let body = render_strata(
417 &display_strata,
418 &prev_strata,
419 color,
420 head_consumed.saturating_add(1),
421 available,
422 continuation_indent,
423 );
424 let mut out = String::with_capacity(body.len() + 1);
425 out.push(' ');
426 out.push_str(&body);
427 out
428}
429
430pub struct PhaseOutcomeReadout;
441
442impl Readout for PhaseOutcomeReadout {
443 fn name(&self) -> &'static str {
444 "phase_outcome"
445 }
446 fn accepts(&self) -> &'static [SubjectKind] {
447 &[SubjectKind::Phase]
448 }
449
450 fn render(
451 &self,
452 ctx: &dyn ReadoutContext,
453 lod: Lod,
454 mode: ContentMode,
455 _opts: &ReadoutOptions,
456 out: &mut dyn ReadoutBuf,
457 ) -> usize {
458 match (lod, mode) {
459 (Lod::Compact, ContentMode::Value) => render_compact_value(ctx, out),
460 (Lod::Compact, ContentMode::Explanation) => render_compact_explanation(ctx, out),
461 (Lod::Labeled, ContentMode::Value) => render_labeled_value(ctx, out),
462 (Lod::Labeled, ContentMode::Explanation) => render_labeled_explanation(ctx, out),
463 (Lod::Expanded, ContentMode::Value) => render_expanded_value(ctx, out),
464 (Lod::Expanded, ContentMode::Explanation) => render_expanded_explanation(ctx, out),
465 }
466 }
467}
468
469fn status_color(outcome: &crate::phase_outcome::Outcome, color: bool) -> &'static str {
480 if !color {
481 return "";
482 }
483 use crate::phase_outcome::{Disposition, Validity};
484 match (outcome.disposition, outcome.validity) {
485 (_, Validity::Failed) => "\x1b[31m", (Disposition::Skipped, _) => "\x1b[2m", (Disposition::Interrupted, Validity::Succeeded) => "\x1b[33m", _ => "\x1b[32m", }
490}
491
492fn completion_meter(ctx: &dyn ReadoutContext) -> String {
508 if ctx.open_ended() {
509 return String::new();
510 }
511 format!(
512 " {:.0}%",
513 ctx.progress_fraction().map(|f| f * 100.0).unwrap_or(100.0)
514 )
515}
516
517fn render_compact_value(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
518 let color = ctx.use_color();
519 let bold = if color { "\x1b[1m" } else { "" };
520 let dim = if color { "\x1b[2m" } else { "" };
521 let blue = if color { "\x1b[34m" } else { "" };
522 let reset = if color { "\x1b[0m" } else { "" };
523
524 let outcome = ctx.outcome();
525 let glyph_color = status_color(&outcome, color);
526 let glyph = outcome.glyph();
527
528 let meter = completion_meter(ctx);
533 let elapsed = ctx.elapsed_secs();
534 let depth_indent = ctx.depth_indent();
535 let name = ctx.subject_name();
536
537 let mut tmp = String::with_capacity(64);
538 let _ = write!(
539 &mut tmp,
540 "{depth_indent}{glyph_color}{glyph}{reset} {bold}{blue}[{name}]{reset}\
541{meter} {dim}({elapsed:.2}s){reset}",
542 );
543 let len = tmp.len();
544 let _ = out.write_str(&tmp);
545 len
546}
547
548fn render_compact_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
551 let color = ctx.use_color();
552 let bold = if color { "\x1b[1m" } else { "" };
553 let dim = if color { "\x1b[2m" } else { "" };
554 let blue = if color { "\x1b[34m" } else { "" };
555 let green = if color { "\x1b[32m" } else { "" };
556 let reset = if color { "\x1b[0m" } else { "" };
557
558 let depth_indent = ctx.depth_indent();
559 let mut tmp = String::with_capacity(96);
560 let _ = write!(
561 &mut tmp,
562 "{depth_indent}{green}done{reset} {bold}{blue}[phase-name]{reset} \
563progress% {dim}(elapsed){reset}",
564 );
565 let len = tmp.len();
566 let _ = out.write_str(&tmp);
567 len
568}
569
570fn render_expanded_value(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
576 let color = ctx.use_color();
577 let bold = if color { "\x1b[1m" } else { "" };
578 let dim = if color { "\x1b[2m" } else { "" };
579 let yellow = if color { "\x1b[33m" } else { "" };
580 let blue = if color { "\x1b[34m" } else { "" };
581 let red = if color { "\x1b[31m" } else { "" };
582 let reset = if color { "\x1b[0m" } else { "" };
583 let outcome = ctx.outcome();
584 let glyph_color = status_color(&outcome, color);
585 let glyph = outcome.glyph();
586 let outcome_errors = ctx.outcome_errors();
592
593 let cycles = ctx.cycles_completed();
594 let errors = ctx.errors();
595 let retries = ctx.retries();
596 let ok = ctx.ops_ok();
597 let skips = ctx.skips();
598 let concurrency = ctx.concurrency();
599 let elapsed = ctx.elapsed_secs();
600 let consumed = ctx.consumed();
601 let total_extent = ctx.cycles_total();
602
603 let result_total = cycles.saturating_sub(skips).max(ok);
609 let ok_pct: f64 = if result_total > 0 {
610 ok as f64 * 100.0 / result_total as f64
611 } else {
612 100.0
613 };
614 let progress_cell: String = if ctx.open_ended() {
617 format!("— (open-ended, {cycles} cycles)")
618 } else {
619 let pct: f64 = ctx.progress_fraction().map(|f| f * 100.0).unwrap_or(100.0);
620 format!("{pct:.0}% ({cycles} of {total_extent})")
621 };
622 let rate: f64 = if elapsed > 0.0 {
623 consumed as f64 / elapsed
624 } else {
625 0.0
626 };
627 let rate_str = format_rate(rate);
628
629 let err_color = if errors > 0 || retries > 0 {
630 yellow
631 } else {
632 dim
633 };
634 let labels = ctx.subject_labels();
635 let depth_indent = ctx.depth_indent();
636 let seq_part: String = String::new();
638 let coords_continuation_indent = format!("{depth_indent} ");
648 let coords_head_consumed = depth_indent.chars().count() + 14; let coords_payload = format_coords_block(
650 labels,
651 color,
652 coords_head_consumed,
653 &coords_continuation_indent,
654 true,
655 );
656 let coords_line = if coords_payload.is_empty() {
657 String::new()
658 } else {
659 format!("\n{depth_indent} coords:{coords_payload}")
662 };
663 let _ = (bold, yellow); let chips_block = render_chips_block(&ctx.status_metric_chips(), depth_indent, dim, reset);
668
669 let errors_block =
670 render_outcome_errors_block(outcome_errors, errors, depth_indent, red, dim, reset);
671 let mut tmp = String::with_capacity(384);
672 let _ = write!(
673 &mut tmp,
674 "{depth_indent}{glyph_color}{glyph}{reset} {bold}{blue}[{name}]{reset}{seq}{coords}\n\
675{depth_indent} status: {glyph_color}{status_label}{reset}\n\
676{depth_indent} progress: {progress_cell}\n\
677{depth_indent} throughput: {rate_str}\n\
678{depth_indent} ok: {ok_pct:.0}% ({ok} of {result_total})\n\
679{depth_indent} reliability: {err_color}e:{errors} r:{retries}{reset}\n\
680{depth_indent} concurrency: {concurrency}\n\
681{chips}\
682{depth_indent} elapsed: {dim}{elapsed:.2}s{reset}\
683{errors_block}",
684 name = ctx.subject_name(),
685 seq = seq_part,
686 coords = coords_line,
687 chips = chips_block,
688 status_label = outcome.label(),
689 errors_block = errors_block,
690 );
691 let len = tmp.len();
692 let _ = out.write_str(&tmp);
693 len
694}
695
696fn render_outcome_errors_block(
705 errors: &[crate::phase_outcome::PhaseErrorDetail],
706 total: u64,
707 indent: &str,
708 red: &str,
709 dim: &str,
710 reset: &str,
711) -> String {
712 use std::fmt::Write as _;
713 if errors.is_empty() {
714 return String::new();
715 }
716 let msg_continuation = format!("{indent} ");
721 let detail_continuation = format!("{indent} ");
722 let reindent = |s: &str, prefix: &str| -> String {
723 let mut out = String::with_capacity(s.len() + prefix.len() * 2);
724 let mut first = true;
725 for line in s.split('\n') {
726 if first {
727 first = false;
728 } else {
729 out.push('\n');
730 out.push_str(prefix);
731 }
732 out.push_str(line);
733 }
734 out
735 };
736 let mut out = String::with_capacity(128);
737 let _ = write!(&mut out, "\n{indent} errors:");
738 for e in errors {
739 let msg = reindent(&e.message, &msg_continuation);
740 let _ = write!(
741 &mut out,
742 "\n{indent} {red}[{class}]{reset} {msg}",
743 class = e.class
744 );
745 if let Some(c) = e.cycle {
746 let _ = write!(&mut out, "\n{indent} {dim}cycle:{reset} {c}");
747 }
748 if let Some(t) = &e.op_template {
749 let t = reindent(t, &detail_continuation);
750 let _ = write!(&mut out, "\n{indent} {dim}op-template:{reset} {t}");
751 }
752 if let Some(r) = &e.op_resolved {
753 let r = reindent(r, &detail_continuation);
754 let _ = write!(&mut out, "\n{indent} {dim}op-resolved:{reset} {r}");
755 }
756 }
757 let captured = errors.len() as u64;
761 if captured < total {
762 let _ = write!(
763 &mut out,
764 "\n{indent} {dim}(+{} more occurred — not captured (buffer cap)){reset}",
765 total - captured
766 );
767 }
768 out
769}
770
771fn render_chips_block(chips: &str, indent: &str, dim: &str, reset: &str) -> String {
777 let entries: Vec<&str> = chips.split_whitespace().collect();
778 if entries.is_empty() {
779 return String::new();
780 }
781 let mut out = String::with_capacity(64 + entries.len() * 24);
782 let _ = writeln!(&mut out, "{indent} metrics:");
783 for chip in &entries {
784 let (name, value) = chip.split_once(':').unwrap_or((chip, ""));
787 let _ = writeln!(&mut out, "{indent} {name:<16} {dim}{value}{reset}");
788 }
789 out
790}
791
792fn render_expanded_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
796 let color = ctx.use_color();
797 let bold = if color { "\x1b[1m" } else { "" };
798 let dim = if color { "\x1b[2m" } else { "" };
799 let blue = if color { "\x1b[34m" } else { "" };
800 let green = if color { "\x1b[32m" } else { "" };
801 let reset = if color { "\x1b[0m" } else { "" };
802
803 let depth_indent = ctx.depth_indent();
804 let mut tmp = String::with_capacity(384);
805 let _ = write!(
806 &mut tmp,
807 "{depth_indent}{green}done{reset} {bold}{blue}[phase-name]{reset}\n\
808{depth_indent} progress: progress% (cycles_completed of cycles_total)\n\
809{depth_indent} throughput: rate (auto-scaled K/s, M/s)\n\
810{depth_indent} ok: ok-pct% (ops_ok of cycles_completed)\n\
811{depth_indent} reliability: e:errors r:retries\n\
812{depth_indent} concurrency: fiber count\n\
813{depth_indent} elapsed: {dim}wallclock seconds{reset}",
814 );
815 let len = tmp.len();
816 let _ = out.write_str(&tmp);
817 len
818}
819
820fn render_labeled_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
828 let color = ctx.use_color();
829 let bold = if color { "\x1b[1m" } else { "" };
830 let dim = if color { "\x1b[2m" } else { "" };
831 let yellow = if color { "\x1b[33m" } else { "" };
832 let blue = if color { "\x1b[34m" } else { "" };
833 let green = if color { "\x1b[32m" } else { "" };
834 let reset = if color { "\x1b[0m" } else { "" };
835
836 let depth_indent = ctx.depth_indent();
837 let seq_part: String = String::new();
839 let coords_part: String = if ctx.subject_labels().is_empty() {
840 String::new()
841 } else {
842 format!(" {bold}{yellow}(scope-coords){reset}")
843 };
844
845 let mut tmp = String::with_capacity(160);
851 let _ = write!(
852 &mut tmp,
853 "{depth_indent}{green}done{reset} {seq}{bold}{blue}[phase-name]{reset}{coords} \
854progress% throughput ok:ok% \
855errors retries concurrency \
856{dim}(elapsed){reset}",
857 seq = seq_part,
858 coords = coords_part,
859 );
860 let len = tmp.len();
861 let _ = out.write_str(&tmp);
862 len
863}
864
865fn render_labeled_value(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
866 let outcome = ctx.outcome();
876 if outcome.is_failure() {
877 return render_labeled_value_failed(ctx, out);
878 }
879 let color = ctx.use_color();
880 let bold = if color { "\x1b[1m" } else { "" };
881 let dim = if color { "\x1b[2m" } else { "" };
882 let yellow = if color { "\x1b[33m" } else { "" };
883 let blue = if color { "\x1b[34m" } else { "" };
884 let reset = if color { "\x1b[0m" } else { "" };
885 let glyph_color = status_color(&outcome, color);
886 let glyph = outcome.glyph();
887
888 let cycles = ctx.cycles_completed();
889 let errors = ctx.errors();
890 let retries = ctx.retries();
891 let ok = ctx.ops_ok();
892 let skips = ctx.skips();
893 let concurrency = ctx.concurrency();
894 let elapsed = ctx.elapsed_secs();
895 let consumed = ctx.consumed();
896 let total_extent = ctx.cycles_total();
897
898 let result_total = cycles.saturating_sub(skips).max(ok);
904 let ok_str: String = if result_total > 0 {
908 format!("{:.0}%", ok as f64 * 100.0 / result_total as f64)
909 } else if skips > 0 {
910 "—".to_string()
911 } else {
912 "100%".to_string()
913 };
914 let skips_chip: String = if skips > 0 {
915 format!(" {dim}skip:{skips}{reset}")
916 } else {
917 String::new()
918 };
919 let meter = completion_meter(ctx);
924 let rate: f64 = if elapsed > 0.0 {
925 consumed as f64 / elapsed
926 } else {
927 0.0
928 };
929 let rate_str = format_rate(rate);
930
931 let err_color = if errors > 0 || retries > 0 {
932 yellow
933 } else {
934 dim
935 };
936
937 let labels = ctx.subject_labels();
938 let depth_indent = ctx.depth_indent();
939 let seq_part: String = String::new();
941 let bar = if total_extent > 0 && total_extent <= 10 {
948 let bg = if color { "\x1b[48;2;50;50;50m" } else { "" };
949 let fg = if color { "\x1b[97m" } else { "" };
950 format!(" {bg}{fg}{}{reset}", ballot_bar(total_extent, ok, errors))
951 } else {
952 String::new()
953 };
954 let bar_visible: usize = if total_extent > 0 && total_extent <= 10 {
955 1 + total_extent as usize
956 } else {
957 0
958 };
959
960 let name = ctx.subject_name();
967 let seq_visible: usize = match ctx.subject_seq() {
968 Some((s, t)) => format!("[{s}/{t}] ").chars().count(),
969 None => 0,
970 };
971 let head_consumed: usize = depth_indent.chars().count()
972 + 2 + bar_visible
974 + seq_visible
975 + 2 + name.chars().count();
977 let continuation_indent = format!("{depth_indent} ");
981 let coords_part = format_coords_block(
986 labels,
987 color,
988 head_consumed,
989 &continuation_indent,
990 true,
991 );
992 let chips = ctx.status_metric_chips();
993
994 let memo = ctx.phase_memo();
1001 let memo_row = if memo.is_empty() {
1002 String::new()
1003 } else {
1004 let bold_yellow = if color { "\x1b[1;33m" } else { "" };
1005 format!("{depth_indent} {bold_yellow}[[ {memo} ]]{reset}\n")
1006 };
1007
1008 let fully_skipped = skips > 0 && cycles > 0 && skips >= cycles && ok == 0 && errors == 0;
1015 if fully_skipped
1016 && crate::observer::skipped_phase_display() == crate::observer::SkippedPhaseDisplay::Mark
1017 {
1018 let mut tmp = String::with_capacity(160);
1019 let _ = write!(
1020 &mut tmp,
1021 "{depth_indent}{dim}⊘{reset} {seq}{bold}{blue}[{name}]{reset}{coords} {dim}gated off{reset}\n\
1022{memo_row}\
1023{depth_indent} {dim}skip:{skips} c:{concurrency} ({elapsed:.2}s){reset}",
1024 memo_row = memo_row,
1025 depth_indent = depth_indent,
1026 dim = dim,
1027 reset = reset,
1028 seq = seq_part,
1029 bold = bold,
1030 blue = blue,
1031 name = ctx.subject_name(),
1032 coords = coords_part,
1033 skips = skips,
1034 concurrency = concurrency,
1035 elapsed = elapsed,
1036 );
1037 let len = tmp.len();
1038 let _ = out.write_str(&tmp);
1039 return len;
1040 }
1041
1042 let mut tmp = String::with_capacity(256);
1050 let _ = write!(
1051 &mut tmp,
1052 "{depth_indent}{glyph_color}{glyph}{reset}{bar} {seq}{bold}{blue}[{name}]{reset}{coords}{meter}\n\
1053{memo_row}\
1054{depth_indent} {rate_str} ok:{ok_str} \
1055{err_color}e:{errors} r:{retries}{reset}{skips_chip} c:{concurrency}{chips} \
1056{dim}({elapsed:.2}s){reset}",
1057 depth_indent = depth_indent,
1058 glyph_color = glyph_color,
1059 glyph = glyph,
1060 bar = bar,
1061 reset = reset,
1062 seq = seq_part,
1063 bold = bold,
1064 blue = blue,
1065 name = ctx.subject_name(),
1066 coords = coords_part,
1067 meter = meter,
1068 rate_str = rate_str,
1069 ok_str = ok_str,
1070 skips_chip = skips_chip,
1071 err_color = err_color,
1072 errors = errors,
1073 retries = retries,
1074 concurrency = concurrency,
1075 chips = chips,
1076 dim = dim,
1077 elapsed = elapsed,
1078 );
1079 let len = tmp.len();
1080 let _ = out.write_str(&tmp);
1081 len
1082}
1083
1084fn render_labeled_value_failed(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
1098 let color = ctx.use_color();
1099 let bold = if color { "\x1b[1m" } else { "" };
1100 let dim = if color { "\x1b[2m" } else { "" };
1101 let blue = if color { "\x1b[34m" } else { "" };
1102 let red = if color { "\x1b[31m" } else { "" };
1103 let reset = if color { "\x1b[0m" } else { "" };
1104
1105 let labels = ctx.subject_labels();
1106 let depth_indent = ctx.depth_indent();
1107 let name = ctx.subject_name();
1108 let seq_part: String = String::new();
1110 let seq_visible: usize = match ctx.subject_seq() {
1111 Some((s, t)) => format!("[{s}/{t}] ").chars().count(),
1112 None => 0,
1113 };
1114 let total_extent = ctx.cycles_total();
1119 let ok = ctx.ops_ok();
1120 let err_count = ctx.errors();
1121 let bar = if total_extent > 0 && total_extent <= 10 {
1122 let bg = if color { "\x1b[48;2;50;50;50m" } else { "" };
1123 let fg = if color { "\x1b[97m" } else { "" };
1124 format!(
1125 " {bg}{fg}{}{reset}",
1126 ballot_bar(total_extent, ok, err_count)
1127 )
1128 } else {
1129 String::new()
1130 };
1131 let bar_visible: usize = if total_extent > 0 && total_extent <= 10 {
1132 1 + total_extent as usize
1133 } else {
1134 0
1135 };
1136 let head_consumed: usize = depth_indent.chars().count()
1137 + 2 + bar_visible
1139 + seq_visible
1140 + 2 + name.chars().count();
1142 let continuation_indent = format!("{depth_indent} ");
1143 let coords_part = format_coords_block(
1144 labels,
1145 color,
1146 head_consumed,
1147 &continuation_indent,
1148 true,
1149 );
1150
1151 let errors = ctx.outcome_errors();
1152 let first = errors.first();
1153 let class_label = first.map(|e| e.class.as_str()).unwrap_or("phase_failed");
1154 let message = first
1158 .map(|e| e.message.lines().next().unwrap_or(e.message.as_str()))
1159 .unwrap_or("unknown error");
1160 let elapsed = ctx.elapsed_secs();
1161 let extra_count = err_count.saturating_sub(1);
1166 let extra_suffix = if extra_count > 0 {
1167 format!(" {dim}(+{extra_count} more){reset}")
1168 } else {
1169 String::new()
1170 };
1171
1172 let mut tmp = String::with_capacity(192);
1173 let _ = write!(
1174 &mut tmp,
1175 "{depth_indent}{red}✗{reset}{bar} {seq_part}{bold}{blue}[{name}]{reset}{coords_part} \
1176{red}{class_label}{reset}\n\
1177{depth_indent} {red}{message}{reset}{extra_suffix} {dim}({elapsed:.2}s){reset}",
1178 );
1179 let len = tmp.len();
1180 let _ = out.write_str(&tmp);
1181 len
1182}
1183
1184fn format_rate(rate: f64) -> String {
1185 if rate >= 1_000_000.0 {
1186 format!("{:.1}M/s", rate / 1_000_000.0)
1187 } else if rate >= 1_000.0 {
1188 format!("{:.1}K/s", rate / 1_000.0)
1189 } else {
1190 format!("{:.0}/s", rate)
1191 }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197 use crate::readouts::buf::StringBuf;
1198
1199 static SERIAL_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
1210
1211 #[test]
1214 fn parse_strata_round_trips_two_strata() {
1215 let s = parse_strata("(profile=default), (sm=OTHER, mnc=8)");
1216 assert_eq!(s.len(), 2);
1217 assert_eq!(
1218 s[0].pairs,
1219 vec![("profile".to_string(), "default".to_string())]
1220 );
1221 assert_eq!(
1222 s[1].pairs,
1223 vec![
1224 ("sm".to_string(), "OTHER".to_string()),
1225 ("mnc".to_string(), "8".to_string()),
1226 ]
1227 );
1228 }
1229
1230 #[test]
1234 fn parse_strata_empty_input_yields_empty() {
1235 assert_eq!(parse_strata("").len(), 0);
1236 }
1237
1238 #[test]
1242 fn strata_diff_keeps_only_changed_pairs() {
1243 let prev = parse_strata("(profile=default), (sm=OTHER, mnc=8)");
1244 let curr = parse_strata("(profile=default), (sm=ADA002, mnc=8)");
1245 let d = strata_diff(&curr, &prev);
1246 assert_eq!(d.len(), 1);
1249 assert_eq!(d[0].pairs, vec![("sm".to_string(), "ADA002".to_string())]);
1250 }
1251
1252 #[test]
1256 fn strata_diff_empty_prior_treats_all_as_changed() {
1257 let curr = parse_strata("(profile=default), (sm=OTHER)");
1258 let d = strata_diff(&curr, &[]);
1259 assert_eq!(d.len(), 2);
1260 assert_eq!(d[0].pairs.len(), 1);
1261 assert_eq!(d[1].pairs.len(), 1);
1262 }
1263
1264 #[test]
1270 fn format_coords_block_completed_phase_summary_elides_unchanged() {
1271 let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1272 if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1278 *g = String::new();
1279 }
1280 let labels_a = "(profile=default), (sm=OTHER, mnc=8)";
1281 let labels_b = "(profile=default), (sm=ADA002, mnc=8)";
1282
1283 let first = format_coords_block(
1284 labels_a, false, 0, " ", true,
1285 );
1286 assert!(first.contains("profile=default"));
1289 assert!(first.contains("sm=OTHER"));
1290 assert!(first.contains("mnc=8"));
1291
1292 let second = format_coords_block(
1293 labels_b, false, 0, " ", true,
1294 );
1295 assert!(
1298 second.contains("sm=ADA002"),
1299 "changed pair missing in second render: {second:?}"
1300 );
1301 assert!(
1302 !second.contains("profile=default"),
1303 "unchanged profile stratum should be elided: {second:?}"
1304 );
1305 assert!(
1306 !second.contains("mnc=8"),
1307 "unchanged mnc should be elided: {second:?}"
1308 );
1309 }
1310
1311 #[test]
1315 fn format_coords_block_active_phase_shows_full_stack() {
1316 let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1317 if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1318 *g = "(profile=default)".into();
1319 }
1320 let labels = "(profile=default)";
1321 let body = format_coords_block(
1322 labels, false, 0, " ", false,
1323 );
1324 assert!(
1325 body.contains("profile=default"),
1326 "active-phase render should show unchanged coords too: {body:?}"
1327 );
1328 }
1329
1330 #[test]
1337 fn first_stratum_wraps_to_continuation_when_head_plus_first_overflows() {
1338 let strata = parse_strata(
1342 "(source_model=OTHER, maximum_node_connections=8, construction_beam_width=50)",
1343 );
1344 let prev: Vec<Stratum> = Vec::new();
1345 let out = render_strata(
1350 &strata, &prev, false, 40,
1351 80, " ",
1352 );
1353 assert!(
1354 out.starts_with('\n'),
1355 "first-stratum overflow should start with a newline; \
1356 got: {out:?}"
1357 );
1358 assert!(
1359 out.contains("source_model=OTHER"),
1360 "first stratum content should still appear: {out:?}"
1361 );
1362 assert!(
1368 !out.starts_with(',') && !out.contains(",\n ("),
1369 "first-stratum overflow path should not produce a comma-wrap; \
1370 got: {out:?}"
1371 );
1372 }
1373
1374 #[test]
1383 fn format_coords_block_active_render_does_not_advance_tracker() {
1384 let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1385 if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1388 *g = "(profile=default), (sm=OTHER)".into();
1389 }
1390 let active_labels = "(profile=default), (sm=ADA002)";
1391 for _ in 0..5 {
1396 let _ = format_coords_block(
1397 active_labels,
1398 false,
1399 0,
1400 " ",
1401 false,
1402 );
1403 let g = LAST_RENDERED_COORDS.lock().expect("lock");
1404 assert_eq!(
1405 g.as_str(),
1406 "(profile=default), (sm=OTHER)",
1407 "active-phase render must NOT advance the tracker"
1408 );
1409 }
1410 let _ = format_coords_block(
1413 active_labels,
1414 false,
1415 0,
1416 " ",
1417 true,
1418 );
1419 let g = LAST_RENDERED_COORDS.lock().expect("lock");
1420 assert_eq!(
1421 g.as_str(),
1422 active_labels,
1423 "completed-phase render must advance the tracker"
1424 );
1425 }
1426
1427 #[test]
1433 fn format_coords_block_no_change_collapses_to_empty() {
1434 let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1435 if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1436 *g = String::new();
1437 }
1438 let labels = "(profile=default)";
1439 let _ = format_coords_block(labels, false, 0, " ", true);
1441 let body = format_coords_block(labels, false, 0, " ", true);
1443 assert_eq!(
1444 body, "",
1445 "no-change render should collapse to empty: {body:?}"
1446 );
1447 }
1448
1449 struct TestCtx {
1453 phase_name: String,
1454 phase_seq: Option<(usize, usize)>,
1455 phase_labels: String,
1456 cycles_completed: u64,
1457 cycles_total: u64,
1458 ops_ok: u64,
1459 skips: u64,
1460 errors: u64,
1461 retries: u64,
1462 concurrency: usize,
1463 elapsed_secs: f64,
1464 consumed: u64,
1465 chips: String,
1466 depth_indent: String,
1467 use_color: bool,
1468 outcome: crate::phase_outcome::Outcome,
1469 outcome_errors: Vec<crate::phase_outcome::PhaseErrorDetail>,
1470 }
1471
1472 impl Default for TestCtx {
1473 fn default() -> Self {
1474 Self {
1475 phase_name: String::new(),
1476 phase_seq: None,
1477 phase_labels: String::new(),
1478 cycles_completed: 0,
1479 cycles_total: 0,
1480 ops_ok: 0,
1481 skips: 0,
1482 errors: 0,
1483 retries: 0,
1484 concurrency: 0,
1485 elapsed_secs: 0.0,
1486 consumed: 0,
1487 chips: String::new(),
1488 depth_indent: String::new(),
1489 use_color: false,
1490 outcome: crate::phase_outcome::Outcome::completed(),
1491 outcome_errors: Vec::new(),
1492 }
1493 }
1494 }
1495
1496 impl ReadoutContext for TestCtx {
1497 fn subject_name(&self) -> &str {
1498 &self.phase_name
1499 }
1500 fn subject_seq(&self) -> Option<(usize, usize)> {
1501 self.phase_seq
1502 }
1503 fn subject_labels(&self) -> &str {
1504 &self.phase_labels
1505 }
1506 fn cycles_completed(&self) -> u64 {
1507 self.cycles_completed
1508 }
1509 fn cycles_total(&self) -> u64 {
1510 self.cycles_total
1511 }
1512 fn ops_ok(&self) -> u64 {
1513 self.ops_ok
1514 }
1515 fn skips(&self) -> u64 {
1516 self.skips
1517 }
1518 fn errors(&self) -> u64 {
1519 self.errors
1520 }
1521 fn retries(&self) -> u64 {
1522 self.retries
1523 }
1524 fn concurrency(&self) -> usize {
1525 self.concurrency
1526 }
1527 fn elapsed_secs(&self) -> f64 {
1528 self.elapsed_secs
1529 }
1530 fn consumed(&self) -> u64 {
1531 self.consumed
1532 }
1533 fn status_metric_chips(&self) -> String {
1534 self.chips.clone()
1535 }
1536 fn depth_indent(&self) -> &str {
1537 &self.depth_indent
1538 }
1539 fn use_color(&self) -> bool {
1540 self.use_color
1541 }
1542 fn event(&self) -> crate::lifecycle::EventType {
1543 crate::lifecycle::EventType::PhaseEnd
1544 }
1545 fn outcome(&self) -> crate::phase_outcome::Outcome {
1546 self.outcome.clone()
1547 }
1548 fn outcome_errors(&self) -> &[crate::phase_outcome::PhaseErrorDetail] {
1549 &self.outcome_errors
1550 }
1551 }
1552
1553 fn render(ctx: &TestCtx) -> String {
1561 let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1568 if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1569 *g = String::new();
1570 }
1571 let mut s = String::new();
1572 let mut buf = StringBuf::new(&mut s);
1573 PhaseOutcomeReadout.render(
1574 ctx,
1575 Lod::Labeled,
1576 ContentMode::Value,
1577 &ReadoutOptions::new(),
1578 &mut buf,
1579 );
1580 s
1581 }
1582
1583 #[test]
1584 fn no_color_no_coords_no_chips() {
1585 let ctx = TestCtx {
1591 phase_name: "setup".into(),
1592 phase_seq: Some((1, 2)),
1593 cycles_completed: 3,
1594 cycles_total: 3,
1595 ops_ok: 3,
1596 concurrency: 1,
1597 elapsed_secs: 0.01,
1598 consumed: 3,
1599 ..Default::default()
1600 };
1601 assert_eq!(
1602 render(&ctx),
1603 "✓ ☑☑☑ [setup] 100%\n 300/s ok:100% e:0 r:0 c:1 (0.01s)"
1604 );
1605 }
1606
1607 #[test]
1608 fn no_color_with_coords_and_chips() {
1609 let ctx = TestCtx {
1610 phase_name: "run".into(),
1611 phase_seq: Some((1, 8)),
1612 phase_labels: "(profile=alpha), (bucket=1, kind=READ)".into(),
1613 cycles_completed: 162,
1614 cycles_total: 162,
1615 ops_ok: 162,
1616 concurrency: 1,
1617 elapsed_secs: 0.01,
1618 consumed: 162,
1619 chips: " recall_at_10:79.62%".into(),
1620 ..Default::default()
1621 };
1622 assert_eq!(
1623 render(&ctx),
1624 "✓ [run] (profile=alpha), (bucket=1, kind=READ) 100%\n 16.2K/s ok:100% e:0 r:0 c:1 recall_at_10:79.62% (0.01s)"
1625 );
1626 }
1627
1628 fn render_at(ctx: &TestCtx, lod: Lod, mode: ContentMode) -> String {
1629 let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1642 if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1643 *g = String::new();
1644 }
1645 let mut s = String::new();
1646 let mut buf = StringBuf::new(&mut s);
1647 PhaseOutcomeReadout.render(ctx, lod, mode, &ReadoutOptions::new(), &mut buf);
1648 s
1649 }
1650
1651 #[test]
1658 fn render_at_isolates_each_render_from_prior_tracker() {
1659 let ctx = TestCtx {
1660 phase_name: "ann_query".into(),
1661 phase_seq: Some((1, 8)),
1662 phase_labels: "profile=alpha, k=10".into(),
1663 cycles_completed: 100,
1664 cycles_total: 100,
1665 ops_ok: 99,
1666 errors: 1,
1667 retries: 0,
1668 concurrency: 4,
1669 elapsed_secs: 1.5,
1670 consumed: 100,
1671 chips: " recall_at_10:79.62% latency_p99:1.23ms".into(),
1672 ..Default::default()
1673 };
1674 let first = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1675 let second = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1676 assert!(
1677 first.contains("profile=alpha, k=10"),
1678 "first render should carry coords: {first}"
1679 );
1680 assert!(
1681 second.contains("profile=alpha, k=10"),
1682 "second render must NOT elide coords (tracker must reset \
1683 per render_at call): {second}"
1684 );
1685 }
1686
1687 #[test]
1688 fn compact_value_drops_seq_rate_counts_chips() {
1689 let ctx = TestCtx {
1696 phase_name: "setup".into(),
1697 phase_seq: Some((1, 2)),
1698 phase_labels: "(profile=alpha)".into(),
1699 cycles_completed: 3,
1700 cycles_total: 3,
1701 ops_ok: 3,
1702 errors: 0,
1703 retries: 0,
1704 concurrency: 1,
1705 elapsed_secs: 0.01,
1706 consumed: 3,
1707 chips: " recall_at_10:79.62%".into(),
1708 ..Default::default()
1709 };
1710 assert_eq!(
1711 render_at(&ctx, Lod::Compact, ContentMode::Value),
1712 "✓ [setup] 100% (0.01s)",
1713 );
1714 }
1715
1716 #[test]
1717 fn compact_value_pct_zero_when_no_extent() {
1718 let ctx = TestCtx {
1723 phase_name: "x".into(),
1724 cycles_completed: 0,
1725 cycles_total: 0,
1726 elapsed_secs: 0.5,
1727 ..Default::default()
1728 };
1729 assert_eq!(
1730 render_at(&ctx, Lod::Compact, ContentMode::Value),
1731 "✓ [x] 100% (0.50s)",
1732 );
1733 }
1734
1735 #[test]
1736 fn compact_explanation_describes_each_field() {
1737 let ctx = TestCtx {
1738 phase_name: "x".into(),
1739 ..Default::default()
1740 };
1741 let s = render_at(&ctx, Lod::Compact, ContentMode::Explanation);
1742 assert!(s.contains("done"), "expected 'done': {s}");
1743 assert!(s.contains("phase-name"), "expected 'phase-name': {s}");
1744 assert!(s.contains("progress%"), "expected 'progress%': {s}");
1745 assert!(s.contains("(elapsed)"), "expected '(elapsed)': {s}");
1746 assert!(
1750 !s.contains("idx/total"),
1751 "compact must not describe seq prefix it doesn't render: {s}"
1752 );
1753 assert!(
1754 !s.contains("throughput"),
1755 "compact must not describe throughput it doesn't render: {s}"
1756 );
1757 }
1758
1759 #[test]
1760 fn expanded_value_emits_multi_line_block() {
1761 let ctx = TestCtx {
1764 phase_name: "ann_query".into(),
1765 phase_seq: Some((1, 8)),
1766 phase_labels: "profile=alpha, k=10".into(),
1767 cycles_completed: 100,
1768 cycles_total: 100,
1769 ops_ok: 99,
1770 errors: 1,
1771 retries: 0,
1772 concurrency: 4,
1773 elapsed_secs: 1.5,
1774 consumed: 100,
1775 chips: " recall_at_10:79.62% latency_p99:1.23ms".into(),
1776 ..Default::default()
1777 };
1778 let s = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1779 assert!(s.contains("✓ [ann_query]"));
1781 assert!(!s.contains("[1/8]"));
1782 assert!(s.contains("profile=alpha, k=10"));
1783 assert!(s.contains("progress: 100% (100 of 100)"));
1786 assert!(s.contains("throughput:"));
1787 assert!(s.contains("ok: 99% (99 of 100)"));
1788 assert!(s.contains("reliability: e:1 r:0"));
1789 assert!(s.contains("concurrency: 4"));
1790 assert!(s.contains("metrics:"));
1791 assert!(s.contains("recall_at_10"));
1793 assert!(s.contains("latency_p99"));
1794 assert!(s.contains("elapsed: 1.50s"));
1795 let line_count = s.lines().count();
1797 assert!(
1798 line_count >= 8,
1799 "expanded should be multi-line (got {line_count}): {s}"
1800 );
1801 }
1802
1803 #[test]
1804 fn expanded_value_omits_metrics_block_when_no_chips() {
1805 let ctx = TestCtx {
1808 phase_name: "setup".into(),
1809 cycles_completed: 1,
1810 cycles_total: 1,
1811 ops_ok: 1,
1812 concurrency: 1,
1813 elapsed_secs: 0.01,
1814 consumed: 1,
1815 chips: String::new(),
1816 ..Default::default()
1817 };
1818 let s = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1819 assert!(
1820 !s.contains("metrics:"),
1821 "expected no metrics: header when chips empty: {s}"
1822 );
1823 }
1824
1825 #[test]
1826 fn expanded_value_omits_coords_line_when_no_labels() {
1827 let ctx = TestCtx {
1828 phase_name: "x".into(),
1829 phase_labels: String::new(),
1830 cycles_completed: 1,
1831 cycles_total: 1,
1832 ops_ok: 1,
1833 concurrency: 1,
1834 elapsed_secs: 0.01,
1835 consumed: 1,
1836 ..Default::default()
1837 };
1838 let s = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1839 assert!(
1840 !s.contains("coords:"),
1841 "expected no coords: line when labels empty: {s}"
1842 );
1843 }
1844
1845 #[test]
1846 fn expanded_explanation_describes_each_row() {
1847 let ctx = TestCtx {
1848 phase_name: "x".into(),
1849 ..Default::default()
1850 };
1851 let s = render_at(&ctx, Lod::Expanded, ContentMode::Explanation);
1852 assert!(s.contains("phase-name"));
1853 assert!(s.contains("progress:"));
1854 assert!(s.contains("throughput:"));
1855 assert!(s.contains("ok:"));
1856 assert!(s.contains("reliability:"));
1857 assert!(s.contains("concurrency:"));
1858 assert!(s.contains("elapsed:"));
1859 assert!(s.lines().count() >= 7);
1861 }
1862
1863 #[test]
1864 fn monotonicity_compact_subset_of_labeled() {
1865 let ctx = TestCtx {
1873 phase_name: "setup".into(),
1874 cycles_completed: 3,
1875 cycles_total: 3,
1876 ops_ok: 3,
1877 concurrency: 1,
1878 elapsed_secs: 0.01,
1879 consumed: 3,
1880 ..Default::default()
1881 };
1882 let labeled = render_at(&ctx, Lod::Labeled, ContentMode::Value);
1883 let compact = render_at(&ctx, Lod::Compact, ContentMode::Value);
1884 assert!(labeled.contains("[setup]") && compact.contains("[setup]"));
1886 assert!(labeled.contains('✓') && compact.contains('✓'));
1888 assert!(labeled.contains("100%") && compact.contains("100%"));
1890 assert!(labeled.contains("(0.01s)") && compact.contains("(0.01s)"));
1892 }
1893
1894 #[test]
1895 fn explanation_mode_describes_each_field() {
1896 let ctx = TestCtx {
1899 phase_name: "setup".into(),
1900 phase_seq: Some((1, 2)),
1901 phase_labels: "(profile=alpha)".into(),
1902 ..Default::default()
1903 };
1904 let mut s = String::new();
1905 let mut buf = StringBuf::new(&mut s);
1906 let n = PhaseOutcomeReadout.render(
1907 &ctx,
1908 Lod::Labeled,
1909 ContentMode::Explanation,
1910 &ReadoutOptions::new(),
1911 &mut buf,
1912 );
1913 assert!(n > 0, "explanation should render");
1914 assert!(s.contains("done"), "expected 'done' descriptor: {s}");
1918 assert!(s.contains("phase-name"), "expected 'phase-name': {s}");
1919 assert!(s.contains("scope-coords"), "expected 'scope-coords': {s}");
1920 assert!(!s.contains("idx/total"), "seq descriptor must be gone: {s}");
1923 assert!(s.contains("progress%"), "expected 'progress%': {s}");
1924 assert!(s.contains("throughput"), "expected 'throughput': {s}");
1925 assert!(s.contains("ok:ok%"), "expected ok descriptor: {s}");
1926 assert!(s.contains("(elapsed)"), "expected '(elapsed)': {s}");
1927 }
1928
1929 #[test]
1936 fn labeled_failed_uses_x_glyph_and_first_error_class() {
1937 let ctx = TestCtx {
1938 phase_name: "ensure_compacted".into(),
1939 phase_labels: String::new(),
1940 elapsed_secs: 14400.0,
1941 outcome: crate::phase_outcome::Outcome::failed(),
1942 outcome_errors: vec![crate::phase_outcome::PhaseErrorDetail {
1943 class: "poll_timeout".into(),
1944 message: "phase-poll deadline reached after 14441.3s".into(),
1945 op_name: None,
1946 cycle: None,
1947 op_template: None,
1948 op_resolved: None,
1949 at_nanos: 0,
1950 retryable: false,
1951 }],
1952 ..Default::default()
1953 };
1954 let out = render(&ctx);
1955 assert!(
1956 out.starts_with("✗ "),
1957 "failed render must start with the ✗ glyph: {out:?}"
1958 );
1959 assert!(
1960 out.contains("[ensure_compacted]"),
1961 "phase name still in line 1: {out:?}"
1962 );
1963 assert!(
1964 out.contains("poll_timeout"),
1965 "first-error class on line 1: {out:?}"
1966 );
1967 assert!(
1968 out.contains("phase-poll deadline"),
1969 "first-error message on line 2: {out:?}"
1970 );
1971 assert!(out.contains("(14400.00s)"), "elapsed on line 2: {out:?}");
1972 }
1973
1974 #[test]
1978 fn labeled_failed_shows_more_count_when_multiple_errors() {
1979 let mk_err = |class: &str, msg: &str| crate::phase_outcome::PhaseErrorDetail {
1980 class: class.into(),
1981 message: msg.into(),
1982 op_name: None,
1983 cycle: None,
1984 op_template: None,
1985 op_resolved: None,
1986 at_nanos: 0,
1987 retryable: false,
1988 };
1989 let ctx = TestCtx {
1990 phase_name: "p".into(),
1991 elapsed_secs: 1.0,
1992 errors: 3,
1996 outcome: crate::phase_outcome::Outcome::failed(),
1997 outcome_errors: vec![
1998 mk_err("A", "msg-a"),
1999 mk_err("B", "msg-b"),
2000 mk_err("C", "msg-c"),
2001 ],
2002 ..Default::default()
2003 };
2004 let out = render(&ctx);
2005 assert!(
2006 out.contains("(+2 more)"),
2007 "expected `(+2 more)` truncation marker: {out:?}"
2008 );
2009 }
2010
2011 #[test]
2015 fn labeled_skipped_uses_tilde_glyph() {
2016 let ctx = TestCtx {
2017 phase_name: "rampup".into(),
2018 outcome: crate::phase_outcome::Outcome::skipped(),
2019 cycles_completed: 0,
2020 cycles_total: 0,
2021 elapsed_secs: 0.0,
2022 ..Default::default()
2023 };
2024 let out = render(&ctx);
2025 assert!(
2026 out.starts_with("~ "),
2027 "skipped render uses ~ glyph: {out:?}"
2028 );
2029 assert!(out.contains("[rampup]"), "phase name preserved: {out:?}");
2030 }
2031
2032 #[test]
2037 fn expanded_failed_includes_per_error_block() {
2038 let ctx = TestCtx {
2039 phase_name: "ann_query".into(),
2040 cycles_completed: 5,
2041 cycles_total: 10,
2042 elapsed_secs: 1.5,
2043 outcome: crate::phase_outcome::Outcome::failed(),
2044 outcome_errors: vec![crate::phase_outcome::PhaseErrorDetail {
2045 class: "Timeout".into(),
2046 message: "read timed out".into(),
2047 op_name: Some("read".into()),
2048 cycle: Some(3),
2049 op_template: Some("SELECT * FROM ks.t WHERE k = {cycle}".into()),
2050 op_resolved: Some("SELECT * FROM ks.t WHERE k = 3".into()),
2051 at_nanos: 0,
2052 retryable: true,
2053 }],
2054 ..Default::default()
2055 };
2056 let s = render_at(&ctx, Lod::Expanded, ContentMode::Value);
2057 assert!(
2058 s.contains("✗ [ann_query]"),
2059 "expanded header uses ✗ glyph for Failed: {s}"
2060 );
2061 assert!(s.contains("status:"), "status row present: {s}");
2062 assert!(s.contains("failed"), "status label shows 'failed': {s}");
2063 assert!(s.contains("errors:"), "errors header present: {s}");
2064 assert!(
2065 s.contains("[Timeout] read timed out"),
2066 "per-error class+message: {s}"
2067 );
2068 assert!(
2069 s.contains("cycle:") && s.contains(" 3"),
2070 "cycle row present when populated: {s}"
2071 );
2072 assert!(s.contains("op-template:"), "op-template row present: {s}");
2073 assert!(s.contains("op-resolved:"), "op-resolved row present: {s}");
2074 }
2075
2076 #[test]
2080 fn compact_glyph_tracks_outcome_status() {
2081 use crate::phase_outcome::Outcome;
2082 for (outcome, want) in [
2083 (Outcome::completed(), '✓'),
2084 (Outcome::failed(), '✗'),
2085 (Outcome::completed_failed(), '✗'),
2086 (Outcome::skipped(), '~'),
2087 (Outcome::interrupted(), '…'),
2088 ] {
2089 let ctx = TestCtx {
2090 phase_name: "x".into(),
2091 elapsed_secs: 0.1,
2092 outcome,
2093 ..Default::default()
2094 };
2095 let s = render_at(&ctx, Lod::Compact, ContentMode::Value);
2096 assert!(
2097 s.starts_with(want),
2098 "compact glyph for {want:?} missing: {s:?}"
2099 );
2100 }
2101 }
2102
2103 #[test]
2107 fn failed_small_phase_renders_ballot_bar_with_errors_first() {
2108 let ctx = TestCtx {
2109 phase_name: "tiny".into(),
2110 cycles_completed: 5,
2111 cycles_total: 5,
2112 ops_ok: 3,
2113 errors: 2,
2114 elapsed_secs: 0.5,
2115 outcome: crate::phase_outcome::Outcome::failed(),
2116 outcome_errors: vec![crate::phase_outcome::PhaseErrorDetail {
2117 class: "Timeout".into(),
2118 message: "deadline exceeded".into(),
2119 op_name: None,
2120 cycle: None,
2121 op_template: None,
2122 op_resolved: None,
2123 at_nanos: 0,
2124 retryable: false,
2125 }],
2126 ..Default::default()
2127 };
2128 let out = render(&ctx);
2129 assert!(
2131 out.starts_with("✗ ☒☒☑☑☑ "),
2132 "failed ≤10 phase should lead with bar (errors first): {out:?}"
2133 );
2134 }
2135
2136 #[test]
2137 fn err_color_promotes_when_errors_or_retries() {
2138 let ctx = TestCtx {
2139 phase_name: "run".into(),
2140 phase_seq: Some((1, 1)),
2141 cycles_completed: 10,
2142 cycles_total: 10,
2143 ops_ok: 9,
2144 errors: 1,
2145 retries: 0,
2146 concurrency: 1,
2147 elapsed_secs: 1.0,
2148 consumed: 10,
2149 use_color: true,
2150 ..Default::default()
2151 };
2152 let out = render(&ctx);
2153 assert!(
2157 out.contains("\x1b[33me:1 r:0\x1b[0m"),
2158 "expected yellow err_color around `e:1 r:0`, got: {out:?}"
2159 );
2160 assert!(
2161 out.contains('\n'),
2162 "expected two-line break in labeled render: {out:?}"
2163 );
2164 }
2165 #[test]
2170 fn fully_skipped_phase_shows_skip_chip_not_fake_ok() {
2171 let ctx = TestCtx {
2172 phase_name: "recall_postcompact".into(),
2173 cycles_completed: 10_000,
2174 cycles_total: 10_000,
2175 skips: 10_000,
2176 elapsed_secs: 0.02,
2177 consumed: 10_000,
2178 concurrency: 20,
2179 ..TestCtx::default()
2180 };
2181 let out = render(&ctx);
2182 assert!(out.contains("gated off"), "explicit skip marker: {out:?}");
2183 assert!(out.contains('⊘'), "skip glyph shown: {out:?}");
2184 assert!(out.contains("skip:10000"), "skip count shown: {out:?}");
2185 assert!(
2186 !out.contains("ok:"),
2187 "no ok% of any kind on a gated-off phase: {out:?}"
2188 );
2189 assert!(!out.contains("/s"), "no rate on a gated-off phase: {out:?}");
2190 }
2191}