1
//! Turn view projections into chart specs. Adapters are pure functions
2
//! that handle top-N clipping, multi-currency scaling decisions, and
3
//! layout choices (x-axis assignment, series arrangement) so renderers
4
//! only have to draw.
5

            
6
use std::collections::BTreeMap;
7

            
8
use num_rational::Rational64;
9
use server::command::report::view::{
10
    AmountView, BreakdownPeriodView, PeriodActivityView, ReportRowView,
11
};
12

            
13
use crate::spec::{ChartKind, ChartSpec, Series, SeriesPoint};
14

            
15
// ---------- shared helpers ----------
16

            
17
37
fn abs(r: Rational64) -> Rational64 {
18
37
    if r < Rational64::new(0, 1) { -r } else { r }
19
37
}
20

            
21
35
fn point(x: impl Into<String>, amount: Rational64) -> SeriesPoint {
22
35
    SeriesPoint {
23
35
        x: x.into(),
24
35
        y_num: *amount.numer(),
25
35
        y_denom: *amount.denom(),
26
35
    }
27
35
}
28

            
29
56
fn amount_for(symbol: &str, amounts: &[AmountView]) -> Rational64 {
30
56
    amounts
31
56
        .iter()
32
58
        .find(|a| a.commodity_symbol == symbol)
33
56
        .map_or_else(|| Rational64::new(0, 1), |a| a.amount)
34
56
}
35

            
36
/// Pick the commodity to plot. When the report already targeted a single
37
/// commodity there's usually only one symbol; otherwise the adapter
38
/// picks the one that first appears and appends a note listing the
39
/// elided symbols so the display can warn the user.
40
13
fn pick_primary_commodity(symbols: &[String]) -> (String, Vec<String>) {
41
13
    match symbols.split_first() {
42
13
        Some((primary, rest)) => (primary.clone(), rest.to_vec()),
43
        None => (String::new(), Vec::new()),
44
    }
45
13
}
46

            
47
13
fn multi_currency_note(elided: &[String]) -> Option<String> {
48
13
    match elided.len() {
49
11
        0 => None,
50
2
        1 => Some(format!(
51
2
            "Showing primary commodity only; {} hidden. Set a target currency to see all.",
52
2
            elided[0],
53
2
        )),
54
        n => Some(format!(
55
            "Showing primary commodity only; {n} others hidden. Set a target currency to see all.",
56
        )),
57
    }
58
13
}
59

            
60
/// Sort ranked `(row, magnitude)` pairs in place according to the
61
/// requested order. Uses the balance adapter's data shape — callers
62
/// that need this for other adapters can swap the row type via the
63
/// tuple's first field; the function is generic in the row ref.
64
6
fn sort_ranked<R: AccountLike>(ranked: &mut [(R, Rational64)], order: SortOrder) {
65
6
    match order {
66
        SortOrder::MagnitudeDesc => {
67
12
            ranked.sort_by_key(|entry| std::cmp::Reverse(entry.1));
68
        }
69
        SortOrder::MagnitudeAsc => {
70
1
            ranked.sort_by_key(|entry| entry.1);
71
        }
72
        SortOrder::NameAsc => {
73
3
            ranked.sort_by(|a, b| a.0.name().cmp(b.0.name()));
74
        }
75
        SortOrder::NameDesc => {
76
3
            ranked.sort_by(|a, b| b.0.name().cmp(a.0.name()));
77
        }
78
    }
79
6
}
80

            
81
trait AccountLike {
82
    fn name(&self) -> &str;
83
}
84

            
85
impl AccountLike for &ReportRowView {
86
12
    fn name(&self) -> &str {
87
12
        &self.account_name
88
12
    }
89
}
90

            
91
// ---------- Balance ----------
92

            
93
/// Sort key applied to a ranked set of rows. Shared by the balance
94
/// adapter and (via the web handler) the balance table rendering —
95
/// keeping them on one enum means the chart can't drift from the
96
/// table ordering.
97
#[derive(Debug, Clone, Copy, Default)]
98
pub enum SortOrder {
99
    #[default]
100
    MagnitudeDesc,
101
    MagnitudeAsc,
102
    NameAsc,
103
    NameDesc,
104
}
105

            
106
pub struct BalanceChartOpts {
107
    pub kind: ChartKind,
108
    pub top_n: usize,
109
    pub sort_order: SortOrder,
110
}
111

            
112
/// Balance chart: one series per commodity, x = top-level account names.
113
/// When more than one commodity is present and the `top_n` most-active
114
/// accounts span several commodities, we still render all bars — each
115
/// series places a bar in the x-slot for its account and zero elsewhere,
116
/// so a mixed-currency report reads as a grouped / stacked bar chart.
117
#[must_use]
118
7
pub fn balance_chart(rows: &[ReportRowView], opts: BalanceChartOpts) -> ChartSpec {
119
18
    let top_level: Vec<&ReportRowView> = rows.iter().filter(|r| r.depth == 0).collect();
120

            
121
7
    if top_level.is_empty() {
122
1
        return ChartSpec {
123
1
            title: "Balance".to_string(),
124
1
            kind: opts.kind,
125
1
            x_label: "Account".to_string(),
126
1
            y_label: "Amount".to_string(),
127
1
            series: vec![],
128
1
            notes: vec!["No top-level accounts to chart.".to_string()],
129
1
        };
130
6
    }
131

            
132
    // Gather commodity symbols in insertion order, then pick the
133
    // primary for plotting. A mixed-currency balance notes the elided
134
    // commodities rather than silently mashing them together.
135
6
    let mut symbols: Vec<String> = Vec::new();
136
17
    for row in &top_level {
137
18
        for a in &row.amounts {
138
18
            if !symbols.iter().any(|s| s == &a.commodity_symbol) {
139
7
                symbols.push(a.commodity_symbol.clone());
140
11
            }
141
        }
142
    }
143
6
    let (primary, elided) = pick_primary_commodity(&symbols);
144

            
145
    // Rank accounts by the primary commodity's absolute amount and
146
    // then apply the caller's sort choice. Keeping magnitude as the
147
    // secondary key means name-sorted charts still break ties
148
    // sensibly.
149
6
    let mut ranked: Vec<(&ReportRowView, Rational64)> = top_level
150
6
        .into_iter()
151
17
        .map(|row| (row, abs(amount_for(&primary, &row.amounts))))
152
6
        .collect();
153
6
    sort_ranked(&mut ranked, opts.sort_order);
154

            
155
6
    let clipped = ranked.len() > opts.top_n && opts.top_n > 0;
156
6
    if opts.top_n > 0 {
157
6
        ranked.truncate(opts.top_n);
158
6
    }
159

            
160
    // One series per account so each bar reads as its own colour and
161
    // the legend names the account. Each series holds a single point
162
    // anchored at the account name.
163
6
    let series: Vec<Series> = ranked
164
6
        .iter()
165
6
        .map(|(row, _)| Series {
166
16
            label: row.account_name.clone(),
167
16
            commodity_symbol: primary.clone(),
168
16
            points: vec![point(&row.account_name, amount_for(&primary, &row.amounts))],
169
16
        })
170
6
        .collect();
171

            
172
6
    let mut notes = Vec::new();
173
6
    if let Some(n) = multi_currency_note(&elided) {
174
1
        notes.push(n);
175
5
    }
176
6
    if clipped {
177
1
        notes.push(format!("Showing top {} accounts by magnitude.", opts.top_n));
178
5
    }
179

            
180
6
    ChartSpec {
181
6
        title: "Balance".to_string(),
182
6
        kind: opts.kind,
183
6
        x_label: "Account".to_string(),
184
6
        y_label: format!("Amount ({primary})"),
185
6
        series,
186
6
        notes,
187
6
    }
188
7
}
189

            
190
// ---------- Activity ----------
191

            
192
pub struct ActivityChartOpts {
193
    pub kind: ChartKind,
194
    pub include_net: bool,
195
}
196

            
197
/// Activity chart: x = period label, one series per group label, plus
198
/// an optional Net series. Picks a single commodity when multiple are
199
/// present and notes the omission.
200
#[must_use]
201
3
pub fn activity_chart(periods: &[PeriodActivityView], opts: ActivityChartOpts) -> ChartSpec {
202
    // Pass 1: distinct commodities across every group total.
203
3
    let mut symbols: Vec<String> = Vec::new();
204
4
    for p in periods {
205
6
        for g in &p.groups {
206
7
            for a in &g.total {
207
7
                if !symbols.iter().any(|s| s == &a.commodity_symbol) {
208
4
                    symbols.push(a.commodity_symbol.clone());
209
4
                }
210
            }
211
7
            for a in &p.net {
212
8
                if !symbols.iter().any(|s| s == &a.commodity_symbol) {
213
                    symbols.push(a.commodity_symbol.clone());
214
7
                }
215
            }
216
        }
217
    }
218
3
    let (primary, elided) = pick_primary_commodity(&symbols);
219

            
220
    // Pass 2: group labels in insertion order.
221
3
    let mut group_labels: Vec<String> = Vec::new();
222
4
    for p in periods {
223
6
        for g in &p.groups {
224
6
            if !group_labels.iter().any(|s| s == &g.label) {
225
4
                group_labels.push(g.label.clone());
226
4
            }
227
        }
228
    }
229

            
230
3
    let mut series: Vec<Series> = group_labels
231
3
        .iter()
232
3
        .map(|label| Series {
233
4
            label: label.clone(),
234
4
            commodity_symbol: primary.clone(),
235
4
            points: periods
236
4
                .iter()
237
6
                .map(|p| {
238
6
                    let amount = p
239
6
                        .groups
240
6
                        .iter()
241
8
                        .find(|g| &g.label == label)
242
6
                        .map_or_else(|| Rational64::new(0, 1), |g| amount_for(&primary, &g.total));
243
6
                    point(&p.label, amount)
244
6
                })
245
4
                .collect(),
246
4
        })
247
3
        .collect();
248

            
249
3
    if opts.include_net {
250
1
        series.push(Series {
251
1
            label: "Net".to_string(),
252
1
            commodity_symbol: primary.clone(),
253
1
            points: periods
254
1
                .iter()
255
2
                .map(|p| point(&p.label, amount_for(&primary, &p.net)))
256
1
                .collect(),
257
        });
258
2
    }
259

            
260
3
    let mut notes = Vec::new();
261
3
    if let Some(n) = multi_currency_note(&elided) {
262
1
        notes.push(n);
263
2
    }
264
3
    if periods.is_empty() {
265
        notes.push("No periods in range.".to_string());
266
3
    }
267

            
268
3
    ChartSpec {
269
3
        title: "Activity".to_string(),
270
3
        kind: opts.kind,
271
3
        x_label: "Period".to_string(),
272
3
        y_label: format!("Amount ({primary})"),
273
3
        series,
274
3
        notes,
275
3
    }
276
3
}
277

            
278
// ---------- Category Breakdown ----------
279

            
280
pub struct BreakdownChartOpts {
281
    pub kind: ChartKind,
282
    pub top_n: usize,
283
}
284

            
285
/// Breakdown chart. Two shapes depending on whether the breakdown has
286
/// period grouping:
287
///
288
/// - **With periods** (len > 1, or the single label is non-empty):
289
///   x = period label, one series per top-N tag value.
290
/// - **Flat** (single period, empty label — meaning no `period_grouping`):
291
///   x = tag value, a single series.
292
#[must_use]
293
4
pub fn breakdown_chart(periods: &[BreakdownPeriodView], opts: BreakdownChartOpts) -> ChartSpec {
294
4
    if periods.is_empty() {
295
        return ChartSpec {
296
            title: "Category Breakdown".to_string(),
297
            kind: opts.kind,
298
            x_label: "Category".to_string(),
299
            y_label: "Amount".to_string(),
300
            series: vec![],
301
            notes: vec!["No rows in range.".to_string()],
302
        };
303
4
    }
304

            
305
4
    let mut symbols: Vec<String> = Vec::new();
306
5
    for p in periods {
307
12
        for row in &p.rows {
308
12
            for a in &row.amounts {
309
12
                if !symbols.iter().any(|s| s == &a.commodity_symbol) {
310
4
                    symbols.push(a.commodity_symbol.clone());
311
8
                }
312
            }
313
        }
314
    }
315
4
    let (primary, elided) = pick_primary_commodity(&symbols);
316

            
317
4
    let flat = periods.len() == 1 && periods[0].label.is_empty();
318

            
319
    // Pull the uncategorized flag off each tag-value up front so flat
320
    // and period shapes can relabel "__uncategorized__" consistently.
321
4
    let mut uncategorized_tags: std::collections::HashSet<String> =
322
4
        std::collections::HashSet::new();
323
5
    for p in periods {
324
12
        for row in &p.rows {
325
12
            if row.is_uncategorized {
326
1
                uncategorized_tags.insert(row.tag_value.clone());
327
11
            }
328
        }
329
    }
330

            
331
    // Accumulate per-tag totals (for top-N ranking).
332
4
    let mut totals: BTreeMap<String, Rational64> = BTreeMap::new();
333
5
    for p in periods {
334
12
        for row in &p.rows {
335
12
            let entry = totals
336
12
                .entry(row.tag_value.clone())
337
12
                .or_insert_with(|| Rational64::new(0, 1));
338
12
            *entry += amount_for(&primary, &row.amounts);
339
        }
340
    }
341

            
342
4
    let mut ranked: Vec<(String, Rational64)> = totals.into_iter().collect();
343
20
    ranked.sort_by_key(|entry| std::cmp::Reverse(abs(entry.1)));
344
4
    let clipped = ranked.len() > opts.top_n && opts.top_n > 0;
345
4
    if opts.top_n > 0 {
346
4
        ranked.truncate(opts.top_n);
347
4
    }
348

            
349
16
    let pretty_tag = |tag: &str| -> String {
350
16
        if uncategorized_tags.contains(tag) {
351
2
            "(uncategorized)".to_string()
352
        } else {
353
14
            tag.to_string()
354
        }
355
16
    };
356

            
357
4
    let mut notes = Vec::new();
358
4
    if let Some(n) = multi_currency_note(&elided) {
359
        notes.push(n);
360
4
    }
361
4
    if clipped {
362
1
        notes.push(format!("Showing top {} categories.", opts.top_n));
363
3
    }
364

            
365
    // Flat shape: one series per category so each bar reads as a
366
    // distinct colour. Each series holds a single point anchored at
367
    // its own tag name. Period shape: one series per category across
368
    // period x-slots.
369
4
    let series = if flat {
370
3
        ranked
371
3
            .iter()
372
3
            .map(|(name, amount)| Series {
373
7
                label: pretty_tag(name),
374
7
                commodity_symbol: primary.clone(),
375
7
                points: vec![point(pretty_tag(name), *amount)],
376
7
            })
377
3
            .collect()
378
    } else {
379
1
        ranked
380
1
            .iter()
381
1
            .map(|(tag, _)| Series {
382
2
                label: pretty_tag(tag),
383
2
                commodity_symbol: primary.clone(),
384
2
                points: periods
385
2
                    .iter()
386
4
                    .map(|p| {
387
5
                        let amount = p.rows.iter().find(|r| &r.tag_value == tag).map_or_else(
388
1
                            || Rational64::new(0, 1),
389
3
                            |r| amount_for(&primary, &r.amounts),
390
                        );
391
4
                        point(&p.label, amount)
392
4
                    })
393
2
                    .collect(),
394
2
            })
395
1
            .collect()
396
    };
397

            
398
    ChartSpec {
399
4
        title: "Category Breakdown".to_string(),
400
4
        kind: opts.kind,
401
4
        x_label: if flat {
402
3
            "Category".to_string()
403
        } else {
404
1
            "Period".to_string()
405
        },
406
4
        y_label: format!("Amount ({primary})"),
407
4
        series,
408
4
        notes,
409
    }
410
4
}
411

            
412
#[cfg(test)]
413
mod tests {
414
    use server::command::report::view::{BreakdownRowView, GroupView};
415
    use uuid::Uuid;
416

            
417
    use super::*;
418

            
419
34
    fn amt(sym: &str, n: i64) -> AmountView {
420
34
        AmountView {
421
34
            commodity_symbol: sym.to_string(),
422
34
            amount: Rational64::new(n, 1),
423
34
        }
424
34
    }
425

            
426
9
    fn row(name: &str, depth: usize, amounts: Vec<AmountView>) -> ReportRowView {
427
9
        ReportRowView {
428
9
            account_id: Uuid::new_v4(),
429
9
            parent_id: None,
430
9
            account_name: name.to_string(),
431
9
            depth,
432
9
            has_children: false,
433
9
            amounts,
434
9
        }
435
9
    }
436

            
437
    // ---- Balance ----
438

            
439
    #[test]
440
1
    fn balance_chart_ranks_and_clips_to_top_n() {
441
1
        let rows = vec![
442
1
            row("Cash", 0, vec![amt("USD", 100)]),
443
1
            row("Bank", 0, vec![amt("USD", 5000)]),
444
1
            row("Credit", 0, vec![amt("USD", -2000)]),
445
1
            row("Checking", 1, vec![amt("USD", 3000)]), // depth 1 — ignored
446
        ];
447

            
448
1
        let spec = balance_chart(
449
1
            &rows,
450
1
            BalanceChartOpts {
451
1
                kind: ChartKind::Bar,
452
1
                top_n: 2,
453
1
                sort_order: SortOrder::MagnitudeDesc,
454
1
            },
455
        );
456

            
457
        // One series per account so each bar gets its own palette
458
        // colour. Account order matches absolute-amount ranking.
459
1
        assert_eq!(spec.series.len(), 2, "one series per top-N account");
460
2
        let labels: Vec<&str> = spec.series.iter().map(|s| s.label.as_str()).collect();
461
1
        assert_eq!(labels, ["Bank", "Credit"]);
462
1
        assert_eq!(spec.series[0].points[0].x, "Bank");
463
1
        assert_eq!(spec.series[0].points[0].y_num, 5000);
464
1
        assert_eq!(spec.series[1].points[0].y_num, -2000);
465
1
        assert!(spec.notes.iter().any(|n| n.contains("top 2")));
466
1
    }
467

            
468
    #[test]
469
1
    fn balance_chart_sort_order_variants() {
470
1
        let rows = vec![
471
1
            row("Gamma", 0, vec![amt("USD", 300)]),
472
1
            row("Alpha", 0, vec![amt("USD", 500)]),
473
1
            row("Beta", 0, vec![amt("USD", -100)]),
474
        ];
475

            
476
4
        let order_labels = |order: SortOrder| -> Vec<String> {
477
4
            balance_chart(
478
4
                &rows,
479
4
                BalanceChartOpts {
480
4
                    kind: ChartKind::Bar,
481
4
                    top_n: 10,
482
4
                    sort_order: order,
483
4
                },
484
4
            )
485
4
            .series
486
4
            .iter()
487
12
            .map(|s| s.label.clone())
488
4
            .collect()
489
4
        };
490

            
491
1
        assert_eq!(
492
1
            order_labels(SortOrder::MagnitudeDesc),
493
1
            vec!["Alpha", "Gamma", "Beta"],
494
            "default: largest absolute amount first"
495
        );
496
1
        assert_eq!(
497
1
            order_labels(SortOrder::MagnitudeAsc),
498
1
            vec!["Beta", "Gamma", "Alpha"],
499
            "ascending walks smallest first"
500
        );
501
1
        assert_eq!(
502
1
            order_labels(SortOrder::NameAsc),
503
1
            vec!["Alpha", "Beta", "Gamma"],
504
            "alphabetical"
505
        );
506
1
        assert_eq!(
507
1
            order_labels(SortOrder::NameDesc),
508
1
            vec!["Gamma", "Beta", "Alpha"],
509
            "reverse alphabetical"
510
        );
511
1
    }
512

            
513
    #[test]
514
1
    fn balance_chart_handles_multi_currency() {
515
1
        let rows = vec![
516
1
            row("Cash", 0, vec![amt("USD", 100)]),
517
1
            row("Bank", 0, vec![amt("EUR", 200), amt("USD", 50)]),
518
        ];
519

            
520
1
        let spec = balance_chart(
521
1
            &rows,
522
1
            BalanceChartOpts {
523
1
                kind: ChartKind::Bar,
524
1
                top_n: 10,
525
1
                sort_order: SortOrder::MagnitudeDesc,
526
1
            },
527
        );
528

            
529
        // Mixed currency picks the primary (USD, the first-seen),
530
        // notes the elided one, and charts account bars using the
531
        // primary's amount.
532
1
        assert_eq!(spec.series.len(), 2, "one series per account");
533
1
        assert_eq!(spec.y_label, "Amount (USD)");
534
1
        let cash = spec.series.iter().find(|s| s.label == "Cash").unwrap();
535
2
        let bank = spec.series.iter().find(|s| s.label == "Bank").unwrap();
536
1
        assert_eq!(cash.points[0].y_num, 100);
537
1
        assert_eq!(bank.points[0].y_num, 50);
538
1
        assert!(
539
1
            spec.notes
540
1
                .iter()
541
1
                .any(|n| n.contains("EUR") && n.contains("hidden")),
542
            "notes mention the elided commodity"
543
        );
544
1
    }
545

            
546
    #[test]
547
1
    fn balance_chart_empty_rows_is_graceful() {
548
1
        let spec = balance_chart(
549
1
            &[],
550
1
            BalanceChartOpts {
551
1
                kind: ChartKind::Bar,
552
1
                top_n: 5,
553
1
                sort_order: SortOrder::MagnitudeDesc,
554
1
            },
555
        );
556
1
        assert!(spec.series.is_empty());
557
1
        assert!(spec.notes.iter().any(|n| n.contains("No top-level")));
558
1
    }
559

            
560
    // ---- Activity ----
561

            
562
    #[test]
563
1
    fn activity_chart_emits_series_per_group_and_optional_net() {
564
1
        let periods = vec![
565
1
            PeriodActivityView {
566
1
                label: "2026-01".to_string(),
567
1
                groups: vec![
568
1
                    GroupView {
569
1
                        label: "Income".to_string(),
570
1
                        flip_sign: true,
571
1
                        rows: vec![],
572
1
                        total: vec![amt("USD", 3200)],
573
1
                    },
574
1
                    GroupView {
575
1
                        label: "Expense".to_string(),
576
1
                        flip_sign: false,
577
1
                        rows: vec![],
578
1
                        total: vec![amt("USD", 2100)],
579
1
                    },
580
1
                ],
581
1
                net: vec![amt("USD", 1100)],
582
1
            },
583
1
            PeriodActivityView {
584
1
                label: "2026-02".to_string(),
585
1
                groups: vec![
586
1
                    GroupView {
587
1
                        label: "Income".to_string(),
588
1
                        flip_sign: true,
589
1
                        rows: vec![],
590
1
                        total: vec![amt("USD", 2800)],
591
1
                    },
592
1
                    GroupView {
593
1
                        label: "Expense".to_string(),
594
1
                        flip_sign: false,
595
1
                        rows: vec![],
596
1
                        total: vec![amt("USD", 1900)],
597
1
                    },
598
1
                ],
599
1
                net: vec![amt("USD", 900)],
600
1
            },
601
        ];
602

            
603
1
        let spec = activity_chart(
604
1
            &periods,
605
1
            ActivityChartOpts {
606
1
                kind: ChartKind::StackedBar,
607
1
                include_net: true,
608
1
            },
609
        );
610

            
611
1
        assert_eq!(spec.series.len(), 3, "Income, Expense, Net — three series");
612
1
        let income = spec.series.iter().find(|s| s.label == "Income").unwrap();
613
2
        let xs: Vec<&str> = income.points.iter().map(|p| p.x.as_str()).collect();
614
1
        assert_eq!(xs, vec!["2026-01", "2026-02"]);
615
1
        let ys: Vec<i64> = income.points.iter().map(|p| p.y_num).collect();
616
1
        assert_eq!(ys, vec![3200, 2800]);
617

            
618
3
        let net = spec.series.iter().find(|s| s.label == "Net").unwrap();
619
1
        assert_eq!(net.points[0].y_num, 1100);
620
1
        assert_eq!(net.points[1].y_num, 900);
621
1
    }
622

            
623
    #[test]
624
1
    fn activity_chart_without_net_omits_the_net_series() {
625
1
        let periods = vec![PeriodActivityView {
626
1
            label: "2026-04".to_string(),
627
1
            groups: vec![GroupView {
628
1
                label: "Income".to_string(),
629
1
                flip_sign: true,
630
1
                rows: vec![],
631
1
                total: vec![amt("USD", 1000)],
632
1
            }],
633
1
            net: vec![amt("USD", 1000)],
634
1
        }];
635

            
636
1
        let spec = activity_chart(
637
1
            &periods,
638
1
            ActivityChartOpts {
639
1
                kind: ChartKind::Bar,
640
1
                include_net: false,
641
1
            },
642
        );
643

            
644
1
        assert!(spec.series.iter().all(|s| s.label != "Net"));
645
1
    }
646

            
647
    #[test]
648
1
    fn activity_chart_multi_currency_notes_omission() {
649
1
        let periods = vec![PeriodActivityView {
650
1
            label: "2026-04".to_string(),
651
1
            groups: vec![GroupView {
652
1
                label: "Income".to_string(),
653
1
                flip_sign: true,
654
1
                rows: vec![],
655
1
                total: vec![amt("USD", 100), amt("EUR", 80)],
656
1
            }],
657
1
            net: vec![amt("USD", 100), amt("EUR", 80)],
658
1
        }];
659

            
660
1
        let spec = activity_chart(
661
1
            &periods,
662
1
            ActivityChartOpts {
663
1
                kind: ChartKind::Bar,
664
1
                include_net: false,
665
1
            },
666
        );
667

            
668
1
        assert_eq!(spec.series[0].commodity_symbol, "USD");
669
1
        assert!(
670
1
            spec.notes
671
1
                .iter()
672
1
                .any(|n| n.contains("EUR") && n.contains("hidden")),
673
            "notes mention the elided commodity"
674
        );
675
1
    }
676

            
677
    // ---- Breakdown ----
678

            
679
11
    fn brow(tag: &str, amounts: Vec<AmountView>) -> BreakdownRowView {
680
11
        BreakdownRowView {
681
11
            tag_value: tag.to_string(),
682
11
            is_uncategorized: false,
683
11
            amounts,
684
11
        }
685
11
    }
686

            
687
    #[test]
688
1
    fn breakdown_chart_flat_shape_single_series() {
689
1
        let periods = vec![BreakdownPeriodView {
690
1
            label: String::new(),
691
1
            rows: vec![
692
1
                brow("food", vec![amt("USD", 400)]),
693
1
                brow("transport", vec![amt("USD", 150)]),
694
1
                brow("rent", vec![amt("USD", 1200)]),
695
1
            ],
696
1
        }];
697

            
698
1
        let spec = breakdown_chart(
699
1
            &periods,
700
1
            BreakdownChartOpts {
701
1
                kind: ChartKind::Bar,
702
1
                top_n: 10,
703
1
            },
704
        );
705

            
706
        // Flat shape emits one series per category so bars read as
707
        // distinct colours. Order matches the absolute-amount ranking.
708
1
        assert_eq!(spec.series.len(), 3, "one series per category");
709
3
        let labels: Vec<&str> = spec.series.iter().map(|s| s.label.as_str()).collect();
710
1
        assert_eq!(labels, ["rent", "food", "transport"]);
711
1
        let rent = &spec.series[0];
712
1
        assert_eq!(rent.points.len(), 1, "single point per flat series");
713
1
        assert_eq!(rent.points[0].y_num, 1200);
714
1
        assert_eq!(spec.x_label, "Category");
715
1
    }
716

            
717
    #[test]
718
1
    fn breakdown_chart_period_shape_one_series_per_category() {
719
1
        let periods = vec![
720
1
            BreakdownPeriodView {
721
1
                label: "2026-01".to_string(),
722
1
                rows: vec![
723
1
                    brow("food", vec![amt("USD", 300)]),
724
1
                    brow("transport", vec![amt("USD", 100)]),
725
1
                ],
726
1
            },
727
1
            BreakdownPeriodView {
728
1
                label: "2026-02".to_string(),
729
1
                rows: vec![brow("food", vec![amt("USD", 350)])],
730
1
            },
731
        ];
732

            
733
1
        let spec = breakdown_chart(
734
1
            &periods,
735
1
            BreakdownChartOpts {
736
1
                kind: ChartKind::Line,
737
1
                top_n: 5,
738
1
            },
739
        );
740

            
741
1
        assert_eq!(spec.series.len(), 2, "food + transport");
742
1
        let food = spec.series.iter().find(|s| s.label == "food").unwrap();
743
2
        let transport = spec.series.iter().find(|s| s.label == "transport").unwrap();
744
1
        assert_eq!(food.points[0].y_num, 300);
745
1
        assert_eq!(food.points[1].y_num, 350);
746
1
        assert_eq!(transport.points[0].y_num, 100);
747
1
        assert_eq!(
748
1
            transport.points[1].y_num, 0,
749
            "missing periods fill with zero"
750
        );
751
1
        assert_eq!(spec.x_label, "Period");
752
1
    }
753

            
754
    #[test]
755
1
    fn breakdown_chart_clips_to_top_n_and_notes() {
756
1
        let periods = vec![BreakdownPeriodView {
757
1
            label: String::new(),
758
1
            rows: vec![
759
1
                brow("a", vec![amt("USD", 100)]),
760
1
                brow("b", vec![amt("USD", 200)]),
761
1
                brow("c", vec![amt("USD", 300)]),
762
1
                brow("d", vec![amt("USD", 400)]),
763
1
            ],
764
1
        }];
765

            
766
1
        let spec = breakdown_chart(
767
1
            &periods,
768
1
            BreakdownChartOpts {
769
1
                kind: ChartKind::Bar,
770
1
                top_n: 2,
771
1
            },
772
        );
773

            
774
        // Top-2 clip leaves two categories, each in its own series.
775
1
        assert_eq!(spec.series.len(), 2);
776
1
        assert!(spec.notes.iter().any(|n| n.contains("top 2")));
777
1
    }
778

            
779
    #[test]
780
1
    fn breakdown_chart_flat_relabels_uncategorized_sentinel() {
781
1
        let periods = vec![BreakdownPeriodView {
782
1
            label: String::new(),
783
1
            rows: vec![
784
1
                brow("food", vec![amt("USD", 400)]),
785
1
                BreakdownRowView {
786
1
                    tag_value: "__uncategorized__".to_string(),
787
1
                    is_uncategorized: true,
788
1
                    amounts: vec![amt("USD", 50)],
789
1
                },
790
1
            ],
791
1
        }];
792

            
793
1
        let spec = breakdown_chart(
794
1
            &periods,
795
1
            BreakdownChartOpts {
796
1
                kind: ChartKind::Bar,
797
1
                top_n: 10,
798
1
            },
799
        );
800

            
801
1
        let uncategorized = spec
802
1
            .series
803
1
            .iter()
804
2
            .find(|s| s.label == "(uncategorized)")
805
1
            .expect("pretty label replaces sentinel");
806
1
        assert_eq!(uncategorized.points[0].x, "(uncategorized)");
807
1
        assert!(
808
2
            !spec.series.iter().any(|s| s.label == "__uncategorized__"),
809
            "sentinel must not leak into the chart"
810
        );
811
1
    }
812
}