use optionstratlib::prelude::{Decimal, Positive};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Clear, Paragraph};
use crate::app::keymap::{Binding, help_sections};
use crate::app::{
App, BundleLoad, LiveScreen, LiveState, Mode, ReplayScreen, ReplayState, ScreenLoad,
is_replay_screen_reachable, live_screen_name, replay_screen_name,
};
use crate::chain::{StreamHealth, TickDir};
use crate::config::ThemeChoice;
use crate::replay::Playback;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum ThemeVariant {
#[default]
Dark,
Light,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Theme {
pub variant: ThemeVariant,
pub no_color: bool,
}
impl Theme {
#[must_use]
pub fn resolve(choice: ThemeChoice, no_color: bool) -> Self {
let variant = match choice {
ThemeChoice::Auto | ThemeChoice::Dark => ThemeVariant::Dark,
ThemeChoice::Light => ThemeVariant::Light,
};
Self { variant, no_color }
}
#[must_use]
fn semantic(self, color: Color, modifier: Modifier) -> Style {
let base = Style::new().add_modifier(modifier);
if self.no_color { base } else { base.fg(color) }
}
#[must_use]
pub fn accent(self) -> Style {
self.semantic(Color::Cyan, Modifier::BOLD)
}
#[must_use]
pub fn dim(self) -> Style {
Style::new().add_modifier(Modifier::DIM)
}
#[must_use]
pub fn warning(self) -> Style {
self.semantic(Color::Yellow, Modifier::BOLD)
}
#[must_use]
pub fn health_style(self, health: &StreamHealth) -> Style {
match health {
StreamHealth::Live => self.semantic(Color::Green, Modifier::BOLD),
StreamHealth::Stale { .. } | StreamHealth::Reconnecting { .. } => {
self.semantic(Color::Yellow, Modifier::BOLD)
}
}
}
#[must_use]
pub fn strike_relation_style(self, rel: StrikeRelation) -> Style {
match rel {
StrikeRelation::BelowSpot => self.semantic(Color::Green, Modifier::empty()),
StrikeRelation::AtSpot => self.semantic(Color::Yellow, Modifier::BOLD),
StrikeRelation::AboveSpot => self.semantic(Color::Red, Modifier::empty()),
}
}
#[must_use]
pub fn tick_dir_style(self, dir: TickDir) -> Style {
match dir {
TickDir::Up => self.semantic(Color::Green, Modifier::empty()),
TickDir::Down => self.semantic(Color::Red, Modifier::empty()),
TickDir::Flat => self.dim(),
}
}
#[must_use]
pub fn pnl_style(self, is_negative: bool) -> Style {
if is_negative {
self.semantic(Color::Red, Modifier::empty())
} else {
self.semantic(Color::Green, Modifier::empty())
}
}
#[must_use]
pub fn book_side_style(self, is_bid: bool) -> Style {
if is_bid {
self.semantic(Color::Green, Modifier::empty())
} else {
self.semantic(Color::Red, Modifier::empty())
}
}
}
pub const AT_SPOT_MARKER: &str = "◀ATM";
pub const ATM_BAND_PERMILLE: i64 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum StrikeRelation {
BelowSpot,
AtSpot,
AboveSpot,
}
impl StrikeRelation {
#[must_use]
pub fn classify(strike: Positive, spot: Positive) -> Self {
let spot_dec = spot.to_dec();
if spot_dec == Decimal::ZERO {
return Self::AtSpot;
}
let band = Decimal::new(ATM_BAND_PERMILLE, 3);
let strike_dec = strike.to_dec();
let lower = Decimal::ONE
.checked_sub(band)
.and_then(|f| spot_dec.checked_mul(f));
let upper = Decimal::ONE
.checked_add(band)
.and_then(|f| spot_dec.checked_mul(f));
let (Some(lower), Some(upper)) = (lower, upper) else {
return Self::AtSpot;
};
if strike_dec < lower {
Self::BelowSpot
} else if strike_dec > upper {
Self::AboveSpot
} else {
Self::AtSpot
}
}
}
#[must_use]
pub fn strike_relation_marker(rel: StrikeRelation) -> &'static str {
match rel {
StrikeRelation::AtSpot => AT_SPOT_MARKER,
StrikeRelation::BelowSpot | StrikeRelation::AboveSpot => "",
}
}
#[must_use]
pub fn tick_dir_glyph(dir: TickDir) -> char {
match dir {
TickDir::Up => '▲',
TickDir::Down => '▼',
TickDir::Flat => '·',
}
}
#[must_use]
pub fn pnl_sign_char(is_negative: bool) -> char {
if is_negative { '−' } else { '+' }
}
#[must_use]
pub fn strike_relation_marker_span(rel: StrikeRelation, theme: Theme) -> Span<'static> {
Span::styled(
strike_relation_marker(rel).to_owned(),
theme.strike_relation_style(rel),
)
}
#[must_use]
pub fn tick_dir_span(dir: TickDir, theme: Theme) -> Span<'static> {
Span::styled(tick_dir_glyph(dir).to_string(), theme.tick_dir_style(dir))
}
#[must_use]
pub fn pnl_sign_span(is_negative: bool, theme: Theme) -> Span<'static> {
Span::styled(
pnl_sign_char(is_negative).to_string(),
theme.pnl_style(is_negative),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum GreekColumn {
Delta,
Gamma,
Theta,
Vega,
}
pub const GREEK_DROP_ORDER: [GreekColumn; 3] =
[GreekColumn::Gamma, GreekColumn::Vega, GreekColumn::Theta];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GreekColumns {
pub delta: bool,
pub gamma: bool,
pub vega: bool,
pub theta: bool,
}
#[must_use]
pub fn greek_columns_for_slots(extra_slots: usize) -> GreekColumns {
GreekColumns {
delta: true,
theta: extra_slots >= 1,
vega: extra_slots >= 2,
gamma: extra_slots >= 3,
}
}
pub const MIN_WIDTH: u16 = 40;
pub const MIN_HEIGHT: u16 = 8;
#[must_use]
pub fn is_too_small(area: Rect) -> bool {
area.width < MIN_WIDTH || area.height < MIN_HEIGHT
}
pub fn draw_too_small(frame: &mut Frame, area: Rect, theme: Theme) {
let text = Text::from(vec![
Line::from(Span::styled("widen the terminal", theme.warning())),
Line::from(Span::styled(
format!(
"have {}x{}, need at least {MIN_WIDTH}x{MIN_HEIGHT}",
area.width, area.height
),
theme.dim(),
)),
]);
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
}
const SPINNER_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
#[must_use]
pub(crate) fn spinner_frame(tick: u64) -> char {
let len = SPINNER_FRAMES.len() as u64;
let idx = (tick % len) as usize;
SPINNER_FRAMES.get(idx).copied().unwrap_or(' ')
}
pub(crate) const SANITIZE_PLACEHOLDER: char = '\u{FFFD}';
#[must_use]
fn is_unsafe_control(c: char) -> bool {
let cp = c as u32;
cp < 0x20 || cp == 0x7f || (0x80..=0x9f).contains(&cp)
}
#[must_use]
pub(crate) fn sanitize(raw: &str) -> String {
raw.chars()
.map(|c| {
if is_unsafe_control(c) {
SANITIZE_PLACEHOLDER
} else {
c
}
})
.collect()
}
#[must_use]
fn health_glyph(health: &StreamHealth) -> char {
match health {
StreamHealth::Live => '●',
StreamHealth::Stale { .. } => '◐',
StreamHealth::Reconnecting { .. } => '↻',
}
}
#[must_use]
fn health_text(health: &StreamHealth) -> String {
match health {
StreamHealth::Live => "live".to_owned(),
StreamHealth::Stale { .. } => "stale".to_owned(),
StreamHealth::Reconnecting { attempt } => format!("reconnecting ({attempt})"),
}
}
#[must_use]
pub fn health_span(health: &StreamHealth, theme: Theme) -> Span<'static> {
Span::styled(
format!("{} {}", health_glyph(health), health_text(health)),
theme.health_style(health),
)
}
#[must_use]
fn live_in_motion(live: &LiveState) -> bool {
matches!(live.load, ScreenLoad::Loading)
|| matches!(live.source.health, StreamHealth::Reconnecting { .. })
}
#[must_use]
fn playback_badge(replay: &ReplayState) -> Option<String> {
match replay.play {
Playback::Playing { speed } => Some(format!("▶ ×{}", speed.multiplier())),
Playback::Paused => match replay.bundle {
BundleLoad::Ready(_) => Some("⏸".to_owned()),
BundleLoad::Loading | BundleLoad::Error { .. } => None,
},
}
}
#[must_use]
pub(crate) fn run_label(replay: &ReplayState) -> String {
replay
.dir
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| replay.dir.to_string_lossy().into_owned())
}
pub fn draw_status(app: &App, frame: &mut Frame, area: Rect, theme: Theme) {
let mut spans = vec![Span::styled("chainview", theme.accent()), Span::raw(" ")];
match &app.mode {
Mode::Live(live) => {
spans.push(Span::styled("live", theme.dim()));
spans.push(Span::raw(" "));
spans.push(Span::raw(sanitize(&live.store.chain().symbol)));
spans.push(Span::raw(" "));
spans.push(Span::raw(sanitize(live.source.provider.as_str())));
spans.push(Span::raw(" "));
spans.push(health_span(&live.source.health, theme));
if live_in_motion(live) {
spans.push(Span::raw(" "));
spans.push(Span::styled(
spinner_frame(app.tick_count).to_string(),
theme.accent(),
));
}
}
Mode::Replay(replay) => {
spans.push(Span::styled("replay", theme.dim()));
spans.push(Span::raw(" "));
spans.push(Span::raw(sanitize(&run_label(replay))));
if matches!(replay.bundle, BundleLoad::Loading) {
spans.push(Span::raw(" "));
spans.push(Span::styled(
spinner_frame(app.tick_count).to_string(),
theme.accent(),
));
}
if let Some(badge) = playback_badge(replay) {
spans.push(Span::raw(" "));
spans.push(Span::styled(badge, theme.accent()));
}
}
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn push_slot(
spans: &mut Vec<Span<'static>>,
slot: u8,
name: &'static str,
reachable: bool,
theme: Theme,
) {
if reachable {
spans.push(Span::styled(format!("{slot} "), theme.accent()));
spans.push(Span::raw(format!("{name} ")));
} else {
spans.push(Span::styled(format!("{slot} ({name}) "), theme.dim()));
}
}
#[must_use]
fn keybar_line(app: &App, theme: Theme) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
match &app.mode {
Mode::Live(live) => {
for (slot, screen) in [
(1u8, LiveScreen::Chain),
(2, LiveScreen::Depth),
(3, LiveScreen::Surface),
(4, LiveScreen::Payoff),
] {
push_slot(
&mut spans,
slot,
live_screen_name(screen),
live.screen_reachable(screen),
theme,
);
}
}
Mode::Replay(_) => {
for (slot, screen) in [(1u8, ReplayScreen::Replay), (2, ReplayScreen::Payoff)] {
push_slot(
&mut spans,
slot,
replay_screen_name(screen),
is_replay_screen_reachable(screen),
theme,
);
}
}
}
spans.push(Span::styled("? help ", theme.dim()));
spans.push(Span::styled("q quit", theme.dim()));
Line::from(spans)
}
pub fn draw_hint(app: &App, frame: &mut Frame, area: Rect, theme: Theme) {
let line = if let Some(hint) = &app.status_hint {
Line::from(vec![
Span::styled("! ", theme.warning()),
Span::styled(sanitize(hint), theme.warning()),
])
} else if app.help_open {
Line::from(Span::styled("?/Esc close help", theme.dim()))
} else {
keybar_line(app, theme)
};
frame.render_widget(Paragraph::new(line), area);
}
const HELP_WIDTH: u16 = 72;
const HELP_LABEL_WIDTH: usize = 10;
fn render_section(
lines: &mut Vec<Line<'static>>,
title: &str,
bindings: &[&'static Binding],
theme: Theme,
) {
lines.push(Line::from(Span::styled(title.to_owned(), theme.accent())));
if bindings.is_empty() {
lines.push(Line::from(Span::styled(
" not available yet".to_owned(),
theme.dim(),
)));
return;
}
for binding in bindings {
let mut spans = vec![
Span::styled(
format!(" {:<HELP_LABEL_WIDTH$} ", binding.keys_label),
theme.accent(),
),
Span::raw(binding.help.to_owned()),
];
if let Some(version) = binding.deferred {
spans.push(Span::styled(format!(" ({version})"), theme.dim()));
}
lines.push(Line::from(spans));
}
}
#[must_use]
fn section_height(bindings: &[&'static Binding]) -> usize {
bindings.len().max(1) + 1
}
#[must_use]
fn render_column(
sections: &[(&'static str, Vec<&'static Binding>)],
theme: Theme,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, (title, bindings)) in sections.iter().enumerate() {
if index > 0 {
lines.push(Line::from(""));
}
render_section(&mut lines, title, bindings, theme);
}
lines
}
pub fn draw_help_overlay(app: &App, frame: &mut Frame, area: Rect, theme: Theme) {
let mut left: Vec<(&'static str, Vec<&'static Binding>)> = Vec::new();
let mut right: Vec<(&'static str, Vec<&'static Binding>)> = Vec::new();
let mut left_h = 0usize;
let mut right_h = 0usize;
for (title, bindings) in help_sections(&app.mode) {
let height = section_height(&bindings);
if left_h <= right_h {
left_h += height;
left.push((title, bindings));
} else {
right_h += height;
right.push((title, bindings));
}
}
let left_lines = render_column(&left, theme);
let right_lines = render_column(&right, theme);
let content = left_lines.len().max(right_lines.len());
let capped = content.min(usize::from(u16::MAX) - 2);
let height = capped as u16 + 2;
let overlay = super::centered_rect(area, HELP_WIDTH, height);
frame.render_widget(Clear, overlay);
let block = Block::bordered().title("Help — ?/Esc to close");
let inner = block.inner(overlay);
frame.render_widget(block, overlay);
let [left_area, right_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(inner);
frame.render_widget(Paragraph::new(Text::from(left_lines)), left_area);
frame.render_widget(Paragraph::new(Text::from(right_lines)), right_area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::tests_support::{live_app_caps, live_app_on, ready_replay_app, replay_app_on};
use crate::app::{LiveScreen, ReplayScreen, ScreenLoad};
use crate::event::{AppEvent, ReplayControl};
use crate::providers::{ChainCapability, GreeksCapability, ProviderCapabilities};
use crate::ui::view::ViewState;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use proptest::prelude::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
#[track_caller]
fn terminal(width: u16, height: u16) -> Terminal<TestBackend> {
match Terminal::new(TestBackend::new(width, height)) {
Ok(t) => t,
Err(e) => panic!("TestBackend construction failed: {e}"),
}
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[track_caller]
fn rendered_frame(app: &App, width: u16, height: u16) -> String {
let mut view = ViewState::new();
view.sync(app);
let mut term = terminal(width, height);
match term.draw(|frame| crate::render(app, &view, frame)) {
Ok(_) => {}
Err(e) => panic!("render failed: {e}"),
}
term.backend()
.buffer()
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
#[test]
fn test_theme_no_color_strikes_color_but_keeps_at_spot_marker() {
let theme = Theme::resolve(ThemeChoice::Auto, true);
let span = strike_relation_marker_span(StrikeRelation::AtSpot, theme);
assert!(
span.style.fg.is_none() && span.style.bg.is_none(),
"NO_COLOR must not set a foreground/background color",
);
assert!(
span.content.contains(AT_SPOT_MARKER),
"the ◀ATM marker must survive NO_COLOR",
);
}
#[test]
fn test_theme_no_color_keeps_tick_and_pnl_markers() {
let theme = Theme::resolve(ThemeChoice::Dark, true);
let up = tick_dir_span(TickDir::Up, theme);
assert!(up.style.fg.is_none() && up.style.bg.is_none());
assert!(up.content.contains('▲'));
let down = tick_dir_span(TickDir::Down, theme);
assert!(down.content.contains('▼'));
let neg = pnl_sign_span(true, theme);
assert!(neg.style.fg.is_none() && neg.style.bg.is_none());
assert!(neg.content.contains('−'));
let pos = pnl_sign_span(false, theme);
assert!(pos.content.contains('+'));
}
#[test]
fn test_theme_with_color_sets_foreground_and_keeps_markers() {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let span = tick_dir_span(TickDir::Up, theme);
assert!(span.style.fg.is_some(), "color mode sets a foreground");
assert!(
span.content.contains('▲'),
"the glyph is present with color too"
);
}
#[test]
fn test_theme_no_color_health_badge_keeps_glyph_and_text() {
let theme = Theme::resolve(ThemeChoice::Auto, true);
let span = health_span(&StreamHealth::Live, theme);
assert!(span.style.fg.is_none() && span.style.bg.is_none());
assert!(span.content.contains('●') && span.content.contains("live"));
}
#[test]
fn test_theme_resolve_maps_choice_to_variant() {
assert_eq!(
Theme::resolve(ThemeChoice::Auto, false).variant,
ThemeVariant::Dark
);
assert_eq!(
Theme::resolve(ThemeChoice::Dark, false).variant,
ThemeVariant::Dark
);
assert_eq!(
Theme::resolve(ThemeChoice::Light, false).variant,
ThemeVariant::Light
);
}
#[track_caller]
fn pos(v: f64) -> Positive {
match Positive::new(v) {
Ok(p) => p,
Err(e) => panic!("invalid test positive {v}: {e}"),
}
}
#[test]
fn test_strike_relation_classify_buckets_below_at_above() {
let spot = pos(60_000.0);
assert_eq!(
StrikeRelation::classify(pos(50_000.0), spot),
StrikeRelation::BelowSpot
);
assert_eq!(
StrikeRelation::classify(pos(60_000.0), spot),
StrikeRelation::AtSpot
);
assert_eq!(
StrikeRelation::classify(pos(70_000.0), spot),
StrikeRelation::AboveSpot
);
assert_eq!(
StrikeRelation::classify(pos(60_100.0), spot),
StrikeRelation::AtSpot
);
}
#[test]
fn test_strike_relation_classify_zero_spot_is_at_spot() {
assert_eq!(
StrikeRelation::classify(pos(60_000.0), pos(0.0)),
StrikeRelation::AtSpot
);
}
#[test]
fn test_greek_columns_for_slots_drop_order_gamma_vega_theta() {
assert_eq!(
greek_columns_for_slots(0),
GreekColumns {
delta: true,
gamma: false,
vega: false,
theta: false
},
);
assert!(greek_columns_for_slots(1).theta);
assert!(!greek_columns_for_slots(1).vega);
assert!(greek_columns_for_slots(2).vega);
assert!(!greek_columns_for_slots(2).gamma);
assert!(greek_columns_for_slots(3).gamma);
assert_eq!(GREEK_DROP_ORDER[0], GreekColumn::Gamma);
}
#[test]
fn test_is_too_small_below_minimum() {
assert!(is_too_small(Rect::new(0, 0, 30, 24)));
assert!(is_too_small(Rect::new(0, 0, 80, 4)));
assert!(!is_too_small(Rect::new(0, 0, 80, 24)));
assert!(!is_too_small(Rect::new(0, 0, MIN_WIDTH, MIN_HEIGHT)));
}
#[test]
fn test_draw_too_small_renders_widen_hint() {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let mut term = terminal(30, 6);
match term.draw(|frame| draw_too_small(frame, frame.area(), theme)) {
Ok(_) => {}
Err(e) => panic!("draw failed: {e}"),
}
let buffer = term.backend().buffer().clone();
let rendered: String = buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(
rendered.contains("widen the terminal"),
"must show the widen hint"
);
}
#[test]
fn test_draw_status_and_hint_render_without_panic() {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let app = live_app_on(LiveScreen::Chain, ScreenLoad::Loading, false);
let mut term = terminal(80, 24);
match term.draw(|frame| {
let area = frame.area();
draw_status(&app, frame, Rect::new(0, 0, area.width, 1), theme);
draw_hint(
&app,
frame,
Rect::new(0, area.height - 1, area.width, 1),
theme,
);
}) {
Ok(_) => {}
Err(e) => panic!("draw failed: {e}"),
}
}
#[test]
fn test_status_bar_playback_badge_shows_speed_and_pause() {
let (mut app, _rx) = ready_replay_app(6);
app.on_event(AppEvent::ReplayControl(ReplayControl::PlayPause));
app.on_event(AppEvent::ReplayControl(ReplayControl::SpeedFaster));
let playing = rendered_frame(&app, 80, 24);
assert!(playing.contains("▶ ×2"), "playing shows ▶ ×2: {playing:?}");
app.on_event(AppEvent::ReplayControl(ReplayControl::PlayPause));
let paused = rendered_frame(&app, 80, 24);
assert!(
paused.contains('⏸'),
"paused-with-bundle shows ⏸: {paused:?}"
);
assert!(!paused.contains("▶ ×"), "paused shows no play badge");
}
#[test]
fn test_status_bar_loading_shows_spinner_not_playback_badge() {
let app = replay_app_on(ReplayScreen::Replay, false);
let text = rendered_frame(&app, 80, 24);
assert!(text.contains('⠋'), "loading shows the spinner (tick 0)");
assert!(
!text.contains('⏸'),
"an unloaded bundle shows no pause badge"
);
assert!(
!text.contains("▶ ×"),
"an unloaded bundle shows no play badge"
);
}
#[test]
fn test_draw_help_overlay_renders_bindings_without_panic() {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let app = live_app_on(LiveScreen::Chain, ScreenLoad::Ready, true);
let mut term = terminal(100, 40);
match term.draw(|frame| draw_help_overlay(&app, frame, frame.area(), theme)) {
Ok(_) => {}
Err(e) => panic!("draw failed: {e}"),
}
}
#[test]
fn test_help_overlay_fits_80x24_shows_every_section() {
let app = live_app_on(LiveScreen::Chain, ScreenLoad::Ready, true);
let text = rendered_frame(&app, 80, 24);
assert!(text.contains("Move strike"), "a Chain key is visible");
assert!(
text.contains("Cycle axis"),
"a Surface key is visible (SF-04: relabeled from \"Greek axis\")",
);
assert!(
text.contains("Commit strategy"),
"a Payoff key (the last section) is visible on 80x24, not clipped",
);
}
#[test]
fn test_help_overlay_marks_deferred_keys_with_version_suffix() {
let app = live_app_on(LiveScreen::Chain, ScreenLoad::Ready, true);
let text = rendered_frame(&app, 100, 40);
assert!(
text.contains("(soon"),
"a deferred key shows its marker in the overlay",
);
}
#[test]
fn test_help_overlay_lists_live_replay_payoff_section() {
let app = replay_app_on(ReplayScreen::Payoff, true);
let text = rendered_frame(&app, 100, 40);
assert!(
text.contains("Payoff"),
"the reachable Payoff screen is listed"
);
assert!(
text.contains("Step forward"),
"the Payoff section lists its scrub keys (no dead 'not available' note)",
);
assert!(
!text.contains("not available"),
"no replay overlay section is a dead 'not available' note",
);
}
fn caps_no_depth() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.depth(false)
.greeks(GreeksCapability::Provided)
.build()
}
#[test]
fn test_dispatch_unavailable_number_key_flashes_hint_and_keeps_screen() {
let mut app = live_app_caps(caps_no_depth());
let before = match &app.mode {
Mode::Live(live) => live.screen,
Mode::Replay(_) => panic!("expected a live app"),
};
let _ = app.dispatch_key_global(press(KeyCode::Char('2')));
let after = match &app.mode {
Mode::Live(live) => live.screen,
Mode::Replay(_) => panic!("expected a live app"),
};
assert_eq!(before, after, "an unavailable number key must not switch");
match &app.status_hint {
Some(hint) => assert!(hint.contains("Depth"), "hint names the screen: {hint}"),
None => panic!("an unavailable number key must flash a hint"),
}
}
#[test]
fn test_dispatch_available_number_key_switches_and_clears_hint() {
let mut app = live_app_caps(caps_no_depth());
let _ = app.dispatch_key_global(press(KeyCode::Char('3')));
match &app.mode {
Mode::Live(live) => assert_eq!(live.screen, LiveScreen::Surface),
Mode::Replay(_) => panic!("expected a live app"),
}
assert!(app.status_hint.is_none(), "a valid switch leaves no hint");
}
#[test]
fn test_dispatch_tab_skips_unavailable_screen() {
let mut app = live_app_caps(caps_no_depth());
let _ = app.dispatch_key_global(press(KeyCode::Tab));
match &app.mode {
Mode::Live(live) => assert_eq!(
live.screen,
LiveScreen::Surface,
"Tab must skip the unavailable Depth screen",
),
Mode::Replay(_) => panic!("expected a live app"),
}
}
#[test]
fn test_dispatch_modal_help_honors_only_toggle_keys() {
let mut app = live_app_caps(caps_no_depth());
let _ = app.dispatch_key_global(press(KeyCode::Char('?')));
assert!(app.help_open);
let screen_before = match &app.mode {
Mode::Live(live) => live.screen,
Mode::Replay(_) => panic!("expected a live app"),
};
let _ = app.dispatch_key_global(press(KeyCode::Char('3')));
assert!(app.help_open, "a swallowed key leaves the overlay open");
assert!(
app.status_hint.is_none(),
"no background hint fires behind help"
);
match &app.mode {
Mode::Live(live) => assert_eq!(
live.screen, screen_before,
"no background screen switch fires behind the overlay",
),
Mode::Replay(_) => panic!("expected a live app"),
}
let _ = app.dispatch_key_global(press(KeyCode::Char('q')));
assert!(
!app.should_quit,
"q does not quit while the overlay is modal"
);
let _ = app.dispatch_key_global(press(KeyCode::Esc));
assert!(!app.help_open, "Esc closes the overlay");
}
#[test]
fn test_render_below_min_size_shows_widen_hint() {
let app = live_app_on(LiveScreen::Chain, ScreenLoad::Ready, false);
let mut view = ViewState::new();
view.sync(&app);
let mut term = terminal(30, 6);
match term.draw(|frame| crate::render(&app, &view, frame)) {
Ok(_) => {}
Err(e) => panic!("render failed: {e}"),
}
let buffer = term.backend().buffer().clone();
let rendered: String = buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(
rendered.contains("widen the terminal"),
"render must show the widen hint below the minimum size",
);
}
#[test]
fn test_sanitize_replaces_c0_controls_including_esc_tab_newline() {
let raw = "a\u{0}b\tc\nd\re\u{7}f\u{1b}g";
let out = sanitize(raw);
assert!(!out.contains('\u{1b}'), "no ESC survives");
assert!(!out.contains('\t') && !out.contains('\n') && !out.contains('\r'));
assert!(!out.contains('\u{7}'), "no BEL survives");
assert_eq!(
out.chars()
.filter(char::is_ascii_alphabetic)
.collect::<String>(),
"abcdefg",
"the printable letters survive, in order",
);
assert_eq!(
out.chars().filter(|&c| c == SANITIZE_PLACEHOLDER).count(),
6,
"each of the six C0 controls becomes one placeholder",
);
}
#[test]
fn test_sanitize_replaces_c1_controls_including_8bit_introducers() {
let raw = "x\u{9b}\u{9d}\u{90}\u{85}y";
let out = sanitize(raw);
for c in ['\u{9b}', '\u{9d}', '\u{90}', '\u{85}'] {
assert!(!out.contains(c), "no C1 control {:#x} survives", c as u32);
}
assert_eq!(
out.chars().filter(|&c| c == SANITIZE_PLACEHOLDER).count(),
4,
);
assert!(out.starts_with('x') && out.ends_with('y'));
}
#[test]
fn test_sanitize_neutralizes_osc_csi_dcs_sequences_to_inert_text() {
let raw = "\u{1b}]52;c;cHduZWQ=\u{7}\u{1b}[2J\u{1b}Pq#0;2\u{1b}\\";
let out = sanitize(raw);
assert!(!out.contains('\u{1b}'), "no ESC introducer survives");
assert!(
out.contains("52;c;cHduZWQ="),
"the OSC params are inert text"
);
assert!(out.contains("[2J"), "the CSI params are inert text");
}
#[test]
fn test_sanitize_preserves_printable_ascii_and_unicode_glyphs() {
let raw = "BTC-27JUN25-60000-C ◀ATM ▲▼· — Γ Δ 49.22%";
assert_eq!(sanitize(raw), raw);
}
#[test]
fn test_is_unsafe_control_matches_c0_del_c1_ranges() {
assert!(is_unsafe_control('\u{0}') && is_unsafe_control('\u{1b}'));
assert!(is_unsafe_control('\u{7f}'), "DEL is unsafe");
assert!(is_unsafe_control('\u{80}') && is_unsafe_control('\u{9f}'));
assert!(!is_unsafe_control(' ') && !is_unsafe_control('A'));
assert!(!is_unsafe_control('◀') && !is_unsafe_control('—'));
}
proptest! {
#[test]
fn prop_sanitize_output_has_no_control_or_introducer(
chars in proptest::collection::vec(any::<char>(), 0..64),
) {
let raw: String = chars.into_iter().collect();
let out = sanitize(&raw);
for c in out.chars() {
let cp = c as u32;
prop_assert!(cp >= 0x20, "C0 control 0x{cp:x} leaked");
prop_assert!(cp != 0x7f, "DEL leaked");
prop_assert!(!(0x80..=0x9f).contains(&cp), "C1 control 0x{cp:x} leaked");
}
for b in out.bytes() {
prop_assert_ne!(b, 0x1b, "raw ESC byte leaked");
}
}
#[test]
fn prop_sanitize_arbitrary_bytes_are_display_safe(
bytes in proptest::collection::vec(any::<u8>(), 0..96),
) {
let raw = String::from_utf8_lossy(&bytes);
let out = sanitize(&raw);
for c in out.chars() {
let cp = c as u32;
prop_assert!(
cp >= 0x20 && cp != 0x7f && !(0x80..=0x9f).contains(&cp),
"control 0x{cp:x} leaked",
);
}
}
}
}