tawasuyu

tawasuyu/nahual

Open-with viewers (text/image/audio/video/svg/font/hex/…) in Rust, on Llimphi.

Rust 49TOML 23Markdown 19JSON 11062 Definiciones

⎇ f63e78141d856d65edfc06c793a3e00a7eff7bfe 02_ruway/nahual/libs/meta-runtime/src/metric.rs Rust · 917 líneas · 31.5 KB Historia Quién tocó qué crudo

feat: nahual standalone — visores open-with sobre Llimphi (front-door, git-dep al monorepo) f63e78141 · Sergio ·

1//! Cómputo de los agregados de un tablero (`DashboardCard`).
2
3use std::collections::BTreeMap;
4
5use serde_json::Value;
6use uuid::Uuid;
7
8use std::cmp::Ordering;
9
10use nahual_meta_schema::{CardFilter, DateBucket, FilterOp, Metric};
11
12/// Resultado de computar una [`Metric`] sobre un conjunto de records.
13#[derive(Debug, Clone, PartialEq)]
14pub enum MetricResult {
15    /// Un único número — `Count` / `Sum` / `Avg` / `Min` / `Max`.
16    Scalar(f64),
17    /// Conteo por grupo, ordenado de mayor a menor — `GroupBy`.
18    Breakdown(Vec<(String, usize)>),
19    /// Valor numérico agregado por grupo, ordenado de mayor a menor —
20    /// `SumBy` / `AvgBy`. Se formatea con el `ValueFormat` de la
21    /// tarjeta (p.ej. moneda), a diferencia del conteo de `Breakdown`.
22    ValueBreakdown(Vec<(String, f64)>),
23    /// Desglose de **dos dimensiones** — `SumBySeries`. `groups` es el
24    /// eje principal (x), ordenado; `series` es una lista de
25    /// `(etiqueta_serie, valores)` donde `valores[i]` es el agregado de
26    /// esa serie en `groups[i]` (0.0 si no hay datos). Cada serie queda
27    /// alineada 1:1 con `groups`. Las series se ordenan por su total
28    /// descendente.
29    MultiBreakdown {
30        groups: Vec<String>,
31        series: Vec<(String, Vec<f64>)>,
32    },
33}
34
35/// Computa el agregado de una tarjeta sobre `records`, aplicando el
36/// `filter` si lo hay.
37pub fn compute_metric(
38    metric: &Metric,
39    filter: Option<&CardFilter>,
40    records: &[(Uuid, Value)],
41) -> MetricResult {
42    let passes = |v: &Value| match filter {
43        None => true,
44        Some(f) => filter_passes(v, f),
45    };
46    match metric {
47        Metric::Count => {
48            let n = records.iter().filter(|(_, v)| passes(v)).count();
49            MetricResult::Scalar(n as f64)
50        }
51        Metric::Sum { field } => {
52            let total: f64 = records
53                .iter()
54                .filter(|(_, v)| passes(v))
55                .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
56                .sum();
57            MetricResult::Scalar(total)
58        }
59        Metric::Avg { field } => {
60            let nums: Vec<f64> = records
61                .iter()
62                .filter(|(_, v)| passes(v))
63                .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
64                .collect();
65            let avg = if nums.is_empty() {
66                0.0
67            } else {
68                nums.iter().sum::<f64>() / nums.len() as f64
69            };
70            MetricResult::Scalar(avg)
71        }
72        Metric::Min { field } => {
73            let m = records
74                .iter()
75                .filter(|(_, v)| passes(v))
76                .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
77                .fold(f64::INFINITY, f64::min);
78            MetricResult::Scalar(if m.is_finite() { m } else { 0.0 })
79        }
80        Metric::Max { field } => {
81            let m = records
82                .iter()
83                .filter(|(_, v)| passes(v))
84                .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
85                .fold(f64::NEG_INFINITY, f64::max);
86            MetricResult::Scalar(if m.is_finite() { m } else { 0.0 })
87        }
88        Metric::CountDistinct { field } => {
89            let distinct: std::collections::BTreeSet<String> = records
90                .iter()
91                .filter(|(_, v)| passes(v))
92                .filter_map(|(_, v)| field_as_text(v, field).filter(|s| !s.is_empty()))
93                .collect();
94            MetricResult::Scalar(distinct.len() as f64)
95        }
96        Metric::GroupBy { field } => {
97            let mut counts: BTreeMap<String, usize> = BTreeMap::new();
98            for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
99                let key = field_as_text(v, field).unwrap_or_else(|| "(vacío)".to_string());
100                *counts.entry(key).or_default() += 1;
101            }
102            let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
103            // Mayor conteo primero; empates ordenados por nombre.
104            ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
105            MetricResult::Breakdown(ranked)
106        }
107        Metric::SumBy { group, value } => {
108            MetricResult::ValueBreakdown(grouped_aggregate(records, &passes, group, value, false))
109        }
110        Metric::AvgBy { group, value } => {
111            MetricResult::ValueBreakdown(grouped_aggregate(records, &passes, group, value, true))
112        }
113        Metric::SumBySeries {
114            group,
115            series,
116            value,
117        } => series_aggregate(records, &passes, group, series, value),
118    }
119}
120
121/// Acumula `value` por cada combinación de `group` × `series`,
122/// devolviendo un [`MetricResult::MultiBreakdown`]: el eje `groups`
123/// ordenado por total descendente y, por cada serie, sus valores
124/// alineados 1:1 con `groups` (0.0 donde no hay datos). Las series
125/// también se ordenan por total descendente.
126fn series_aggregate(
127    records: &[(Uuid, Value)],
128    passes: &impl Fn(&Value) -> bool,
129    group: &str,
130    series: &str,
131    value: &str,
132) -> MetricResult {
133    // (grupo, serie) → suma.
134    let mut acc: BTreeMap<(String, String), f64> = BTreeMap::new();
135    // Totales para ordenar ejes.
136    let mut group_total: BTreeMap<String, f64> = BTreeMap::new();
137    let mut series_total: BTreeMap<String, f64> = BTreeMap::new();
138    for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
139        let Some(n) = v.get(value).and_then(Value::as_f64) else {
140            continue;
141        };
142        let g = field_as_text(v, group).unwrap_or_else(|| "(vacío)".to_string());
143        let s = field_as_text(v, series).unwrap_or_else(|| "(vacío)".to_string());
144        *acc.entry((g.clone(), s.clone())).or_default() += n;
145        *group_total.entry(g).or_default() += n;
146        *series_total.entry(s).or_default() += n;
147    }
148    // Ejes ordenados por total desc, empates por nombre.
149    let rank = |m: BTreeMap<String, f64>| -> Vec<String> {
150        let mut v: Vec<(String, f64)> = m.into_iter().collect();
151        v.sort_by(|a, b| {
152            b.1.partial_cmp(&a.1)
153                .unwrap_or(Ordering::Equal)
154                .then_with(|| a.0.cmp(&b.0))
155        });
156        v.into_iter().map(|(k, _)| k).collect()
157    };
158    let groups = rank(group_total);
159    let series_keys = rank(series_total);
160    let series: Vec<(String, Vec<f64>)> = series_keys
161        .into_iter()
162        .map(|s| {
163            let row = groups
164                .iter()
165                .map(|g| acc.get(&(g.clone(), s.clone())).copied().unwrap_or(0.0))
166                .collect();
167            (s, row)
168        })
169        .collect();
170    MetricResult::MultiBreakdown { groups, series }
171}
172
173/// Acumula `value` por cada valor distinto de `group`, devolviendo la
174/// suma (`avg = false`) o el promedio (`avg = true`) por grupo,
175/// ordenado de mayor a menor (empates por nombre de grupo).
176fn grouped_aggregate(
177    records: &[(Uuid, Value)],
178    passes: &impl Fn(&Value) -> bool,
179    group: &str,
180    value: &str,
181    avg: bool,
182) -> Vec<(String, f64)> {
183    // (suma, cuenta-de-numéricos) por grupo.
184    let mut acc: BTreeMap<String, (f64, usize)> = BTreeMap::new();
185    for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
186        let key = field_as_text(v, group).unwrap_or_else(|| "(vacío)".to_string());
187        let entry = acc.entry(key).or_insert((0.0, 0));
188        if let Some(n) = v.get(value).and_then(Value::as_f64) {
189            entry.0 += n;
190            entry.1 += 1;
191        }
192    }
193    let mut ranked: Vec<(String, f64)> = acc
194        .into_iter()
195        .map(|(k, (sum, count))| {
196            let out = if avg && count > 0 {
197                sum / count as f64
198            } else if avg {
199                0.0
200            } else {
201                sum
202            };
203            (k, out)
204        })
205        .collect();
206    ranked.sort_by(|a, b| {
207        b.1.partial_cmp(&a.1)
208            .unwrap_or(std::cmp::Ordering::Equal)
209            .then_with(|| a.0.cmp(&b.0))
210    });
211    ranked
212}
213
214/// Versión pública del predicado de filtro: decide si un record entra
215/// dado un [`CardFilter`]. Útil para componer filtros fuera del motor
216/// (p.ej. controles interactivos que pre-filtran los records).
217pub fn record_matches(v: &Value, f: &CardFilter) -> bool {
218    filter_passes(v, f)
219}
220
221/// Decide si un record pasa el filtro de una tarjeta. Las comparaciones
222/// de orden (`gt`/`lt`/`between`) son numéricas cuando ambos lados
223/// parsean como número, y lexicográficas si no — lo que cubre rangos
224/// de fecha en ISO-8601 sin parser de fechas.
225fn filter_passes(v: &Value, f: &CardFilter) -> bool {
226    let cell = field_as_text(v, &f.field);
227    match f.op {
228        FilterOp::Eq => cell.as_deref() == f.value.as_deref(),
229        FilterOp::Ne => cell.as_deref() != f.value.as_deref(),
230        FilterOp::NonEmpty => cell.map(|s| !s.is_empty()).unwrap_or(false),
231        FilterOp::Gt | FilterOp::Gte | FilterOp::Lt | FilterOp::Lte => {
232            let (Some(cell), Some(bound)) = (cell, f.value.as_ref()) else {
233                return false;
234            };
235            let ord = cmp_text(&cell, bound);
236            match f.op {
237                FilterOp::Gt => ord == Ordering::Greater,
238                FilterOp::Gte => ord != Ordering::Less,
239                FilterOp::Lt => ord == Ordering::Less,
240                FilterOp::Lte => ord != Ordering::Greater,
241                _ => unreachable!(),
242            }
243        }
244        FilterOp::Between => {
245            let Some(cell) = cell else {
246                return false;
247            };
248            let lo_ok = f
249                .min
250                .as_ref()
251                .map_or(true, |lo| cmp_text(&cell, lo) != Ordering::Less);
252            let hi_ok = f
253                .max
254                .as_ref()
255                .map_or(true, |hi| cmp_text(&cell, hi) != Ordering::Greater);
256            lo_ok && hi_ok
257        }
258    }
259}
260
261/// Orden entre dos valores como texto: numérico si ambos parsean,
262/// lexicográfico en caso contrario.
263fn cmp_text(a: &str, b: &str) -> Ordering {
264    match (a.parse::<f64>(), b.parse::<f64>()) {
265        (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
266        _ => a.cmp(b),
267    }
268}
269
270/// Serializa un desglose (`Breakdown` conteo o `ValueBreakdown` valor)
271/// a CSV de dos columnas. `value_header` rotula la segunda columna.
272/// Reusa el `to_csv` del runtime para el quoting.
273pub fn breakdown_to_csv(
274    result: &MetricResult,
275    group_header: &str,
276    value_header: &str,
277) -> Option<String> {
278    let rows: Vec<Vec<String>> = match result {
279        MetricResult::Breakdown(rows) => rows
280            .iter()
281            .map(|(k, n)| vec![k.clone(), n.to_string()])
282            .collect(),
283        MetricResult::ValueBreakdown(rows) => rows
284            .iter()
285            .map(|(k, v)| {
286                let n = if v.fract() == 0.0 {
287                    format!("{}", *v as i64)
288                } else {
289                    v.to_string()
290                };
291                vec![k.clone(), n]
292            })
293            .collect(),
294        // Matriz: una columna por serie. `value_header` se ignora — los
295        // encabezados de valor son los nombres de las series.
296        MetricResult::MultiBreakdown { groups, series } => {
297            let mut headers = vec![group_header.to_string()];
298            headers.extend(series.iter().map(|(name, _)| name.clone()));
299            let rows: Vec<Vec<String>> = groups
300                .iter()
301                .enumerate()
302                .map(|(i, g)| {
303                    let mut row = vec![g.clone()];
304                    for (_, vals) in series {
305                        let v = vals.get(i).copied().unwrap_or(0.0);
306                        let n = if v.fract() == 0.0 {
307                            format!("{}", v as i64)
308                        } else {
309                            v.to_string()
310                        };
311                        row.push(n);
312                    }
313                    row
314                })
315                .collect();
316            return Some(crate::csv::to_csv(&headers, &rows));
317        }
318        MetricResult::Scalar(_) => return None,
319    };
320    Some(crate::csv::to_csv(
321        &[group_header.to_string(), value_header.to_string()],
322        &rows,
323    ))
324}
325
326/// Etiqueta de la fila que agrupa el resto de un desglose recortado.
327pub const OTROS_LABEL: &str = "Otros";
328
329/// Recorta un desglose a sus `limit` filas de mayor valor (el motor ya
330/// las entrega ordenadas de mayor a menor) y colapsa el resto en una
331/// fila [`OTROS_LABEL`]. Devuelve `true` si efectivamente agregó esa
332/// fila (hubo más de `limit` grupos), para que la capa de presentación
333/// la marque como no-navegable.
334///
335/// El valor de "Otros" es la **suma** del resto para conteos
336/// (`GroupBy`) y para sumas (`SumBy`, `additive = true`); para
337/// promedios (`AvgBy`, `additive = false`) es el promedio aritmético de
338/// los valores de los grupos restantes (aproximación clara: la fila
339/// "Otros" no es un grupo real). `limit == 0` no recorta nada.
340pub fn limit_breakdown(result: &mut MetricResult, limit: usize, additive: bool) -> bool {
341    if limit == 0 {
342        return false;
343    }
344    match result {
345        MetricResult::Breakdown(rows) if rows.len() > limit => {
346            let tail: usize = rows[limit..].iter().map(|(_, n)| *n).sum();
347            rows.truncate(limit);
348            rows.push((OTROS_LABEL.to_string(), tail));
349            true
350        }
351        MetricResult::ValueBreakdown(rows) if rows.len() > limit => {
352            let rest = &rows[limit..];
353            let value = if additive {
354                rest.iter().map(|(_, v)| *v).sum()
355            } else {
356                let sum: f64 = rest.iter().map(|(_, v)| *v).sum();
357                sum / rest.len() as f64
358            };
359            rows.truncate(limit);
360            rows.push((OTROS_LABEL.to_string(), value));
361            true
362        }
363        // El recorte top-N no aplica a desgloses de dos dimensiones.
364        _ => false,
365    }
366}
367
368/// Trunca una fecha ISO-8601 a la granularidad pedida, para agrupar
369/// series temporales (`2026-01-15T10:30Z` / `2026-01-15` → `2026`,
370/// `2026-01` o `2026-01-15`). Si `raw` no empieza con un año de 4
371/// dígitos seguido de `-` (o no es una fecha reconocible), se devuelve
372/// sin cambios — el bucketing es inofensivo sobre datos no-fecha.
373pub fn bucket_date(raw: &str, bucket: DateBucket) -> String {
374    // Reconocer el prefijo `YYYY-`: 4 dígitos + guión.
375    let looks_like_date = raw.len() >= 5
376        && raw.as_bytes()[..4].iter().all(u8::is_ascii_digit)
377        && raw.as_bytes()[4] == b'-';
378    if !looks_like_date {
379        return raw.to_string();
380    }
381    // Fecha = los primeros 10 chars (`YYYY-MM-DD`) si los hay.
382    let date = raw.get(0..10).unwrap_or(raw);
383    let end = match bucket {
384        DateBucket::Year => 4,
385        DateBucket::Month => 7,
386        DateBucket::Day => 10,
387    };
388    date.get(0..end).unwrap_or(date).to_string()
389}
390
391/// Reordena las filas de un desglose por su clave de grupo ascendente
392/// (lexicográfico — coincide con el orden cronológico para claves ISO
393/// de [`bucket_date`]). No-op sobre escalares.
394pub fn sort_breakdown_by_key(result: &mut MetricResult) {
395    match result {
396        MetricResult::Breakdown(rows) => rows.sort_by(|a, b| a.0.cmp(&b.0)),
397        MetricResult::ValueBreakdown(rows) => rows.sort_by(|a, b| a.0.cmp(&b.0)),
398        // Reordena el eje `groups` por clave y permuta cada serie con la
399        // misma permutación, manteniendo la alineación 1:1.
400        MetricResult::MultiBreakdown { groups, series } => {
401            let mut perm: Vec<usize> = (0..groups.len()).collect();
402            perm.sort_by(|&a, &b| groups[a].cmp(&groups[b]));
403            *groups = perm.iter().map(|&i| groups[i].clone()).collect();
404            for (_, vals) in series.iter_mut() {
405                *vals = perm.iter().map(|&i| vals[i]).collect();
406            }
407        }
408        MetricResult::Scalar(_) => {}
409    }
410}
411
412/// Reescribe un desglose ordenado a su **acumulado** (running total):
413/// cada valor pasa a ser la suma corrida de sí mismo y todos los
414/// anteriores. Pensado para series temporales (orden cronológico, vía
415/// `bucket`) — p.ej. el saldo acumulado de tesorería mes a mes. En
416/// multi-serie, cada serie acumula de forma independiente a lo largo
417/// del eje de grupos. `Breakdown` (conteo) se acumula igual. No-op
418/// sobre `Scalar`. Asume que `result` ya está en el orden deseado
419/// (típicamente tras `sort_breakdown_by_key`); sólo tiene sentido sobre
420/// métricas aditivas (`Count`/`Sum`).
421pub fn cumulative_breakdown(result: &mut MetricResult) {
422    match result {
423        MetricResult::Breakdown(rows) => {
424            let mut acc = 0usize;
425            for (_, v) in rows.iter_mut() {
426                acc += *v;
427                *v = acc;
428            }
429        }
430        MetricResult::ValueBreakdown(rows) => {
431            let mut acc = 0.0;
432            for (_, v) in rows.iter_mut() {
433                acc += *v;
434                *v = acc;
435            }
436        }
437        MetricResult::MultiBreakdown { series, .. } => {
438            for (_, vals) in series.iter_mut() {
439                let mut acc = 0.0;
440                for v in vals.iter_mut() {
441                    acc += *v;
442                    *v = acc;
443                }
444            }
445        }
446        MetricResult::Scalar(_) => {}
447    }
448}
449
450/// Valor de un campo de nivel superior como texto plano, para comparar
451/// (filtros) o agrupar (`GroupBy`).
452fn field_as_text(v: &Value, field: &str) -> Option<String> {
453    match v.get(field)? {
454        Value::Null => None,
455        Value::String(s) => Some(s.clone()),
456        other => Some(other.to_string()),
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use serde_json::json;
464
465    fn recs(items: &[Value]) -> Vec<(Uuid, Value)> {
466        items.iter().map(|v| (Uuid::new_v4(), v.clone())).collect()
467    }
468
469    #[test]
470    fn bucket_date_truncates_iso() {
471        assert_eq!(bucket_date("2026-01-15", DateBucket::Year), "2026");
472        assert_eq!(bucket_date("2026-01-15", DateBucket::Month), "2026-01");
473        assert_eq!(bucket_date("2026-01-15", DateBucket::Day), "2026-01-15");
474        // Con hora.
475        assert_eq!(bucket_date("2026-03-09T10:30:00Z", DateBucket::Month), "2026-03");
476        // No-fecha → sin cambios (inofensivo).
477        assert_eq!(bucket_date("activo", DateBucket::Month), "activo");
478        assert_eq!(bucket_date("", DateBucket::Year), "");
479    }
480
481    #[test]
482    fn sort_breakdown_by_key_is_chronological_for_iso() {
483        let mut r = MetricResult::ValueBreakdown(vec![
484            ("2026-03".into(), 30.0),
485            ("2026-01".into(), 10.0),
486            ("2026-02".into(), 20.0),
487        ]);
488        sort_breakdown_by_key(&mut r);
489        assert_eq!(
490            r,
491            MetricResult::ValueBreakdown(vec![
492                ("2026-01".into(), 10.0),
493                ("2026-02".into(), 20.0),
494                ("2026-03".into(), 30.0),
495            ])
496        );
497    }
498
499    #[test]
500    fn cumulative_breakdown_running_total() {
501        // Serie de valores → saldo acumulado.
502        let mut r = MetricResult::ValueBreakdown(vec![
503            ("2026-01".into(), 10.0),
504            ("2026-02".into(), 20.0),
505            ("2026-03".into(), 5.0),
506        ]);
507        cumulative_breakdown(&mut r);
508        assert_eq!(
509            r,
510            MetricResult::ValueBreakdown(vec![
511                ("2026-01".into(), 10.0),
512                ("2026-02".into(), 30.0),
513                ("2026-03".into(), 35.0),
514            ])
515        );
516        // Conteo acumulado.
517        let mut c = MetricResult::Breakdown(vec![("a".into(), 2), ("b".into(), 3), ("c".into(), 1)]);
518        cumulative_breakdown(&mut c);
519        assert_eq!(
520            c,
521            MetricResult::Breakdown(vec![("a".into(), 2), ("b".into(), 5), ("c".into(), 6)])
522        );
523        // Multi-serie: cada serie acumula por separado.
524        let mut m = MetricResult::MultiBreakdown {
525            groups: vec!["ene".into(), "feb".into(), "mar".into()],
526            series: vec![
527                ("pagado".into(), vec![1.0, 2.0, 3.0]),
528                ("no".into(), vec![10.0, 0.0, 5.0]),
529            ],
530        };
531        cumulative_breakdown(&mut m);
532        assert_eq!(
533            m,
534            MetricResult::MultiBreakdown {
535                groups: vec!["ene".into(), "feb".into(), "mar".into()],
536                series: vec![
537                    ("pagado".into(), vec![1.0, 3.0, 6.0]),
538                    ("no".into(), vec![10.0, 10.0, 15.0]),
539                ],
540            }
541        );
542        // No-op sobre escalar.
543        let mut s = MetricResult::Scalar(42.0);
544        cumulative_breakdown(&mut s);
545        assert_eq!(s, MetricResult::Scalar(42.0));
546    }
547
548    #[test]
549    fn limit_breakdown_counts_sums_the_tail() {
550        let mut r = MetricResult::Breakdown(vec![
551            ("a".into(), 10),
552            ("b".into(), 6),
553            ("c".into(), 3),
554            ("d".into(), 1),
555        ]);
556        let collapsed = limit_breakdown(&mut r, 2, true);
557        assert!(collapsed);
558        assert_eq!(
559            r,
560            MetricResult::Breakdown(vec![
561                ("a".into(), 10),
562                ("b".into(), 6),
563                (OTROS_LABEL.into(), 4), // 3 + 1
564            ])
565        );
566    }
567
568    #[test]
569    fn limit_breakdown_avg_bucket_is_mean_not_sum() {
570        let mut r = MetricResult::ValueBreakdown(vec![
571            ("a".into(), 100.0),
572            ("b".into(), 80.0),
573            ("c".into(), 60.0),
574            ("d".into(), 40.0),
575        ]);
576        // additive = false (AvgBy): el bucket promedia los restantes.
577        let collapsed = limit_breakdown(&mut r, 2, false);
578        assert!(collapsed);
579        assert_eq!(
580            r,
581            MetricResult::ValueBreakdown(vec![
582                ("a".into(), 100.0),
583                ("b".into(), 80.0),
584                (OTROS_LABEL.into(), 50.0), // (60 + 40) / 2
585            ])
586        );
587    }
588
589    #[test]
590    fn limit_breakdown_noop_when_within_limit() {
591        let mut r = MetricResult::Breakdown(vec![("a".into(), 3), ("b".into(), 1)]);
592        let before = r.clone();
593        assert!(!limit_breakdown(&mut r, 5, true));
594        assert!(!limit_breakdown(&mut r, 0, true));
595        assert_eq!(r, before);
596    }
597
598    #[test]
599    fn count_all_and_filtered() {
600        let rs = recs(&[
601            json!({"etapa": "ganada"}),
602            json!({"etapa": "ganada"}),
603            json!({"etapa": "perdida"}),
604        ]);
605        assert_eq!(
606            compute_metric(&Metric::Count, None, &rs),
607            MetricResult::Scalar(3.0)
608        );
609        let f = CardFilter {
610            field: "etapa".into(),
611            op: FilterOp::Eq,
612            value: Some("ganada".into()),
613            min: None,
614            max: None,
615        };
616        assert_eq!(
617            compute_metric(&Metric::Count, Some(&f), &rs),
618            MetricResult::Scalar(2.0)
619        );
620    }
621
622    #[test]
623    fn count_distinct_counts_unique_non_empty_values() {
624        let rs = recs(&[
625            json!({"cliente": "acme"}),
626            json!({"cliente": "acme"}),
627            json!({"cliente": "globex"}),
628            json!({"cliente": ""}),      // vacío → no cuenta
629            json!({"otro": "x"}),        // sin el campo → no cuenta
630        ]);
631        assert_eq!(
632            compute_metric(&Metric::CountDistinct { field: "cliente".into() }, None, &rs),
633            MetricResult::Scalar(2.0) // acme, globex
634        );
635        // Con filtro: sólo los records que pasan entran al set distinto.
636        let f = CardFilter {
637            field: "cliente".into(),
638            op: FilterOp::Eq,
639            value: Some("acme".into()),
640            min: None,
641            max: None,
642        };
643        assert_eq!(
644            compute_metric(&Metric::CountDistinct { field: "cliente".into() }, Some(&f), &rs),
645            MetricResult::Scalar(1.0)
646        );
647    }
648
649    fn filt(field: &str, op: FilterOp, value: Option<&str>) -> CardFilter {
650        CardFilter {
651            field: field.into(),
652            op,
653            value: value.map(Into::into),
654            min: None,
655            max: None,
656        }
657    }
658
659    #[test]
660    fn numeric_range_filters() {
661        let rs = recs(&[
662            json!({"monto": 100}),
663            json!({"monto": 500}),
664            json!({"monto": 900}),
665        ]);
666        // gte 500 → 500 y 900.
667        assert_eq!(
668            compute_metric(&Metric::Count, Some(&filt("monto", FilterOp::Gte, Some("500"))), &rs),
669            MetricResult::Scalar(2.0)
670        );
671        // lt 500 → solo 100.
672        assert_eq!(
673            compute_metric(&Metric::Count, Some(&filt("monto", FilterOp::Lt, Some("500"))), &rs),
674            MetricResult::Scalar(1.0)
675        );
676        // between [200, 800] → solo 500.
677        let between = CardFilter {
678            field: "monto".into(),
679            op: FilterOp::Between,
680            value: None,
681            min: Some("200".into()),
682            max: Some("800".into()),
683        };
684        assert_eq!(
685            compute_metric(&Metric::Count, Some(&between), &rs),
686            MetricResult::Scalar(1.0)
687        );
688    }
689
690    #[test]
691    fn date_range_is_lexicographic() {
692        let rs = recs(&[
693            json!({"fecha": "2026-01-15"}),
694            json!({"fecha": "2026-06-30"}),
695            json!({"fecha": "2027-02-01"}),
696        ]);
697        let q1_h1 = CardFilter {
698            field: "fecha".into(),
699            op: FilterOp::Between,
700            value: None,
701            min: Some("2026-01-01".into()),
702            max: Some("2026-12-31".into()),
703        };
704        assert_eq!(
705            compute_metric(&Metric::Count, Some(&q1_h1), &rs),
706            MetricResult::Scalar(2.0)
707        );
708    }
709
710    #[test]
711    fn non_empty_filter() {
712        let rs = recs(&[json!({"nota": "x"}), json!({"nota": ""}), json!({"otro": 1})]);
713        assert_eq!(
714            compute_metric(&Metric::Count, Some(&filt("nota", FilterOp::NonEmpty, None)), &rs),
715            MetricResult::Scalar(1.0)
716        );
717    }
718
719    #[test]
720    fn breakdown_csv_roundtrip() {
721        let res = MetricResult::ValueBreakdown(vec![
722            ("ACME".into(), 1500.0),
723            ("Globex".into(), 2000.0),
724        ]);
725        let csv = breakdown_to_csv(&res, "Cliente", "Monto").unwrap();
726        assert_eq!(csv, "Cliente,Monto\nACME,1500\nGlobex,2000\n");
727        assert!(breakdown_to_csv(&MetricResult::Scalar(1.0), "a", "b").is_none());
728    }
729
730    #[test]
731    fn sum_skips_missing_and_non_numeric() {
732        let rs = recs(&[
733            json!({"monto": 1000}),
734            json!({"monto": 2500}),
735            json!({"otro": 1}),
736        ]);
737        assert_eq!(
738            compute_metric(
739                &Metric::Sum {
740                    field: "monto".into()
741                },
742                None,
743                &rs
744            ),
745            MetricResult::Scalar(3500.0)
746        );
747    }
748
749    #[test]
750    fn group_by_counts_and_ranks_by_frequency() {
751        let rs = recs(&[
752            json!({"etapa": "prospecto"}),
753            json!({"etapa": "ganada"}),
754            json!({"etapa": "ganada"}),
755        ]);
756        assert_eq!(
757            compute_metric(
758                &Metric::GroupBy {
759                    field: "etapa".into()
760                },
761                None,
762                &rs
763            ),
764            MetricResult::Breakdown(vec![
765                ("ganada".to_string(), 2),
766                ("prospecto".to_string(), 1),
767            ])
768        );
769    }
770
771    #[test]
772    fn avg_min_max_over_numeric() {
773        let rs = recs(&[
774            json!({"monto": 100}),
775            json!({"monto": 300}),
776            json!({"otro": 1}), // ignorado
777        ]);
778        assert_eq!(
779            compute_metric(&Metric::Avg { field: "monto".into() }, None, &rs),
780            MetricResult::Scalar(200.0)
781        );
782        assert_eq!(
783            compute_metric(&Metric::Min { field: "monto".into() }, None, &rs),
784            MetricResult::Scalar(100.0)
785        );
786        assert_eq!(
787            compute_metric(&Metric::Max { field: "monto".into() }, None, &rs),
788            MetricResult::Scalar(300.0)
789        );
790    }
791
792    #[test]
793    fn avg_empty_is_zero_not_nan() {
794        let rs = recs(&[json!({"otro": 1})]);
795        assert_eq!(
796            compute_metric(&Metric::Avg { field: "monto".into() }, None, &rs),
797            MetricResult::Scalar(0.0)
798        );
799        assert_eq!(
800            compute_metric(&Metric::Min { field: "monto".into() }, None, &rs),
801            MetricResult::Scalar(0.0)
802        );
803    }
804
805    #[test]
806    fn sum_by_aggregates_and_ranks_by_value() {
807        let rs = recs(&[
808            json!({"cliente": "ACME", "monto": 1000}),
809            json!({"cliente": "ACME", "monto": 500}),
810            json!({"cliente": "Globex", "monto": 2000}),
811        ]);
812        assert_eq!(
813            compute_metric(
814                &Metric::SumBy {
815                    group: "cliente".into(),
816                    value: "monto".into()
817                },
818                None,
819                &rs
820            ),
821            MetricResult::ValueBreakdown(vec![
822                ("Globex".to_string(), 2000.0),
823                ("ACME".to_string(), 1500.0),
824            ])
825        );
826    }
827
828    #[test]
829    fn sum_by_series_builds_aligned_matrix() {
830        let rs = recs(&[
831            json!({"mes": "2026-01", "plan": "pro", "monto": 100}),
832            json!({"mes": "2026-01", "plan": "free", "monto": 30}),
833            json!({"mes": "2026-02", "plan": "pro", "monto": 200}),
834            // free no factura en feb → 0.0 alineado.
835        ]);
836        let r = compute_metric(
837            &Metric::SumBySeries {
838                group: "mes".into(),
839                series: "plan".into(),
840                value: "monto".into(),
841            },
842            None,
843            &rs,
844        );
845        // groups por total desc: feb(200) > ene(130). series por total
846        // desc: pro(300) > free(30).
847        assert_eq!(
848            r,
849            MetricResult::MultiBreakdown {
850                groups: vec!["2026-02".into(), "2026-01".into()],
851                series: vec![
852                    ("pro".into(), vec![200.0, 100.0]),
853                    ("free".into(), vec![0.0, 30.0]),
854                ],
855            }
856        );
857    }
858
859    #[test]
860    fn sort_multi_breakdown_permutes_series() {
861        let mut r = MetricResult::MultiBreakdown {
862            groups: vec!["2026-02".into(), "2026-01".into()],
863            series: vec![
864                ("pro".into(), vec![200.0, 100.0]),
865                ("free".into(), vec![0.0, 30.0]),
866            ],
867        };
868        sort_breakdown_by_key(&mut r);
869        // groups cronológicos; cada serie sigue la misma permutación.
870        assert_eq!(
871            r,
872            MetricResult::MultiBreakdown {
873                groups: vec!["2026-01".into(), "2026-02".into()],
874                series: vec![
875                    ("pro".into(), vec![100.0, 200.0]),
876                    ("free".into(), vec![30.0, 0.0]),
877                ],
878            }
879        );
880    }
881
882    #[test]
883    fn multi_breakdown_csv_is_a_matrix() {
884        let r = MetricResult::MultiBreakdown {
885            groups: vec!["2026-01".into(), "2026-02".into()],
886            series: vec![
887                ("pro".into(), vec![100.0, 200.0]),
888                ("free".into(), vec![30.0, 0.0]),
889            ],
890        };
891        let csv = breakdown_to_csv(&r, "Mes", "ignorado").unwrap();
892        assert_eq!(csv, "Mes,pro,free\n2026-01,100,30\n2026-02,200,0\n");
893    }
894
895    #[test]
896    fn avg_by_is_per_group_mean() {
897        let rs = recs(&[
898            json!({"plan": "pro", "monto": 100}),
899            json!({"plan": "pro", "monto": 300}),
900            json!({"plan": "free", "monto": 50}),
901        ]);
902        assert_eq!(
903            compute_metric(
904                &Metric::AvgBy {
905                    group: "plan".into(),
906                    value: "monto".into()
907                },
908                None,
909                &rs
910            ),
911            MetricResult::ValueBreakdown(vec![
912                ("pro".to_string(), 200.0),
913                ("free".to_string(), 50.0),
914            ])
915        );
916    }
917}