use optionstratlib::prelude::{Decimal, ToPrimitive};
use optionstratlib::visualization::{GraphData, Series2D, Surface3D};
use crate::ui::theme::sanitize;
const MAX_SURFACE_CELLS: usize = 4096;
const DEGENERATE_PAD_MIN: f64 = 1.0;
const DEGENERATE_PAD_FRACTION: f64 = 0.005;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AxisBounds {
min: f64,
max: f64,
}
impl AxisBounds {
#[must_use]
pub fn new(min: f64, max: f64) -> Option<Self> {
(min.is_finite() && max.is_finite() && min <= max).then_some(Self { min, max })
}
#[must_use]
pub fn min(self) -> f64 {
self.min
}
#[must_use]
pub fn max(self) -> f64 {
self.max
}
#[must_use]
pub fn to_array(self) -> [f64; 2] {
[self.min, self.max]
}
#[must_use]
fn from_values(values: impl Iterator<Item = f64>) -> Option<Self> {
let mut values = values;
let first = values.next()?;
let mut min = first;
let mut max = first;
for value in values {
if value < min {
min = value;
}
if value > max {
max = value;
}
}
Self::new(min, max).map(Self::padded)
}
#[must_use]
fn padded(self) -> Self {
if self.min < self.max {
return self;
}
let value = self.min;
let pad = (value.abs() * DEGENERATE_PAD_FRACTION).max(DEGENERATE_PAD_MIN);
Self::new(value - pad, value + pad).unwrap_or(self)
}
#[must_use]
fn endpoint_labels(self) -> Vec<String> {
let mid = self.min + (self.max - self.min) / 2.0;
vec![fmt_coord(self.min), fmt_coord(mid), fmt_coord(self.max)]
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectedSeries {
points: Vec<(f64, f64)>,
x: AxisBounds,
y: AxisBounds,
x_labels: Vec<String>,
y_labels: Vec<String>,
name: String,
}
impl ProjectedSeries {
#[must_use]
pub fn points(&self) -> &[(f64, f64)] {
&self.points
}
#[must_use]
pub fn x_bounds(&self) -> [f64; 2] {
self.x.to_array()
}
#[must_use]
pub fn y_bounds(&self) -> [f64; 2] {
self.y.to_array()
}
#[must_use]
pub fn x_labels(&self) -> &[String] {
&self.x_labels
}
#[must_use]
pub fn y_labels(&self) -> &[String] {
&self.y_labels
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectedSurface {
rows: Vec<Vec<Option<f64>>>,
x: AxisBounds,
y: AxisBounds,
z: AxisBounds,
x_labels: Vec<String>,
y_labels: Vec<String>,
z_labels: Vec<String>,
name: String,
}
impl ProjectedSurface {
#[must_use]
pub fn rows(&self) -> &[Vec<Option<f64>>] {
&self.rows
}
#[must_use]
pub fn x_bounds(&self) -> [f64; 2] {
self.x.to_array()
}
#[must_use]
pub fn y_bounds(&self) -> [f64; 2] {
self.y.to_array()
}
#[must_use]
pub fn z_bounds(&self) -> [f64; 2] {
self.z.to_array()
}
#[must_use]
pub fn x_labels(&self) -> &[String] {
&self.x_labels
}
#[must_use]
pub fn y_labels(&self) -> &[String] {
&self.y_labels
}
#[must_use]
pub fn z_labels(&self) -> &[String] {
&self.z_labels
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EmptyReason {
NoData,
Degenerate,
Unsupported,
}
#[derive(Debug, Clone, PartialEq)]
pub enum GraphProjection {
Ready(ProjectedSeries),
ReadySurface(ProjectedSurface),
Empty(EmptyReason),
}
impl GraphProjection {
#[must_use]
pub fn ready(&self) -> Option<&ProjectedSeries> {
match self {
Self::Ready(series) => Some(series),
Self::ReadySurface(_) | Self::Empty(_) => None,
}
}
#[must_use]
pub fn ready_surface(&self) -> Option<&ProjectedSurface> {
match self {
Self::ReadySurface(surface) => Some(surface),
Self::Ready(_) | Self::Empty(_) => None,
}
}
#[must_use]
pub fn empty_reason(&self) -> Option<EmptyReason> {
match self {
Self::Empty(reason) => Some(*reason),
Self::Ready(_) | Self::ReadySurface(_) => None,
}
}
}
#[must_use]
pub fn project(graph: &GraphData) -> GraphProjection {
match graph {
GraphData::Series(series) => project_series(series),
GraphData::GraphSurface(surface) => project_surface(surface),
GraphData::MultiSeries(_) => GraphProjection::Empty(EmptyReason::Unsupported),
}
}
#[must_use]
fn project_series(series: &Series2D) -> GraphProjection {
if series.x.len() != series.y.len() {
return GraphProjection::Empty(EmptyReason::Degenerate);
}
if series.x.is_empty() {
return GraphProjection::Empty(EmptyReason::NoData);
}
let mut points: Vec<(f64, f64)> = Vec::with_capacity(series.x.len());
for (x, y) in series.x.iter().zip(series.y.iter()) {
if let Some(point) = finite_xy(coord(x), coord(y)) {
points.push(point);
}
}
if points.is_empty() {
return GraphProjection::Empty(EmptyReason::Degenerate);
}
let (Some(x), Some(y)) = (
AxisBounds::from_values(points.iter().map(|(x, _)| *x)),
AxisBounds::from_values(points.iter().map(|(_, y)| *y)),
) else {
return GraphProjection::Empty(EmptyReason::Degenerate);
};
let x_labels = x.endpoint_labels();
let y_labels = y.endpoint_labels();
GraphProjection::Ready(ProjectedSeries {
points,
x,
y,
x_labels,
y_labels,
name: sanitize(&series.name),
})
}
#[must_use]
fn project_surface(surface: &Surface3D) -> GraphProjection {
if surface.x.len() != surface.y.len() || surface.x.len() != surface.z.len() {
return GraphProjection::Empty(EmptyReason::Degenerate);
}
if surface.x.is_empty() {
return GraphProjection::Empty(EmptyReason::NoData);
}
let mut points: Vec<(f64, f64, f64)> = Vec::with_capacity(surface.x.len());
for ((x, y), z) in surface.x.iter().zip(&surface.y).zip(&surface.z) {
let (xf, yf, zf) = (coord(x), coord(y), coord(z));
if xf.is_finite() && yf.is_finite() && zf.is_finite() {
points.push((xf, yf, zf));
}
}
if points.is_empty() {
return GraphProjection::Empty(EmptyReason::Degenerate);
}
let strikes = distinct_sorted(points.iter().map(|p| p.0));
let vols = distinct_sorted(points.iter().map(|p| p.1));
let too_large = strikes
.len()
.checked_mul(vols.len())
.is_none_or(|cells| cells > MAX_SURFACE_CELLS);
if strikes.is_empty() || vols.is_empty() || too_large {
return GraphProjection::Empty(EmptyReason::Degenerate);
}
let (Some(x), Some(y), Some(z)) = (
AxisBounds::from_values(strikes.iter().copied()),
AxisBounds::from_values(vols.iter().copied()),
AxisBounds::from_values(points.iter().map(|p| p.2)),
) else {
return GraphProjection::Empty(EmptyReason::Degenerate);
};
let vols_desc: Vec<f64> = vols.iter().rev().copied().collect();
let span = z.max - z.min;
let mut rows: Vec<Vec<Option<f64>>> = vec![vec![None; strikes.len()]; vols_desc.len()];
for (px, py, pz) in &points {
let col = strikes.iter().position(|s| s == px);
let row = vols_desc.iter().position(|v| v == py);
if let (Some(col), Some(row)) = (col, row)
&& let Some(cell) = rows.get_mut(row).and_then(|r| r.get_mut(col))
{
let norm = if span > 0.0 {
((pz - z.min) / span).clamp(0.0, 1.0)
} else {
0.5
};
*cell = Some(norm);
}
}
let x_labels = x.endpoint_labels();
let y_labels = y.endpoint_labels();
let z_labels = z.endpoint_labels();
GraphProjection::ReadySurface(ProjectedSurface {
rows,
x,
y,
z,
x_labels,
y_labels,
z_labels,
name: sanitize(&surface.name),
})
}
#[must_use]
fn distinct_sorted(values: impl Iterator<Item = f64>) -> Vec<f64> {
let mut out: Vec<f64> = values.collect();
out.sort_by(f64::total_cmp);
out.dedup();
out
}
#[must_use]
fn coord(value: &Decimal) -> f64 {
value.to_f64().unwrap_or(f64::NAN)
}
#[must_use]
fn finite_xy(x: f64, y: f64) -> Option<(f64, f64)> {
(x.is_finite() && y.is_finite()).then_some((x, y))
}
#[must_use]
fn fmt_coord(value: f64) -> String {
format!("{value:.2}")
}
#[derive(Debug, Clone, PartialEq)]
pub struct GraphCache {
input: GraphData,
projection: GraphProjection,
}
impl GraphCache {
#[must_use]
pub fn new(input: GraphData) -> Self {
let projection = project(&input);
Self { input, projection }
}
pub fn update(&mut self, input: GraphData) {
self.projection = project(&input);
self.input = input;
}
#[must_use]
pub fn projection(&self) -> &GraphProjection {
&self.projection
}
#[must_use]
pub fn input(&self) -> &GraphData {
&self.input
}
}
#[cfg(test)]
mod tests {
use optionstratlib::prelude::Decimal;
use optionstratlib::visualization::{GraphData, Series2D, Surface3D};
use proptest::prelude::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
use super::{
AxisBounds, EmptyReason, GraphCache, GraphProjection, ProjectedSeries, coord, finite_xy,
project,
};
fn dec(mantissa: i64, scale: u32) -> Decimal {
Decimal::new(mantissa, scale)
}
fn series(name: &str, xs: &[Decimal], ys: &[Decimal]) -> Series2D {
Series2D {
x: xs.to_vec(),
y: ys.to_vec(),
name: name.to_owned(),
..Default::default()
}
}
#[track_caller]
fn assert_close(actual: f64, expected: f64) {
assert!(
(actual - expected).abs() < 1e-9,
"expected {expected}, got {actual}",
);
}
#[track_caller]
fn ready(projection: &GraphProjection) -> &ProjectedSeries {
match projection.ready() {
Some(series) => series,
None => panic!("expected a Ready projection, got {projection:?}"),
}
}
#[test]
fn test_project_series_known_points_projects_expected_dataset_and_bounds() {
let graph = GraphData::Series(series(
"payoff",
&[dec(1, 0), dec(2, 0), dec(3, 0)],
&[dec(10, 0), dec(-5, 0), dec(7, 0)],
));
let projection = project(&graph);
let s = ready(&projection);
assert_eq!(s.points().len(), 3, "all three points survive");
let expected = [(1.0, 10.0), (2.0, -5.0), (3.0, 7.0)];
for (got, want) in s.points().iter().zip(expected.iter()) {
assert_close(got.0, want.0);
assert_close(got.1, want.1);
}
assert_close(s.x_bounds()[0], 1.0);
assert_close(s.x_bounds()[1], 3.0);
assert_close(s.y_bounds()[0], -5.0);
assert_close(s.y_bounds()[1], 10.0);
assert_eq!(s.name(), "payoff", "the series name projects through");
}
#[test]
fn test_project_series_preserves_input_point_order() {
let graph = GraphData::Series(series(
"s",
&[dec(5, 0), dec(1, 0), dec(9, 0)],
&[dec(0, 0), dec(0, 0), dec(0, 0)],
));
let s = ready(&project(&graph)).clone();
let xs: Vec<f64> = s.points().iter().map(|(x, _)| *x).collect();
assert_close(xs.first().copied().unwrap_or_default(), 5.0);
assert_close(xs.get(1).copied().unwrap_or_default(), 1.0);
assert_close(xs.get(2).copied().unwrap_or_default(), 9.0);
}
#[test]
fn test_project_empty_series_yields_empty_no_data() {
let graph = GraphData::Series(series("empty", &[], &[]));
assert_eq!(
project(&graph).empty_reason(),
Some(EmptyReason::NoData),
"an empty series projects Empty(NoData)",
);
}
#[test]
fn test_project_mismatched_lengths_yields_empty_degenerate() {
let graph = GraphData::Series(series("bad", &[dec(1, 0), dec(2, 0)], &[dec(3, 0)]));
assert_eq!(
project(&graph).empty_reason(),
Some(EmptyReason::Degenerate),
"a length mismatch projects Empty(Degenerate)",
);
}
#[test]
fn test_project_multiseries_variant_yields_empty_unsupported() {
let graph = GraphData::MultiSeries(vec![series("a", &[dec(1, 0)], &[dec(2, 0)])]);
assert_eq!(
project(&graph).empty_reason(),
Some(EmptyReason::Unsupported),
);
}
#[test]
fn test_project_graphsurface_builds_a_normalized_grid() {
let graph = GraphData::GraphSurface(Surface3D {
x: vec![dec(10, 0), dec(20, 0), dec(10, 0), dec(20, 0)],
y: vec![dec(2, 1), dec(2, 1), dec(4, 1), dec(4, 1)],
z: vec![dec(1, 0), dec(2, 0), dec(3, 0), dec(4, 0)],
name: "delta".to_owned(),
});
let projection = project(&graph);
let surface = match projection.ready_surface() {
Some(s) => s,
None => panic!("expected a ReadySurface, got {projection:?}"),
};
assert_eq!(surface.rows().len(), 2, "two vol rows");
let top = surface.rows().first().cloned().unwrap_or_default();
assert_close(
top.first().copied().flatten().unwrap_or_default(),
2.0 / 3.0,
);
assert_close(top.get(1).copied().flatten().unwrap_or_default(), 1.0);
let bottom = surface.rows().get(1).cloned().unwrap_or_default();
assert_close(bottom.first().copied().flatten().unwrap_or_default(), 0.0);
assert_close(
bottom.get(1).copied().flatten().unwrap_or_default(),
1.0 / 3.0,
);
assert_close(surface.x_bounds()[0], 10.0);
assert_close(surface.x_bounds()[1], 20.0);
assert_close(surface.z_bounds()[0], 1.0);
assert_close(surface.z_bounds()[1], 4.0);
assert_eq!(surface.name(), "delta");
}
#[test]
fn test_project_empty_graphsurface_yields_empty_no_data() {
let graph = GraphData::GraphSurface(Surface3D::default());
assert_eq!(
project(&graph).empty_reason(),
Some(EmptyReason::NoData),
"an empty surface projects Empty(NoData)",
);
}
#[test]
fn test_project_mismatched_graphsurface_yields_empty_degenerate() {
let graph = GraphData::GraphSurface(Surface3D {
x: vec![dec(1, 0), dec(2, 0)],
y: vec![dec(1, 0)],
z: vec![dec(1, 0)],
name: "bad".to_owned(),
});
assert_eq!(
project(&graph).empty_reason(),
Some(EmptyReason::Degenerate),
);
}
#[test]
fn test_finite_xy_drops_nan_and_inf_keeps_finite() {
assert_eq!(finite_xy(f64::NAN, 1.0), None, "NaN x is dropped");
assert_eq!(finite_xy(1.0, f64::NAN), None, "NaN y is dropped");
assert_eq!(finite_xy(f64::INFINITY, 1.0), None, "+Inf x is dropped");
assert_eq!(finite_xy(1.0, f64::NEG_INFINITY), None, "-Inf y is dropped");
match finite_xy(2.0, -3.0) {
Some((x, y)) => {
assert_close(x, 2.0);
assert_close(y, -3.0);
}
None => panic!("a finite pair must pass the gate"),
}
}
#[test]
fn test_coord_finite_decimal_converts_and_is_finite() {
assert!(coord(&dec(12345, 2)).is_finite());
assert_close(coord(&dec(12345, 2)), 123.45);
}
#[test]
fn test_axis_bounds_from_values_computes_min_and_max() {
match AxisBounds::from_values([3.0, -1.0, 7.5, 2.0].into_iter()) {
Some(bounds) => {
assert_close(bounds.min(), -1.0);
assert_close(bounds.max(), 7.5);
assert_eq!(bounds.to_array(), [-1.0, 7.5]);
}
None => panic!("non-empty values must yield bounds"),
}
assert_eq!(
AxisBounds::from_values(std::iter::empty()),
None,
"empty values yield no bounds",
);
}
#[test]
fn test_axis_bounds_new_rejects_non_finite_and_unordered() {
assert_eq!(AxisBounds::new(f64::NAN, 1.0), None, "NaN min is rejected");
assert_eq!(AxisBounds::new(1.0, f64::NAN), None, "NaN max is rejected");
assert_eq!(
AxisBounds::new(f64::INFINITY, 1.0),
None,
"+Inf min is rejected",
);
assert_eq!(
AxisBounds::new(1.0, f64::NEG_INFINITY),
None,
"-Inf max is rejected",
);
assert_eq!(AxisBounds::new(5.0, 3.0), None, "max < min is rejected");
}
#[test]
fn test_axis_bounds_new_accepts_valid_and_round_trips() {
match AxisBounds::new(-1.0, 7.5) {
Some(bounds) => {
assert_close(bounds.min(), -1.0);
assert_close(bounds.max(), 7.5);
assert_eq!(bounds.to_array(), [-1.0, 7.5]);
}
None => panic!("valid finite, ordered bounds must construct"),
}
assert!(
AxisBounds::new(3.0, 3.0).is_some(),
"a degenerate min == max is a valid (finite, ordered) interval",
);
}
#[test]
fn test_project_single_point_yields_ready_with_padded_bounds() {
let graph = GraphData::Series(series("one", &[dec(42, 0)], &[dec(7, 0)]));
let s = ready(&project(&graph)).clone();
assert_eq!(s.points().len(), 1);
let point = s.points().first().copied().unwrap_or_default();
assert_close(point.0, 42.0);
assert_close(point.1, 7.0);
assert_close(s.x_bounds()[0], 41.0);
assert_close(s.x_bounds()[1], 43.0);
assert_close(s.y_bounds()[0], 6.0);
assert_close(s.y_bounds()[1], 8.0);
assert!(
s.x_bounds()[0] < s.x_bounds()[1],
"the x axis has real width"
);
assert!(
s.y_bounds()[0] < s.y_bounds()[1],
"the y axis has real width"
);
}
#[track_caller]
fn plot_ink_cells(series: &ProjectedSeries) -> usize {
let backend = TestBackend::new(40, 12);
let mut terminal = match Terminal::new(backend) {
Ok(t) => t,
Err(e) => panic!("TestBackend terminal construction failed: {e}"),
};
let draw = |series: &ProjectedSeries, frame: &mut ratatui::Frame| {
let dataset = Dataset::default()
.name(series.name().to_owned())
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.data(series.points());
let chart = Chart::new(vec![dataset])
.x_axis(Axis::default().bounds(series.x_bounds()))
.y_axis(Axis::default().bounds(series.y_bounds()));
frame.render_widget(chart, frame.area());
};
match terminal.draw(|frame| draw(series, frame)) {
Ok(_) => {}
Err(e) => panic!("draw failed: {e}"),
}
terminal
.backend()
.buffer()
.content()
.iter()
.filter(|cell| cell.symbol() != " ")
.count()
}
#[test]
fn test_project_single_point_series_renders_non_blank_chart() {
let graph = GraphData::Series(series("one", &[dec(42, 0)], &[dec(7, 0)]));
let s = ready(&project(&graph)).clone();
assert!(
plot_ink_cells(&s) > 0,
"a single-point series must paint some plot ink, not a blank chart",
);
}
#[test]
fn test_project_flat_series_renders_non_blank_chart() {
let graph = GraphData::Series(series(
"flat",
&[dec(1, 0), dec(2, 0), dec(3, 0), dec(4, 0)],
&[dec(5, 0), dec(5, 0), dec(5, 0), dec(5, 0)],
));
let s = ready(&project(&graph)).clone();
assert!(
s.y_bounds()[0] < s.y_bounds()[1],
"the flat y axis was padded to real width",
);
assert!(
plot_ink_cells(&s) > 0,
"a flat series must paint a visible flat line, not a blank chart",
);
}
#[test]
fn test_projected_series_labels_are_precomputed_min_mid_max() {
let graph = GraphData::Series(series(
"s",
&[dec(0, 0), dec(10, 0)],
&[dec(-4, 0), dec(4, 0)],
));
let s = ready(&project(&graph)).clone();
assert_eq!(s.x_labels(), ["0.00", "5.00", "10.00"]);
assert_eq!(s.y_labels(), ["-4.00", "0.00", "4.00"]);
}
#[test]
fn test_graph_cache_new_projects_once_and_caches() {
let graph = GraphData::Series(series("s", &[dec(1, 0)], &[dec(2, 0)]));
let cache = GraphCache::new(graph.clone());
assert_eq!(cache.input(), &graph, "the source GraphData is retained");
assert!(
cache.projection().ready().is_some(),
"the projection is cached and Ready",
);
}
#[test]
fn test_graph_cache_update_reprojects_new_input() {
let mut cache = GraphCache::new(GraphData::Series(series("empty", &[], &[])));
assert_eq!(
cache.projection().empty_reason(),
Some(EmptyReason::NoData),
"the initial empty series projects Empty(NoData)",
);
let next = GraphData::Series(series(
"filled",
&[dec(1, 0), dec(2, 0)],
&[dec(3, 0), dec(4, 0)],
));
cache.update(next.clone());
assert_eq!(cache.input(), &next);
assert!(
cache.projection().ready().is_some(),
"after update the projection is Ready",
);
}
#[test]
fn test_draw_reads_cached_projection_builds_no_graphdata() {
let graph = GraphData::Series(series(
"payoff",
&[dec(1, 0), dec(2, 0), dec(3, 0)],
&[dec(-2, 0), dec(0, 0), dec(5, 0)],
));
let cache = GraphCache::new(graph.clone());
let before = cache.input().clone();
let backend = TestBackend::new(80, 24);
let mut terminal = match Terminal::new(backend) {
Ok(t) => t,
Err(e) => panic!("TestBackend terminal construction failed: {e}"),
};
let draw = |cache: &GraphCache, frame: &mut ratatui::Frame| {
if let Some(series) = cache.projection().ready() {
let dataset = Dataset::default()
.name(series.name().to_owned())
.graph_type(GraphType::Line)
.data(series.points());
let chart = Chart::new(vec![dataset])
.x_axis(Axis::default().bounds(series.x_bounds()))
.y_axis(Axis::default().bounds(series.y_bounds()));
frame.render_widget(chart, frame.area());
}
};
match terminal.draw(|frame| draw(&cache, frame)) {
Ok(_) => {}
Err(e) => panic!("draw failed: {e}"),
}
assert_eq!(
cache.input(),
&before,
"a draw must not mutate or rebuild the cached GraphData",
);
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
#[test]
fn test_project_arbitrary_series_never_panics_and_is_well_formed(
xs in proptest::collection::vec((any::<i32>(), 0u32..6), 0..24),
ys in proptest::collection::vec((any::<i32>(), 0u32..6), 0..24),
) {
let to_decimals = |raw: &[(i32, u32)]| -> Vec<Decimal> {
raw.iter().map(|(m, s)| Decimal::new(i64::from(*m), *s)).collect()
};
let xd = to_decimals(&xs);
let yd = to_decimals(&ys);
let graph = GraphData::Series(series("prop", &xd, &yd));
let projection = project(&graph);
if let GraphProjection::Ready(s) = &projection {
prop_assert!(!s.points().is_empty(), "a Ready projection is non-empty");
prop_assert!(
s.points().len() <= xd.len().min(yd.len()),
"no point is invented beyond the paired inputs",
);
for (x, y) in s.points() {
prop_assert!(x.is_finite() && y.is_finite(), "no non-finite coord survives");
}
prop_assert!(s.x_bounds()[0] <= s.x_bounds()[1], "x min <= max");
prop_assert!(s.y_bounds()[0] <= s.y_bounds()[1], "y min <= max");
}
}
#[test]
fn test_project_arbitrary_surface_never_panics_and_is_well_formed(
xs in proptest::collection::vec((0i32..8, 0u32..3), 0..24),
ys in proptest::collection::vec((0i32..4, 0u32..2), 0..24),
zs in proptest::collection::vec((any::<i32>(), 0u32..3), 0..24),
) {
let to_decimals = |raw: &[(i32, u32)]| -> Vec<Decimal> {
raw.iter().map(|(m, s)| Decimal::new(i64::from(*m), *s)).collect()
};
let graph = GraphData::GraphSurface(Surface3D {
x: to_decimals(&xs),
y: to_decimals(&ys),
z: to_decimals(&zs),
name: "prop".to_owned(),
});
let projection = project(&graph);
if let GraphProjection::ReadySurface(s) = &projection {
let cols = s.rows().first().map_or(0, Vec::len);
for row in s.rows() {
prop_assert_eq!(row.len(), cols, "the grid is rectangular");
for v in row.iter().flatten() {
prop_assert!((0.0..=1.0).contains(v), "z normalized into [0,1]");
}
}
prop_assert!(s.x_bounds()[0] <= s.x_bounds()[1], "x min <= max");
prop_assert!(s.y_bounds()[0] <= s.y_bounds()[1], "y min <= max");
prop_assert!(s.z_bounds()[0] <= s.z_bounds()[1], "z min <= max");
}
}
}
}