1use std::collections::HashMap;
22use std::sync::Arc;
23
24use super::buf::StringBuf;
25use super::context::ReadoutContext;
26use super::readout::{ContentMode, Lod, Readout, ReadoutOptions};
27use crate::lifecycle::EventType;
28
29pub type ReadoutHandle = Arc<dyn Readout>;
36
37pub enum RenderStep {
42 Literal(String),
46 Render {
49 readout: ReadoutHandle,
50 lod: Lod,
51 layout: LayoutMode,
52 options: ReadoutOptions,
53 color: Option<crate::readouts::color::ColorSpec>,
58 },
59 ColorDirective(crate::readouts::color::ColorSpec),
64}
65
66impl std::fmt::Debug for RenderStep {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 RenderStep::Literal(s) => write!(f, "Literal({s:?})"),
70 RenderStep::Render {
71 readout,
72 lod,
73 layout,
74 color,
75 ..
76 } => {
77 write!(
78 f,
79 "Render {{ name: {:?}, lod: {lod:?}, layout: {layout:?}, color: {color:?} }}",
80 readout.name()
81 )
82 }
83 RenderStep::ColorDirective(c) => {
84 write!(f, "ColorDirective({c:?})")
85 }
86 }
87 }
88}
89
90#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
94pub enum LayoutMode {
95 #[default]
98 Auto,
99 Inline,
101 Block,
103}
104
105pub enum LayoutHint {
110 InlineCompact,
113 Block,
116 Focused(Box<LayoutHint>),
121}
122
123pub enum BinderKey {
128 CycleFocusNext,
131 CycleFocusPrev,
133 CycleLodUp,
136 CycleLodDown,
138 OverlayHeld(bool),
142}
143
144#[derive(Debug, Default)]
156pub struct BakedBody {
157 pub(crate) steps: Vec<RenderStep>,
158}
159
160impl BakedBody {
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 pub fn from_steps(steps: Vec<RenderStep>) -> Self {
170 Self { steps }
171 }
172
173 pub fn steps(&self) -> &[RenderStep] {
178 &self.steps
179 }
180
181 pub fn from_single(readout: ReadoutHandle, lod: Lod) -> Self {
185 Self::from_steps(vec![RenderStep::Render {
186 readout,
187 lod,
188 layout: LayoutMode::Auto,
189 options: ReadoutOptions::new(),
190 color: None,
191 }])
192 }
193
194 pub fn fire(&self, ctx: &dyn ReadoutContext, mode: ContentMode, sink: &mut dyn ReadoutSink) {
201 walk_body(self, ctx, mode, sink, &Overrides::default());
202 }
203}
204
205#[derive(Default, Clone, Copy)]
210struct Overrides {
211 lod: Option<Lod>,
214 focused: bool,
217}
218
219fn walk_body(
226 body: &BakedBody,
227 ctx: &dyn ReadoutContext,
228 mode: ContentMode,
229 sink: &mut dyn ReadoutSink,
230 overrides: &Overrides,
231) {
232 let palette = crate::readouts::color::Palette::default();
233 let color_enabled = ctx.use_color();
234 let mut pending_inline: Option<crate::readouts::color::ColorSpec> = None;
235
236 for step in &body.steps {
237 match step {
238 RenderStep::ColorDirective(c) => {
239 pending_inline = Some(c.clone());
240 }
241 RenderStep::Literal(s) => {
242 if let Some(c) = pending_inline.take() {
243 sink.literal(&c.ansi_open(palette, color_enabled));
244 sink.literal(s);
245 sink.literal(c.ansi_close(color_enabled));
246 } else {
247 sink.literal(s);
248 }
249 }
250 RenderStep::Render {
251 readout,
252 lod,
253 layout,
254 options,
255 color,
256 } => {
257 let effective_lod = overrides.lod.unwrap_or(*lod);
258 let mut hint = layout_hint_for(effective_lod, mode, *layout);
259 if overrides.focused {
260 hint = LayoutHint::Focused(Box::new(hint));
261 }
262 let effective_color = color.clone().or_else(|| pending_inline.take());
263 if let Some(c) = effective_color {
264 sink.literal(&c.ansi_open(palette, color_enabled));
265 sink.render(readout.clone(), ctx, effective_lod, mode, options, hint);
266 sink.literal(c.ansi_close(color_enabled));
267 } else {
268 sink.render(readout.clone(), ctx, effective_lod, mode, options, hint);
269 }
270 }
271 }
272 }
273}
274
275pub fn layout_hint_for(lod: Lod, _mode: ContentMode, layout: LayoutMode) -> LayoutHint {
283 match layout {
284 LayoutMode::Inline => LayoutHint::InlineCompact,
285 LayoutMode::Block => LayoutHint::Block,
286 LayoutMode::Auto => match lod {
288 Lod::Compact => LayoutHint::InlineCompact,
289 Lod::Labeled | Lod::Expanded => LayoutHint::Block,
290 },
291 }
292}
293
294pub trait ReadoutSink {
300 fn literal(&mut self, s: &str);
304
305 fn render(
309 &mut self,
310 readout: ReadoutHandle,
311 ctx: &dyn ReadoutContext,
312 lod: Lod,
313 mode: ContentMode,
314 options: &ReadoutOptions,
315 layout: LayoutHint,
316 );
317
318 fn line_break(&mut self);
320}
321
322pub struct StringSink {
332 buf: String,
333 pending_break: bool,
336 fresh_line: bool,
340}
341
342impl StringSink {
343 pub fn new() -> Self {
344 Self {
345 buf: String::new(),
346 pending_break: false,
347 fresh_line: true,
348 }
349 }
350
351 pub fn with_capacity(cap: usize) -> Self {
352 Self {
353 buf: String::with_capacity(cap),
354 pending_break: false,
355 fresh_line: true,
356 }
357 }
358
359 pub fn take(self) -> String {
361 self.buf
362 }
363
364 pub fn as_str(&self) -> &str {
366 &self.buf
367 }
368}
369
370impl Default for StringSink {
371 fn default() -> Self {
372 Self::new()
373 }
374}
375
376impl ReadoutSink for StringSink {
377 fn literal(&mut self, s: &str) {
378 self.flush_pending_break();
379 self.buf.push_str(s);
380 if !s.is_empty() {
381 self.fresh_line = false;
382 }
383 }
384
385 fn render(
386 &mut self,
387 readout: ReadoutHandle,
388 ctx: &dyn ReadoutContext,
389 lod: Lod,
390 mode: ContentMode,
391 options: &ReadoutOptions,
392 layout: LayoutHint,
393 ) {
394 match flatten_layout(layout) {
402 FlatLayoutHint::Inline => {
403 self.flush_pending_break();
404 let mut buf = StringBuf::new(&mut self.buf);
405 readout.render(ctx, lod, mode, options, &mut buf);
406 self.fresh_line = false;
407 }
408 FlatLayoutHint::Block => {
409 let snapshot_buf_len = self.buf.len();
416 let snapshot_pending = self.pending_break;
417 let snapshot_fresh = self.fresh_line;
418 let inserted_newline = if !self.fresh_line {
419 self.buf.push('\n');
420 true
421 } else {
422 false
423 };
424 let pre_render_len = self.buf.len();
425 let mut buf = StringBuf::new(&mut self.buf);
426 readout.render(ctx, lod, mode, options, &mut buf);
427 if self.buf.len() == pre_render_len {
428 if inserted_newline {
431 self.buf.truncate(snapshot_buf_len);
432 }
433 self.pending_break = snapshot_pending;
434 self.fresh_line = snapshot_fresh;
435 } else {
436 self.pending_break = true;
437 self.fresh_line = false;
438 }
439 }
440 }
441 }
442
443 fn line_break(&mut self) {
444 if !self.fresh_line {
445 self.buf.push('\n');
446 }
447 self.pending_break = false;
448 self.fresh_line = true;
449 }
450}
451
452impl StringSink {
453 fn flush_pending_break(&mut self) {
454 if self.pending_break {
455 if !self.fresh_line {
456 self.buf.push('\n');
457 }
458 self.pending_break = false;
459 self.fresh_line = true;
460 }
461 }
462}
463
464enum FlatLayoutHint {
469 Inline,
470 Block,
471}
472
473fn flatten_layout(layout: LayoutHint) -> FlatLayoutHint {
474 match layout {
475 LayoutHint::InlineCompact => FlatLayoutHint::Inline,
476 LayoutHint::Block => FlatLayoutHint::Block,
477 LayoutHint::Focused(inner) => flatten_layout(*inner),
478 }
479}
480
481pub trait ReadoutBinder: Send {
495 fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink);
499
500 fn on_key(&mut self, _key: BinderKey) {}
503}
504
505pub struct DefaultBinder {
521 pub(crate) bindings: HashMap<EventType, Vec<BakedBody>>,
522}
523
524impl DefaultBinder {
525 pub fn new() -> Self {
526 Self {
527 bindings: HashMap::new(),
528 }
529 }
530
531 pub fn bind(&mut self, event: EventType, body: BakedBody) {
534 self.bindings.entry(event).or_default().push(body);
535 }
536
537 pub fn set(&mut self, event: EventType, body: BakedBody) {
541 self.bindings.insert(event, vec![body]);
542 }
543
544 pub fn slot_len(&self, event: EventType) -> usize {
549 self.bindings.get(&event).map(|v| v.len()).unwrap_or(0)
550 }
551
552 pub fn merge(&mut self, other: DefaultBinder) {
557 for (event, bodies) in other.bindings {
558 self.bindings.entry(event).or_default().extend(bodies);
559 }
560 }
561
562 pub fn unbind(&mut self, event: EventType) {
566 self.bindings.remove(&event);
567 }
568
569 pub fn take_bodies(&mut self, event: EventType) -> Vec<BakedBody> {
575 self.bindings.remove(&event).unwrap_or_default()
576 }
577}
578
579impl Default for DefaultBinder {
580 fn default() -> Self {
581 Self::new()
582 }
583}
584
585impl ReadoutBinder for DefaultBinder {
586 fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink) {
587 if let Some(bodies) = self.bindings.get(&event) {
588 for body in bodies {
589 body.fire(ctx, ContentMode::Value, sink);
590 }
591 }
592 }
593}
594
595pub fn build_binder_from_workload(
611 bindings: &nmbrs_workload::model::ReadoutsBindings,
612 defaults: &[(EventType, BakedBody)],
613) -> Result<DefaultBinder, String> {
614 use super::parse::bake;
615 let mut binder = DefaultBinder::new();
616
617 for (event, body) in defaults {
619 let cloned = BakedBody::from_steps(body.steps.iter().map(clone_step).collect());
620 validate_body_for_event(&cloned, *event)?;
621 binder.bind(*event, cloned);
622 }
623
624 let slots: &[(EventType, &[String])] = &[
628 (
629 EventType::SessionStart,
630 bindings.on_session_start.as_slice(),
631 ),
632 (EventType::SessionEnd, bindings.on_session_end.as_slice()),
633 (EventType::PhaseStart, bindings.on_phase_start.as_slice()),
634 (EventType::PhaseEnd, bindings.on_phase_end.as_slice()),
635 (EventType::EachStart, bindings.on_each_start.as_slice()),
636 (EventType::EachEnd, bindings.on_each_end.as_slice()),
637 (EventType::ScopeStart, bindings.on_scope_start.as_slice()),
638 (EventType::ScopeEnd, bindings.on_scope_end.as_slice()),
639 (EventType::Update, bindings.on_update.as_slice()),
640 ];
641 for (event, bodies) in slots {
642 if bodies.is_empty() {
643 continue;
644 }
645 binder.bindings.remove(event);
646 for body_str in *bodies {
647 let (baked, _warnings) =
648 bake(body_str).map_err(|e| format!("readouts.{}: {e}", event.slot_name()))?;
649 validate_body_for_event(&baked, *event)?;
650 binder.bind(*event, baked);
651 }
652 }
653 Ok(binder)
654}
655
656pub struct TuiReadoutBinder {
684 inner: DefaultBinder,
685 focus: std::collections::HashMap<EventType, Option<usize>>,
688 lod_overrides: std::collections::HashMap<(EventType, usize), Lod>,
691 overlay_held: bool,
693 last_event: Option<EventType>,
696}
697
698impl TuiReadoutBinder {
699 pub fn new() -> Self {
700 Self {
701 inner: DefaultBinder::new(),
702 focus: std::collections::HashMap::new(),
703 lod_overrides: std::collections::HashMap::new(),
704 overlay_held: false,
705 last_event: None,
706 }
707 }
708
709 pub fn from_default(inner: DefaultBinder) -> Self {
714 Self {
715 inner,
716 focus: std::collections::HashMap::new(),
717 lod_overrides: std::collections::HashMap::new(),
718 overlay_held: false,
719 last_event: None,
720 }
721 }
722
723 pub fn bind(&mut self, event: EventType, body: BakedBody) {
724 self.inner.bind(event, body);
725 }
726
727 pub fn slot_len(&self, event: EventType) -> usize {
728 self.inner.slot_len(event)
729 }
730
731 pub fn focus_for(&self, event: EventType) -> Option<usize> {
736 self.focus.get(&event).copied().flatten()
737 }
738
739 pub fn overlay_held(&self) -> bool {
743 self.overlay_held
744 }
745
746 pub fn lod_override(&self, event: EventType, idx: usize) -> Option<Lod> {
749 self.lod_overrides.get(&(event, idx)).copied()
750 }
751}
752
753impl Default for TuiReadoutBinder {
754 fn default() -> Self {
755 Self::new()
756 }
757}
758
759impl ReadoutBinder for TuiReadoutBinder {
760 fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink) {
761 self.last_event = Some(event);
762 let global_held = crate::observer::is_explain_held();
773 let mode = if self.overlay_held || global_held {
774 ContentMode::Explanation
775 } else {
776 ContentMode::Value
777 };
778 let Some(bodies) = self.inner.bindings.get(&event) else {
779 return;
780 };
781 let focus_idx = self.focus.get(&event).copied().flatten();
782 for (i, body) in bodies.iter().enumerate() {
783 let override_lod = self.lod_overrides.get(&(event, i)).copied();
788 let focused = focus_idx == Some(i);
789 fire_body_with_overrides(body, ctx, mode, sink, override_lod, focused);
790 }
791 }
792
793 fn on_key(&mut self, key: BinderKey) {
794 match key {
795 BinderKey::OverlayHeld(v) => {
796 self.overlay_held = v;
797 }
798 BinderKey::CycleFocusNext => {
799 self.cycle_focus(1);
800 }
801 BinderKey::CycleFocusPrev => {
802 self.cycle_focus(-1);
803 }
804 BinderKey::CycleLodUp => {
805 self.cycle_focused_lod(1);
806 }
807 BinderKey::CycleLodDown => {
808 self.cycle_focused_lod(-1);
809 }
810 }
811 }
812}
813
814impl TuiReadoutBinder {
815 fn cycle_focus(&mut self, delta: i32) {
818 let Some(slot) = self.last_event else {
819 return;
820 };
821 let Some(bodies) = self.inner.bindings.get(&slot) else {
822 return;
823 };
824 let len = bodies.len();
825 if len == 0 {
826 return;
827 }
828 let cur = self.focus.get(&slot).copied().flatten().unwrap_or(0) as i32;
829 let next = (cur + delta).rem_euclid(len as i32) as usize;
830 self.focus.insert(slot, Some(next));
831 }
832
833 fn cycle_focused_lod(&mut self, delta: i32) {
838 let Some(slot) = self.last_event else {
839 return;
840 };
841 let Some(focus_opt) = self.focus.get(&slot).copied() else {
842 return;
843 };
844 let Some(idx) = focus_opt else {
845 return;
846 };
847
848 let bodies = self.inner.bindings.get(&slot);
853 let Some(bodies) = bodies else {
854 return;
855 };
856 let baked_lod = bodies
857 .get(idx)
858 .and_then(first_render_lod)
859 .unwrap_or(Lod::Labeled);
860 let cur = self
861 .lod_overrides
862 .get(&(slot, idx))
863 .copied()
864 .unwrap_or(baked_lod);
865 let next = step_lod(cur, delta);
866 self.lod_overrides.insert((slot, idx), next);
867 }
868}
869
870fn first_render_lod(body: &BakedBody) -> Option<Lod> {
871 body.steps.iter().find_map(|step| match step {
872 RenderStep::Render { lod, .. } => Some(*lod),
873 _ => None,
874 })
875}
876
877fn step_lod(cur: Lod, delta: i32) -> Lod {
878 let order = [Lod::Compact, Lod::Labeled, Lod::Expanded];
879 let pos = order.iter().position(|l| *l == cur).unwrap_or(1) as i32;
880 let next = (pos + delta).rem_euclid(order.len() as i32) as usize;
881 order[next]
882}
883
884fn fire_body_with_overrides(
889 body: &BakedBody,
890 ctx: &dyn ReadoutContext,
891 mode: ContentMode,
892 sink: &mut dyn ReadoutSink,
893 override_lod: Option<Lod>,
894 focused: bool,
895) {
896 walk_body(
897 body,
898 ctx,
899 mode,
900 sink,
901 &Overrides {
902 lod: override_lod,
903 focused,
904 },
905 );
906}
907
908pub fn build_event_binder(
926 bindings: &nmbrs_workload::model::ReadoutsBindings,
927 event: EventType,
928 default: BakedBody,
929) -> Result<DefaultBinder, String> {
930 build_event_binder_with_cli(bindings, event, default, None)
931}
932
933pub fn build_event_binder_with_cli(
953 bindings: &nmbrs_workload::model::ReadoutsBindings,
954 event: EventType,
955 default: BakedBody,
956 cli_override: Option<&str>,
957) -> Result<DefaultBinder, String> {
958 use super::parse::bake;
959
960 if let Some(body_str) = cli_override
961 && event == EventType::Update
962 {
963 let workload_bodies = bindings.get(event.slot_name());
967 if !workload_bodies.is_empty() {
968 crate::diag!(
969 crate::observer::LogLevel::Warn,
970 "readouts: --readout override [{body}] replaces workload binding {workload:?} \
971 for slot {slot}. Use a `+` prefix on the override (e.g. `--readout=+x`) \
972 if you intended to extend rather than replace.",
973 body = body_str,
974 workload = workload_bodies,
975 slot = event.slot_name(),
976 );
977 }
978 let mut binder = DefaultBinder::new();
979 let stripped = body_str.trim_start().strip_prefix('+').unwrap_or(body_str);
980 let plus_prefix = body_str.trim_start().starts_with('+');
981 if plus_prefix {
982 let inner = build_event_binder(bindings, event, default)?;
985 binder.merge(inner);
986 let (baked, _) = bake(stripped).map_err(|e| format!("readouts: --readout: {e}"))?;
987 validate_body_for_event(&baked, event)?;
988 binder.bind(event, baked);
989 } else {
990 let (baked, _) = bake(stripped).map_err(|e| format!("readouts: --readout: {e}"))?;
991 validate_body_for_event(&baked, event)?;
992 binder.bind(event, baked);
993 }
994 return Ok(binder);
995 }
996
997 build_event_binder_inner(bindings, event, default)
998}
999
1000fn build_event_binder_inner(
1001 bindings: &nmbrs_workload::model::ReadoutsBindings,
1002 event: EventType,
1003 default: BakedBody,
1004) -> Result<DefaultBinder, String> {
1005 use super::parse::bake;
1006 let mut binder = DefaultBinder::new();
1007 let bodies = bindings.get(event.slot_name());
1008 if bodies.is_empty() {
1009 validate_body_for_event(&default, event)?;
1010 binder.bind(event, default);
1011 return Ok(binder);
1012 }
1013
1014 let any_plain = bodies.iter().any(|b| !b.trim_start().starts_with('+'));
1015 let any_appended = bodies.iter().any(|b| b.trim_start().starts_with('+'));
1016
1017 if !any_plain && any_appended {
1022 validate_body_for_event(&default, event)?;
1023 binder.bind(event, default);
1024 }
1025
1026 for body_str in bodies {
1027 let stripped = body_str.trim_start().strip_prefix('+').unwrap_or(body_str);
1028 let (baked, _warnings) =
1029 bake(stripped).map_err(|e| format!("readouts.{}: {e}", event.slot_name()))?;
1030 validate_body_for_event(&baked, event)?;
1031 binder.bind(event, baked);
1032 }
1033 Ok(binder)
1034}
1035
1036fn clone_step(step: &RenderStep) -> RenderStep {
1037 match step {
1038 RenderStep::Literal(s) => RenderStep::Literal(s.clone()),
1039 RenderStep::Render {
1040 readout,
1041 lod,
1042 layout,
1043 options,
1044 color,
1045 } => RenderStep::Render {
1046 readout: readout.clone(),
1047 lod: *lod,
1048 layout: *layout,
1049 options: options.clone(),
1050 color: color.clone(),
1051 },
1052 RenderStep::ColorDirective(c) => RenderStep::ColorDirective(c.clone()),
1053 }
1054}
1055
1056pub fn validate_body_for_event(body: &BakedBody, event: EventType) -> Result<(), String> {
1065 let slot_kind = event.subject_kind();
1066 for step in &body.steps {
1067 if let RenderStep::Render { readout, .. } = step {
1068 let accepted = readout.accepts();
1069 if !accepted.contains(&slot_kind) {
1070 return Err(format!(
1071 "readouts.{slot}: readout '{name}' does not accept \
1072 subject kind {slot_kind:?} (accepts {accepted:?})",
1073 slot = event.slot_name(),
1074 name = readout.name(),
1075 ));
1076 }
1077 }
1078 }
1079 Ok(())
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use crate::readouts::Registry;
1086
1087 #[test]
1090 fn validate_rejects_phase_readout_at_session_slot() {
1091 let bindings = nmbrs_workload::model::ReadoutsBindings {
1095 on_session_end: vec!["phase_status".to_string()],
1096 ..Default::default()
1097 };
1098 let res = build_event_binder(&bindings, EventType::SessionEnd, BakedBody::new());
1099 let err = match res {
1100 Ok(_) => panic!("expected validation error"),
1101 Err(e) => e,
1102 };
1103 assert!(err.contains("phase_status"), "{err}");
1104 assert!(err.contains("does not accept"), "{err}");
1105 assert!(err.contains("Session"), "{err}");
1106 }
1107
1108 #[test]
1109 fn validate_accepts_session_summary_at_session_slot() {
1110 let bindings = nmbrs_workload::model::ReadoutsBindings {
1111 on_session_end: vec!["session_summary".to_string()],
1112 ..Default::default()
1113 };
1114 assert!(
1115 build_event_binder(&bindings, EventType::SessionEnd, BakedBody::new(),).is_ok(),
1116 "session_summary at on_session_end should be valid"
1117 );
1118 }
1119
1120 #[test]
1121 fn validate_accepts_trace_at_every_slot() {
1122 type SlotCase = (EventType, fn(&mut nmbrs_workload::model::ReadoutsBindings));
1127 let cases: &[SlotCase] = &[
1128 (EventType::SessionEnd, |b| {
1129 b.on_session_end = vec!["trace".into()]
1130 }),
1131 (EventType::PhaseEnd, |b| {
1132 b.on_phase_end = vec!["trace".into()]
1133 }),
1134 (EventType::EachEnd, |b| b.on_each_end = vec!["trace".into()]),
1135 (EventType::ScopeEnd, |b| {
1136 b.on_scope_end = vec!["trace".into()]
1137 }),
1138 ];
1139 for (event, set_slot) in cases {
1140 let mut bindings = nmbrs_workload::model::ReadoutsBindings::default();
1141 set_slot(&mut bindings);
1142 assert!(
1143 build_event_binder(&bindings, *event, BakedBody::new()).is_ok(),
1144 "trace at {event:?} should validate"
1145 );
1146 }
1147 }
1148
1149 #[test]
1153 fn default_binder_fires_phase_outcome_at_phase_end() {
1154 struct Ctx;
1155 impl ReadoutContext for Ctx {
1156 fn subject_name(&self) -> &str {
1157 "setup"
1158 }
1159 fn subject_seq(&self) -> Option<(usize, usize)> {
1160 Some((1, 2))
1161 }
1162 fn subject_labels(&self) -> &str {
1163 ""
1164 }
1165 fn cycles_completed(&self) -> u64 {
1166 3
1167 }
1168 fn cycles_total(&self) -> u64 {
1169 3
1170 }
1171 fn ops_ok(&self) -> u64 {
1172 3
1173 }
1174 fn errors(&self) -> u64 {
1175 0
1176 }
1177 fn retries(&self) -> u64 {
1178 0
1179 }
1180 fn concurrency(&self) -> usize {
1181 1
1182 }
1183 fn elapsed_secs(&self) -> f64 {
1184 0.01
1185 }
1186 fn consumed(&self) -> u64 {
1187 3
1188 }
1189 fn status_metric_chips(&self) -> String {
1190 String::new()
1191 }
1192 fn depth_indent(&self) -> &str {
1193 ""
1194 }
1195 fn use_color(&self) -> bool {
1196 false
1197 }
1198 fn event(&self) -> EventType {
1199 EventType::PhaseEnd
1200 }
1201 }
1202 let mut binder = DefaultBinder::new();
1203 let phase_outcome = Registry::lookup("phase_outcome").unwrap();
1204 binder.set(
1205 EventType::PhaseEnd,
1206 BakedBody::from_single(phase_outcome, Lod::Labeled),
1207 );
1208
1209 let mut sink = StringSink::new();
1210 binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
1211
1212 let out = sink.take();
1213 assert!(out.contains("✓"), "phase_outcome's ✓ missing: {out}");
1214 assert!(out.contains("[setup]"), "phase name missing: {out}");
1215 assert!(!out.contains("[1/2]"), "seq must not appear in body: {out}");
1218 }
1219
1220 #[test]
1221 fn default_binder_dispatches_only_to_matching_event() {
1222 struct Ctx;
1223 impl ReadoutContext for Ctx {
1224 fn subject_name(&self) -> &str {
1225 "x"
1226 }
1227 fn subject_seq(&self) -> Option<(usize, usize)> {
1228 None
1229 }
1230 fn subject_labels(&self) -> &str {
1231 ""
1232 }
1233 fn cycles_completed(&self) -> u64 {
1234 0
1235 }
1236 fn cycles_total(&self) -> u64 {
1237 0
1238 }
1239 fn ops_ok(&self) -> u64 {
1240 0
1241 }
1242 fn errors(&self) -> u64 {
1243 0
1244 }
1245 fn retries(&self) -> u64 {
1246 0
1247 }
1248 fn concurrency(&self) -> usize {
1249 1
1250 }
1251 fn elapsed_secs(&self) -> f64 {
1252 0.0
1253 }
1254 fn consumed(&self) -> u64 {
1255 0
1256 }
1257 fn status_metric_chips(&self) -> String {
1258 String::new()
1259 }
1260 fn depth_indent(&self) -> &str {
1261 ""
1262 }
1263 fn use_color(&self) -> bool {
1264 false
1265 }
1266 fn event(&self) -> EventType {
1267 EventType::PhaseEnd
1268 }
1269 }
1270 let mut binder = DefaultBinder::new();
1271 let phase_outcome = Registry::lookup("phase_outcome").unwrap();
1272 binder.set(
1273 EventType::PhaseEnd,
1274 BakedBody::from_single(phase_outcome, Lod::Labeled),
1275 );
1276
1277 let mut sink = StringSink::new();
1279 binder.fire(EventType::Update, &Ctx, &mut sink);
1280 assert_eq!(sink.take(), "");
1281 }
1282
1283 #[test]
1284 fn string_sink_block_inserts_newlines() {
1285 let mut sink = StringSink::new();
1286 sink.literal("a");
1287 sink.line_break();
1291 sink.literal("b");
1292 sink.line_break();
1293 sink.line_break();
1295 sink.literal("c");
1296 assert_eq!(sink.take(), "a\nb\nc");
1297 }
1298
1299 #[test]
1306 fn empty_block_render_contributes_no_blank_line() {
1307 use crate::lifecycle::SubjectKind;
1308 use crate::readouts::buf::ReadoutBuf;
1309 struct EmptyReadout;
1310 impl Readout for EmptyReadout {
1311 fn name(&self) -> &'static str {
1312 "empty"
1313 }
1314 fn accepts(&self) -> &'static [SubjectKind] {
1315 &[SubjectKind::Phase]
1316 }
1317 fn render(
1318 &self,
1319 _ctx: &dyn ReadoutContext,
1320 _lod: Lod,
1321 _mode: ContentMode,
1322 _opts: &ReadoutOptions,
1323 _out: &mut dyn ReadoutBuf,
1324 ) -> usize {
1325 0
1326 }
1327 }
1328 struct Filler;
1329 impl Readout for Filler {
1330 fn name(&self) -> &'static str {
1331 "filler"
1332 }
1333 fn accepts(&self) -> &'static [SubjectKind] {
1334 &[SubjectKind::Phase]
1335 }
1336 fn render(
1337 &self,
1338 _ctx: &dyn ReadoutContext,
1339 _lod: Lod,
1340 _mode: ContentMode,
1341 _opts: &ReadoutOptions,
1342 out: &mut dyn ReadoutBuf,
1343 ) -> usize {
1344 let _ = out.write_str("filler-text");
1345 "filler-text".len()
1346 }
1347 }
1348 struct Ctx;
1349 impl ReadoutContext for Ctx {
1350 fn subject_name(&self) -> &str {
1351 "x"
1352 }
1353 fn subject_seq(&self) -> Option<(usize, usize)> {
1354 None
1355 }
1356 fn subject_labels(&self) -> &str {
1357 ""
1358 }
1359 fn cycles_completed(&self) -> u64 {
1360 0
1361 }
1362 fn cycles_total(&self) -> u64 {
1363 0
1364 }
1365 fn ops_ok(&self) -> u64 {
1366 0
1367 }
1368 fn errors(&self) -> u64 {
1369 0
1370 }
1371 fn retries(&self) -> u64 {
1372 0
1373 }
1374 fn concurrency(&self) -> usize {
1375 0
1376 }
1377 fn elapsed_secs(&self) -> f64 {
1378 0.0
1379 }
1380 fn consumed(&self) -> u64 {
1381 0
1382 }
1383 fn status_metric_chips(&self) -> String {
1384 String::new()
1385 }
1386 fn depth_indent(&self) -> &str {
1387 ""
1388 }
1389 fn use_color(&self) -> bool {
1390 false
1391 }
1392 fn event(&self) -> EventType {
1393 EventType::PhaseEnd
1394 }
1395 }
1396 let mut sink = StringSink::new();
1397 let opts = ReadoutOptions::new();
1398 let h_filler: ReadoutHandle = std::sync::Arc::new(Filler);
1399 let h_empty: ReadoutHandle = std::sync::Arc::new(EmptyReadout);
1400
1401 sink.render(
1403 h_filler.clone(),
1404 &Ctx,
1405 Lod::Labeled,
1406 ContentMode::Value,
1407 &opts,
1408 LayoutHint::Block,
1409 );
1410 sink.render(
1412 h_empty,
1413 &Ctx,
1414 Lod::Labeled,
1415 ContentMode::Value,
1416 &opts,
1417 LayoutHint::Block,
1418 );
1419 sink.render(
1421 h_filler,
1422 &Ctx,
1423 Lod::Labeled,
1424 ContentMode::Value,
1425 &opts,
1426 LayoutHint::Block,
1427 );
1428
1429 assert_eq!(sink.take(), "filler-text\nfiller-text");
1433 }
1434
1435 #[test]
1436 fn layout_hint_auto_picks_inline_for_compact() {
1437 assert!(matches!(
1438 layout_hint_for(Lod::Compact, ContentMode::Value, LayoutMode::Auto),
1439 LayoutHint::InlineCompact
1440 ));
1441 assert!(matches!(
1442 layout_hint_for(Lod::Labeled, ContentMode::Value, LayoutMode::Auto),
1443 LayoutHint::Block
1444 ));
1445 assert!(matches!(
1446 layout_hint_for(Lod::Expanded, ContentMode::Value, LayoutMode::Auto),
1447 LayoutHint::Block
1448 ));
1449 }
1450
1451 #[test]
1452 fn layout_hint_inline_overrides_lod() {
1453 assert!(matches!(
1456 layout_hint_for(Lod::Expanded, ContentMode::Value, LayoutMode::Inline),
1457 LayoutHint::InlineCompact
1458 ));
1459 }
1460
1461 #[test]
1462 fn layout_hint_block_overrides_lod() {
1463 assert!(matches!(
1466 layout_hint_for(Lod::Compact, ContentMode::Value, LayoutMode::Block),
1467 LayoutHint::Block
1468 ));
1469 }
1470
1471 fn empty_bindings() -> nmbrs_workload::model::ReadoutsBindings {
1474 nmbrs_workload::model::ReadoutsBindings::default()
1475 }
1476
1477 fn default_phase_outcome() -> BakedBody {
1478 BakedBody::from_single(Registry::lookup("phase_outcome").unwrap(), Lod::Labeled)
1479 }
1480
1481 #[test]
1482 fn no_workload_binding_uses_default() {
1483 let bindings = empty_bindings();
1485 let binder =
1486 build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1487 assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
1488 }
1489
1490 #[test]
1491 fn plain_workload_binding_replaces_default() {
1492 let mut bindings = empty_bindings();
1495 bindings.on_phase_end = vec!["trace".to_string()];
1496 let binder =
1497 build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1498 assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
1500 }
1501
1502 #[test]
1503 fn plus_prefix_workload_binding_appends_to_default() {
1504 let mut bindings = empty_bindings();
1507 bindings.on_phase_end = vec!["+trace".to_string()];
1508 let binder =
1509 build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1510 assert_eq!(binder.slot_len(EventType::PhaseEnd), 2);
1512 }
1513
1514 #[test]
1515 fn multiple_plus_prefix_appends_in_order() {
1516 let mut bindings = empty_bindings();
1517 bindings.on_phase_end = vec!["+trace".to_string(), "+trace".to_string()];
1518 let binder =
1519 build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1520 assert_eq!(binder.slot_len(EventType::PhaseEnd), 3);
1522 }
1523
1524 #[test]
1525 fn cli_override_replaces_workload_binding_at_update() {
1526 let mut bindings = empty_bindings();
1527 bindings.on_update = vec!["phase_status".to_string()];
1528 let binder = build_event_binder_with_cli(
1529 &bindings,
1530 EventType::Update,
1531 default_phase_outcome(),
1532 Some("trace"),
1533 )
1534 .unwrap();
1535 assert_eq!(binder.slot_len(EventType::Update), 1);
1538 }
1539
1540 #[test]
1541 fn cli_override_plus_prefix_appends_to_workload_resolved() {
1542 let mut bindings = empty_bindings();
1543 bindings.on_update = vec!["phase_status".to_string()];
1544 let binder = build_event_binder_with_cli(
1545 &bindings,
1546 EventType::Update,
1547 default_phase_outcome(),
1548 Some("+trace"),
1549 )
1550 .unwrap();
1551 assert_eq!(binder.slot_len(EventType::Update), 2);
1553 }
1554
1555 #[test]
1556 fn cli_override_only_applies_to_update_slot() {
1557 let bindings = empty_bindings();
1558 let binder = build_event_binder_with_cli(
1559 &bindings,
1560 EventType::PhaseEnd,
1561 default_phase_outcome(),
1562 Some("trace"),
1563 )
1564 .unwrap();
1565 assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
1567 struct Ctx;
1570 impl ReadoutContext for Ctx {
1571 fn subject_name(&self) -> &str {
1572 "x"
1573 }
1574 fn subject_seq(&self) -> Option<(usize, usize)> {
1575 None
1576 }
1577 fn subject_labels(&self) -> &str {
1578 ""
1579 }
1580 fn cycles_completed(&self) -> u64 {
1581 0
1582 }
1583 fn cycles_total(&self) -> u64 {
1584 0
1585 }
1586 fn ops_ok(&self) -> u64 {
1587 0
1588 }
1589 fn errors(&self) -> u64 {
1590 0
1591 }
1592 fn retries(&self) -> u64 {
1593 0
1594 }
1595 fn concurrency(&self) -> usize {
1596 1
1597 }
1598 fn elapsed_secs(&self) -> f64 {
1599 0.0
1600 }
1601 fn consumed(&self) -> u64 {
1602 0
1603 }
1604 fn status_metric_chips(&self) -> String {
1605 String::new()
1606 }
1607 fn depth_indent(&self) -> &str {
1608 ""
1609 }
1610 fn use_color(&self) -> bool {
1611 false
1612 }
1613 fn event(&self) -> EventType {
1614 EventType::PhaseEnd
1615 }
1616 }
1617 let mut binder_local = binder;
1618 let mut sink = StringSink::new();
1619 binder_local.fire(EventType::PhaseEnd, &Ctx, &mut sink);
1620 let out = sink.take();
1621 assert!(
1622 out.contains("✓"),
1623 "default phase_outcome body should fire: {out}"
1624 );
1625 }
1626
1627 #[test]
1628 fn mixed_plain_and_plus_treats_whole_list_as_replacement() {
1629 let mut bindings = empty_bindings();
1633 bindings.on_phase_end = vec!["trace".to_string(), "+trace".to_string()];
1634 let binder =
1635 build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1636 assert_eq!(binder.slot_len(EventType::PhaseEnd), 2);
1638 }
1639
1640 fn make_tui_binder_with_two_bodies() -> TuiReadoutBinder {
1643 let mut binder = TuiReadoutBinder::new();
1644 let phase_outcome = Registry::lookup("phase_outcome").unwrap();
1645 let trace = Registry::lookup("trace").unwrap();
1646 binder.bind(
1647 EventType::PhaseEnd,
1648 BakedBody::from_single(phase_outcome, Lod::Labeled),
1649 );
1650 binder.bind(
1651 EventType::PhaseEnd,
1652 BakedBody::from_single(trace, Lod::Labeled),
1653 );
1654 binder
1655 }
1656
1657 #[test]
1658 fn tui_binder_overlay_held_toggles_mode() {
1659 let mut binder = TuiReadoutBinder::new();
1660 assert!(!binder.overlay_held());
1661 binder.on_key(BinderKey::OverlayHeld(true));
1662 assert!(binder.overlay_held());
1663 binder.on_key(BinderKey::OverlayHeld(false));
1664 assert!(!binder.overlay_held());
1665 }
1666
1667 #[test]
1688 fn global_explain_toggle_flips_explanation_mode() {
1689 let _guard = EXPLAIN_GLOBAL_TEST_LOCK
1690 .lock()
1691 .unwrap_or_else(|p| p.into_inner());
1692 std::thread::sleep(std::time::Duration::from_millis(300));
1698 if crate::observer::is_explain_held() {
1699 crate::observer::toggle_explain();
1700 std::thread::sleep(std::time::Duration::from_millis(300));
1701 }
1702 assert!(
1703 !crate::observer::is_explain_held(),
1704 "baseline: explain flag MUST be off"
1705 );
1706 crate::observer::toggle_explain();
1708 assert!(
1709 crate::observer::is_explain_held(),
1710 "first press MUST turn the flag on"
1711 );
1712 std::thread::sleep(std::time::Duration::from_millis(300));
1714 crate::observer::toggle_explain();
1715 assert!(
1716 !crate::observer::is_explain_held(),
1717 "second press MUST turn the flag off"
1718 );
1719 }
1720
1721 static EXPLAIN_GLOBAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1722
1723 #[test]
1727 fn global_explain_toggle_debounces_auto_repeat() {
1728 let _guard = EXPLAIN_GLOBAL_TEST_LOCK
1729 .lock()
1730 .unwrap_or_else(|p| p.into_inner());
1731 std::thread::sleep(std::time::Duration::from_millis(300));
1733 if crate::observer::is_explain_held() {
1734 crate::observer::toggle_explain();
1735 std::thread::sleep(std::time::Duration::from_millis(300));
1736 }
1737 assert!(!crate::observer::is_explain_held(), "baseline: off");
1738 crate::observer::toggle_explain();
1740 assert!(crate::observer::is_explain_held());
1741 crate::observer::toggle_explain();
1743 assert!(
1744 crate::observer::is_explain_held(),
1745 "rapid second press MUST be debounced, leaving state on"
1746 );
1747 std::thread::sleep(std::time::Duration::from_millis(300));
1749 crate::observer::toggle_explain();
1750 }
1751
1752 #[test]
1753 fn tui_binder_focus_cycles_through_slot() {
1754 struct Ctx;
1755 impl ReadoutContext for Ctx {
1756 fn subject_name(&self) -> &str {
1757 "x"
1758 }
1759 fn subject_seq(&self) -> Option<(usize, usize)> {
1760 None
1761 }
1762 fn subject_labels(&self) -> &str {
1763 ""
1764 }
1765 fn cycles_completed(&self) -> u64 {
1766 0
1767 }
1768 fn cycles_total(&self) -> u64 {
1769 0
1770 }
1771 fn ops_ok(&self) -> u64 {
1772 0
1773 }
1774 fn errors(&self) -> u64 {
1775 0
1776 }
1777 fn retries(&self) -> u64 {
1778 0
1779 }
1780 fn concurrency(&self) -> usize {
1781 1
1782 }
1783 fn elapsed_secs(&self) -> f64 {
1784 0.0
1785 }
1786 fn consumed(&self) -> u64 {
1787 0
1788 }
1789 fn status_metric_chips(&self) -> String {
1790 String::new()
1791 }
1792 fn depth_indent(&self) -> &str {
1793 ""
1794 }
1795 fn use_color(&self) -> bool {
1796 false
1797 }
1798 fn event(&self) -> EventType {
1799 EventType::PhaseEnd
1800 }
1801 }
1802 let mut binder = make_tui_binder_with_two_bodies();
1803 let mut sink = StringSink::new();
1805 binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
1806 assert_eq!(binder.focus_for(EventType::PhaseEnd), None);
1808
1809 binder.on_key(BinderKey::CycleFocusNext);
1810 assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(1));
1814
1815 binder.on_key(BinderKey::CycleFocusNext);
1816 assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(0));
1818
1819 binder.on_key(BinderKey::CycleFocusPrev);
1820 assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(1));
1822 }
1823
1824 #[test]
1825 fn tui_binder_lod_cycle_stamps_override() {
1826 struct Ctx;
1827 impl ReadoutContext for Ctx {
1828 fn subject_name(&self) -> &str {
1829 "x"
1830 }
1831 fn subject_seq(&self) -> Option<(usize, usize)> {
1832 None
1833 }
1834 fn subject_labels(&self) -> &str {
1835 ""
1836 }
1837 fn cycles_completed(&self) -> u64 {
1838 0
1839 }
1840 fn cycles_total(&self) -> u64 {
1841 0
1842 }
1843 fn ops_ok(&self) -> u64 {
1844 0
1845 }
1846 fn errors(&self) -> u64 {
1847 0
1848 }
1849 fn retries(&self) -> u64 {
1850 0
1851 }
1852 fn concurrency(&self) -> usize {
1853 1
1854 }
1855 fn elapsed_secs(&self) -> f64 {
1856 0.0
1857 }
1858 fn consumed(&self) -> u64 {
1859 0
1860 }
1861 fn status_metric_chips(&self) -> String {
1862 String::new()
1863 }
1864 fn depth_indent(&self) -> &str {
1865 ""
1866 }
1867 fn use_color(&self) -> bool {
1868 false
1869 }
1870 fn event(&self) -> EventType {
1871 EventType::PhaseEnd
1872 }
1873 }
1874 let mut binder = make_tui_binder_with_two_bodies();
1875 let mut sink = StringSink::new();
1876 binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
1877 binder.on_key(BinderKey::CycleFocusNext); binder.on_key(BinderKey::CycleLodUp);
1881 assert_eq!(
1882 binder.lod_override(EventType::PhaseEnd, 1),
1883 Some(Lod::Expanded)
1884 );
1885
1886 binder.on_key(BinderKey::CycleLodUp);
1888 assert_eq!(
1889 binder.lod_override(EventType::PhaseEnd, 1),
1890 Some(Lod::Compact)
1891 );
1892
1893 binder.on_key(BinderKey::CycleLodDown);
1895 assert_eq!(
1896 binder.lod_override(EventType::PhaseEnd, 1),
1897 Some(Lod::Expanded)
1898 );
1899 }
1900
1901 #[test]
1902 fn step_lod_cycles_three_levels() {
1903 assert_eq!(step_lod(Lod::Compact, 1), Lod::Labeled);
1904 assert_eq!(step_lod(Lod::Labeled, 1), Lod::Expanded);
1905 assert_eq!(step_lod(Lod::Expanded, 1), Lod::Compact);
1906 assert_eq!(step_lod(Lod::Compact, -1), Lod::Expanded);
1907 }
1908}