frust_material/date_picker/date.rs
1// Ported from `material_3_expressive` v1.0.8's date-picker date helpers (MIT,
2// © 2026 Paa Developments;
3// `tmp/material_3_expressive/lib/components/date_pickers/utils/m3e_date_picker_utils.dart`,
4// `models/m3e_date_picker_models.dart`, `models/m3e_calendar_labels.dart`,
5// retrieved 2026-08-20).
6// Upstream: <https://github.com/paadevelopments/material_3_expressive>
7//
8// Porting decision: the reference reaches Dart's built-in `DateTime` for every
9// calendar computation — `DateTime(year, month + 1, 0).day` for a month's
10// length, `.weekday` for the grid offset, `isBefore`/`isAfter` for bounds. This
11// crate's production dependencies are `frust` + `kurbo`/`peniko` only (no
12// `chrono`, no `time` — see `docs/PLUGINS_ARCHITECTURE.md`'s Design-System
13// Plugins), so the civil-calendar arithmetic is implemented here directly, over
14// a proleptic-Gregorian day count.
15
16//! The date-picker family's own civil-calendar value type and arithmetic.
17//!
18//! [`MaterialDate`] is a whole-day, time-zone-free, proleptic-Gregorian date:
19//! exactly the `DateTime(y, m, d)` shape the reference normalizes every input
20//! to through its `M3EDatePickerUtils.dateOnly`, with the time component gone
21//! rather than pinned to midnight.
22//!
23//! # No date library, by charter
24//!
25//! Everything a calendar grid needs is bounded and closed-form: how long a
26//! month is, whether a year leaps, and which weekday a month starts on. All
27//! three are derived here from one primitive — [`MaterialDate::epoch_day`],
28//! Howard Hinnant's `days_from_civil` (public domain, `chrono`-Compatible Howard
29//! Hinnant date algorithms, <https://howardhinnant.github.io/date_algorithms.html>)
30//! — which additionally gives day-difference and ordering for free.
31//!
32//! # Clamping, not failing
33//!
34//! [`MaterialDate::new`] never fails: the year is clamped to
35//! [`MIN_YEAR`]`..=`[`MAX_YEAR`], the month to `1..=12`, and the day to that
36//! month's own length. A calendar widget resolving a month page or a year cell
37//! has no useful error path — an out-of-range component means "the nearest real
38//! date", the same way [`MaterialDate::add_months`] shortens 31 January by a
39//! month into 28/29 February (the reference's own `normalizeSelectedDay`).
40//! [`MaterialDate::parse_compact`], by contrast, *does* reject — a user typing
41//! `13/40/2026` must see the field's format error, not a silently clamped date.
42
43/// The earliest year [`MaterialDate`] represents. Year 1 rather than year 0:
44/// the proleptic Gregorian calendar has no year 0, and a picker that could
45/// address one would format it as a date no locale can parse back.
46pub const MIN_YEAR: i32 = 1;
47
48/// The latest year [`MaterialDate`] represents — the four-digit ceiling
49/// [`MaterialDate::format_compact`]'s `yyyy` field can round-trip.
50pub const MAX_YEAR: i32 = 9999;
51
52/// Whether `year` is a leap year in the proleptic Gregorian calendar: divisible
53/// by 4, except centuries, except every fourth century.
54///
55/// ```
56/// use frust_material::date_picker::is_leap_year;
57///
58/// assert!(is_leap_year(2024));
59/// assert!(is_leap_year(2000));
60/// assert!(!is_leap_year(1900));
61/// assert!(!is_leap_year(2023));
62/// ```
63pub const fn is_leap_year(year: i32) -> bool {
64 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
65}
66
67/// How many days `month` (`1..=12`, clamped) has in `year` — the reference's
68/// `M3EDatePickerUtils.daysInMonth`.
69pub fn days_in_month(year: i32, month: u32) -> u32 {
70 match month.clamp(1, 12) {
71 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
72 4 | 6 | 9 | 11 => 30,
73 // February.
74 _ => {
75 if is_leap_year(year) {
76 29
77 } else {
78 28
79 }
80 }
81 }
82}
83
84/// A whole calendar day: year, month (`1..=12`), day (`1..=`[`days_in_month`]).
85///
86/// Named `MaterialDate` rather than a bare `Date` because this crate flat
87/// re-exports every widget/spec type at its root (`frust_material::MaterialDate`),
88/// where a bare `Date` would collide with any other catalog or app type of the
89/// same name — the same reason [`crate::MaterialTokens`]/[`crate::MaterialMotion`]
90/// carry the prefix.
91///
92/// Ordering is chronological ([`Ord`] over `(year, month, day)`, which is
93/// equivalent to ordering by [`Self::epoch_day`] because every field is
94/// normalized on construction), so `first_date <= day && day <= last_date` is
95/// the bounds test the whole family uses.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
97pub struct MaterialDate {
98 year: i32,
99 month: u32,
100 day: u32,
101}
102
103impl MaterialDate {
104 /// The nearest real date to `(year, month, day)`: the year clamped to
105 /// [`MIN_YEAR`]`..=`[`MAX_YEAR`], the month to `1..=12`, the day to that
106 /// month's own length. See the [module docs](self)' clamping note.
107 ///
108 /// ```
109 /// use frust_material::MaterialDate;
110 ///
111 /// // 31 February resolves to the 29th in a leap year, the 28th otherwise.
112 /// assert_eq!(MaterialDate::new(2024, 2, 31), MaterialDate::new(2024, 2, 29));
113 /// assert_eq!(MaterialDate::new(2023, 2, 31), MaterialDate::new(2023, 2, 28));
114 /// ```
115 pub fn new(year: i32, month: u32, day: u32) -> Self {
116 let year = year.clamp(MIN_YEAR, MAX_YEAR);
117 let month = month.clamp(1, 12);
118 let day = day.clamp(1, days_in_month(year, month));
119 Self { year, month, day }
120 }
121
122 /// This date's year.
123 pub fn year(self) -> i32 {
124 self.year
125 }
126
127 /// This date's month, `1..=12`.
128 pub fn month(self) -> u32 {
129 self.month
130 }
131
132 /// This date's day of the month, `1..=`[`days_in_month`].
133 pub fn day(self) -> u32 {
134 self.day
135 }
136
137 /// The first of this date's own month — the reference's
138 /// `M3EDatePickerUtils.getMonth(date.year, date.month)`, which is how every
139 /// "displayed month" value in this family is normalized.
140 pub fn first_of_month(self) -> Self {
141 Self {
142 year: self.year,
143 month: self.month,
144 day: 1,
145 }
146 }
147
148 /// Days since 1970-01-01 (negative before it) — Howard Hinnant's
149 /// `days_from_civil`. The one primitive [`Self::weekday_index`] and
150 /// [`Self::days_until`] are derived from.
151 ///
152 /// ```
153 /// use frust_material::MaterialDate;
154 ///
155 /// assert_eq!(MaterialDate::new(1970, 1, 1).epoch_day(), 0);
156 /// assert_eq!(MaterialDate::new(1969, 12, 31).epoch_day(), -1);
157 /// ```
158 pub fn epoch_day(self) -> i64 {
159 let y = i64::from(self.year) - i64::from(self.month <= 2);
160 let era = if y >= 0 { y } else { y - 399 } / 400;
161 // Year of era, 0..=399.
162 let yoe = y - era * 400;
163 let m = i64::from(self.month);
164 let d = i64::from(self.day);
165 // Day of year counted from 1 March, 0..=365.
166 let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
167 // Day of era, 0..=146096.
168 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
169 era * 146_097 + doe - 719_468
170 }
171
172 /// Signed day count from `self` to `other` (`other - self`).
173 pub fn days_until(self, other: Self) -> i64 {
174 other.epoch_day() - self.epoch_day()
175 }
176
177 /// This date's weekday as a **Sunday-first** index: `0` Sunday through `6`
178 /// Saturday.
179 ///
180 /// The reference reads Dart's `DateTime.weekday` (Monday-first, `1..=7`)
181 /// and folds it with `% 7` (`M3ECalendarLabels.firstWeekday`); this returns
182 /// the folded value directly, since every consumer here — the grid's
183 /// leading-blank count and the weekday header row — wants the Sunday-first
184 /// one.
185 ///
186 /// ```
187 /// use frust_material::MaterialDate;
188 ///
189 /// // 2000-01-01 was a Saturday; 2026-08-20 a Thursday.
190 /// assert_eq!(MaterialDate::new(2000, 1, 1).weekday_index(), 6);
191 /// assert_eq!(MaterialDate::new(2026, 8, 20).weekday_index(), 4);
192 /// ```
193 pub fn weekday_index(self) -> u32 {
194 (self.epoch_day() + 4).rem_euclid(7) as u32
195 }
196
197 /// How many days this date's own month has.
198 pub fn days_in_this_month(self) -> u32 {
199 days_in_month(self.year, self.month)
200 }
201
202 /// This date shifted by `delta` whole months, keeping the day of the month
203 /// where the target month is long enough and shortening it where it is not
204 /// — the reference's `addMonthsToMonthDate` + `normalizeSelectedDay` pair.
205 ///
206 /// ```
207 /// use frust_material::MaterialDate;
208 ///
209 /// let jan31 = MaterialDate::new(2026, 1, 31);
210 /// assert_eq!(jan31.add_months(1), MaterialDate::new(2026, 2, 28));
211 /// assert_eq!(jan31.add_months(-1), MaterialDate::new(2025, 12, 31));
212 /// ```
213 pub fn add_months(self, delta: i32) -> Self {
214 let total = i64::from(self.year) * 12 + i64::from(self.month) - 1 + i64::from(delta);
215 let year = total.div_euclid(12);
216 let month = total.rem_euclid(12) as u32 + 1;
217 // `new` clamps the year back into range, so a runaway `delta` saturates
218 // at the calendar's ends rather than wrapping.
219 let year = year.clamp(i64::from(MIN_YEAR), i64::from(MAX_YEAR)) as i32;
220 Self::new(year, month, self.day)
221 }
222
223 /// Whole months from `self`'s month to `other`'s — the reference's
224 /// `monthDelta`. Day-of-month is ignored on both sides.
225 pub fn month_delta(self, other: Self) -> i32 {
226 (other.year - self.year) * 12 + other.month as i32 - self.month as i32
227 }
228
229 /// `self` clamped into `first..=last` (the reference's `clampDate`). A
230 /// reversed pair (`last < first`) collapses onto `first`, so a caller that
231 /// hands the bounds in the wrong order gets a defined date rather than a
232 /// panic.
233 pub fn clamp_to(self, first: Self, last: Self) -> Self {
234 if self < first {
235 return first;
236 }
237 if self > last {
238 return if last < first { first } else { last };
239 }
240 self
241 }
242
243 /// Whether `self` lies within `first..=last`, inclusive — the reference's
244 /// `isSelectable` *bounds* half (its optional predicate is applied by the
245 /// widget, which owns the caller-supplied closure).
246 pub fn is_within(self, first: Self, last: Self) -> bool {
247 self >= first && self <= last
248 }
249
250 /// This date in the compact, parseable `mm/dd/yyyy` form — Flutter's
251 /// `MaterialLocalizations.formatCompactDate` for `en_US`, which is the
252 /// format the reference's input mode both renders and parses.
253 ///
254 /// See [`crate::DatePickerStrings`] for why this port ships one fixed
255 /// English/US format rather than a locale-resolved one.
256 ///
257 /// ```
258 /// use frust_material::MaterialDate;
259 ///
260 /// assert_eq!(MaterialDate::new(2026, 8, 20).format_compact(), "08/20/2026");
261 /// ```
262 pub fn format_compact(self) -> String {
263 format!("{:02}/{:02}/{:04}", self.month, self.day, self.year)
264 }
265
266 /// Parse the compact `mm/dd/yyyy` form back into a date, or `None` if the
267 /// text is not exactly that — Flutter's `parseCompactDate` for `en_US`,
268 /// which is what drives the input mode's *format* error.
269 ///
270 /// Rejects (rather than clamping — see the [module docs](self)): a part
271 /// count other than three, any non-ASCII-digit character, an empty part, a
272 /// month outside `1..=12`, a day outside the month's own length, and a year
273 /// outside [`MIN_YEAR`]`..=`[`MAX_YEAR`]. Surrounding whitespace is trimmed
274 /// first, so a paste with a trailing newline still parses.
275 ///
276 /// ```
277 /// use frust_material::MaterialDate;
278 ///
279 /// assert_eq!(MaterialDate::parse_compact("08/20/2026"), Some(MaterialDate::new(2026, 8, 20)));
280 /// assert_eq!(MaterialDate::parse_compact("8/2/2026"), Some(MaterialDate::new(2026, 8, 2)));
281 /// assert_eq!(MaterialDate::parse_compact("02/30/2026"), None);
282 /// assert_eq!(MaterialDate::parse_compact("2026-08-20"), None);
283 /// ```
284 pub fn parse_compact(text: &str) -> Option<Self> {
285 let mut parts = text.trim().split('/');
286 let month = parse_field(parts.next()?, 2)?;
287 let day = parse_field(parts.next()?, 2)?;
288 let year = parse_field(parts.next()?, 4)?;
289 if parts.next().is_some() {
290 return None;
291 }
292 let year = i32::try_from(year).ok()?;
293 if !(MIN_YEAR..=MAX_YEAR).contains(&year) || !(1..=12).contains(&month) {
294 return None;
295 }
296 if day < 1 || day > days_in_month(year, month) {
297 return None;
298 }
299 Some(Self { year, month, day })
300 }
301}
302
303/// One `/`-separated field of a compact date: `1..=max_digits` ASCII digits and
304/// nothing else.
305fn parse_field(text: &str, max_digits: usize) -> Option<u32> {
306 if text.is_empty() || text.len() > max_digits || !text.bytes().all(|b| b.is_ascii_digit()) {
307 return None;
308 }
309 text.parse::<u32>().ok()
310}
311
312/// A selected date range: a `start`, and an `end` that is `None` while the user
313/// is mid-selection — the reference's `M3EDateRange`.
314///
315/// # Scope: the model ships, the range *dialog* does not
316///
317/// The reference ships a full range picker
318/// (`M3EDateRangePickerDialog`/`M3ECalendarDateRangePicker`), presented as a
319/// vertically scrolling list of *every* month between `firstDate` and
320/// `lastDate`. This port carries the range **model** (this type, plus
321/// [`Self::extend`]'s selection rule) and the day cell's range visuals
322/// ([`crate::CalendarDatePicker::range`]), so an app can drive range selection
323/// against the single-month calendar; the scrolling multi-month dialog itself
324/// is a deliberate v1 cut, for the same reason [`crate::dialog`]'s selection
325/// list has no scroll physics — see [`mod@crate::date_picker`]'s scope section.
326#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
327pub struct DateRange {
328 /// The first day of the range.
329 pub start: MaterialDate,
330 /// The last day of the range, or `None` while only a start has been picked.
331 pub end: Option<MaterialDate>,
332}
333
334impl DateRange {
335 /// A range with only its `start` picked.
336 pub fn new(start: MaterialDate) -> Self {
337 Self { start, end: None }
338 }
339
340 /// A closed `start..=end` range. A reversed pair is swapped rather than
341 /// stored inverted, so [`Self::contains`] can never be vacuously false.
342 pub fn closed(start: MaterialDate, end: MaterialDate) -> Self {
343 if end < start {
344 Self {
345 start: end,
346 end: Some(start),
347 }
348 } else {
349 Self {
350 start,
351 end: Some(end),
352 }
353 }
354 }
355
356 /// Whether both ends are picked — the reference's `M3EDateRange.isComplete`.
357 pub fn is_complete(self) -> bool {
358 self.end.is_some()
359 }
360
361 /// Whether `day` lies inside the range, inclusive of both ends. An
362 /// unfinished range contains only its own start.
363 pub fn contains(self, day: MaterialDate) -> bool {
364 match self.end {
365 Some(end) => day >= self.start && day <= end,
366 None => day == self.start,
367 }
368 }
369
370 /// The range a tap on `date` produces, given `current` — the reference's
371 /// `_M3ECalendarDateRangePickerState._updateSelection`: a tap **closes** an
372 /// open range when it lands on or after its start, and otherwise **restarts**
373 /// a fresh open range at the tapped day.
374 ///
375 /// ```
376 /// use frust_material::{DateRange, MaterialDate};
377 ///
378 /// let d = |day| MaterialDate::new(2026, 8, day);
379 /// let opened = DateRange::extend(None, d(3));
380 /// assert_eq!(opened, DateRange::new(d(3)));
381 ///
382 /// // A later day closes it...
383 /// assert_eq!(DateRange::extend(Some(opened), d(9)).end, Some(d(9)));
384 /// // ...an earlier one restarts it.
385 /// assert_eq!(DateRange::extend(Some(opened), d(1)), DateRange::new(d(1)));
386 /// // A tap on a *closed* range always restarts.
387 /// let closed = DateRange::closed(d(3), d(9));
388 /// assert_eq!(DateRange::extend(Some(closed), d(5)), DateRange::new(d(5)));
389 /// ```
390 pub fn extend(current: Option<Self>, date: MaterialDate) -> Self {
391 match current {
392 Some(range) if range.end.is_none() && date >= range.start => Self {
393 start: range.start,
394 end: Some(date),
395 },
396 _ => Self::new(date),
397 }
398 }
399}
400
401#[cfg(test)]
402impl MaterialDate {
403 /// Test-only inverse of [`Self::epoch_day`] (Hinnant's `civil_from_days`),
404 /// used to walk a weekday cycle across month/year boundaries without
405 /// hand-writing every date.
406 fn from_epoch_day_for_test(z: i64) -> Self {
407 let z = z + 719_468;
408 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
409 let doe = z - era * 146_097;
410 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
411 let y = yoe + era * 400;
412 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
413 let mp = (5 * doy + 2) / 153;
414 let d = doy - (153 * mp + 2) / 5 + 1;
415 let m = if mp < 10 { mp + 3 } else { mp - 9 };
416 Self::new((y + i64::from(m <= 2)) as i32, m as u32, d as u32)
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 // ---- leap years ----------------------------------------------------
425
426 #[test]
427 fn the_leap_rule_covers_all_three_century_cases() {
428 // Divisible by 400 leaps; by 100 (not 400) does not; by 4 does.
429 assert!(is_leap_year(2000));
430 assert!(is_leap_year(1600));
431 assert!(!is_leap_year(1900));
432 assert!(!is_leap_year(2100));
433 assert!(is_leap_year(2024));
434 assert!(is_leap_year(2028));
435 assert!(!is_leap_year(2023));
436 assert!(!is_leap_year(2025));
437 }
438
439 #[test]
440 fn february_follows_the_leap_rule_and_every_other_month_is_fixed() {
441 assert_eq!(days_in_month(2000, 2), 29);
442 assert_eq!(days_in_month(1900, 2), 28);
443 assert_eq!(days_in_month(2024, 2), 29);
444 assert_eq!(days_in_month(2023, 2), 28);
445
446 let lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
447 for (i, expected) in lengths.into_iter().enumerate() {
448 let month = i as u32 + 1;
449 assert_eq!(days_in_month(2023, month), expected, "month {month}");
450 }
451 // A leap year differs in exactly one month.
452 let leap_total: u32 = (1..=12).map(|m| days_in_month(2024, m)).sum();
453 let common_total: u32 = (1..=12).map(|m| days_in_month(2023, m)).sum();
454 assert_eq!(leap_total, 366);
455 assert_eq!(common_total, 365);
456 }
457
458 #[test]
459 fn an_out_of_range_month_clamps_rather_than_panicking() {
460 assert_eq!(days_in_month(2026, 0), days_in_month(2026, 1));
461 assert_eq!(days_in_month(2026, 13), days_in_month(2026, 12));
462 }
463
464 // ---- weekday -------------------------------------------------------
465
466 #[test]
467 fn weekdays_are_pinned_against_known_dates() {
468 // Sunday-first indices: 0 Sun, 1 Mon, 2 Tue, 3 Wed, 4 Thu, 5 Fri, 6 Sat.
469 let cases = [
470 (MaterialDate::new(1970, 1, 1), 4), // Thursday (epoch)
471 (MaterialDate::new(2000, 1, 1), 6), // Saturday
472 (MaterialDate::new(1900, 1, 1), 1), // Monday
473 (MaterialDate::new(2024, 2, 29), 4), // Thursday (leap day)
474 (MaterialDate::new(2026, 8, 20), 4), // Thursday
475 (MaterialDate::new(2026, 3, 1), 0), // Sunday
476 (MaterialDate::new(2025, 12, 25), 4), // Thursday
477 (MaterialDate::new(1969, 7, 20), 0), // Sunday (Apollo 11)
478 ];
479 for (date, expected) in cases {
480 assert_eq!(date.weekday_index(), expected, "{date:?}");
481 }
482 }
483
484 #[test]
485 fn the_weekday_cycle_advances_by_one_per_day_across_a_month_boundary() {
486 let mut date = MaterialDate::new(2024, 2, 26);
487 let mut expected = date.weekday_index();
488 for _ in 0..10 {
489 assert_eq!(date.weekday_index(), expected);
490 date = MaterialDate::from_epoch_day_for_test(date.epoch_day() + 1);
491 expected = (expected + 1) % 7;
492 }
493 // Ten days on from 26 Feb 2024 (a leap year) is 7 March.
494 assert_eq!(date, MaterialDate::new(2024, 3, 7));
495 }
496
497 // ---- epoch day -----------------------------------------------------
498
499 #[test]
500 fn epoch_days_are_pinned_and_monotonic() {
501 assert_eq!(MaterialDate::new(1970, 1, 1).epoch_day(), 0);
502 assert_eq!(MaterialDate::new(1970, 1, 2).epoch_day(), 1);
503 assert_eq!(MaterialDate::new(1969, 12, 31).epoch_day(), -1);
504 assert_eq!(MaterialDate::new(2000, 1, 1).epoch_day(), 10_957);
505 assert_eq!(MaterialDate::new(2026, 1, 1).epoch_day(), 20_454);
506 assert_eq!(
507 MaterialDate::new(2023, 1, 1).days_until(MaterialDate::new(2024, 1, 1)),
508 365
509 );
510 assert_eq!(
511 MaterialDate::new(2024, 1, 1).days_until(MaterialDate::new(2025, 1, 1)),
512 366,
513 "2024 is a leap year"
514 );
515 }
516
517 #[test]
518 fn ordering_is_chronological() {
519 let a = MaterialDate::new(2026, 1, 31);
520 let b = MaterialDate::new(2026, 2, 1);
521 let c = MaterialDate::new(2027, 1, 1);
522 assert!(a < b && b < c);
523 assert!(a.is_within(a, c));
524 assert!(!c.is_within(a, b));
525 // Tuple ordering and epoch-day ordering agree on normalized values.
526 assert!(a.epoch_day() < b.epoch_day());
527 }
528
529 // ---- construction / clamping ---------------------------------------
530
531 #[test]
532 fn construction_clamps_every_component() {
533 assert_eq!(
534 MaterialDate::new(2024, 2, 31),
535 MaterialDate::new(2024, 2, 29)
536 );
537 assert_eq!(
538 MaterialDate::new(2023, 2, 31),
539 MaterialDate::new(2023, 2, 28)
540 );
541 assert_eq!(MaterialDate::new(2026, 0, 5).month(), 1);
542 assert_eq!(MaterialDate::new(2026, 99, 5).month(), 12);
543 assert_eq!(MaterialDate::new(2026, 4, 0).day(), 1);
544 assert_eq!(MaterialDate::new(-5, 1, 1).year(), MIN_YEAR);
545 assert_eq!(MaterialDate::new(50_000, 1, 1).year(), MAX_YEAR);
546 }
547
548 #[test]
549 fn first_of_month_keeps_the_month_and_drops_the_day() {
550 let d = MaterialDate::new(2026, 8, 20);
551 assert_eq!(d.first_of_month(), MaterialDate::new(2026, 8, 1));
552 assert_eq!(d.first_of_month().first_of_month(), d.first_of_month());
553 }
554
555 // ---- month arithmetic ----------------------------------------------
556
557 #[test]
558 fn add_months_wraps_the_year_and_shortens_a_too_long_day() {
559 let jan31 = MaterialDate::new(2026, 1, 31);
560 assert_eq!(jan31.add_months(1), MaterialDate::new(2026, 2, 28));
561 assert_eq!(jan31.add_months(11), MaterialDate::new(2026, 12, 31));
562 assert_eq!(jan31.add_months(12), MaterialDate::new(2027, 1, 31));
563 assert_eq!(jan31.add_months(-1), MaterialDate::new(2025, 12, 31));
564 assert_eq!(jan31.add_months(-13), MaterialDate::new(2024, 12, 31));
565 assert_eq!(jan31.add_months(0), jan31);
566 // A leap February keeps the 29th.
567 assert_eq!(
568 MaterialDate::new(2024, 1, 31).add_months(1),
569 MaterialDate::new(2024, 2, 29)
570 );
571 }
572
573 #[test]
574 fn add_months_saturates_at_the_calendar_ends() {
575 let last = MaterialDate::new(MAX_YEAR, 12, 31);
576 assert_eq!(last.add_months(1).year(), MAX_YEAR);
577 let first = MaterialDate::new(MIN_YEAR, 1, 1);
578 assert_eq!(first.add_months(-1).year(), MIN_YEAR);
579 // Even an absurd delta stays in range rather than wrapping.
580 assert_eq!(last.add_months(i32::MAX).year(), MAX_YEAR);
581 assert_eq!(first.add_months(i32::MIN).year(), MIN_YEAR);
582 }
583
584 #[test]
585 fn month_delta_counts_whole_months_and_ignores_the_day() {
586 let a = MaterialDate::new(2026, 1, 31);
587 let b = MaterialDate::new(2026, 3, 1);
588 assert_eq!(a.month_delta(b), 2);
589 assert_eq!(b.month_delta(a), -2);
590 assert_eq!(a.month_delta(a), 0);
591 assert_eq!(
592 MaterialDate::new(2020, 1, 1).month_delta(MaterialDate::new(2030, 1, 1)),
593 120
594 );
595 }
596
597 #[test]
598 fn clamp_to_pins_both_ends_and_survives_a_reversed_pair() {
599 let first = MaterialDate::new(2026, 1, 1);
600 let last = MaterialDate::new(2026, 12, 31);
601 assert_eq!(MaterialDate::new(2025, 6, 1).clamp_to(first, last), first);
602 assert_eq!(MaterialDate::new(2027, 6, 1).clamp_to(first, last), last);
603 let inside = MaterialDate::new(2026, 6, 1);
604 assert_eq!(inside.clamp_to(first, last), inside);
605 // Reversed bounds collapse onto `first` rather than producing a value
606 // outside both.
607 assert_eq!(MaterialDate::new(2027, 6, 1).clamp_to(last, first), last);
608 }
609
610 // ---- compact format / parse ----------------------------------------
611
612 #[test]
613 fn compact_formatting_zero_pads_every_field() {
614 assert_eq!(
615 MaterialDate::new(2026, 8, 20).format_compact(),
616 "08/20/2026"
617 );
618 assert_eq!(MaterialDate::new(2026, 1, 1).format_compact(), "01/01/2026");
619 assert_eq!(
620 MaterialDate::new(999, 12, 31).format_compact(),
621 "12/31/0999"
622 );
623 }
624
625 #[test]
626 fn compact_parsing_round_trips_every_formatted_date() {
627 for year in [1900, 1970, 2000, 2024, 2026] {
628 for month in 1..=12 {
629 for day in [1, 15, days_in_month(year, month)] {
630 let d = MaterialDate::new(year, month, day);
631 assert_eq!(
632 MaterialDate::parse_compact(&d.format_compact()),
633 Some(d),
634 "{d:?}"
635 );
636 }
637 }
638 }
639 }
640
641 #[test]
642 fn compact_parsing_accepts_unpadded_fields_and_trims_whitespace() {
643 let expected = Some(MaterialDate::new(2026, 8, 2));
644 assert_eq!(MaterialDate::parse_compact("8/2/2026"), expected);
645 assert_eq!(MaterialDate::parse_compact(" 08/02/2026 \n"), expected);
646 }
647
648 #[test]
649 fn compact_parsing_rejects_every_malformed_shape() {
650 for bad in [
651 "",
652 " ",
653 "2026-08-20",
654 "08/20",
655 "08/20/2026/1",
656 "08//2026",
657 "/20/2026",
658 "08/20/",
659 "aa/20/2026",
660 "08/2o/2026",
661 "+8/20/2026",
662 "08/20/ 2026",
663 "13/01/2026", // month out of range
664 "00/01/2026", // month zero
665 "02/30/2026", // day past February
666 "02/29/2023", // not a leap year
667 "04/31/2026", // day past a 30-day month
668 "08/20/00000", // five-digit year
669 "08/20/0000", // year below MIN_YEAR
670 ] {
671 assert_eq!(MaterialDate::parse_compact(bad), None, "accepted {bad:?}");
672 }
673 // 29 February *is* accepted in a leap year.
674 assert_eq!(
675 MaterialDate::parse_compact("02/29/2024"),
676 Some(MaterialDate::new(2024, 2, 29))
677 );
678 }
679
680 // ---- ranges ---------------------------------------------------------
681
682 #[test]
683 fn a_closed_range_orders_its_ends() {
684 let d = |day| MaterialDate::new(2026, 8, day);
685 let forward = DateRange::closed(d(3), d(9));
686 let reversed = DateRange::closed(d(9), d(3));
687 assert_eq!(forward, reversed);
688 assert!(forward.is_complete());
689 assert!(!DateRange::new(d(3)).is_complete());
690 }
691
692 #[test]
693 fn range_containment_is_inclusive_and_an_open_range_holds_only_its_start() {
694 let d = |day| MaterialDate::new(2026, 8, day);
695 let range = DateRange::closed(d(3), d(9));
696 assert!(range.contains(d(3)));
697 assert!(range.contains(d(6)));
698 assert!(range.contains(d(9)));
699 assert!(!range.contains(d(2)));
700 assert!(!range.contains(d(10)));
701
702 let open = DateRange::new(d(3));
703 assert!(open.contains(d(3)));
704 assert!(!open.contains(d(4)));
705 }
706
707 #[test]
708 fn extend_closes_forward_and_restarts_backward() {
709 let d = |day| MaterialDate::new(2026, 8, day);
710 let open = DateRange::extend(None, d(3));
711 assert_eq!(open, DateRange::new(d(3)));
712
713 // Same day closes the range onto itself (`!date.isBefore(start)`).
714 assert_eq!(DateRange::extend(Some(open), d(3)).end, Some(d(3)));
715 assert_eq!(DateRange::extend(Some(open), d(9)).end, Some(d(9)));
716 assert_eq!(DateRange::extend(Some(open), d(1)), DateRange::new(d(1)));
717
718 let closed = DateRange::closed(d(3), d(9));
719 assert_eq!(DateRange::extend(Some(closed), d(5)), DateRange::new(d(5)));
720 assert_eq!(
721 DateRange::extend(Some(closed), d(20)),
722 DateRange::new(d(20))
723 );
724 }
725}