use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use umbral_auth::AuthUser;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Span {
pub cols: u8,
pub rows: u8,
}
impl Default for Span {
fn default() -> Self {
Self { cols: 3, rows: 1 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum WidgetKind {
Kpi,
Card,
Line,
Bar,
Donut,
Radial,
Heatmap,
Progress,
Table,
Feed,
}
impl WidgetKind {
pub fn as_str(&self) -> &'static str {
match self {
WidgetKind::Kpi => "kpi",
WidgetKind::Card => "card",
WidgetKind::Line => "line",
WidgetKind::Bar => "bar",
WidgetKind::Donut => "donut",
WidgetKind::Radial => "radial",
WidgetKind::Heatmap => "heatmap",
WidgetKind::Progress => "progress",
WidgetKind::Table => "table",
WidgetKind::Feed => "feed",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KpiPayload {
pub value: String,
pub unit: Option<String>,
pub delta: Option<f64>,
pub sparkline: Option<Vec<f64>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardPayload {
pub value: String,
pub unit: Option<String>,
pub icon: Option<String>,
pub subtitle: Option<String>,
pub delta_percent: Option<f64>,
pub delta_label: Option<String>,
pub sparkline: Option<Vec<f64>>,
}
impl CardPayload {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
unit: None,
icon: None,
subtitle: None,
delta_percent: None,
delta_label: None,
sparkline: None,
}
}
pub fn unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn growth(mut self, current: f64, previous: f64) -> Self {
if previous.is_finite() && previous != 0.0 && current.is_finite() {
self.delta_percent = Some(((current - previous) / previous.abs()) * 100.0);
}
self
}
pub fn delta(mut self, percent: f64, label: impl Into<String>) -> Self {
self.delta_percent = Some(percent);
self.delta_label = Some(label.into());
self
}
pub fn delta_label(mut self, label: impl Into<String>) -> Self {
self.delta_label = Some(label.into());
self
}
pub fn sparkline(mut self, points: impl IntoIterator<Item = f64>) -> Self {
self.sparkline = Some(points.into_iter().collect());
self
}
}
pub fn humanize_number(n: f64) -> String {
if !n.is_finite() {
return "—".to_string();
}
let abs = n.abs();
let sign = if n < 0.0 { "-" } else { "" };
if abs < 1000.0 {
if (abs.fract() - 0.0).abs() < f64::EPSILON {
return format!("{sign}{}", abs as i64);
}
return format!("{sign}{:.2}", abs);
}
if abs < 1_000_000.0 {
if abs < 10_000.0 {
return format_thousands(n);
}
return format!("{sign}{:.1}K", abs / 1_000.0);
}
if abs < 1_000_000_000.0 {
return format!("{sign}{:.2}M", abs / 1_000_000.0);
}
if abs < 1_000_000_000_000.0 {
return format!("{sign}{:.2}B", abs / 1_000_000_000.0);
}
format!("{sign}{:.2}T", abs / 1_000_000_000_000.0)
}
pub fn format_thousands(n: f64) -> String {
if !n.is_finite() {
return "—".to_string();
}
let sign = if n < 0.0 { "-" } else { "" };
let abs = n.abs();
let int_part = abs.trunc() as u128;
let frac_part = abs - abs.trunc();
let int_str = int_part.to_string();
let bytes = int_str.as_bytes();
let mut grouped = String::with_capacity(int_str.len() + int_str.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i) % 3 == 0 {
grouped.push(',');
}
grouped.push(*b as char);
}
if frac_part > 0.0 {
format!("{sign}{grouped}.{:02}", (frac_part * 100.0).round() as u64)
} else {
format!("{sign}{grouped}")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Series {
pub name: String,
pub points: Vec<ChartPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartPoint {
pub x: String,
pub y: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinePayload {
pub series: Vec<Series>,
pub x_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BarPayload {
pub series: Vec<Series>,
pub x_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DonutSlice {
pub label: String,
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DonutPayload {
pub slices: Vec<DonutSlice>,
}
impl DonutPayload {
pub fn new(slices: Vec<DonutSlice>) -> Self {
Self { slices }
}
pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
Self::new(
pairs
.into_iter()
.map(|(label, value)| DonutSlice {
label: label.into(),
value,
color: None,
})
.collect(),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RadialTrack {
pub label: String,
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RadialPayload {
pub tracks: Vec<RadialTrack>,
}
impl RadialPayload {
pub fn new(tracks: Vec<RadialTrack>) -> Self {
Self {
tracks: tracks
.into_iter()
.map(|t| RadialTrack {
value: clamp_percent(t.value),
..t
})
.collect(),
}
}
pub fn single(label: impl Into<String>, percent: f64) -> Self {
Self::new(vec![RadialTrack {
label: label.into(),
value: percent,
color: None,
}])
}
pub fn goal(label: impl Into<String>, current: f64, target: f64) -> Self {
let pct = if target > 0.0 {
current / target * 100.0
} else {
0.0
};
Self::single(label, pct)
}
pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
Self::new(
pairs
.into_iter()
.map(|(label, value)| RadialTrack {
label: label.into(),
value,
color: None,
})
.collect(),
)
}
}
fn clamp_percent(v: f64) -> f64 {
if v.is_finite() {
v.clamp(0.0, 100.0)
} else {
0.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeatmapCell {
pub x: String,
pub y: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeatmapRow {
pub name: String,
pub cells: Vec<HeatmapCell>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeatmapPayload {
pub rows: Vec<HeatmapRow>,
}
impl HeatmapPayload {
pub fn new(rows: Vec<HeatmapRow>) -> Self {
Self { rows }
}
pub fn from_grid<R, C>(
row_labels: impl IntoIterator<Item = R>,
col_labels: impl IntoIterator<Item = C>,
values: Vec<Vec<f64>>,
) -> Self
where
R: Into<String>,
C: Into<String>,
{
let cols: Vec<String> = col_labels.into_iter().map(Into::into).collect();
let rows = row_labels
.into_iter()
.enumerate()
.map(|(r, label)| {
let row_vals = values.get(r);
let cells = cols
.iter()
.enumerate()
.map(|(c, x)| HeatmapCell {
x: x.clone(),
y: row_vals.and_then(|v| v.get(c)).copied().unwrap_or(0.0),
})
.collect();
HeatmapRow {
name: label.into(),
cells,
}
})
.collect();
Self { rows }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressItem {
pub label: String,
pub display: String,
pub percent: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressPayload {
pub items: Vec<ProgressItem>,
}
impl ProgressPayload {
pub fn new(items: Vec<ProgressItem>) -> Self {
Self { items }
}
pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
let reference = items
.iter()
.map(|(_, v)| *v)
.filter(|v| v.is_finite())
.fold(0.0_f64, f64::max);
Self::build(items, reference)
}
pub fn from_pairs_of<L: Into<String>>(
pairs: impl IntoIterator<Item = (L, f64)>,
target: f64,
) -> Self {
let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
let reference = if target > 0.0 {
target
} else {
items
.iter()
.map(|(_, v)| *v)
.filter(|v| v.is_finite())
.fold(0.0_f64, f64::max)
};
Self::build(items, reference)
}
fn build(items: Vec<(String, f64)>, reference: f64) -> Self {
let items = items
.into_iter()
.map(|(label, value)| {
let percent = if reference > 0.0 && value.is_finite() {
(value / reference * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
ProgressItem {
label,
display: format_thousands(value),
percent,
color: None,
}
})
.collect();
Self { items }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableColumn {
pub key: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TablePayload {
pub columns: Vec<TableColumn>,
pub rows: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_all_url: Option<String>,
}
impl TablePayload {
pub fn new(columns: Vec<TableColumn>, rows: Vec<serde_json::Value>) -> Self {
Self {
columns,
rows,
view_all_url: None,
}
}
pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
self.view_all_url = Some(format!(
"{}/{}/",
crate::branding::current().base_path,
T::TABLE,
));
self
}
pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
self.view_all_url = Some(url.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedItem {
pub actor: String,
pub verb: String,
pub object: String,
pub object_link: Option<String>,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedPayload {
pub items: Vec<FeedItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_all_url: Option<String>,
}
impl FeedPayload {
pub fn new(items: Vec<FeedItem>) -> Self {
Self {
items,
view_all_url: None,
}
}
pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
self.view_all_url = Some(format!(
"{}/{}/",
crate::branding::current().base_path,
T::TABLE,
));
self
}
pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
self.view_all_url = Some(url.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum WidgetPayload {
Kpi(KpiPayload),
Card(CardPayload),
Line(LinePayload),
Bar(BarPayload),
Donut(DonutPayload),
Radial(RadialPayload),
Heatmap(HeatmapPayload),
Progress(ProgressPayload),
Table(TablePayload),
Feed(FeedPayload),
}
fn csv_field(value: &str) -> String {
let dangerous = value
.chars()
.next()
.is_some_and(|c| matches!(c, '=' | '+' | '-' | '@'));
let value = if dangerous {
format!("\t{value}")
} else {
value.to_string()
};
if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value
}
}
fn csv_row<I: IntoIterator<Item = String>>(cells: I) -> String {
cells
.into_iter()
.map(|c| csv_field(&c))
.collect::<Vec<_>>()
.join(",")
}
fn num(v: f64) -> String {
if v.fract() == 0.0 {
format!("{}", v as i64)
} else {
format!("{v}")
}
}
impl WidgetPayload {
pub fn to_csv(&self) -> Option<String> {
let mut out = String::new();
match self {
WidgetPayload::Line(LinePayload { series, .. })
| WidgetPayload::Bar(BarPayload { series, .. }) => {
out.push_str("series,x,y\n");
for s in series {
for p in &s.points {
out.push_str(&csv_row([s.name.clone(), p.x.clone(), num(p.y)]));
out.push('\n');
}
}
}
WidgetPayload::Donut(p) => {
out.push_str("label,value\n");
for s in &p.slices {
out.push_str(&csv_row([s.label.clone(), num(s.value)]));
out.push('\n');
}
}
WidgetPayload::Radial(p) => {
out.push_str("label,value\n");
for t in &p.tracks {
out.push_str(&csv_row([t.label.clone(), num(t.value)]));
out.push('\n');
}
}
WidgetPayload::Progress(p) => {
for i in &p.items {
out.push_str(&csv_row([
i.label.clone(),
num(i.percent),
i.display.clone(),
]));
out.push('\n');
}
}
WidgetPayload::Heatmap(p) => {
out.push_str("row,column,value\n");
for r in &p.rows {
for c in &r.cells {
out.push_str(&csv_row([r.name.clone(), c.x.clone(), num(c.y)]));
out.push('\n');
}
}
}
WidgetPayload::Table(p) => {
out.push_str(&csv_row(p.columns.iter().map(|c| c.label.clone())));
out.push('\n');
for row in &p.rows {
let cells = p.columns.iter().map(|c| match row.get(&c.key) {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Null) | None => String::new(),
Some(v) => v.to_string(),
});
out.push_str(&csv_row(cells));
out.push('\n');
}
}
WidgetPayload::Kpi(_) | WidgetPayload::Card(_) | WidgetPayload::Feed(_) => return None,
}
Some(out)
}
}
#[derive(Debug, Clone, Default)]
pub struct WidgetParams {
pub period: Option<String>,
pub start: Option<String>,
pub end: Option<String>,
pub raw: std::collections::HashMap<String, String>,
}
impl WidgetParams {
pub fn from_query<S: AsRef<str>>(query: S) -> Self {
let mut out = Self::default();
for pair in query.as_ref().split('&').filter(|s| !s.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let value = urlencoding_decode(v);
match k {
"period" => out.period = Some(value),
"start" => out.start = Some(value),
"end" => out.end = Some(value),
_ => {
out.raw.insert(k.to_string(), value);
}
}
}
out
}
pub fn period_days(&self) -> Option<i64> {
let p = self.period.as_deref()?;
let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
}
pub fn choice(&self, key: &str) -> Option<&str> {
self.raw
.get(key)
.map(String::as_str)
.filter(|v| !v.is_empty())
}
pub fn date_range(&self) -> Option<(&str, &str)> {
match (self.start.as_deref(), self.end.as_deref()) {
(Some(s), Some(e)) if !s.is_empty() && !e.is_empty() => Some((s, e)),
_ => None,
}
}
}
fn urlencoding_decode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push(char::from((h as u8) * 16 + l as u8));
i += 3;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
b => {
out.push(b as char);
i += 1;
}
}
}
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WidgetFilterKind {
Period { presets: Vec<FilterOption> },
DateRange,
Choice { options: Vec<FilterOption> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterOption {
pub value: String,
pub label: String,
}
impl FilterOption {
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetFilter {
pub key: String,
pub label: String,
#[serde(flatten)]
pub kind: WidgetFilterKind,
pub default: Option<String>,
#[serde(default)]
pub active: Option<String>,
#[serde(default)]
pub active_start: Option<String>,
#[serde(default)]
pub active_end: Option<String>,
#[serde(default)]
pub carry: String,
#[serde(default)]
pub carry_lead: String,
}
impl WidgetFilter {
pub fn period<I, V, L>(presets: I) -> Self
where
I: IntoIterator<Item = (V, L)>,
V: Into<String>,
L: Into<String>,
{
Self {
key: "period".to_string(),
label: "Period".to_string(),
kind: WidgetFilterKind::Period {
presets: presets
.into_iter()
.map(|(v, l)| FilterOption::new(v, l))
.collect(),
},
default: None,
active: None,
active_start: None,
active_end: None,
carry: String::new(),
carry_lead: String::new(),
}
}
pub fn period_default() -> Self {
Self::period([("7d", "7d"), ("30d", "30d"), ("90d", "90d")]).with_default("30d")
}
pub fn date_range() -> Self {
Self {
key: "range".to_string(),
label: "Date range".to_string(),
kind: WidgetFilterKind::DateRange,
default: None,
active: None,
active_start: None,
active_end: None,
carry: String::new(),
carry_lead: String::new(),
}
}
pub fn choice<I, V, L>(key: impl Into<String>, label: impl Into<String>, options: I) -> Self
where
I: IntoIterator<Item = (V, L)>,
V: Into<String>,
L: Into<String>,
{
Self {
key: key.into(),
label: label.into(),
kind: WidgetFilterKind::Choice {
options: options
.into_iter()
.map(|(v, l)| FilterOption::new(v, l))
.collect(),
},
default: None,
active: None,
active_start: None,
active_end: None,
carry: String::new(),
carry_lead: String::new(),
}
}
pub fn with_default(mut self, value: impl Into<String>) -> Self {
self.default = Some(value.into());
self
}
pub fn active_value(&self) -> Option<&str> {
self.active.as_deref().or(self.default.as_deref())
}
}
pub(crate) type DataFuture = Pin<Box<dyn Future<Output = WidgetPayload> + Send + 'static>>;
pub(crate) type DataFnInner =
Arc<dyn Fn(AuthUser, WidgetParams) -> DataFuture + Send + Sync + 'static>;
#[derive(Clone)]
pub struct WidgetDataFn(pub(crate) DataFnInner);
impl WidgetDataFn {
pub fn new<F, Fut>(f: F) -> Self
where
F: Fn(AuthUser) -> Fut + Send + Sync + 'static,
Fut: Future<Output = WidgetPayload> + Send + 'static,
{
Self(Arc::new(move |user, _params| Box::pin(f(user))))
}
pub fn with_params<F, Fut>(f: F) -> Self
where
F: Fn(AuthUser, WidgetParams) -> Fut + Send + Sync + 'static,
Fut: Future<Output = WidgetPayload> + Send + 'static,
{
Self(Arc::new(move |user, params| Box::pin(f(user, params))))
}
}
impl std::fmt::Debug for WidgetDataFn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("WidgetDataFn(<fn>)")
}
}
#[derive(Debug, Clone)]
pub struct Widget {
pub key: &'static str,
pub title: String,
pub kind: WidgetKind,
pub default_span: Span,
pub permission: Option<&'static str>,
pub data: WidgetDataFn,
pub default_period: Option<&'static str>,
pub filters: Vec<WidgetFilter>,
}
impl Widget {
pub fn new(
key: &'static str,
title: impl Into<String>,
kind: WidgetKind,
data: WidgetDataFn,
) -> Self {
Self {
key,
title: title.into(),
kind,
default_span: Span::default(),
permission: None,
data,
default_period: None,
filters: Vec::new(),
}
}
pub fn with_permission(mut self, codename: &'static str) -> Self {
self.permission = Some(codename);
self
}
pub fn filter(mut self, filter: WidgetFilter) -> Self {
self.filters.push(filter);
self
}
pub fn with_filters(mut self, filters: impl IntoIterator<Item = WidgetFilter>) -> Self {
self.filters = filters.into_iter().collect();
self
}
pub(crate) fn effective_filters(&self) -> Vec<WidgetFilter> {
if self.filters.is_empty() && matches!(self.kind, WidgetKind::Line) {
let mut period = WidgetFilter::period_default();
if let Some(d) = self.default_period {
period.default = Some(d.to_string());
}
return vec![period];
}
self.filters.clone()
}
pub fn with_span(mut self, cols: u8, rows: u8) -> Self {
self.default_span = Span { cols, rows };
self
}
pub fn with_default_period(mut self, period: &'static str) -> Self {
self.default_period = Some(period);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetInstance {
pub key: String,
pub span: Span,
}
#[derive(Debug, Clone, Serialize)]
pub struct CatalogEntry {
pub key: &'static str,
pub title: String,
pub kind: String,
pub default_span: Span,
}
#[derive(Debug, Clone)]
pub struct WidgetSection {
pub title: String,
pub subtitle: Option<String>,
pub widgets: Vec<Widget>,
}
impl WidgetSection {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
subtitle: None,
widgets: Vec::new(),
}
}
pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn widget(mut self, w: Widget) -> Self {
self.widgets.push(w);
self
}
pub fn widgets(mut self, ws: impl IntoIterator<Item = Widget>) -> Self {
self.widgets.extend(ws);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn radial_kind_serializes_as_radial() {
assert_eq!(WidgetKind::Radial.as_str(), "radial");
}
#[test]
fn radial_single_builds_one_track() {
let p = RadialPayload::single("Monthly goal", 73.0);
assert_eq!(p.tracks.len(), 1);
assert_eq!(p.tracks[0].label, "Monthly goal");
assert_eq!(p.tracks[0].value, 73.0);
assert!(p.tracks[0].color.is_none());
}
#[test]
fn radial_clamps_out_of_range_and_non_finite_percents() {
assert_eq!(RadialPayload::single("over", 150.0).tracks[0].value, 100.0);
assert_eq!(RadialPayload::single("under", -20.0).tracks[0].value, 0.0);
assert_eq!(RadialPayload::single("nan", f64::NAN).tracks[0].value, 0.0);
assert_eq!(
RadialPayload::single("inf", f64::INFINITY).tracks[0].value,
0.0,
);
}
#[test]
fn radial_goal_is_current_over_target() {
assert_eq!(RadialPayload::goal("g", 73.0, 100.0).tracks[0].value, 73.0);
assert_eq!(
RadialPayload::goal("g", 120.0, 100.0).tracks[0].value,
100.0
);
assert_eq!(RadialPayload::goal("g", 5.0, 0.0).tracks[0].value, 0.0);
}
#[test]
fn radial_from_pairs_keeps_order_and_clamps() {
let p = RadialPayload::from_pairs([("Free", 8.0), ("Pro", 150.0), ("Team", 34.0)]);
assert_eq!(p.tracks.len(), 3);
assert_eq!(p.tracks[0].label, "Free");
assert_eq!(p.tracks[1].value, 100.0); assert_eq!(p.tracks[2].label, "Team");
}
#[test]
fn radial_payload_serializes_with_kind_tag() {
let payload = WidgetPayload::Radial(RadialPayload::single("Quota", 42.0));
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(json["kind"], "radial");
assert_eq!(json["tracks"][0]["label"], "Quota");
assert_eq!(json["tracks"][0]["value"], 42.0);
assert!(json["tracks"][0].get("color").is_none());
}
#[test]
fn heatmap_kind_serializes_as_heatmap() {
assert_eq!(WidgetKind::Heatmap.as_str(), "heatmap");
}
#[test]
fn heatmap_from_grid_is_rectangular_and_padded() {
let p = HeatmapPayload::from_grid(
["Mon", "Tue", "Wed"],
["AM", "PM"],
vec![vec![3.0], vec![1.0, 2.0, 99.0], vec![4.0, 5.0]],
);
assert_eq!(p.rows.len(), 3);
for row in &p.rows {
assert_eq!(row.cells.len(), 2, "row `{}` must be rectangular", row.name);
assert_eq!(row.cells[0].x, "AM");
assert_eq!(row.cells[1].x, "PM");
}
assert_eq!(p.rows[0].name, "Mon");
assert_eq!(p.rows[0].cells[1].y, 0.0); assert_eq!(p.rows[1].cells[1].y, 2.0); assert_eq!(p.rows[2].cells[0].y, 4.0);
}
#[test]
fn heatmap_payload_serializes_with_kind_tag() {
let payload = WidgetPayload::Heatmap(HeatmapPayload::from_grid(
["Row"],
["a", "b"],
vec![vec![7.0, 8.0]],
));
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(json["kind"], "heatmap");
assert_eq!(json["rows"][0]["name"], "Row");
assert_eq!(json["rows"][0]["cells"][0]["x"], "a");
assert_eq!(json["rows"][0]["cells"][1]["y"], 8.0);
}
#[test]
fn progress_kind_serializes_as_progress() {
assert_eq!(WidgetKind::Progress.as_str(), "progress");
}
#[test]
fn progress_from_pairs_sizes_against_largest_value() {
let p = ProgressPayload::from_pairs([("A", 100.0), ("B", 50.0), ("C", 25.0)]);
assert_eq!(p.items.len(), 3);
assert_eq!(p.items[0].percent, 100.0);
assert_eq!(p.items[1].percent, 50.0);
assert_eq!(p.items[2].percent, 25.0);
assert_eq!(p.items[0].label, "A");
assert_eq!(p.items[0].display, "100");
}
#[test]
fn progress_from_pairs_of_sizes_against_target_and_clamps() {
let p = ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 150.0)], 100.0);
assert_eq!(p.items[0].percent, 82.0);
assert_eq!(p.items[1].percent, 100.0);
}
#[test]
fn progress_non_positive_target_falls_back_to_max() {
let p = ProgressPayload::from_pairs_of([("A", 40.0), ("B", 10.0)], 0.0);
assert_eq!(p.items[0].percent, 100.0);
assert_eq!(p.items[1].percent, 25.0);
}
#[test]
fn progress_payload_serializes_with_kind_tag() {
let payload = WidgetPayload::Progress(ProgressPayload::from_pairs([("Pro", 48200.0)]));
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(json["kind"], "progress");
assert_eq!(json["items"][0]["label"], "Pro");
assert_eq!(json["items"][0]["display"], "48,200");
assert_eq!(json["items"][0]["percent"], 100.0);
assert!(json["items"][0].get("color").is_none());
}
}
#[cfg(test)]
mod csv_tests {
use super::*;
#[test]
fn csv_quotes_commas_quotes_and_newlines() {
assert_eq!(csv_field("plain"), "plain");
assert_eq!(csv_field("a,b"), "\"a,b\"");
assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
assert_eq!(csv_field("two\nlines"), "\"two\nlines\"");
}
#[test]
fn csv_neutralises_spreadsheet_formulas() {
for (payload, still_reads) in [
("=HYPERLINK(\"http://evil\")", "HYPERLINK"),
("+1+1", "+1+1"),
("-2+3", "-2+3"),
("@SUM(A1)", "@SUM(A1)"),
] {
let out = csv_field(payload);
assert!(
out.starts_with('\t') || out.starts_with("\"\t"),
"a formula-leading cell must be neutralised, got {out}"
);
assert!(
out.contains(still_reads),
"and must remain readable, got {out}"
);
}
}
#[test]
fn a_bar_payload_exports_one_row_per_point() {
let payload = WidgetPayload::Bar(BarPayload {
series: vec![Series {
name: "sales".into(),
points: vec![
ChartPoint {
x: "Mon".into(),
y: 3.0,
},
ChartPoint {
x: "Tue".into(),
y: 4.5,
},
],
}],
x_type: "day".into(),
});
let csv = payload.to_csv().expect("a bar chart has rows");
assert_eq!(csv, "series,x,y\nsales,Mon,3\nsales,Tue,4.5\n");
}
#[test]
fn a_kpi_has_nothing_to_export() {
let payload = WidgetPayload::Kpi(KpiPayload {
value: "42".into(),
unit: None,
delta: None,
sparkline: None,
});
assert!(payload.to_csv().is_none());
}
}