use std::collections::HashMap;
use std::sync::Arc;
use super::buf::StringBuf;
use super::context::ReadoutContext;
use super::readout::{ContentMode, Lod, Readout, ReadoutOptions};
use crate::lifecycle::EventType;
pub type ReadoutHandle = Arc<dyn Readout>;
pub enum RenderStep {
Literal(String),
Render {
readout: ReadoutHandle,
lod: Lod,
layout: LayoutMode,
options: ReadoutOptions,
color: Option<crate::readouts::color::ColorSpec>,
},
ColorDirective(crate::readouts::color::ColorSpec),
}
impl std::fmt::Debug for RenderStep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RenderStep::Literal(s) => write!(f, "Literal({s:?})"),
RenderStep::Render {
readout,
lod,
layout,
color,
..
} => {
write!(
f,
"Render {{ name: {:?}, lod: {lod:?}, layout: {layout:?}, color: {color:?} }}",
readout.name()
)
}
RenderStep::ColorDirective(c) => {
write!(f, "ColorDirective({c:?})")
}
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum LayoutMode {
#[default]
Auto,
Inline,
Block,
}
pub enum LayoutHint {
InlineCompact,
Block,
Focused(Box<LayoutHint>),
}
pub enum BinderKey {
CycleFocusNext,
CycleFocusPrev,
CycleLodUp,
CycleLodDown,
OverlayHeld(bool),
}
#[derive(Debug, Default)]
pub struct BakedBody {
pub(crate) steps: Vec<RenderStep>,
}
impl BakedBody {
pub fn new() -> Self {
Self::default()
}
pub fn from_steps(steps: Vec<RenderStep>) -> Self {
Self { steps }
}
pub fn steps(&self) -> &[RenderStep] {
&self.steps
}
pub fn from_single(readout: ReadoutHandle, lod: Lod) -> Self {
Self::from_steps(vec![RenderStep::Render {
readout,
lod,
layout: LayoutMode::Auto,
options: ReadoutOptions::new(),
color: None,
}])
}
pub fn fire(&self, ctx: &dyn ReadoutContext, mode: ContentMode, sink: &mut dyn ReadoutSink) {
walk_body(self, ctx, mode, sink, &Overrides::default());
}
}
#[derive(Default, Clone, Copy)]
struct Overrides {
lod: Option<Lod>,
focused: bool,
}
fn walk_body(
body: &BakedBody,
ctx: &dyn ReadoutContext,
mode: ContentMode,
sink: &mut dyn ReadoutSink,
overrides: &Overrides,
) {
let palette = crate::readouts::color::Palette::default();
let color_enabled = ctx.use_color();
let mut pending_inline: Option<crate::readouts::color::ColorSpec> = None;
for step in &body.steps {
match step {
RenderStep::ColorDirective(c) => {
pending_inline = Some(c.clone());
}
RenderStep::Literal(s) => {
if let Some(c) = pending_inline.take() {
sink.literal(&c.ansi_open(palette, color_enabled));
sink.literal(s);
sink.literal(c.ansi_close(color_enabled));
} else {
sink.literal(s);
}
}
RenderStep::Render {
readout,
lod,
layout,
options,
color,
} => {
let effective_lod = overrides.lod.unwrap_or(*lod);
let mut hint = layout_hint_for(effective_lod, mode, *layout);
if overrides.focused {
hint = LayoutHint::Focused(Box::new(hint));
}
let effective_color = color.clone().or_else(|| pending_inline.take());
if let Some(c) = effective_color {
sink.literal(&c.ansi_open(palette, color_enabled));
sink.render(readout.clone(), ctx, effective_lod, mode, options, hint);
sink.literal(c.ansi_close(color_enabled));
} else {
sink.render(readout.clone(), ctx, effective_lod, mode, options, hint);
}
}
}
}
}
pub fn layout_hint_for(lod: Lod, _mode: ContentMode, layout: LayoutMode) -> LayoutHint {
match layout {
LayoutMode::Inline => LayoutHint::InlineCompact,
LayoutMode::Block => LayoutHint::Block,
LayoutMode::Auto => match lod {
Lod::Compact => LayoutHint::InlineCompact,
Lod::Labeled | Lod::Expanded => LayoutHint::Block,
},
}
}
pub trait ReadoutSink {
fn literal(&mut self, s: &str);
fn render(
&mut self,
readout: ReadoutHandle,
ctx: &dyn ReadoutContext,
lod: Lod,
mode: ContentMode,
options: &ReadoutOptions,
layout: LayoutHint,
);
fn line_break(&mut self);
}
pub struct StringSink {
buf: String,
pending_break: bool,
fresh_line: bool,
}
impl StringSink {
pub fn new() -> Self {
Self {
buf: String::new(),
pending_break: false,
fresh_line: true,
}
}
pub fn with_capacity(cap: usize) -> Self {
Self {
buf: String::with_capacity(cap),
pending_break: false,
fresh_line: true,
}
}
pub fn take(self) -> String {
self.buf
}
pub fn as_str(&self) -> &str {
&self.buf
}
}
impl Default for StringSink {
fn default() -> Self {
Self::new()
}
}
impl ReadoutSink for StringSink {
fn literal(&mut self, s: &str) {
self.flush_pending_break();
self.buf.push_str(s);
if !s.is_empty() {
self.fresh_line = false;
}
}
fn render(
&mut self,
readout: ReadoutHandle,
ctx: &dyn ReadoutContext,
lod: Lod,
mode: ContentMode,
options: &ReadoutOptions,
layout: LayoutHint,
) {
match flatten_layout(layout) {
FlatLayoutHint::Inline => {
self.flush_pending_break();
let mut buf = StringBuf::new(&mut self.buf);
readout.render(ctx, lod, mode, options, &mut buf);
self.fresh_line = false;
}
FlatLayoutHint::Block => {
let snapshot_buf_len = self.buf.len();
let snapshot_pending = self.pending_break;
let snapshot_fresh = self.fresh_line;
let inserted_newline = if !self.fresh_line {
self.buf.push('\n');
true
} else {
false
};
let pre_render_len = self.buf.len();
let mut buf = StringBuf::new(&mut self.buf);
readout.render(ctx, lod, mode, options, &mut buf);
if self.buf.len() == pre_render_len {
if inserted_newline {
self.buf.truncate(snapshot_buf_len);
}
self.pending_break = snapshot_pending;
self.fresh_line = snapshot_fresh;
} else {
self.pending_break = true;
self.fresh_line = false;
}
}
}
}
fn line_break(&mut self) {
if !self.fresh_line {
self.buf.push('\n');
}
self.pending_break = false;
self.fresh_line = true;
}
}
impl StringSink {
fn flush_pending_break(&mut self) {
if self.pending_break {
if !self.fresh_line {
self.buf.push('\n');
}
self.pending_break = false;
self.fresh_line = true;
}
}
}
enum FlatLayoutHint {
Inline,
Block,
}
fn flatten_layout(layout: LayoutHint) -> FlatLayoutHint {
match layout {
LayoutHint::InlineCompact => FlatLayoutHint::Inline,
LayoutHint::Block => FlatLayoutHint::Block,
LayoutHint::Focused(inner) => flatten_layout(*inner),
}
}
pub trait ReadoutBinder: Send {
fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink);
fn on_key(&mut self, _key: BinderKey) {}
}
pub struct DefaultBinder {
pub(crate) bindings: HashMap<EventType, Vec<BakedBody>>,
}
impl DefaultBinder {
pub fn new() -> Self {
Self {
bindings: HashMap::new(),
}
}
pub fn bind(&mut self, event: EventType, body: BakedBody) {
self.bindings.entry(event).or_default().push(body);
}
pub fn set(&mut self, event: EventType, body: BakedBody) {
self.bindings.insert(event, vec![body]);
}
pub fn slot_len(&self, event: EventType) -> usize {
self.bindings.get(&event).map(|v| v.len()).unwrap_or(0)
}
pub fn merge(&mut self, other: DefaultBinder) {
for (event, bodies) in other.bindings {
self.bindings.entry(event).or_default().extend(bodies);
}
}
pub fn unbind(&mut self, event: EventType) {
self.bindings.remove(&event);
}
pub fn take_bodies(&mut self, event: EventType) -> Vec<BakedBody> {
self.bindings.remove(&event).unwrap_or_default()
}
}
impl Default for DefaultBinder {
fn default() -> Self {
Self::new()
}
}
impl ReadoutBinder for DefaultBinder {
fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink) {
if let Some(bodies) = self.bindings.get(&event) {
for body in bodies {
body.fire(ctx, ContentMode::Value, sink);
}
}
}
}
pub fn build_binder_from_workload(
bindings: &nmbrs_workload::model::ReadoutsBindings,
defaults: &[(EventType, BakedBody)],
) -> Result<DefaultBinder, String> {
use super::parse::bake;
let mut binder = DefaultBinder::new();
for (event, body) in defaults {
let cloned = BakedBody::from_steps(body.steps.iter().map(clone_step).collect());
validate_body_for_event(&cloned, *event)?;
binder.bind(*event, cloned);
}
let slots: &[(EventType, &[String])] = &[
(
EventType::SessionStart,
bindings.on_session_start.as_slice(),
),
(EventType::SessionEnd, bindings.on_session_end.as_slice()),
(EventType::PhaseStart, bindings.on_phase_start.as_slice()),
(EventType::PhaseEnd, bindings.on_phase_end.as_slice()),
(EventType::EachStart, bindings.on_each_start.as_slice()),
(EventType::EachEnd, bindings.on_each_end.as_slice()),
(EventType::ScopeStart, bindings.on_scope_start.as_slice()),
(EventType::ScopeEnd, bindings.on_scope_end.as_slice()),
(EventType::Update, bindings.on_update.as_slice()),
];
for (event, bodies) in slots {
if bodies.is_empty() {
continue;
}
binder.bindings.remove(event);
for body_str in *bodies {
let (baked, _warnings) =
bake(body_str).map_err(|e| format!("readouts.{}: {e}", event.slot_name()))?;
validate_body_for_event(&baked, *event)?;
binder.bind(*event, baked);
}
}
Ok(binder)
}
pub struct TuiReadoutBinder {
inner: DefaultBinder,
focus: std::collections::HashMap<EventType, Option<usize>>,
lod_overrides: std::collections::HashMap<(EventType, usize), Lod>,
overlay_held: bool,
last_event: Option<EventType>,
}
impl TuiReadoutBinder {
pub fn new() -> Self {
Self {
inner: DefaultBinder::new(),
focus: std::collections::HashMap::new(),
lod_overrides: std::collections::HashMap::new(),
overlay_held: false,
last_event: None,
}
}
pub fn from_default(inner: DefaultBinder) -> Self {
Self {
inner,
focus: std::collections::HashMap::new(),
lod_overrides: std::collections::HashMap::new(),
overlay_held: false,
last_event: None,
}
}
pub fn bind(&mut self, event: EventType, body: BakedBody) {
self.inner.bind(event, body);
}
pub fn slot_len(&self, event: EventType) -> usize {
self.inner.slot_len(event)
}
pub fn focus_for(&self, event: EventType) -> Option<usize> {
self.focus.get(&event).copied().flatten()
}
pub fn overlay_held(&self) -> bool {
self.overlay_held
}
pub fn lod_override(&self, event: EventType, idx: usize) -> Option<Lod> {
self.lod_overrides.get(&(event, idx)).copied()
}
}
impl Default for TuiReadoutBinder {
fn default() -> Self {
Self::new()
}
}
impl ReadoutBinder for TuiReadoutBinder {
fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink) {
self.last_event = Some(event);
let global_held = crate::observer::is_explain_held();
let mode = if self.overlay_held || global_held {
ContentMode::Explanation
} else {
ContentMode::Value
};
let Some(bodies) = self.inner.bindings.get(&event) else {
return;
};
let focus_idx = self.focus.get(&event).copied().flatten();
for (i, body) in bodies.iter().enumerate() {
let override_lod = self.lod_overrides.get(&(event, i)).copied();
let focused = focus_idx == Some(i);
fire_body_with_overrides(body, ctx, mode, sink, override_lod, focused);
}
}
fn on_key(&mut self, key: BinderKey) {
match key {
BinderKey::OverlayHeld(v) => {
self.overlay_held = v;
}
BinderKey::CycleFocusNext => {
self.cycle_focus(1);
}
BinderKey::CycleFocusPrev => {
self.cycle_focus(-1);
}
BinderKey::CycleLodUp => {
self.cycle_focused_lod(1);
}
BinderKey::CycleLodDown => {
self.cycle_focused_lod(-1);
}
}
}
}
impl TuiReadoutBinder {
fn cycle_focus(&mut self, delta: i32) {
let Some(slot) = self.last_event else {
return;
};
let Some(bodies) = self.inner.bindings.get(&slot) else {
return;
};
let len = bodies.len();
if len == 0 {
return;
}
let cur = self.focus.get(&slot).copied().flatten().unwrap_or(0) as i32;
let next = (cur + delta).rem_euclid(len as i32) as usize;
self.focus.insert(slot, Some(next));
}
fn cycle_focused_lod(&mut self, delta: i32) {
let Some(slot) = self.last_event else {
return;
};
let Some(focus_opt) = self.focus.get(&slot).copied() else {
return;
};
let Some(idx) = focus_opt else {
return;
};
let bodies = self.inner.bindings.get(&slot);
let Some(bodies) = bodies else {
return;
};
let baked_lod = bodies
.get(idx)
.and_then(first_render_lod)
.unwrap_or(Lod::Labeled);
let cur = self
.lod_overrides
.get(&(slot, idx))
.copied()
.unwrap_or(baked_lod);
let next = step_lod(cur, delta);
self.lod_overrides.insert((slot, idx), next);
}
}
fn first_render_lod(body: &BakedBody) -> Option<Lod> {
body.steps.iter().find_map(|step| match step {
RenderStep::Render { lod, .. } => Some(*lod),
_ => None,
})
}
fn step_lod(cur: Lod, delta: i32) -> Lod {
let order = [Lod::Compact, Lod::Labeled, Lod::Expanded];
let pos = order.iter().position(|l| *l == cur).unwrap_or(1) as i32;
let next = (pos + delta).rem_euclid(order.len() as i32) as usize;
order[next]
}
fn fire_body_with_overrides(
body: &BakedBody,
ctx: &dyn ReadoutContext,
mode: ContentMode,
sink: &mut dyn ReadoutSink,
override_lod: Option<Lod>,
focused: bool,
) {
walk_body(
body,
ctx,
mode,
sink,
&Overrides {
lod: override_lod,
focused,
},
);
}
pub fn build_event_binder(
bindings: &nmbrs_workload::model::ReadoutsBindings,
event: EventType,
default: BakedBody,
) -> Result<DefaultBinder, String> {
build_event_binder_with_cli(bindings, event, default, None)
}
pub fn build_event_binder_with_cli(
bindings: &nmbrs_workload::model::ReadoutsBindings,
event: EventType,
default: BakedBody,
cli_override: Option<&str>,
) -> Result<DefaultBinder, String> {
use super::parse::bake;
if let Some(body_str) = cli_override
&& event == EventType::Update
{
let workload_bodies = bindings.get(event.slot_name());
if !workload_bodies.is_empty() {
crate::diag!(
crate::observer::LogLevel::Warn,
"readouts: --readout override [{body}] replaces workload binding {workload:?} \
for slot {slot}. Use a `+` prefix on the override (e.g. `--readout=+x`) \
if you intended to extend rather than replace.",
body = body_str,
workload = workload_bodies,
slot = event.slot_name(),
);
}
let mut binder = DefaultBinder::new();
let stripped = body_str.trim_start().strip_prefix('+').unwrap_or(body_str);
let plus_prefix = body_str.trim_start().starts_with('+');
if plus_prefix {
let inner = build_event_binder(bindings, event, default)?;
binder.merge(inner);
let (baked, _) = bake(stripped).map_err(|e| format!("readouts: --readout: {e}"))?;
validate_body_for_event(&baked, event)?;
binder.bind(event, baked);
} else {
let (baked, _) = bake(stripped).map_err(|e| format!("readouts: --readout: {e}"))?;
validate_body_for_event(&baked, event)?;
binder.bind(event, baked);
}
return Ok(binder);
}
build_event_binder_inner(bindings, event, default)
}
fn build_event_binder_inner(
bindings: &nmbrs_workload::model::ReadoutsBindings,
event: EventType,
default: BakedBody,
) -> Result<DefaultBinder, String> {
use super::parse::bake;
let mut binder = DefaultBinder::new();
let bodies = bindings.get(event.slot_name());
if bodies.is_empty() {
validate_body_for_event(&default, event)?;
binder.bind(event, default);
return Ok(binder);
}
let any_plain = bodies.iter().any(|b| !b.trim_start().starts_with('+'));
let any_appended = bodies.iter().any(|b| b.trim_start().starts_with('+'));
if !any_plain && any_appended {
validate_body_for_event(&default, event)?;
binder.bind(event, default);
}
for body_str in bodies {
let stripped = body_str.trim_start().strip_prefix('+').unwrap_or(body_str);
let (baked, _warnings) =
bake(stripped).map_err(|e| format!("readouts.{}: {e}", event.slot_name()))?;
validate_body_for_event(&baked, event)?;
binder.bind(event, baked);
}
Ok(binder)
}
fn clone_step(step: &RenderStep) -> RenderStep {
match step {
RenderStep::Literal(s) => RenderStep::Literal(s.clone()),
RenderStep::Render {
readout,
lod,
layout,
options,
color,
} => RenderStep::Render {
readout: readout.clone(),
lod: *lod,
layout: *layout,
options: options.clone(),
color: color.clone(),
},
RenderStep::ColorDirective(c) => RenderStep::ColorDirective(c.clone()),
}
}
pub fn validate_body_for_event(body: &BakedBody, event: EventType) -> Result<(), String> {
let slot_kind = event.subject_kind();
for step in &body.steps {
if let RenderStep::Render { readout, .. } = step {
let accepted = readout.accepts();
if !accepted.contains(&slot_kind) {
return Err(format!(
"readouts.{slot}: readout '{name}' does not accept \
subject kind {slot_kind:?} (accepts {accepted:?})",
slot = event.slot_name(),
name = readout.name(),
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::readouts::Registry;
#[test]
fn validate_rejects_phase_readout_at_session_slot() {
let bindings = nmbrs_workload::model::ReadoutsBindings {
on_session_end: vec!["phase_status".to_string()],
..Default::default()
};
let res = build_event_binder(&bindings, EventType::SessionEnd, BakedBody::new());
let err = match res {
Ok(_) => panic!("expected validation error"),
Err(e) => e,
};
assert!(err.contains("phase_status"), "{err}");
assert!(err.contains("does not accept"), "{err}");
assert!(err.contains("Session"), "{err}");
}
#[test]
fn validate_accepts_session_summary_at_session_slot() {
let bindings = nmbrs_workload::model::ReadoutsBindings {
on_session_end: vec!["session_summary".to_string()],
..Default::default()
};
assert!(
build_event_binder(&bindings, EventType::SessionEnd, BakedBody::new(),).is_ok(),
"session_summary at on_session_end should be valid"
);
}
#[test]
fn validate_accepts_trace_at_every_slot() {
type SlotCase = (EventType, fn(&mut nmbrs_workload::model::ReadoutsBindings));
let cases: &[SlotCase] = &[
(EventType::SessionEnd, |b| {
b.on_session_end = vec!["trace".into()]
}),
(EventType::PhaseEnd, |b| {
b.on_phase_end = vec!["trace".into()]
}),
(EventType::EachEnd, |b| b.on_each_end = vec!["trace".into()]),
(EventType::ScopeEnd, |b| {
b.on_scope_end = vec!["trace".into()]
}),
];
for (event, set_slot) in cases {
let mut bindings = nmbrs_workload::model::ReadoutsBindings::default();
set_slot(&mut bindings);
assert!(
build_event_binder(&bindings, *event, BakedBody::new()).is_ok(),
"trace at {event:?} should validate"
);
}
}
#[test]
fn default_binder_fires_phase_outcome_at_phase_end() {
struct Ctx;
impl ReadoutContext for Ctx {
fn subject_name(&self) -> &str {
"setup"
}
fn subject_seq(&self) -> Option<(usize, usize)> {
Some((1, 2))
}
fn subject_labels(&self) -> &str {
""
}
fn cycles_completed(&self) -> u64 {
3
}
fn cycles_total(&self) -> u64 {
3
}
fn ops_ok(&self) -> u64 {
3
}
fn errors(&self) -> u64 {
0
}
fn retries(&self) -> u64 {
0
}
fn concurrency(&self) -> usize {
1
}
fn elapsed_secs(&self) -> f64 {
0.01
}
fn consumed(&self) -> u64 {
3
}
fn status_metric_chips(&self) -> String {
String::new()
}
fn depth_indent(&self) -> &str {
""
}
fn use_color(&self) -> bool {
false
}
fn event(&self) -> EventType {
EventType::PhaseEnd
}
}
let mut binder = DefaultBinder::new();
let phase_outcome = Registry::lookup("phase_outcome").unwrap();
binder.set(
EventType::PhaseEnd,
BakedBody::from_single(phase_outcome, Lod::Labeled),
);
let mut sink = StringSink::new();
binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
let out = sink.take();
assert!(out.contains("✓"), "phase_outcome's ✓ missing: {out}");
assert!(out.contains("[setup]"), "phase name missing: {out}");
assert!(!out.contains("[1/2]"), "seq must not appear in body: {out}");
}
#[test]
fn default_binder_dispatches_only_to_matching_event() {
struct Ctx;
impl ReadoutContext for Ctx {
fn subject_name(&self) -> &str {
"x"
}
fn subject_seq(&self) -> Option<(usize, usize)> {
None
}
fn subject_labels(&self) -> &str {
""
}
fn cycles_completed(&self) -> u64 {
0
}
fn cycles_total(&self) -> u64 {
0
}
fn ops_ok(&self) -> u64 {
0
}
fn errors(&self) -> u64 {
0
}
fn retries(&self) -> u64 {
0
}
fn concurrency(&self) -> usize {
1
}
fn elapsed_secs(&self) -> f64 {
0.0
}
fn consumed(&self) -> u64 {
0
}
fn status_metric_chips(&self) -> String {
String::new()
}
fn depth_indent(&self) -> &str {
""
}
fn use_color(&self) -> bool {
false
}
fn event(&self) -> EventType {
EventType::PhaseEnd
}
}
let mut binder = DefaultBinder::new();
let phase_outcome = Registry::lookup("phase_outcome").unwrap();
binder.set(
EventType::PhaseEnd,
BakedBody::from_single(phase_outcome, Lod::Labeled),
);
let mut sink = StringSink::new();
binder.fire(EventType::Update, &Ctx, &mut sink);
assert_eq!(sink.take(), "");
}
#[test]
fn string_sink_block_inserts_newlines() {
let mut sink = StringSink::new();
sink.literal("a");
sink.line_break();
sink.literal("b");
sink.line_break();
sink.line_break();
sink.literal("c");
assert_eq!(sink.take(), "a\nb\nc");
}
#[test]
fn empty_block_render_contributes_no_blank_line() {
use crate::lifecycle::SubjectKind;
use crate::readouts::buf::ReadoutBuf;
struct EmptyReadout;
impl Readout for EmptyReadout {
fn name(&self) -> &'static str {
"empty"
}
fn accepts(&self) -> &'static [SubjectKind] {
&[SubjectKind::Phase]
}
fn render(
&self,
_ctx: &dyn ReadoutContext,
_lod: Lod,
_mode: ContentMode,
_opts: &ReadoutOptions,
_out: &mut dyn ReadoutBuf,
) -> usize {
0
}
}
struct Filler;
impl Readout for Filler {
fn name(&self) -> &'static str {
"filler"
}
fn accepts(&self) -> &'static [SubjectKind] {
&[SubjectKind::Phase]
}
fn render(
&self,
_ctx: &dyn ReadoutContext,
_lod: Lod,
_mode: ContentMode,
_opts: &ReadoutOptions,
out: &mut dyn ReadoutBuf,
) -> usize {
let _ = out.write_str("filler-text");
"filler-text".len()
}
}
struct Ctx;
impl ReadoutContext for Ctx {
fn subject_name(&self) -> &str {
"x"
}
fn subject_seq(&self) -> Option<(usize, usize)> {
None
}
fn subject_labels(&self) -> &str {
""
}
fn cycles_completed(&self) -> u64 {
0
}
fn cycles_total(&self) -> u64 {
0
}
fn ops_ok(&self) -> u64 {
0
}
fn errors(&self) -> u64 {
0
}
fn retries(&self) -> u64 {
0
}
fn concurrency(&self) -> usize {
0
}
fn elapsed_secs(&self) -> f64 {
0.0
}
fn consumed(&self) -> u64 {
0
}
fn status_metric_chips(&self) -> String {
String::new()
}
fn depth_indent(&self) -> &str {
""
}
fn use_color(&self) -> bool {
false
}
fn event(&self) -> EventType {
EventType::PhaseEnd
}
}
let mut sink = StringSink::new();
let opts = ReadoutOptions::new();
let h_filler: ReadoutHandle = std::sync::Arc::new(Filler);
let h_empty: ReadoutHandle = std::sync::Arc::new(EmptyReadout);
sink.render(
h_filler.clone(),
&Ctx,
Lod::Labeled,
ContentMode::Value,
&opts,
LayoutHint::Block,
);
sink.render(
h_empty,
&Ctx,
Lod::Labeled,
ContentMode::Value,
&opts,
LayoutHint::Block,
);
sink.render(
h_filler,
&Ctx,
Lod::Labeled,
ContentMode::Value,
&opts,
LayoutHint::Block,
);
assert_eq!(sink.take(), "filler-text\nfiller-text");
}
#[test]
fn layout_hint_auto_picks_inline_for_compact() {
assert!(matches!(
layout_hint_for(Lod::Compact, ContentMode::Value, LayoutMode::Auto),
LayoutHint::InlineCompact
));
assert!(matches!(
layout_hint_for(Lod::Labeled, ContentMode::Value, LayoutMode::Auto),
LayoutHint::Block
));
assert!(matches!(
layout_hint_for(Lod::Expanded, ContentMode::Value, LayoutMode::Auto),
LayoutHint::Block
));
}
#[test]
fn layout_hint_inline_overrides_lod() {
assert!(matches!(
layout_hint_for(Lod::Expanded, ContentMode::Value, LayoutMode::Inline),
LayoutHint::InlineCompact
));
}
#[test]
fn layout_hint_block_overrides_lod() {
assert!(matches!(
layout_hint_for(Lod::Compact, ContentMode::Value, LayoutMode::Block),
LayoutHint::Block
));
}
fn empty_bindings() -> nmbrs_workload::model::ReadoutsBindings {
nmbrs_workload::model::ReadoutsBindings::default()
}
fn default_phase_outcome() -> BakedBody {
BakedBody::from_single(Registry::lookup("phase_outcome").unwrap(), Lod::Labeled)
}
#[test]
fn no_workload_binding_uses_default() {
let bindings = empty_bindings();
let binder =
build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
}
#[test]
fn plain_workload_binding_replaces_default() {
let mut bindings = empty_bindings();
bindings.on_phase_end = vec!["trace".to_string()];
let binder =
build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
}
#[test]
fn plus_prefix_workload_binding_appends_to_default() {
let mut bindings = empty_bindings();
bindings.on_phase_end = vec!["+trace".to_string()];
let binder =
build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
assert_eq!(binder.slot_len(EventType::PhaseEnd), 2);
}
#[test]
fn multiple_plus_prefix_appends_in_order() {
let mut bindings = empty_bindings();
bindings.on_phase_end = vec!["+trace".to_string(), "+trace".to_string()];
let binder =
build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
assert_eq!(binder.slot_len(EventType::PhaseEnd), 3);
}
#[test]
fn cli_override_replaces_workload_binding_at_update() {
let mut bindings = empty_bindings();
bindings.on_update = vec!["phase_status".to_string()];
let binder = build_event_binder_with_cli(
&bindings,
EventType::Update,
default_phase_outcome(),
Some("trace"),
)
.unwrap();
assert_eq!(binder.slot_len(EventType::Update), 1);
}
#[test]
fn cli_override_plus_prefix_appends_to_workload_resolved() {
let mut bindings = empty_bindings();
bindings.on_update = vec!["phase_status".to_string()];
let binder = build_event_binder_with_cli(
&bindings,
EventType::Update,
default_phase_outcome(),
Some("+trace"),
)
.unwrap();
assert_eq!(binder.slot_len(EventType::Update), 2);
}
#[test]
fn cli_override_only_applies_to_update_slot() {
let bindings = empty_bindings();
let binder = build_event_binder_with_cli(
&bindings,
EventType::PhaseEnd,
default_phase_outcome(),
Some("trace"),
)
.unwrap();
assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
struct Ctx;
impl ReadoutContext for Ctx {
fn subject_name(&self) -> &str {
"x"
}
fn subject_seq(&self) -> Option<(usize, usize)> {
None
}
fn subject_labels(&self) -> &str {
""
}
fn cycles_completed(&self) -> u64 {
0
}
fn cycles_total(&self) -> u64 {
0
}
fn ops_ok(&self) -> u64 {
0
}
fn errors(&self) -> u64 {
0
}
fn retries(&self) -> u64 {
0
}
fn concurrency(&self) -> usize {
1
}
fn elapsed_secs(&self) -> f64 {
0.0
}
fn consumed(&self) -> u64 {
0
}
fn status_metric_chips(&self) -> String {
String::new()
}
fn depth_indent(&self) -> &str {
""
}
fn use_color(&self) -> bool {
false
}
fn event(&self) -> EventType {
EventType::PhaseEnd
}
}
let mut binder_local = binder;
let mut sink = StringSink::new();
binder_local.fire(EventType::PhaseEnd, &Ctx, &mut sink);
let out = sink.take();
assert!(
out.contains("✓"),
"default phase_outcome body should fire: {out}"
);
}
#[test]
fn mixed_plain_and_plus_treats_whole_list_as_replacement() {
let mut bindings = empty_bindings();
bindings.on_phase_end = vec!["trace".to_string(), "+trace".to_string()];
let binder =
build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
assert_eq!(binder.slot_len(EventType::PhaseEnd), 2);
}
fn make_tui_binder_with_two_bodies() -> TuiReadoutBinder {
let mut binder = TuiReadoutBinder::new();
let phase_outcome = Registry::lookup("phase_outcome").unwrap();
let trace = Registry::lookup("trace").unwrap();
binder.bind(
EventType::PhaseEnd,
BakedBody::from_single(phase_outcome, Lod::Labeled),
);
binder.bind(
EventType::PhaseEnd,
BakedBody::from_single(trace, Lod::Labeled),
);
binder
}
#[test]
fn tui_binder_overlay_held_toggles_mode() {
let mut binder = TuiReadoutBinder::new();
assert!(!binder.overlay_held());
binder.on_key(BinderKey::OverlayHeld(true));
assert!(binder.overlay_held());
binder.on_key(BinderKey::OverlayHeld(false));
assert!(!binder.overlay_held());
}
#[test]
fn global_explain_toggle_flips_explanation_mode() {
let _guard = EXPLAIN_GLOBAL_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
std::thread::sleep(std::time::Duration::from_millis(300));
if crate::observer::is_explain_held() {
crate::observer::toggle_explain();
std::thread::sleep(std::time::Duration::from_millis(300));
}
assert!(
!crate::observer::is_explain_held(),
"baseline: explain flag MUST be off"
);
crate::observer::toggle_explain();
assert!(
crate::observer::is_explain_held(),
"first press MUST turn the flag on"
);
std::thread::sleep(std::time::Duration::from_millis(300));
crate::observer::toggle_explain();
assert!(
!crate::observer::is_explain_held(),
"second press MUST turn the flag off"
);
}
static EXPLAIN_GLOBAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn global_explain_toggle_debounces_auto_repeat() {
let _guard = EXPLAIN_GLOBAL_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
std::thread::sleep(std::time::Duration::from_millis(300));
if crate::observer::is_explain_held() {
crate::observer::toggle_explain();
std::thread::sleep(std::time::Duration::from_millis(300));
}
assert!(!crate::observer::is_explain_held(), "baseline: off");
crate::observer::toggle_explain();
assert!(crate::observer::is_explain_held());
crate::observer::toggle_explain();
assert!(
crate::observer::is_explain_held(),
"rapid second press MUST be debounced, leaving state on"
);
std::thread::sleep(std::time::Duration::from_millis(300));
crate::observer::toggle_explain();
}
#[test]
fn tui_binder_focus_cycles_through_slot() {
struct Ctx;
impl ReadoutContext for Ctx {
fn subject_name(&self) -> &str {
"x"
}
fn subject_seq(&self) -> Option<(usize, usize)> {
None
}
fn subject_labels(&self) -> &str {
""
}
fn cycles_completed(&self) -> u64 {
0
}
fn cycles_total(&self) -> u64 {
0
}
fn ops_ok(&self) -> u64 {
0
}
fn errors(&self) -> u64 {
0
}
fn retries(&self) -> u64 {
0
}
fn concurrency(&self) -> usize {
1
}
fn elapsed_secs(&self) -> f64 {
0.0
}
fn consumed(&self) -> u64 {
0
}
fn status_metric_chips(&self) -> String {
String::new()
}
fn depth_indent(&self) -> &str {
""
}
fn use_color(&self) -> bool {
false
}
fn event(&self) -> EventType {
EventType::PhaseEnd
}
}
let mut binder = make_tui_binder_with_two_bodies();
let mut sink = StringSink::new();
binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
assert_eq!(binder.focus_for(EventType::PhaseEnd), None);
binder.on_key(BinderKey::CycleFocusNext);
assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(1));
binder.on_key(BinderKey::CycleFocusNext);
assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(0));
binder.on_key(BinderKey::CycleFocusPrev);
assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(1));
}
#[test]
fn tui_binder_lod_cycle_stamps_override() {
struct Ctx;
impl ReadoutContext for Ctx {
fn subject_name(&self) -> &str {
"x"
}
fn subject_seq(&self) -> Option<(usize, usize)> {
None
}
fn subject_labels(&self) -> &str {
""
}
fn cycles_completed(&self) -> u64 {
0
}
fn cycles_total(&self) -> u64 {
0
}
fn ops_ok(&self) -> u64 {
0
}
fn errors(&self) -> u64 {
0
}
fn retries(&self) -> u64 {
0
}
fn concurrency(&self) -> usize {
1
}
fn elapsed_secs(&self) -> f64 {
0.0
}
fn consumed(&self) -> u64 {
0
}
fn status_metric_chips(&self) -> String {
String::new()
}
fn depth_indent(&self) -> &str {
""
}
fn use_color(&self) -> bool {
false
}
fn event(&self) -> EventType {
EventType::PhaseEnd
}
}
let mut binder = make_tui_binder_with_two_bodies();
let mut sink = StringSink::new();
binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
binder.on_key(BinderKey::CycleFocusNext);
binder.on_key(BinderKey::CycleLodUp);
assert_eq!(
binder.lod_override(EventType::PhaseEnd, 1),
Some(Lod::Expanded)
);
binder.on_key(BinderKey::CycleLodUp);
assert_eq!(
binder.lod_override(EventType::PhaseEnd, 1),
Some(Lod::Compact)
);
binder.on_key(BinderKey::CycleLodDown);
assert_eq!(
binder.lod_override(EventType::PhaseEnd, 1),
Some(Lod::Expanded)
);
}
#[test]
fn step_lod_cycles_three_levels() {
assert_eq!(step_lod(Lod::Compact, 1), Lod::Labeled);
assert_eq!(step_lod(Lod::Labeled, 1), Lod::Expanded);
assert_eq!(step_lod(Lod::Expanded, 1), Lod::Compact);
assert_eq!(step_lod(Lod::Compact, -1), Lod::Expanded);
}
}