1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::Scale;
5
6#[derive(Clone, Copy, Debug)]
8enum DateBreak {
9 Secs(f64),
11 Months(u32),
13}
14
15#[derive(Clone, Debug)]
17pub struct ScaleDateTime {
18 aesthetic: Aesthetic,
19 name: String,
20 min: f64,
21 max: f64,
22 trained: bool,
23 expand: (f64, f64),
24 date_breaks: Option<DateBreak>,
25 date_labels: Option<String>,
26 n_breaks: Option<usize>,
28 locale: Option<crate::locale::Locale>,
30}
31
32struct DateParts {
34 year: i64,
35 month: u32,
36 day: u32,
37 hour: u32,
38 minute: u32,
39 second: u32,
40}
41
42fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
44 let y = if m <= 2 { y - 1 } else { y };
45 let era = (if y >= 0 { y } else { y - 399 }) / 400;
46 let yoe = y - era * 400;
47 let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
48 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
49 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
50 era * 146_097 + doe - 719_468
51}
52
53fn secs_from_civil(y: i64, m: u32, d: u32) -> i64 {
54 days_from_civil(y, m, d) * 86_400
55}
56
57fn civil_from_secs(secs: i64) -> DateParts {
59 let (mut days, rem) = if secs >= 0 {
60 (secs / 86_400, secs % 86_400)
61 } else {
62 let d = (secs - 86_400 + 1) / 86_400;
63 (d, secs - d * 86_400)
64 };
65 let hour = (rem / 3600) as u32;
66 let minute = ((rem % 3600) / 60) as u32;
67 let second = (rem % 60) as u32;
68
69 days += 719_468;
70 let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
71 let doe = (days - era * 146_097) as u32;
72 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
73 let y = yoe as i64 + era * 400;
74 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
75 let mp = (5 * doy + 2) / 153;
76 let day = doy - (153 * mp + 2) / 5 + 1;
77 let month = if mp < 10 { mp + 3 } else { mp - 9 };
78 let year = if month <= 2 { y + 1 } else { y };
79 DateParts {
80 year,
81 month,
82 day,
83 hour,
84 minute,
85 second,
86 }
87}
88
89const MONTHS_SHORT: [&str; 12] = [
90 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
91];
92const MONTHS_LONG: [&str; 12] = [
93 "January",
94 "February",
95 "March",
96 "April",
97 "May",
98 "June",
99 "July",
100 "August",
101 "September",
102 "October",
103 "November",
104 "December",
105];
106const DAYS_SHORT: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
107const DAYS_LONG: [&str; 7] = [
108 "Sunday",
109 "Monday",
110 "Tuesday",
111 "Wednesday",
112 "Thursday",
113 "Friday",
114 "Saturday",
115];
116
117#[cfg(test)]
120fn strftime(secs: f64, fmt: &str) -> String {
121 strftime_locale(secs, fmt, None)
122}
123
124pub(crate) fn strftime_locale(
126 secs: f64,
127 fmt: &str,
128 locale: Option<&crate::locale::Locale>,
129) -> String {
130 let p = civil_from_secs(secs as i64);
131 let mi = (p.month.clamp(1, 12) - 1) as usize;
132 let wd = ((secs as i64).div_euclid(86_400) + 4).rem_euclid(7) as usize;
134 let month_short = locale.map_or(MONTHS_SHORT[mi], |l| l.months_short[mi].as_str());
135 let month_long = locale.map_or(MONTHS_LONG[mi], |l| l.months_long[mi].as_str());
136 let day_short = locale.map_or(DAYS_SHORT[wd], |l| l.days_short[wd].as_str());
137 let day_long = locale.map_or(DAYS_LONG[wd], |l| l.days_long[wd].as_str());
138 let mut out = String::new();
139 let mut chars = fmt.chars().peekable();
140 while let Some(c) = chars.next() {
141 if c != '%' {
142 out.push(c);
143 continue;
144 }
145 match chars.next() {
146 Some('Y') => out.push_str(&format!("{:04}", p.year)),
147 Some('y') => out.push_str(&format!("{:02}", p.year.rem_euclid(100))),
148 Some('m') => out.push_str(&format!("{:02}", p.month)),
149 Some('b') => out.push_str(month_short),
150 Some('B') => out.push_str(month_long),
151 Some('a') => out.push_str(day_short),
152 Some('A') => out.push_str(day_long),
153 Some('d') => out.push_str(&format!("{:02}", p.day)),
154 Some('e') => out.push_str(&format!("{:2}", p.day)),
155 Some('H') => out.push_str(&format!("{:02}", p.hour)),
156 Some('M') => out.push_str(&format!("{:02}", p.minute)),
157 Some('S') => out.push_str(&format!("{:02}", p.second)),
158 Some('%') => out.push('%'),
159 Some('-') if matches!(chars.peek(), Some('d' | 'm')) => match chars.next() {
161 Some('d') => out.push_str(&p.day.to_string()),
162 _ => out.push_str(&p.month.to_string()),
163 },
164 Some(other) => {
165 out.push('%');
166 out.push(other);
167 }
168 None => out.push('%'),
169 }
170 }
171 out
172}
173
174fn parse_date_break(spec: &str) -> Option<DateBreak> {
176 let spec = spec.trim().to_lowercase();
177 let mut parts = spec.split_whitespace();
178 let first = parts.next()?;
179 let (n, unit) = match first.parse::<f64>() {
180 Ok(n) => (n, parts.next()?.to_string()),
181 Err(_) => (1.0, first.to_string()),
182 };
183 let unit = unit.trim_end_matches('s');
184 let secs = |s: f64| Some(DateBreak::Secs(n * s));
185 match unit {
186 "sec" | "second" => secs(1.0),
187 "min" | "minute" => secs(60.0),
188 "hour" => secs(3600.0),
189 "day" => secs(86_400.0),
190 "week" => secs(604_800.0),
191 "month" => Some(DateBreak::Months(n.max(1.0) as u32)),
192 "year" => Some(DateBreak::Months((n.max(1.0) as u32) * 12)),
193 _ => None,
194 }
195}
196
197impl ScaleDateTime {
198 pub fn new() -> Self {
199 ScaleDateTime {
200 aesthetic: Aesthetic::X,
201 name: String::new(),
202 min: f64::INFINITY,
203 max: f64::NEG_INFINITY,
204 trained: false,
205 expand: (0.05, 0.0),
206 date_breaks: None,
207 date_labels: None,
208 n_breaks: None,
209 locale: None,
210 }
211 }
212
213 pub fn for_aesthetic(mut self, aes: Aesthetic) -> Self {
214 self.aesthetic = aes;
215 self
216 }
217
218 pub fn with_name(mut self, name: &str) -> Self {
219 self.name = name.to_string();
220 self
221 }
222
223 pub fn with_date_breaks(mut self, spec: &str) -> Self {
226 self.date_breaks = parse_date_break(spec);
227 self
228 }
229
230 pub fn with_n_breaks(mut self, n: usize) -> Self {
233 self.n_breaks = Some(n.max(1));
234 self
235 }
236
237 pub fn with_date_labels(mut self, fmt: &str) -> Self {
241 self.date_labels = Some(fmt.to_string());
242 self
243 }
244
245 pub fn with_locale(mut self, locale: crate::locale::Locale) -> Self {
250 self.locale = Some(locale);
251 self
252 }
253
254 fn label(&self, secs: f64, step: f64) -> String {
255 match (&self.date_labels, &self.locale) {
256 (Some(fmt), loc) => strftime_locale(secs, fmt, loc.as_ref()),
257 (None, Some(loc)) => loc.format_datetime(secs as i64),
258 (None, None) => Self::format_datetime(secs, step),
259 }
260 }
261
262 fn month_label(&self, y: i64, m: u32, secs: f64) -> String {
265 if let Some(fmt) = &self.date_labels {
266 return strftime_locale(secs, fmt, self.locale.as_ref());
267 }
268 match &self.locale {
269 Some(loc) => strftime_locale(secs, &loc.month_format, Some(loc)),
270 None => format!("{y:04}-{m:02}"),
271 }
272 }
273
274 fn year_label(&self, y: i64, secs: f64) -> String {
276 match &self.date_labels {
277 Some(fmt) => strftime_locale(secs, fmt, self.locale.as_ref()),
278 None => format!("{y}"),
279 }
280 }
281
282 fn expanded_range(&self) -> (f64, f64) {
283 let range = self.max - self.min;
284 let mult = self.expand.0;
285 let add = self.expand.1;
286 (self.min - range * mult - add, self.max + range * mult + add)
287 }
288
289 fn nice_datetime_step(range_secs: f64, n: usize) -> f64 {
291 const MINUTE: f64 = 60.0;
292 const HOUR: f64 = 3600.0;
293 const DAY: f64 = 86400.0;
294 const WEEK: f64 = 7.0 * DAY;
295 const MONTH: f64 = 30.0 * DAY;
296 const YEAR: f64 = 365.25 * DAY;
297
298 let candidates = [
299 1.0,
300 5.0,
301 10.0,
302 30.0,
303 MINUTE,
304 5.0 * MINUTE,
305 10.0 * MINUTE,
306 30.0 * MINUTE,
307 HOUR,
308 3.0 * HOUR,
309 6.0 * HOUR,
310 12.0 * HOUR,
311 DAY,
312 2.0 * DAY,
313 WEEK,
314 2.0 * WEEK,
315 MONTH,
316 3.0 * MONTH,
317 6.0 * MONTH,
318 YEAR,
319 2.0 * YEAR,
320 5.0 * YEAR,
321 10.0 * YEAR,
322 20.0 * YEAR,
323 50.0 * YEAR,
324 100.0 * YEAR,
325 ];
326
327 let target = range_secs / n.max(1) as f64;
328 for &c in &candidates {
329 if c >= target {
330 return c;
331 }
332 }
333 let n = (target / (100.0 * YEAR)).ceil();
335 n * 100.0 * YEAR
336 }
337
338 fn format_datetime(secs: f64, _step: f64) -> String {
341 let epoch_secs = secs as i64;
342 crate::data::format_epoch_secs(epoch_secs)
343 }
344}
345
346impl Default for ScaleDateTime {
347 fn default() -> Self {
348 Self::new()
349 }
350}
351
352impl Scale for ScaleDateTime {
353 fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
354 if self.locale.is_none() {
355 self.locale = Some(locale.clone());
356 }
357 }
358
359 fn locale(&self) -> Option<&crate::locale::Locale> {
360 self.locale.as_ref()
361 }
362
363 fn format_value(&self, value: &Value) -> Option<String> {
364 let f = value.as_f64()?;
365 if !f.is_finite() {
366 return None;
367 }
368 Some(self.label(f, 0.0))
375 }
376
377 fn aesthetic(&self) -> Aesthetic {
378 self.aesthetic.clone()
379 }
380
381 fn train(&mut self, values: &[Value]) {
382 for v in values {
383 if let Some(f) = v.as_f64() {
384 if f.is_finite() {
385 if f < self.min {
386 self.min = f;
387 }
388 if f > self.max {
389 self.max = f;
390 }
391 }
392 }
393 }
394 self.trained = true;
395 }
396
397 fn map(&self, value: &Value) -> f64 {
398 let f = match value.as_f64() {
399 Some(f) => f,
400 None => return 0.0,
401 };
402 if f == f64::INFINITY {
404 return 1.0;
405 }
406 if f == f64::NEG_INFINITY {
407 return 0.0;
408 }
409 if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
412 return 0.5;
413 }
414 let (emin, emax) = self.expanded_range();
415 let range = emax - emin;
416 if range.abs() < f64::EPSILON {
417 0.5
418 } else {
419 (f - emin) / range
420 }
421 }
422
423 fn domain(&self) -> Option<(f64, f64)> {
424 if self.trained && self.min.is_finite() && self.max.is_finite() {
425 Some((self.min, self.max))
426 } else {
427 None
428 }
429 }
430
431 fn expanded_domain(&self) -> Option<(f64, f64)> {
432 if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
433 return None;
434 }
435 let (a, b) = self.expanded_range();
436 if !(a.is_finite() && b.is_finite()) {
437 return None;
438 }
439 if (b - a).abs() < f64::EPSILON {
440 return Some((a - 0.5, b + 0.5));
442 }
443 Some((a, b))
444 }
445
446 fn breaks(&self) -> Vec<(f64, String)> {
447 if !self.trained || self.min > self.max {
448 return vec![];
449 }
450
451 let range = self.max - self.min;
452 if super::util::is_degenerate_range(self.min, self.max) {
453 return vec![(0.5, self.label(self.min, 1.0))];
454 }
455
456 let (emin, emax) = self.expanded_range();
457 const MAX_ABS_SECS: f64 = 1e12;
460 if !(emin.abs() <= MAX_ABS_SECS && emax.abs() <= MAX_ABS_SECS) {
461 return vec![];
462 }
463
464 if let Some(DateBreak::Months(n)) = self.date_breaks {
466 let n = n.max(1);
467 let start = civil_from_secs(emin.ceil() as i64);
468 let (mut y, mut m) = (start.year, start.month);
469 if (secs_from_civil(y, m, 1) as f64) < emin {
471 m += 1;
472 if m > 12 {
473 m = 1;
474 y += 1;
475 }
476 }
477 let mut breaks = Vec::new();
478 let mut guard = 0;
479 loop {
480 let secs = secs_from_civil(y, m, 1) as f64;
481 if secs > emax + 1.0 || guard > 10_000 {
482 break;
483 }
484 breaks.push((self.map(&Value::Float(secs)), self.label(secs, 0.0)));
485 m += n;
486 while m > 12 {
487 m -= 12;
488 y += 1;
489 }
490 guard += 1;
491 }
492 return breaks;
493 }
494
495 let explicit_secs = matches!(self.date_breaks, Some(DateBreak::Secs(s)) if s > 0.0);
496 let step = match self.date_breaks {
497 Some(DateBreak::Secs(s)) if s > 0.0 => s,
498 _ => Self::nice_datetime_step(range, self.n_breaks.unwrap_or(5)),
499 };
500
501 const DAY: f64 = 86_400.0;
505 const MONTH: f64 = 30.0 * DAY;
506 const YEAR: f64 = 365.25 * DAY;
507 if !explicit_secs && step >= 0.9 * YEAR {
508 let n = ((step / YEAR).round() as i64).max(1);
509 let mut y = civil_from_secs(emin.ceil() as i64).year.div_euclid(n) * n;
510 while (secs_from_civil(y, 1, 1) as f64) < emin {
511 y += n;
512 }
513 let mut breaks = Vec::new();
514 while (secs_from_civil(y, 1, 1) as f64) <= emax + 1.0 {
515 let secs = secs_from_civil(y, 1, 1) as f64;
516 breaks.push((self.map(&Value::Float(secs)), self.year_label(y, secs)));
517 y += n;
518 }
519 return breaks;
520 }
521 if !explicit_secs && step >= 0.9 * MONTH {
522 let n = ((step / MONTH).round() as i64).max(1);
523 let start = civil_from_secs(emin.ceil() as i64);
524 let mut tm = start.year * 12 + (start.month as i64 - 1); while (secs_from_civil(tm.div_euclid(12), tm.rem_euclid(12) as u32 + 1, 1) as f64)
526 < emin
527 {
528 tm += 1;
529 }
530 let mut breaks = Vec::new();
531 let mut guard = 0;
532 loop {
533 let (y, m) = (tm.div_euclid(12), tm.rem_euclid(12) as u32 + 1);
534 let secs = secs_from_civil(y, m, 1) as f64;
535 if secs > emax + 1.0 || guard > 10_000 {
536 break;
537 }
538 breaks.push((self.map(&Value::Float(secs)), self.month_label(y, m, secs)));
539 tm += n;
540 guard += 1;
541 }
542 return breaks;
543 }
544
545 let start = (emin / step).ceil() * step;
546 super::util::stepped_breaks(start, emax, step)
547 .into_iter()
548 .map(|v| (self.map(&Value::Float(v)), self.label(v, step)))
549 .collect()
550 }
551
552 fn name(&self) -> &str {
553 &self.name
554 }
555
556 fn set_name(&mut self, name: &str) {
557 self.name = name.to_string();
558 }
559
560 fn set_limits(&mut self, min: f64, max: f64) {
561 self.min = min;
562 self.max = max;
563 self.trained = true;
564 }
565
566 fn clone_box(&self) -> Box<dyn Scale> {
567 Box::new(self.clone())
568 }
569
570 fn reset_training(&mut self) {
571 self.min = f64::INFINITY;
572 self.max = f64::NEG_INFINITY;
573 self.trained = false;
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn format_value_is_plain_iso_without_a_locale_and_locale_formatted_with_one() {
583 let s = ScaleDateTime::new();
584 assert_eq!(
586 s.format_value(&Value::Float(1_791_763_200.0)),
587 Some("2026-10-12".to_string())
588 );
589 assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
590
591 let mut with_locale = ScaleDateTime::new();
592 with_locale.set_default_locale(&crate::locale::Locale::de_de());
593 let formatted = with_locale
594 .format_value(&Value::Float(1_791_763_200.0))
595 .unwrap();
596 assert_ne!(formatted, "2026-10-12");
597 }
598
599 #[test]
600 fn format_value_honours_with_date_labels() {
601 let s = ScaleDateTime::new().with_date_labels("%d.%m.%Y");
605 assert_eq!(
606 s.format_value(&Value::Float(1_791_763_200.0)), Some("12.10.2026".to_string())
608 );
609 }
610
611 #[test]
612 fn auto_month_and_year_breaks_honour_with_date_labels() {
613 let monthly = |fmt: Option<&str>| {
616 let mut s = ScaleDateTime::new();
617 if let Some(f) = fmt {
618 s = s.with_date_labels(f);
619 }
620 let vals: Vec<Value> = (0..18)
622 .map(
623 |i| Value::Float(secs_from_civil(2025 + i / 12, (i % 12) as u32 + 1, 1) as f64),
624 )
625 .collect();
626 s.train(&vals);
627 s.breaks().into_iter().map(|(_, l)| l).collect::<Vec<_>>()
628 };
629 assert_eq!(monthly(None), vec!["2025-01", "2025-07", "2026-01"]);
630 assert_eq!(
631 monthly(Some("%b %Y")),
632 vec!["Jan 2025", "Jul 2025", "Jan 2026"]
633 );
634 assert_eq!(monthly(Some("%b")), vec!["Jan", "Jul", "Jan"]);
635
636 let yearly = |fmt: Option<&str>| {
637 let mut s = ScaleDateTime::new();
638 if let Some(f) = fmt {
639 s = s.with_date_labels(f);
640 }
641 let vals: Vec<Value> = (2010..2021)
642 .map(|y| Value::Float(secs_from_civil(y, 1, 1) as f64))
643 .collect();
644 s.train(&vals);
645 s.breaks().into_iter().map(|(_, l)| l).collect::<Vec<_>>()
646 };
647 let plain = yearly(None);
648 assert!(
649 !plain.is_empty() && plain.iter().all(|l| l.len() == 4),
650 "{plain:?}"
651 );
652 assert_eq!(yearly(Some("%Y")), plain);
653 assert_eq!(
654 yearly(Some("%b %Y")),
655 plain.iter().map(|y| format!("Jan {y}")).collect::<Vec<_>>()
656 );
657 }
658
659 #[test]
660 fn civil_roundtrip() {
661 let p = civil_from_secs(1_615_811_445);
663 assert_eq!((p.year, p.month, p.day), (2021, 3, 15));
664 assert_eq!((p.hour, p.minute, p.second), (12, 30, 45));
665 assert_eq!(secs_from_civil(2021, 3, 15), 1_615_766_400); }
667
668 #[test]
669 fn multiyear_default_breaks_are_clean_years() {
670 let mut s = ScaleDateTime::new();
673 s.train(&[
674 Value::DateTime(secs_from_civil(2011, 2, 1)),
675 Value::DateTime(secs_from_civil(2016, 4, 1)),
676 ]);
677 let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
678 assert!(!labels.is_empty());
679 for l in &labels {
680 assert!(
681 l.len() == 4 && l.chars().all(|c| c.is_ascii_digit()),
682 "expected a bare year label, got {l:?}"
683 );
684 }
685 assert!(labels.contains(&"2014".to_string()));
686 }
687
688 #[test]
689 fn strftime_subset() {
690 let s = 1_615_766_400.0; assert_eq!(strftime(s, "%Y-%m-%d"), "2021-03-15");
692 assert_eq!(strftime(s, "%b %Y"), "Mar 2021");
693 assert_eq!(strftime(s, "%B"), "March");
694 assert_eq!(strftime(s, "100%%"), "100%");
695 }
696
697 #[test]
698 fn strftime_weekdays_and_unpadded() {
699 let s = 1_615_766_400.0; assert_eq!(strftime(s, "%a %A"), "Mon Monday");
701 assert_eq!(strftime(s - 86_400.0 * 10.0, "%-d.%-m. %d"), "5.3. 05");
702 assert_eq!(strftime(s, "%-x"), "%-x");
703 assert_eq!(strftime(-86_400.0, "%a"), "Wed"); }
705
706 #[test]
707 fn locale_month_and_day_labels() {
708 let de = crate::locale::Locale::de_de();
709 let mut s = ScaleDateTime::new().with_locale(de.clone());
711 s.set_limits(
712 secs_from_civil(2026, 7, 1) as f64,
713 secs_from_civil(2027, 2, 1) as f64,
714 );
715 let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
716 assert!(labels.contains(&"Okt. 2026".to_string()), "{labels:?}");
717 assert!(labels.contains(&"Jan. 2027".to_string()), "{labels:?}");
718 let mut plain = ScaleDateTime::new();
720 plain.set_limits(
721 secs_from_civil(2026, 7, 1) as f64,
722 secs_from_civil(2027, 2, 1) as f64,
723 );
724 let labels: Vec<String> = plain.breaks().into_iter().map(|(_, l)| l).collect();
725 assert!(labels.contains(&"2026-10".to_string()), "{labels:?}");
726 let mut f = ScaleDateTime::new()
728 .with_date_labels("%B")
729 .with_date_breaks("1 month");
730 f.set_default_locale(&de);
731 f.set_limits(
732 secs_from_civil(2026, 2, 20) as f64,
733 secs_from_civil(2026, 4, 2) as f64,
734 );
735 let labels: Vec<String> = f.breaks().into_iter().map(|(_, l)| l).collect();
736 assert_eq!(labels, ["März", "April"]);
737 let mut d = ScaleDateTime::new().with_locale(de);
739 d.set_limits(
740 secs_from_civil(2026, 10, 12) as f64,
741 secs_from_civil(2026, 10, 16) as f64,
742 );
743 let labels: Vec<String> = d.breaks().into_iter().map(|(_, l)| l).collect();
744 assert!(labels.contains(&"15. Okt. 2026".to_string()), "{labels:?}");
745 let mut own = ScaleDateTime::new().with_locale(crate::locale::Locale::fr_fr());
747 own.set_default_locale(&crate::locale::Locale::de_de());
748 assert_eq!(own.locale().unwrap().tag, "fr-FR");
749 }
750
751 #[test]
752 fn parse_specs() {
753 assert!(matches!(
754 parse_date_break("1 month"),
755 Some(DateBreak::Months(1))
756 ));
757 assert!(matches!(
758 parse_date_break("3 months"),
759 Some(DateBreak::Months(3))
760 ));
761 assert!(matches!(
762 parse_date_break("1 year"),
763 Some(DateBreak::Months(12))
764 ));
765 assert!(
766 matches!(parse_date_break("2 weeks"), Some(DateBreak::Secs(s)) if s == 1_209_600.0)
767 );
768 assert!(matches!(
769 parse_date_break("month"),
770 Some(DateBreak::Months(1))
771 ));
772 assert!(parse_date_break("fortnight").is_none());
773 }
774
775 #[test]
776 fn monthly_breaks_land_on_first_of_month() {
777 let mut s = ScaleDateTime::new()
778 .with_date_breaks("1 month")
779 .with_date_labels("%Y-%m-%d");
780 s.set_limits(
782 secs_from_civil(2021, 1, 10) as f64,
783 secs_from_civil(2021, 4, 20) as f64,
784 );
785 let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
786 assert!(labels.iter().all(|l| l.ends_with("-01")), "{labels:?}");
787 assert!(labels.contains(&"2021-02-01".to_string()));
788 }
789}