use crossterm::event::KeyEvent;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::symbols::Marker;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph};
use crate::app::keymap::{KeyChord, SurfaceAction, resolve_surface};
use crate::app::{LiveState, ScreenLoad, SurfaceAxis, SurfaceView};
use crate::chain::StreamHealth;
use crate::event::AppEvent;
use crate::ui::graph::{EmptyReason, GraphProjection, ProjectedSeries, ProjectedSurface};
use crate::ui::theme::{Theme, health_span, sanitize, spinner_frame};
const RAMP: [char; 7] = ['·', ':', '+', '*', '#', '%', '@'];
const EM_DASH: &str = "—";
pub fn draw(
state: &LiveState,
surface: &GraphProjection,
frame: &mut Frame,
area: Rect,
theme: Theme,
tick: u64,
) {
let panel = &state.surface;
let health = state.overlay_aware_health();
let stale = !matches!(health, StreamHealth::Live);
let block = Block::bordered().title(title_line(panel.view(), panel.axis(), theme, health));
let inner = block.inner(area);
frame.render_widget(block, area);
match &state.load {
ScreenLoad::Loading => {
draw_state_body(
frame,
inner,
Text::from(vec![
Line::from(Span::styled(
format!(
"{} connecting to {}…",
spinner_frame(tick),
sanitize(state.source.provider.as_str())
),
theme.accent(),
)),
Line::from(Span::styled("waiting for the first chain", theme.dim())),
]),
);
}
ScreenLoad::Error { message } => {
draw_state_body(
frame,
inner,
Text::from(vec![
Line::from(Span::styled(
format!("! {}", sanitize(message)),
theme.warning(),
)),
Line::from(Span::styled("press r to reconnect", theme.dim())),
]),
);
}
ScreenLoad::Ready => match panel.view() {
SurfaceView::Smile => {
draw_series(frame, inner, theme, surface, "strike", "IV", true, stale)
}
SurfaceView::Curve => draw_series(
frame,
inner,
theme,
surface,
"strike",
panel.axis().label(),
panel.axis() == SurfaceAxis::Volatility,
stale,
),
SurfaceView::Surface => draw_surface(frame, inner, theme, panel.axis(), surface, stale),
},
}
}
#[must_use]
fn title_line(
view: SurfaceView,
axis: SurfaceAxis,
theme: Theme,
health: &StreamHealth,
) -> Line<'static> {
let mut spans = vec![
Span::styled("Surface", theme.accent()),
Span::styled(format!(" {}", view.label()), theme.dim()),
];
match view {
SurfaceView::Smile => spans.push(Span::styled(
format!(" axis: {} (curve/surface)", axis.label()),
theme.dim(),
)),
SurfaceView::Curve => {
spans.push(Span::styled(format!(" {}", axis.label()), theme.accent()));
}
SurfaceView::Surface => {
if axis == SurfaceAxis::Volatility {
spans.push(Span::styled(" IV (n/a)", theme.warning()));
} else {
spans.push(Span::styled(format!(" {}", axis.label()), theme.accent()));
}
}
}
if !matches!(health, StreamHealth::Live) {
spans.push(Span::raw(" "));
spans.push(health_span(health, theme));
}
Line::from(spans)
}
#[allow(clippy::too_many_arguments)]
fn draw_series(
frame: &mut Frame,
inner: Rect,
theme: Theme,
projection: &GraphProjection,
x_title: &str,
y_title: &str,
y_percent: bool,
stale: bool,
) {
let Some(series) = projection.ready() else {
draw_state_body(
frame,
inner,
insufficient_text(theme, projection.empty_reason()),
);
return;
};
draw_chart(
frame, inner, theme, series, x_title, y_title, y_percent, stale,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_chart(
frame: &mut Frame,
inner: Rect,
theme: Theme,
series: &ProjectedSeries,
x_title: &str,
y_title: &str,
y_percent: bool,
stale: bool,
) {
let series_style = if stale { theme.dim() } else { theme.accent() };
let dataset = Dataset::default()
.name(series.name().to_owned())
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(series_style)
.data(series.points());
let y_labels = if y_percent {
percent_labels(series.y_bounds())
} else {
raw_labels(series.y_labels())
};
let chart = Chart::new(vec![dataset])
.x_axis(
Axis::default()
.title(x_title.to_owned())
.bounds(series.x_bounds())
.labels(raw_labels(series.x_labels()))
.style(theme.dim()),
)
.y_axis(
Axis::default()
.title(y_title.to_owned())
.bounds(series.y_bounds())
.labels(y_labels)
.style(theme.dim()),
);
frame.render_widget(chart, inner);
}
fn draw_surface(
frame: &mut Frame,
inner: Rect,
theme: Theme,
axis: SurfaceAxis,
projection: &GraphProjection,
stale: bool,
) {
if axis == SurfaceAxis::Volatility {
draw_state_body(
frame,
inner,
Text::from(vec![
Line::from(Span::styled(
"IV has no surface — the 3D z is a Greek/Price",
theme.warning(),
)),
Line::from(Span::styled("press x for the IV smile", theme.dim())),
]),
);
return;
}
let Some(surface) = projection.ready_surface() else {
draw_state_body(
frame,
inner,
insufficient_text(theme, projection.empty_reason()),
);
return;
};
draw_heatmap(frame, inner, theme, surface, stale);
}
fn draw_heatmap(
frame: &mut Frame,
inner: Rect,
theme: Theme,
surface: &ProjectedSurface,
stale: bool,
) {
let [header, body, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(inner);
let header_line = Line::from(vec![
Span::styled(format!("z {} ", sanitize(surface.name())), theme.accent()),
Span::styled(
format!(
"[{} .. {}]",
label_at(surface.z_labels(), 0),
label_at(surface.z_labels(), 2)
),
theme.dim(),
),
Span::styled(
format!(
" vol {}% top .. {}% bottom",
fmt_percent(surface.y_bounds()[1]),
fmt_percent(surface.y_bounds()[0])
),
theme.dim(),
),
]);
frame.render_widget(Paragraph::new(header_line), header);
let rows = surface.rows();
let width = usize::from(body.width);
let height = usize::from(body.height);
let col_count = rows.first().map_or(0, Vec::len);
let row_idx = sample_indices(rows.len(), height);
let col_idx = sample_indices(col_count, width);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(row_idx.len());
for r in &row_idx {
let Some(row) = rows.get(*r) else { continue };
let mut spans: Vec<Span<'static>> = Vec::with_capacity(col_idx.len());
for c in &col_idx {
let cell = row.get(*c).copied().flatten();
spans.push(cell_span(cell, theme, stale));
}
lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(Text::from(lines)), body);
let ramp: String = RAMP.iter().collect();
let footer_line = Line::from(vec![
Span::styled(
format!(
"strike {} .. {}",
label_at(surface.x_labels(), 0),
label_at(surface.x_labels(), 2)
),
theme.dim(),
),
Span::styled(format!(" low {ramp} high"), theme.dim()),
]);
frame.render_widget(Paragraph::new(footer_line), footer);
}
#[must_use]
fn cell_span(cell: Option<f64>, theme: Theme, stale: bool) -> Span<'static> {
match cell {
Some(intensity) => {
let glyph = ramp_glyph(intensity);
let style = if stale {
theme.dim()
} else if intensity >= 0.66 {
theme.accent()
} else if intensity >= 0.33 {
theme.warning()
} else {
theme.dim()
};
Span::styled(glyph.to_string(), style)
}
None => Span::raw(" "),
}
}
#[must_use]
fn ramp_glyph(intensity: f64) -> char {
let last = RAMP.len().saturating_sub(1);
let clamped = intensity.clamp(0.0, 1.0);
let idx = (clamped * last as f64).round();
let idx = if idx.is_finite() { idx as usize } else { 0 };
RAMP.get(idx.min(last)).copied().unwrap_or(' ')
}
#[must_use]
fn sample_indices(len: usize, max: usize) -> Vec<usize> {
if len == 0 || max == 0 {
return Vec::new();
}
if len <= max {
return (0..len).collect();
}
let mut out = Vec::with_capacity(max);
for i in 0..max {
let numerator = i.checked_mul(len.max(1) - 1).unwrap_or(0);
let denominator = (max - 1).max(1);
out.push((numerator / denominator).min(len - 1));
}
out.dedup();
out
}
#[must_use]
fn insufficient_text(theme: Theme, reason: Option<EmptyReason>) -> Text<'static> {
let (primary, hint) = match reason {
Some(EmptyReason::NoData) | None => (
"insufficient IV for this expiry",
Some("no reliable IV samples yet — press r to reconnect"),
),
Some(EmptyReason::Degenerate) => (
"degenerate geometry — cannot build this view",
Some("the expiry did not price — press r to reconnect"),
),
Some(EmptyReason::Unsupported) => ("this view is not available", None),
};
let mut lines = vec![Line::from(Span::styled(
primary.to_owned(),
theme.warning(),
))];
if let Some(hint) = hint {
lines.push(Line::from(Span::styled(hint.to_owned(), theme.dim())));
}
Text::from(lines)
}
fn draw_state_body(frame: &mut Frame, inner: Rect, text: Text<'static>) {
let height = u16::try_from(text.height())
.unwrap_or(u16::MAX)
.min(inner.height);
let [centered] = Layout::vertical([Constraint::Length(height)])
.flex(Flex::Center)
.areas(inner);
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), centered);
}
#[must_use]
fn raw_labels(labels: &[String]) -> Vec<Span<'static>> {
labels.iter().map(|l| Span::raw(l.clone())).collect()
}
#[must_use]
fn percent_labels(bounds: [f64; 2]) -> Vec<Span<'static>> {
let [lo, hi] = bounds;
let mid = lo + (hi - lo) / 2.0;
vec![
Span::raw(format!("{}%", fmt_percent(lo))),
Span::raw(format!("{}%", fmt_percent(mid))),
Span::raw(format!("{}%", fmt_percent(hi))),
]
}
#[must_use]
fn fmt_percent(fraction: f64) -> String {
if fraction.is_finite() {
format!("{:.1}", fraction * 100.0)
} else {
EM_DASH.to_owned()
}
}
#[must_use]
fn label_at(labels: &[String], idx: usize) -> &str {
labels.get(idx).map_or(EM_DASH, String::as_str)
}
#[must_use]
pub fn handle_key(state: &mut LiveState, key: KeyEvent) -> Option<AppEvent> {
let chord = KeyChord::from_event(key)?;
match resolve_surface(chord)? {
SurfaceAction::CycleGreek => {
let forward = chord != KeyChord::Char('G');
let LiveState { store, surface, .. } = state;
surface.cycle_axis(forward, store);
None
}
SurfaceAction::ToggleView => {
let LiveState { store, surface, .. } = state;
surface.cycle_view(store);
None
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use chrono::{DateTime, Utc};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::Positive;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use super::{cell_span, draw, handle_key, insufficient_text, ramp_glyph, sample_indices};
use crate::app::{
LiveState, OverlayBinding, ScreenLoad, SourceBinding, SurfaceAxis, SurfaceView,
};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSource, ChainStore, ExpirySource, ProviderId, StreamHealth,
};
use crate::config::ThemeChoice;
use crate::providers::{ChainCapability, GreeksCapability, ProviderCapabilities};
use crate::ui::golden::buffer_to_text;
use crate::ui::graph::{EmptyReason, GraphProjection, project};
use crate::ui::theme::Theme;
const EXP: i64 = 1_700_000_000;
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("invalid provider id `{id}`: {e}"),
}
}
#[track_caller]
fn utc(secs: i64) -> DateTime<Utc> {
match DateTime::<Utc>::from_timestamp(secs, 0) {
Some(t) => t,
None => panic!("invalid test timestamp: {secs}"),
}
}
#[track_caller]
fn pos(value: f64) -> Positive {
match Positive::new(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive `{value}`: {e}"),
}
}
fn full_row(strike: f64) -> OptionData {
let mut od = OptionData {
strike_price: pos(strike),
call_bid: Some(pos(3_000.0)),
call_ask: Some(pos(3_100.0)),
put_bid: Some(pos(2_000.0)),
put_ask: Some(pos(2_100.0)),
implied_volatility: pos(0.5),
..Default::default()
};
od.set_mid_prices();
od
}
fn caps() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.greeks(GreeksCapability::Provided)
.build()
}
fn store_from(chain: OptionChain) -> ChainStore {
ChainStore::seed(
ChainFetch::new(
chain,
ExpirySource::new("BTC", utc(EXP), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(EXP),
)
}
fn live_state(load: ScreenLoad) -> LiveState {
let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let _ = chain.options.insert(full_row(60_000.0));
let _ = chain.options.insert(full_row(62_000.0));
let _ = chain.options.insert(full_row(64_000.0));
let mut state = LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store_from(chain),
);
state.screen = crate::app::LiveScreen::Surface;
state.load = load;
state
}
fn empty_iv_state() -> LiveState {
let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
let _ = chain.options.insert(OptionData {
strike_price: pos(60_000.0),
implied_volatility: Positive::ZERO,
..Default::default()
});
let mut state = LiveState::new(
SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
store_from(chain),
);
state.screen = crate::app::LiveScreen::Surface;
state.load = ScreenLoad::Ready;
state
}
#[must_use]
fn projection(state: &LiveState) -> GraphProjection {
project(state.surface.active_graph_data())
}
#[track_caller]
fn render(state: &LiveState, width: u16, height: u16) {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let proj = projection(state);
let mut term = match Terminal::new(TestBackend::new(width, height)) {
Ok(t) => t,
Err(e) => panic!("TestBackend construction failed: {e}"),
};
let area = Rect::new(0, 0, width, height);
match term.draw(|frame| draw(state, &proj, frame, area, theme, 0)) {
Ok(_) => {}
Err(e) => panic!("surface draw failed: {e}"),
}
}
#[track_caller]
fn render_text(state: &LiveState, width: u16, height: u16) -> String {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let proj = projection(state);
let mut term = match Terminal::new(TestBackend::new(width, height)) {
Ok(t) => t,
Err(e) => panic!("TestBackend construction failed: {e}"),
};
let area = Rect::new(0, 0, width, height);
match term.draw(|frame| draw(state, &proj, frame, area, theme, 0)) {
Ok(_) => {}
Err(e) => panic!("surface draw failed: {e}"),
}
buffer_to_text(term.backend().buffer())
}
#[track_caller]
fn title_row(state: &LiveState, width: u16, height: u16) -> String {
render_text(state, width, height)
.lines()
.next()
.unwrap_or_default()
.to_owned()
}
fn press(state: &mut LiveState, code: KeyCode) {
let _ = handle_key(state, KeyEvent::new(code, KeyModifiers::NONE));
}
#[track_caller]
fn smile_line_is_accent(state: &LiveState, width: u16, height: u16) -> bool {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let proj = projection(state);
let mut term = match Terminal::new(TestBackend::new(width, height)) {
Ok(t) => t,
Err(e) => panic!("TestBackend construction failed: {e}"),
};
let area = Rect::new(0, 0, width, height);
match term.draw(|frame| draw(state, &proj, frame, area, theme, 0)) {
Ok(_) => {}
Err(e) => panic!("surface draw failed: {e}"),
}
let buffer = term.backend().buffer();
for y in 0..height {
for x in 0..width {
if let Some(cell) = buffer.cell((x, y))
&& cell
.symbol()
.chars()
.any(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
&& cell.fg == Color::Cyan
{
return true;
}
}
}
false
}
#[test]
fn test_render_loading_ready_error_and_empty_states() {
render(&live_state(ScreenLoad::Loading), 120, 40);
render(
&live_state(ScreenLoad::Error {
message: "provider unreachable".to_owned(),
}),
120,
40,
);
let mut ready = live_state(ScreenLoad::Ready);
render(&ready, 120, 40); press(&mut ready, KeyCode::Char('x')); render(&ready, 120, 40);
press(&mut ready, KeyCode::Char('x')); render(&ready, 120, 40);
render(&empty_iv_state(), 120, 40);
render(&live_state(ScreenLoad::Ready), 40, 8);
}
#[test]
fn test_stale_health_badges_the_surface_title() {
let mut state = live_state(ScreenLoad::Ready);
state.source.health = StreamHealth::Stale { since: utc(EXP) };
let title = title_row(&state, 120, 40);
assert!(
title.contains("stale"),
"the stale badge renders in the surface title: {title:?}",
);
let text = render_text(&state, 120, 40);
assert!(
text.contains("strike"),
"the last smile still renders: {text:?}"
);
}
#[test]
fn test_reconnecting_health_badges_the_surface_title() {
let mut state = live_state(ScreenLoad::Ready);
state.source.health = StreamHealth::Reconnecting { attempt: 3 };
let title = title_row(&state, 120, 40);
assert!(
title.contains("reconnecting"),
"the reconnecting badge renders in the surface title: {title:?}",
);
}
#[test]
fn test_live_health_shows_no_surface_badge() {
let title = title_row(&live_state(ScreenLoad::Ready), 120, 40);
assert!(
!title.contains("stale") && !title.contains("reconnecting"),
"a live feed shows no health badge: {title:?}",
);
}
#[test]
fn test_stale_overlay_dims_and_badges_the_surface_when_source_is_live() {
let state = live_state(ScreenLoad::Ready).with_overlay(OverlayBinding::new(
pid("dxlink"),
caps(),
StreamHealth::Stale { since: utc(EXP) },
));
assert!(
matches!(state.source.health, StreamHealth::Live),
"the chain source stays live",
);
let title = title_row(&state, 120, 40);
assert!(
title.contains("stale"),
"a stale overlay badges the surface title: {title:?}",
);
let text = render_text(&state, 120, 40);
assert!(
text.contains("strike"),
"the last smile still renders: {text:?}"
);
assert!(
!smile_line_is_accent(&state, 120, 40),
"a stale overlay dims the surface line (not accent-colored)",
);
}
#[test]
fn test_live_overlay_leaves_the_surface_bright() {
let state = live_state(ScreenLoad::Ready).with_overlay(OverlayBinding::new(
pid("dxlink"),
caps(),
StreamHealth::Live,
));
let title = title_row(&state, 120, 40);
assert!(
!title.contains("stale") && !title.contains("reconnecting"),
"a live source + live overlay shows no badge: {title:?}",
);
assert!(
smile_line_is_accent(&state, 120, 40),
"a fully-live surface renders its line bright (accent)",
);
}
#[test]
fn test_g_cycles_axis_forward_and_shift_g_back() {
let mut state = live_state(ScreenLoad::Ready);
assert_eq!(state.surface.axis(), SurfaceAxis::Delta);
press(&mut state, KeyCode::Char('g'));
assert_eq!(
state.surface.axis(),
SurfaceAxis::Gamma,
"`g` advances the axis"
);
press(&mut state, KeyCode::Char('G'));
assert_eq!(state.surface.axis(), SurfaceAxis::Delta, "`G` steps back");
}
#[test]
fn test_x_cycles_the_view_smile_curve_surface() {
let mut state = live_state(ScreenLoad::Ready);
assert_eq!(state.surface.view(), SurfaceView::Smile);
press(&mut state, KeyCode::Char('x'));
assert_eq!(state.surface.view(), SurfaceView::Curve);
press(&mut state, KeyCode::Char('x'));
assert_eq!(state.surface.view(), SurfaceView::Surface);
press(&mut state, KeyCode::Char('x'));
assert_eq!(
state.surface.view(),
SurfaceView::Smile,
"cycles back to the smile"
);
}
#[test]
fn test_active_projection_shape_matches_the_view() {
let mut state = live_state(ScreenLoad::Ready);
assert!(
projection(&state).ready().is_some(),
"the smile projects a series"
);
press(&mut state, KeyCode::Char('x')); assert!(
projection(&state).ready().is_some(),
"the curve projects a series"
);
press(&mut state, KeyCode::Char('x')); assert!(
projection(&state).ready_surface().is_some(),
"the surface projects a heat-map grid",
);
}
#[test]
fn test_empty_iv_expiry_routes_to_empty_projection() {
let state = empty_iv_state();
assert!(
projection(&state).empty_reason().is_some(),
"no reliable IV → the insufficient-IV empty state, not a fabricated curve",
);
}
#[test]
fn test_surface_refuses_volatility_axis_but_renders_the_honest_state() {
let mut state = live_state(ScreenLoad::Ready);
press(&mut state, KeyCode::Char('x')); press(&mut state, KeyCode::Char('x')); for _ in 0..4 {
press(&mut state, KeyCode::Char('g'));
}
assert_eq!(state.surface.axis(), SurfaceAxis::Volatility);
assert!(
projection(&state).ready_surface().is_none(),
"the Volatility axis yields no surface geometry",
);
render(&state, 120, 40); }
#[test]
fn test_g_moves_the_smile_title_axis_hint() {
let mut state = live_state(ScreenLoad::Ready);
assert_eq!(state.surface.view(), SurfaceView::Smile);
let before = title_row(&state, 120, 40);
assert!(
before.contains("axis:"),
"the smile title carries the axis hint, got {before:?}",
);
assert!(before.contains('Δ'), "the pending axis starts at delta");
press(&mut state, KeyCode::Char('g'));
let after = title_row(&state, 120, 40);
assert_ne!(before, after, "pressing g moves the smile title axis hint");
assert!(
after.contains('Γ'),
"the hint advanced to gamma, got {after:?}"
);
}
#[test]
fn test_surface_refused_axis_title_is_annotated_not_an_iv_surface() {
let mut state = live_state(ScreenLoad::Ready);
press(&mut state, KeyCode::Char('x')); press(&mut state, KeyCode::Char('x')); for _ in 0..4 {
press(&mut state, KeyCode::Char('g')); }
assert_eq!(state.surface.axis(), SurfaceAxis::Volatility);
let title = title_row(&state, 120, 40);
assert!(
title.contains("n/a"),
"the refused surface axis is annotated n/a, got {title:?}",
);
}
#[test]
fn test_heatmap_header_notes_the_vol_axis_direction() {
let mut state = live_state(ScreenLoad::Ready);
press(&mut state, KeyCode::Char('x')); press(&mut state, KeyCode::Char('x')); let text = render_text(&state, 120, 40);
assert!(
text.contains("top") && text.contains("bottom"),
"the header marks the vol direction top/bottom",
);
}
#[test]
fn test_ramp_glyph_maps_intensity_to_light_then_dense() {
assert_eq!(
ramp_glyph(0.0),
'·',
"a present minimum-intensity cell is `·`, not a blank",
);
assert_eq!(
ramp_glyph(1.0),
'@',
"the highest intensity is the densest glyph"
);
assert_eq!(ramp_glyph(-5.0), '·');
assert_eq!(ramp_glyph(5.0), '@');
assert_eq!(ramp_glyph(f64::NAN), '·', "a non-finite intensity is safe");
}
#[test]
fn test_cell_span_present_min_is_dot_and_gap_is_space() {
let theme = Theme::resolve(ThemeChoice::Auto, false);
assert_eq!(
cell_span(Some(0.0), theme, false).content.as_ref(),
"·",
"a present minimum cell has ink",
);
assert_eq!(
cell_span(None, theme, false).content.as_ref(),
" ",
"a data gap is a blank space",
);
}
#[test]
fn test_cell_span_dims_the_heat_glyph_when_stale() {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let bright = cell_span(Some(1.0), theme, false);
assert!(
bright.style.fg.is_some() && bright.style.add_modifier.contains(Modifier::BOLD),
"a live max-intensity cell is bright accent (color + bold)",
);
let stale = cell_span(Some(1.0), theme, true);
assert!(
stale.style.fg.is_none() && stale.style.add_modifier.contains(Modifier::DIM),
"a stale max-intensity cell dims (no color, DIM modifier)",
);
assert_eq!(
stale.content.as_ref(),
"@",
"the densest ramp glyph still carries the structure when stale",
);
assert_eq!(cell_span(None, theme, true).content.as_ref(), " ");
}
#[test]
fn test_insufficient_text_hints_are_reason_specific() {
let theme = Theme::resolve(ThemeChoice::Auto, false);
let flatten = |reason: Option<EmptyReason>| -> String {
insufficient_text(theme, reason)
.lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect::<Vec<_>>()
.join(" ")
};
let nodata = flatten(Some(EmptyReason::NoData));
assert!(
nodata.contains("insufficient IV"),
"NoData names the IV cause"
);
assert!(
nodata.contains("reconnect"),
"NoData keeps the reconnect hint"
);
let degenerate = flatten(Some(EmptyReason::Degenerate));
assert!(
degenerate.contains("degenerate geometry"),
"Degenerate is a distinguishable state",
);
assert!(
!degenerate.contains("insufficient IV"),
"Degenerate is not folded into the insufficient-IV state (P3-01)",
);
let unsupported = flatten(Some(EmptyReason::Unsupported));
assert!(
unsupported.contains("not available"),
"Unsupported names itself"
);
assert!(
!unsupported.contains("reconnect"),
"Unsupported drops the misleading reconnect hint (P3-03)",
);
}
#[test]
fn test_sample_indices_fits_and_downsamples() {
assert_eq!(sample_indices(3, 10), vec![0, 1, 2], "fits → all indices");
assert!(sample_indices(0, 10).is_empty());
assert!(sample_indices(10, 0).is_empty());
let sampled = sample_indices(100, 5);
assert!(sampled.len() <= 5, "downsampled to at most `max`");
assert!(sampled.iter().all(|&i| i < 100), "every index is in range");
assert_eq!(
sampled.first().copied(),
Some(0),
"starts at the first column"
);
}
#[test]
fn test_draw_reads_cached_projection_builds_no_graphdata() {
let state = live_state(ScreenLoad::Ready);
let before = state.surface.active_graph_data().clone();
render(&state, 120, 40);
assert_eq!(
state.surface.active_graph_data(),
&before,
"a draw must not build or mutate the cached surface GraphData",
);
}
}