Skip to main content

tokenburn_core/features/report/
chart.rs

1//! Chart data shared by every front-end: a stacked time series (per tool) and
2//! the "share by project" pie. Front-ends only *draw* it — ratatui bars, iced
3//! canvas, SVG — so all of them agree on the numbers.
4
5use std::collections::BTreeMap;
6
7use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Timelike};
8
9use super::types::{Bucket, Query, Window};
10use crate::features::currency::Currency;
11use crate::features::usage::{Row, Summary, Tool};
12pub use crate::utils::fmt::{axis_tokens, short_label, short_name};
13
14/// What a chart measures.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum Metric {
17    #[default]
18    Tokens,
19    /// Estimated `$` cost (only for tools that log one).
20    Cost,
21}
22
23impl Metric {
24    pub const ALL: [Metric; 2] = [Metric::Tokens, Metric::Cost];
25
26    pub fn label(self) -> &'static str {
27        match self {
28            Metric::Tokens => "tokens",
29            Metric::Cost => "cost",
30        }
31    }
32}
33
34impl std::fmt::Display for Metric {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.write_str(self.label())
37    }
38}
39
40impl std::str::FromStr for Metric {
41    type Err = String;
42    fn from_str(s: &str) -> Result<Self, String> {
43        match s.to_ascii_lowercase().as_str() {
44            "tokens" => Ok(Metric::Tokens),
45            "cost" => Ok(Metric::Cost),
46            other => Err(format!("must be tokens or cost (got '{other}')")),
47        }
48    }
49}
50
51/// At most this many buckets are returned (the most recent ones).
52pub const MAX_BUCKETS: usize = 120;
53
54/// Per-bucket, per-tool totals: `values[bucket][tool.series_index()]`.
55#[derive(Debug, Clone, Default, PartialEq)]
56pub struct TimeSeries {
57    pub bucket: Bucket,
58    /// One label per bucket, oldest first (`2026-05-01`, `2026-05`, `2026-05-01 13:00`).
59    pub labels: Vec<String>,
60    pub tokens: Vec<[u64; 8]>,
61    pub cost: Vec<[f64; 8]>,
62}
63
64impl TimeSeries {
65    pub fn is_empty(&self) -> bool {
66        self.labels.is_empty()
67    }
68
69    /// The value of `tool` in bucket `i` for `metric`.
70    pub fn value(&self, metric: Metric, i: usize, tool: Tool) -> f64 {
71        match metric {
72            Metric::Tokens => self.tokens[i][tool.series_index()] as f64,
73            Metric::Cost => self.cost[i][tool.series_index()],
74        }
75    }
76
77    /// Total of all tools in bucket `i`.
78    pub fn bucket_total(&self, metric: Metric, i: usize) -> f64 {
79        Tool::ALL.iter().map(|t| self.value(metric, i, *t)).sum()
80    }
81
82    /// The tallest stacked bar — the y axis maximum.
83    pub fn max_total(&self, metric: Metric) -> f64 {
84        (0..self.labels.len())
85            .map(|i| self.bucket_total(metric, i))
86            .fold(0.0, f64::max)
87    }
88
89    /// Tools that have any value for `metric`, in canonical order (the legend).
90    pub fn active_tools(&self, metric: Metric) -> Vec<Tool> {
91        Tool::ALL
92            .into_iter()
93            .filter(|t| (0..self.labels.len()).any(|i| self.value(metric, i, *t) > 0.0))
94            .collect()
95    }
96}
97
98impl TimeSeries {
99    /// The same series with every cost converted from US dollars to `currency`
100    /// (tokens are untouched), ready to be drawn with that currency's axis labels.
101    pub fn in_currency(&self, currency: &Currency) -> TimeSeries {
102        let mut out = self.clone();
103        for bucket in &mut out.cost {
104            for v in bucket.iter_mut() {
105                *v = currency.convert(*v);
106            }
107        }
108        out
109    }
110}
111
112/// Time-series charts always show a trend, so an un-bucketed query is charted at
113/// the natural granularity of its window.
114pub fn chart_bucket(query: &Query) -> Bucket {
115    match query.bucket {
116        Bucket::None => match query.window {
117            Window::Today => Bucket::Hour,
118            Window::Month => Bucket::Day,
119            Window::All => Bucket::Month,
120        },
121        b => b,
122    }
123}
124
125/// Start of the bucket containing `ts` (local time).
126fn floor(ts: DateTime<Local>, bucket: Bucket) -> NaiveDate {
127    // Hours are handled separately; this is the date part for day/month.
128    match bucket {
129        Bucket::Month | Bucket::None => ts.date_naive().with_day(1).unwrap_or(ts.date_naive()),
130        _ => ts.date_naive(),
131    }
132}
133
134fn label(ts: DateTime<Local>, bucket: Bucket) -> String {
135    ts.format(bucket.format()).to_string()
136}
137
138/// The label of the chart bucket containing `row` — the same string
139/// [`TimeSeries::labels`] uses, so a clicked bar can be matched to its rows.
140pub fn bucket_label_of(row: &Row, bucket: Bucket) -> String {
141    label(row.ts.with_timezone(&Local), bucket)
142}
143
144/// Build the stacked series for `rows` at [`chart_bucket`] granularity, with
145/// empty buckets filled in so gaps in time show as gaps in the chart.
146pub fn time_series(rows: &[Row], query: &Query) -> TimeSeries {
147    let bucket = chart_bucket(query);
148    let mut map: BTreeMap<String, ([u64; 8], [f64; 8])> = BTreeMap::new();
149    let (mut first, mut last): (Option<DateTime<Local>>, Option<DateTime<Local>>) = (None, None);
150
151    for r in rows {
152        let ts = r.ts.with_timezone(&Local);
153        first = Some(first.map_or(ts, |f| f.min(ts)));
154        last = Some(last.map_or(ts, |l| l.max(ts)));
155        let slot = map.entry(label(ts, bucket)).or_default();
156        let i = r.tool.series_index();
157        slot.0[i] += r.input + r.output + r.cache_read + r.cache_write;
158        slot.1[i] += r.cost;
159    }
160
161    let (Some(first), Some(last)) = (first, last) else {
162        return TimeSeries {
163            bucket,
164            ..Default::default()
165        };
166    };
167
168    // Walk every bucket from the first to the last, whether or not it has rows.
169    let mut labels = Vec::new();
170    let mut cursor = start_of(first, bucket);
171    let end = start_of(last, bucket);
172    while cursor <= end && labels.len() < 100_000 {
173        labels.push(label(cursor, bucket));
174        cursor = next(cursor, bucket);
175    }
176    let skip = labels.len().saturating_sub(MAX_BUCKETS);
177    let labels: Vec<String> = labels.into_iter().skip(skip).collect();
178
179    let mut tokens = Vec::with_capacity(labels.len());
180    let mut cost = Vec::with_capacity(labels.len());
181    for l in &labels {
182        let (t, c) = map.get(l).copied().unwrap_or_default();
183        tokens.push(t);
184        cost.push(c);
185    }
186    TimeSeries {
187        bucket,
188        labels,
189        tokens,
190        cost,
191    }
192}
193
194/// Midnight / hour / first-of-month at or before `ts`.
195fn start_of(ts: DateTime<Local>, bucket: Bucket) -> DateTime<Local> {
196    let naive = match bucket {
197        Bucket::Hour => ts.date_naive().and_hms_opt(ts.hour(), 0, 0),
198        _ => floor(ts, bucket).and_hms_opt(0, 0, 0),
199    };
200    naive
201        .and_then(|n| Local.from_local_datetime(&n).earliest())
202        .unwrap_or(ts)
203}
204
205fn next(ts: DateTime<Local>, bucket: Bucket) -> DateTime<Local> {
206    match bucket {
207        Bucket::Hour => ts + Duration::hours(1),
208        Bucket::Month | Bucket::None => {
209            let (y, m) = if ts.month() == 12 {
210                (ts.year() + 1, 1)
211            } else {
212                (ts.year(), ts.month() + 1)
213            };
214            Local
215                .with_ymd_and_hms(y, m, 1, 0, 0, 0)
216                .earliest()
217                .unwrap_or(ts + Duration::days(31))
218        }
219        // Add a day to the *date* (not 24 h) so DST changes cannot skip or repeat one.
220        Bucket::Day => ts
221            .date_naive()
222            .succ_opt()
223            .and_then(|d| d.and_hms_opt(0, 0, 0))
224            .and_then(|n| Local.from_local_datetime(&n).earliest())
225            .unwrap_or(ts + Duration::days(1)),
226    }
227}
228
229// ── axes and geometry (shared by the GUI canvas and the web SVG) ───────────
230
231/// A "nice" axis maximum ≥ `max`: 1, 2, 2.5, 5 or 10 × a power of ten.
232pub fn nice_max(max: f64) -> f64 {
233    if max <= 0.0 || !max.is_finite() {
234        return 1.0;
235    }
236    let exp = max.log10().floor();
237    let base = 10f64.powf(exp);
238    let f = max / base;
239    let nice = [1.0, 2.0, 2.5, 5.0, 10.0]
240        .into_iter()
241        .find(|n| *n >= f - 1e-9)
242        .unwrap_or(10.0);
243    nice * base
244}
245
246/// Axis label for a value: `1.5M` tokens or `$12.50`. A cost `v` must already be in
247/// `currency` (see [`TimeSeries::in_currency`], [`Pie::in_currency`]).
248pub fn axis_label(metric: Metric, v: f64, currency: &Currency) -> String {
249    match metric {
250        Metric::Tokens => axis_tokens(v),
251        Metric::Cost => currency.axis(v),
252    }
253}
254
255/// Which bucket is under `x` (relative to the plot's left edge)? `None` outside.
256pub fn bucket_at(x: f32, plot_width: f32, buckets: usize) -> Option<usize> {
257    if buckets == 0 || x < 0.0 || x >= plot_width || plot_width <= 0.0 {
258        return None;
259    }
260    Some(((x / plot_width) * buckets as f32) as usize).filter(|i| *i < buckets)
261}
262
263/// `(start, end)` angles in radians (clockwise from 12 o'clock) for each slice.
264pub fn slice_angles(values: &[f64]) -> Vec<(f32, f32)> {
265    let total: f64 = values.iter().sum();
266    if total <= 0.0 {
267        return Vec::new();
268    }
269    let mut acc = 0.0;
270    values
271        .iter()
272        .map(|v| {
273            let start = acc / total;
274            acc += v;
275            let end = acc / total;
276            (
277                (start * std::f64::consts::TAU) as f32,
278                (end * std::f64::consts::TAU) as f32,
279            )
280        })
281        .collect()
282}
283
284// ── pie ─────────────────────────────────────────────────────────────────────
285
286/// One pie slice.
287#[derive(Debug, Clone, PartialEq)]
288pub struct Slice {
289    pub label: String,
290    /// The tool, when the label starts with `tool:` (for colouring).
291    pub tool: Option<Tool>,
292    /// The full `tool:project` key (what [`super::Selection`] matches on);
293    /// `None` for the folded `other` slice, which can't be selected.
294    pub key: Option<String>,
295    pub value: f64,
296}
297
298/// What the pie measures and its slices (largest first, tail folded into `other`).
299#[derive(Debug, Clone, PartialEq)]
300pub struct Pie {
301    pub metric: Metric,
302    pub slices: Vec<Slice>,
303}
304
305impl Pie {
306    /// The same pie with a cost metric converted from US dollars to `currency`
307    /// (a tokens pie is untouched). Slice proportions never change.
308    pub fn in_currency(&self, currency: &Currency) -> Pie {
309        let mut out = self.clone();
310        if out.metric == Metric::Cost {
311            for s in &mut out.slices {
312                s.value = currency.convert(s.value);
313            }
314        }
315        out
316    }
317
318    pub fn total(&self) -> f64 {
319        self.slices.iter().map(|s| s.value).sum()
320    }
321}
322
323/// Turn the per-project breakdown into at most `top_n` slices plus an `other`
324/// slice for the remainder. The metric is cost when any entry has a cost, tokens
325/// otherwise; zero-valued entries are dropped.
326pub fn pie(projects: &[(String, Summary)], top_n: usize) -> Pie {
327    let metric = if projects.iter().any(|(_, s)| s.cost_usd > 0.0) {
328        Metric::Cost
329    } else {
330        Metric::Tokens
331    };
332    let value = |s: &Summary| match metric {
333        Metric::Cost => s.cost_usd,
334        Metric::Tokens => s.total_tokens as f64,
335    };
336
337    let mut slices: Vec<Slice> = projects
338        .iter()
339        .map(|(name, s)| Slice {
340            label: short_name(name),
341            tool: name
342                .split_once(':')
343                .and_then(|(t, _)| t.parse::<Tool>().ok()),
344            key: Some(name.clone()),
345            value: value(s),
346        })
347        .filter(|s| s.value > 0.0)
348        .collect();
349    slices.sort_by(|a, b| {
350        b.value
351            .partial_cmp(&a.value)
352            .unwrap_or(std::cmp::Ordering::Equal)
353    });
354
355    let top_n = top_n.max(1);
356    if slices.len() > top_n {
357        let rest: f64 = slices.split_off(top_n).iter().map(|s| s.value).sum();
358        slices.push(Slice {
359            label: "other".into(),
360            tool: None,
361            key: None,
362            value: rest,
363        });
364    }
365    Pie { metric, slices }
366}
367
368/// `ts` as UTC from an RFC 3339 string.
369#[cfg(test)]
370fn utc(s: &str) -> chrono::DateTime<chrono::Utc> {
371    s.parse().unwrap()
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn row(tool: Tool, ts: &str, tokens: u64, cost: f64) -> Row {
379        Row {
380            tool,
381            project: "p".into(),
382            id: "i".into(),
383            ts: utc(ts),
384            input: tokens,
385            output: 0,
386            cache_read: 0,
387            cache_write: 0,
388            cost,
389        }
390    }
391
392    fn q(bucket: Bucket, window: Window) -> Query {
393        Query {
394            bucket,
395            window,
396            ..Query::default()
397        }
398    }
399
400    #[test]
401    fn empty_input_gives_an_empty_series() {
402        let ts = time_series(&[], &q(Bucket::Day, Window::Month));
403        assert!(ts.is_empty());
404        assert_eq!(ts.max_total(Metric::Tokens), 0.0);
405        assert!(ts.active_tools(Metric::Tokens).is_empty());
406    }
407
408    #[test]
409    fn rows_are_stacked_per_bucket_and_tool() {
410        let rows = [
411            row(Tool::Pi, "2026-05-10T12:00:00Z", 100, 1.0),
412            row(Tool::Pi, "2026-05-10T13:00:00Z", 50, 0.5),
413            row(Tool::Claude, "2026-05-10T12:30:00Z", 25, 0.0),
414        ];
415        let ts = time_series(&rows, &q(Bucket::Day, Window::Month));
416        assert_eq!(ts.labels.len(), 1);
417        assert_eq!(ts.value(Metric::Tokens, 0, Tool::Pi), 150.0);
418        assert_eq!(ts.value(Metric::Tokens, 0, Tool::Claude), 25.0);
419        assert_eq!(ts.bucket_total(Metric::Tokens, 0), 175.0);
420        assert!((ts.value(Metric::Cost, 0, Tool::Pi) - 1.5).abs() < 1e-9);
421        assert_eq!(ts.active_tools(Metric::Tokens), [Tool::Pi, Tool::Claude]);
422        assert_eq!(
423            ts.active_tools(Metric::Cost),
424            [Tool::Pi],
425            "claude has no cost"
426        );
427    }
428
429    #[test]
430    fn gaps_between_the_first_and_last_day_are_filled_with_zeros() {
431        let rows = [
432            row(Tool::Pi, "2026-05-10T12:00:00Z", 10, 0.0),
433            row(Tool::Pi, "2026-05-14T12:00:00Z", 20, 0.0),
434        ];
435        let ts = time_series(&rows, &q(Bucket::Day, Window::Month));
436        assert_eq!(ts.labels.len(), 5, "10th…14th");
437        let totals: Vec<f64> = (0..5).map(|i| ts.bucket_total(Metric::Tokens, i)).collect();
438        assert_eq!(totals, [10.0, 0.0, 0.0, 0.0, 20.0]);
439        assert!(ts.labels.windows(2).all(|w| w[0] < w[1]), "oldest first");
440    }
441
442    #[test]
443    fn monthly_series_steps_over_year_ends() {
444        let rows = [
445            row(Tool::Pi, "2025-11-15T12:00:00Z", 1, 0.0),
446            row(Tool::Pi, "2026-02-15T12:00:00Z", 1, 0.0),
447        ];
448        let ts = time_series(&rows, &q(Bucket::Month, Window::All));
449        assert_eq!(ts.labels, ["2025-11", "2025-12", "2026-01", "2026-02"]);
450    }
451
452    #[test]
453    fn hourly_series_steps_by_the_hour() {
454        let rows = [
455            row(Tool::Pi, "2026-05-10T10:05:00Z", 1, 0.0),
456            row(Tool::Pi, "2026-05-10T13:55:00Z", 1, 0.0),
457        ];
458        let ts = time_series(&rows, &q(Bucket::Hour, Window::Today));
459        // 3 h 50 min apart: 4 hour-buckets, or 5 in a half-hour-offset time zone.
460        assert!((4..=5).contains(&ts.labels.len()), "{:?}", ts.labels);
461    }
462
463    #[test]
464    fn an_unbucketed_query_is_charted_at_the_windows_natural_granularity() {
465        assert_eq!(chart_bucket(&q(Bucket::None, Window::Today)), Bucket::Hour);
466        assert_eq!(chart_bucket(&q(Bucket::None, Window::Month)), Bucket::Day);
467        assert_eq!(chart_bucket(&q(Bucket::None, Window::All)), Bucket::Month);
468        assert_eq!(
469            chart_bucket(&q(Bucket::Hour, Window::All)),
470            Bucket::Hour,
471            "an explicit bucket is kept"
472        );
473    }
474
475    #[test]
476    fn only_the_most_recent_buckets_are_kept() {
477        let rows = [
478            row(Tool::Pi, "2020-01-01T12:00:00Z", 1, 0.0),
479            row(Tool::Pi, "2026-05-14T12:00:00Z", 1, 0.0),
480        ];
481        let ts = time_series(&rows, &q(Bucket::Day, Window::All));
482        assert_eq!(ts.labels.len(), MAX_BUCKETS);
483        assert_eq!(ts.tokens.len(), MAX_BUCKETS);
484        assert!(
485            ts.labels.last().unwrap().starts_with("2026-05"),
486            "the newest bucket survives"
487        );
488    }
489
490    #[test]
491    fn metric_parses_and_displays() {
492        assert_eq!("COST".parse::<Metric>().unwrap(), Metric::Cost);
493        assert!("bytes".parse::<Metric>().is_err());
494        assert_eq!(Metric::Tokens.to_string(), "tokens");
495    }
496
497    fn s(cost: f64, tokens: u64) -> Summary {
498        Summary {
499            turns: 1,
500            total_tokens: tokens,
501            cost_usd: cost,
502            ..Summary::default()
503        }
504    }
505
506    #[test]
507    fn pie_uses_cost_when_any_entry_has_one() {
508        let p = pie(
509            &[("pi:a".into(), s(2.0, 10)), ("zed:m".into(), s(0.0, 99))],
510            6,
511        );
512        assert_eq!(p.metric, Metric::Cost);
513        assert_eq!(p.slices.len(), 1, "zero-cost entries are dropped");
514        assert_eq!(p.slices[0].tool, Some(Tool::Pi));
515    }
516
517    #[test]
518    fn pie_falls_back_to_tokens() {
519        let p = pie(
520            &[("zed:m".into(), s(0.0, 50)), ("zed:n".into(), s(0.0, 70))],
521            6,
522        );
523        assert_eq!(p.metric, Metric::Tokens);
524        assert_eq!(p.slices[0].label, "zed:n", "largest first");
525        assert_eq!(p.total(), 120.0);
526    }
527
528    #[test]
529    fn pie_folds_the_tail_into_other() {
530        let projects: Vec<_> = (1..=5)
531            .map(|i| (format!("pi:p{i}"), s(f64::from(i), 1)))
532            .collect();
533        let p = pie(&projects, 3);
534        assert_eq!(p.slices.len(), 4);
535        assert_eq!(p.slices[3].label, "other");
536        assert_eq!(p.slices[3].tool, None);
537        assert!((p.slices[3].value - 3.0).abs() < 1e-9, "1 + 2");
538    }
539
540    #[test]
541    fn nice_max_rounds_up_to_one_two_two_and_a_half_five_ten() {
542        assert_eq!(nice_max(0.0), 1.0);
543        assert_eq!(nice_max(f64::NAN), 1.0);
544        assert_eq!(nice_max(0.9), 1.0);
545        assert_eq!(nice_max(1.2), 2.0);
546        assert_eq!(nice_max(2.3), 2.5);
547        assert_eq!(nice_max(3.0), 5.0);
548        assert_eq!(nice_max(7.0), 10.0);
549        assert_eq!(nice_max(1_300_000.0), 2_000_000.0);
550        assert_eq!(nice_max(12.0), 20.0);
551        for v in [0.3, 1.0, 4.9, 99.0, 123_456.0, 8e9] {
552            assert!(nice_max(v) >= v, "nice_max({v}) must not cut the data off");
553        }
554    }
555
556    #[test]
557    fn axis_labels_use_compact_numbers_and_dollars() {
558        let usd = Currency::USD;
559        assert_eq!(axis_label(Metric::Tokens, 1_500_000.0, &usd), "1.5M");
560        assert_eq!(axis_label(Metric::Cost, 12.5, &usd), "$12.50");
561        assert_eq!(axis_label(Metric::Cost, 0.25, &usd), "$0.25");
562        assert_eq!(axis_label(Metric::Cost, 250.0, &usd), "$250");
563        let eur = crate::features::currency::find("EUR").unwrap();
564        assert_eq!(axis_label(Metric::Cost, 12.5, &eur), "€12.50");
565        assert_eq!(
566            axis_label(Metric::Tokens, 2_000.0, &eur),
567            "2k",
568            "tokens ignore the currency"
569        );
570    }
571
572    #[test]
573    fn hover_maps_x_to_a_bucket() {
574        assert_eq!(bucket_at(0.0, 100.0, 4), Some(0));
575        assert_eq!(bucket_at(24.9, 100.0, 4), Some(0));
576        assert_eq!(bucket_at(25.0, 100.0, 4), Some(1));
577        assert_eq!(bucket_at(99.9, 100.0, 4), Some(3));
578        assert_eq!(bucket_at(100.0, 100.0, 4), None, "right edge is outside");
579        assert_eq!(bucket_at(-1.0, 100.0, 4), None);
580        assert_eq!(bucket_at(5.0, 100.0, 0), None);
581    }
582
583    #[test]
584    fn slice_angles_cover_the_whole_circle_without_gaps() {
585        let a = slice_angles(&[1.0, 1.0, 2.0]);
586        assert_eq!(a.len(), 3);
587        assert_eq!(a[0].0, 0.0);
588        for w in a.windows(2) {
589            assert!((w[0].1 - w[1].0).abs() < 1e-5, "slices must touch");
590        }
591        assert!((a[2].1 - std::f32::consts::TAU).abs() < 1e-4);
592        assert!(
593            (a[2].1 - a[2].0 - std::f32::consts::PI).abs() < 1e-4,
594            "half the total = half the circle"
595        );
596        assert!(slice_angles(&[]).is_empty());
597        assert!(slice_angles(&[0.0, 0.0]).is_empty());
598    }
599
600    #[test]
601    fn an_empty_pie_has_no_slices() {
602        assert!(pie(&[], 6).slices.is_empty());
603    }
604
605    #[test]
606    fn converting_a_series_scales_costs_only() {
607        let rows = [Row {
608            tool: Tool::Pi,
609            project: "p".into(),
610            id: "i".into(),
611            ts: utc("2026-05-10T12:00:00Z"),
612            input: 1000,
613            output: 0,
614            cache_read: 0,
615            cache_write: 0,
616            cost: 10.0,
617        }];
618        let q = Query {
619            bucket: Bucket::Day,
620            window: Window::All,
621            ..Query::default()
622        };
623        let usd = time_series(&rows, &q);
624        let eur = crate::features::currency::find("EUR").unwrap();
625        let conv = usd.in_currency(&eur);
626        assert_eq!(conv.labels, usd.labels);
627        assert_eq!(conv.tokens, usd.tokens, "tokens are not money");
628        assert!((conv.bucket_total(Metric::Cost, 0) - 9.2).abs() < 1e-9);
629        assert_eq!(usd.in_currency(&Currency::USD), usd, "USD is the identity");
630    }
631
632    #[test]
633    fn converting_a_pie_scales_a_cost_pie_and_keeps_the_proportions() {
634        let slice = |v: f64| Slice {
635            label: "x".into(),
636            tool: None,
637            key: None,
638            value: v,
639        };
640        let eur = crate::features::currency::find("EUR").unwrap();
641        let cost = Pie {
642            metric: Metric::Cost,
643            slices: vec![slice(30.0), slice(10.0)],
644        };
645        let conv = cost.in_currency(&eur);
646        assert!((conv.total() - 36.8).abs() < 1e-9);
647        assert!((conv.slices[0].value / conv.total() - 0.75).abs() < 1e-9);
648        let tokens = Pie {
649            metric: Metric::Tokens,
650            slices: vec![slice(300.0)],
651        };
652        assert_eq!(
653            tokens.in_currency(&eur),
654            tokens,
655            "a tokens pie is untouched"
656        );
657    }
658}