⎇ f63e78141d856d65edfc06c793a3e00a7eff7bfe
02_ruway/nahual/libs/meta-runtime/src/metric.rs
Historia
Quién tocó qué
crudo
feat: nahual standalone — visores open-with sobre Llimphi (front-door, git-dep al monorepo)
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}