time and date input format tests
authorBen Pfaff <blp@cs.stanford.edu>
Mon, 31 Mar 2025 19:11:14 +0000 (12:11 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Mon, 31 Mar 2025 19:11:14 +0000 (12:11 -0700)
rust/Cargo.lock
rust/pspp/Cargo.toml
rust/pspp/src/calendar.rs
rust/pspp/src/format/display.rs
rust/pspp/src/format/mod.rs
rust/pspp/src/format/parse.rs

index 8495ff462eb48932a885ec4ce3f6c4cf73bec019..181a98afc8c8b0345109990ec85e41a95e447fc3 100644 (file)
@@ -238,16 +238,16 @@ dependencies = [
 
 [[package]]
 name = "chrono"
-version = "0.4.38"
+version = "0.4.40"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
  "js-sys",
  "num-traits",
  "wasm-bindgen",
- "windows-targets 0.52.6",
+ "windows-link",
 ]
 
 [[package]]
@@ -1571,6 +1571,12 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[package]]
+name = "windows-link"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
+
 [[package]]
 name = "windows-sys"
 version = "0.48.0"
index b55be5777832045b24ff491c71ba7ec2684c5dc4..e563b43c58328eefb93fd38e892eab8575290534 100644 (file)
@@ -17,7 +17,7 @@ num-derive = "0.4.0"
 num-traits = "0.2.16"
 ordered-float = "3.7.0"
 thiserror = "1.0"
-chrono = "0.4.26"
+chrono = "0.4.40"
 finl_unicode = "1.2.0"
 unicase = "2.6.0"
 libc = "0.2.147"
index 7a8562d061bc608c5f249c972ba150e9565a1f4b..239d084776c735427c7dd9b9c057f70000ddcf68 100644 (file)
@@ -1,4 +1,5 @@
-use chrono::{Datelike, Days, NaiveDate};
+use chrono::{Datelike, Days, Month, NaiveDate};
+use num::FromPrimitive;
 use thiserror::Error as ThisError;
 
 use crate::format::Settings;
@@ -102,11 +103,19 @@ pub fn calendar_gregorian_adjust(
     }
 }
 
-pub fn calendar_raw_gregorian_to_offset(y: i32, m: i32, d: i32) -> i32 {
-    fn is_leap_year(y: i32) -> bool {
-        y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
-    }
+pub fn is_leap_year(y: i32) -> bool {
+    NaiveDate::from_yo_opt(y, 1).unwrap().leap_year()
+}
 
+pub fn days_in_month(year: i32, month: i32) -> i32 {
+    Month::from_i32(month)
+        .unwrap()
+        .num_days(year)
+        .unwrap()
+        .into()
+}
+
+pub fn calendar_raw_gregorian_to_offset(y: i32, m: i32, d: i32) -> i32 {
     -577735 + 365 * (y - 1) + (y - 1) / 4 - (y - 1) / 100
         + (y - 1) / 400
         + (367 * m - 362) / 12
index f73b7352950c56387ff5c8ca48642af3eb346061..8ff4c418985745bbca622fe300c76a0f7a991f1c 100644 (file)
@@ -82,7 +82,7 @@ impl<'a, 'b> Display for DisplayValue<'a, 'b> {
             | Type::MoYr
             | Type::WkYr
             | Type::DateTime
-            | Type::YMDHMS
+            | Type::YmdHms
             | Type::MTime
             | Type::Time
             | Type::DTime
@@ -454,7 +454,7 @@ impl<'a, 'b> DisplayValue<'a, 'b> {
                     if year <= 9999 {
                         write!(&mut output, "{year:04}").unwrap();
                     } else if self.format.type_ == Type::DateTime
-                        || self.format.type_ == Type::YMDHMS
+                        || self.format.type_ == Type::YmdHms
                     {
                         write!(&mut output, "****").unwrap();
                     } else {
index ff350f2d1639eb6c94e94d3485f4f8216c96c554..13df780d52463fb7f72813f7ff4722b1303f4a59 100644 (file)
@@ -98,7 +98,7 @@ impl From<Type> for Category {
             | Type::MoYr
             | Type::WkYr
             | Type::DateTime
-            | Type::YMDHMS => Self::Date,
+            | Type::YmdHms => Self::Date,
             Type::MTime | Type::Time | Type::DTime => Self::Time,
             Type::WkDay | Type::Month => Self::DateComponent,
             Type::A | Type::AHex => Self::String,
@@ -169,7 +169,7 @@ pub enum Type {
     MoYr,
     WkYr,
     DateTime,
-    YMDHMS,
+    YmdHms,
     MTime,
     Time,
     DTime,
@@ -235,7 +235,7 @@ impl Type {
             Self::MoYr => 6,
             Self::WkYr => 8,
             Self::DateTime => 17,
-            Self::YMDHMS => 16,
+            Self::YmdHms => 16,
             Self::MTime => 5,
             Self::Time => 5,
             Self::DTime => 8,
@@ -275,7 +275,7 @@ impl Type {
             | Self::MoYr
             | Self::WkYr => 0,
             Self::DateTime => width - 21,
-            Self::YMDHMS => width - 20,
+            Self::YmdHms => width - 20,
             Self::MTime => width - 6,
             Self::Time => width - 9,
             Self::DTime => width - 12,
@@ -363,7 +363,7 @@ impl Type {
             Self::MoYr => "MOYR",
             Self::WkYr => "WKYR",
             Self::DateTime => "DATETIME",
-            Self::YMDHMS => "YMDHMS",
+            Self::YmdHms => "YMDHMS",
             Self::MTime => "MTIME",
             Self::Time => "TIME",
             Self::DTime => "DTIME",
@@ -659,7 +659,7 @@ impl TryFrom<u16> for Type {
             38 => Ok(Self::EDate),
             39 => Ok(Self::SDate),
             40 => Ok(Self::MTime),
-            41 => Ok(Self::YMDHMS),
+            41 => Ok(Self::YmdHms),
             _ => Err(Error::UnknownFormat { value: source }),
         }
     }
@@ -854,6 +854,9 @@ impl Settings {
             ..self
         }
     }
+    pub fn with_epoch(self, epoch: Epoch) -> Self {
+        Self { epoch, ..self }
+    }
     fn number_style(&self, type_: Type) -> &NumberStyle {
         static DEFAULT: LazyLock<NumberStyle> =
             LazyLock::new(|| NumberStyle::new("", "", Decimal::Dot, None, false));
@@ -912,7 +915,7 @@ impl Settings {
             | Type::MoYr
             | Type::WkYr
             | Type::DateTime
-            | Type::YMDHMS
+            | Type::YmdHms
             | Type::MTime
             | Type::Time
             | Type::DTime
@@ -1129,7 +1132,7 @@ impl DateTemplate {
             Type::MoYr => ("mmm yy", "mmm yyyy"),
             Type::WkYr => ("ww WK yy", "ww WK yyyy"),
             Type::DateTime => ("dd-mmm-yyyy HH:MM", "dd-mmm-yyyy HH:MM:SS"),
-            Type::YMDHMS => ("yyyy-mm-dd HH:MM", "yyyy-mm-dd HH:MM:SS"),
+            Type::YmdHms => ("yyyy-mm-dd HH:MM", "yyyy-mm-dd HH:MM:SS"),
             Type::MTime => ("MM", "MM:SS"),
             Type::Time => ("HH:MM", "HH:MM:SS"),
             Type::DTime => ("D HH:MM", "D HH:MM:SS"),
index 217186cb3beef3300cf337fd5813eafb9e920b93..20617fc13a05aab89b7ab73151d3bfff8efc599d 100644 (file)
@@ -109,7 +109,7 @@ pub struct ParseValue<'a> {
 }
 
 impl Format {
-    fn parser(&self) -> ParseValue {
+    pub fn parser(&self) -> ParseValue {
         ParseValue::new(*self)
     }
 }
@@ -159,7 +159,7 @@ impl<'a> ParseValue<'a> {
             | Type::MoYr
             | Type::WkYr
             | Type::DateTime
-            | Type::YMDHMS
+            | Type::YmdHms
             | Type::MTime
             | Type::Time
             | Type::DTime => self.parse_date(s),
@@ -288,7 +288,6 @@ impl<'a> ParseValue<'a> {
             }
             write!(&mut number, "{exponent}").unwrap();
         }
-        println!("{number}");
 
         match f64::from_str(&number) {
             Ok(value) => Ok(Value::Number(Some(value))),
@@ -318,7 +317,7 @@ impl<'a> ParseValue<'a> {
         let mut time_sign = None;
         let mut time = 0.0;
 
-        let mut iter = DateTemplate::for_format(self.format).unwrap();
+        let mut iter = DateTemplate::new(self.format.type_, 0).unwrap();
         let template_width = iter.len();
         while let Some(TemplateItem { c, n }) = iter.next() {
             match c {
@@ -338,7 +337,7 @@ impl<'a> ParseValue<'a> {
                         .is_some_and(|item| item.c.is_ascii_alphabetic())
                     {
                         usize::MAX
-                    } else if orig_input.len() >= template_width + 2 {
+                    } else if p.0.len() >= template_width + 2 {
                         4
                     } else {
                         2
@@ -425,8 +424,9 @@ impl<'a> ParseValue<'a> {
     }
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
 enum Sign {
+    #[default]
     Positive,
     Negative,
 }
@@ -469,7 +469,7 @@ fn parse_yday<'a>(p: &mut StrParser<'a>) -> Result<i32, ParseErrorKind> {
     if !(1..=366).contains(&yday) {
         return Err(ParseErrorKind::InvalidYDay(yday));
     }
-    p.0 = &p.0[..3];
+    p.0 = &p.0[3..];
     Ok(yday)
 }
 
@@ -483,7 +483,7 @@ fn parse_month<'a>(p: &mut StrParser<'a>) -> Result<i32, ParseErrorKind> {
         let name = p.strip_matches(|c| c.is_ascii_alphabetic());
 
         static ENGLISH_NAMES: [&str; 12] = [
-            "jan", "fe", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
+            "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
         ];
         if let Some(month) = match_name(&name[..3.min(name.len())], &ENGLISH_NAMES) {
             return Ok(month);
@@ -492,7 +492,7 @@ fn parse_month<'a>(p: &mut StrParser<'a>) -> Result<i32, ParseErrorKind> {
         static ROMAN_NAMES: [&str; 12] = [
             "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii",
         ];
-        if let Some(month) = match_name(name, &ENGLISH_NAMES) {
+        if let Some(month) = match_name(name, &ROMAN_NAMES) {
             return Ok(month);
         }
     }
@@ -559,12 +559,13 @@ fn parse_year<'a>(
     settings: &Settings,
     max_digits: usize,
 ) -> Result<i32, ParseErrorKind> {
-    let head = p.0;
+    let head = p.clone().strip_matches(|c| c.is_ascii_digit());
     let head = if head.len() > max_digits {
         head.get(..max_digits).ok_or(ParseErrorKind::DateSyntax)?
     } else {
         head
     };
+
     let year = head
         .parse::<i32>()
         .map_err(|_| ParseErrorKind::DateSyntax)?;
@@ -649,11 +650,12 @@ mod test {
     };
 
     use encoding_rs::UTF_8;
-    use serde::de::Expected;
 
     use crate::{
-        format::{parse::Sign, DateTemplate, Format, Type},
-        settings::Settings,
+        calendar::{days_in_month, is_leap_year},
+        dictionary::Value,
+        format::{parse::Sign, Epoch, Format, Settings as FormatSettings, Type},
+        settings::{self, Settings as PsppSettings},
     };
 
     fn test(name: &str, type_: Type) {
@@ -661,8 +663,7 @@ mod test {
         let input_stream = BufReader::new(File::open(base.join("num-in.txt")).unwrap());
         let expected_stream = BufReader::new(File::open(base.join(name)).unwrap());
         let format = Format::new(type_, 40, 1).unwrap();
-        let settings = Settings::global().formats.number_style(type_);
-        println!("{:?}", settings);
+        let settings = PsppSettings::global().formats.number_style(type_);
         for (line_number, (input, expected)) in input_stream
             .lines()
             .map(|result| result.unwrap())
@@ -725,6 +726,29 @@ mod test {
         second: i32,
     }
 
+    impl TestDate {
+        const fn new(
+            year: i32,
+            month: i32,
+            day: i32,
+            yday: i32,
+            hour: i32,
+            minute: i32,
+            second: i32,
+        ) -> Self {
+            Self {
+                year,
+                month,
+                day,
+                yday,
+                hour,
+                minute,
+                second,
+            }
+        }
+    }
+
+    #[derive(Copy, Clone, Debug)]
     struct ExpectDate {
         year: i32,
         month: i32,
@@ -733,13 +757,28 @@ mod test {
         sign: Sign,
     }
 
-    struct DateVisitor<'a> {
+    impl Default for ExpectDate {
+        fn default() -> Self {
+            Self {
+                year: 0,
+                month: 0,
+                day: 1,
+                time: 0,
+                sign: Sign::default(),
+            }
+        }
+    }
+
+    struct DateTester<'a> {
         date: TestDate,
         template: &'a str,
+        formatted: String,
+        type_: Type,
     }
 
-    impl<'a> DateVisitor<'a> {
-        fn visit(&self, formatted: String, expected: ExpectDate) {
+    impl<'a> DateTester<'a> {
+        fn visit(&self, extra: &str, mut expected: ExpectDate) {
+            let formatted = format!("{}{extra}", self.formatted);
             if !self.template.is_empty() {
                 fn years(y: i32) -> Vec<i32> {
                     match y {
@@ -749,18 +788,17 @@ mod test {
                 }
                 let mut iter = self.template.chars();
                 let first = iter.next().unwrap();
-                let next = DateVisitor {
+                let next = DateTester {
                     date: self.date.clone(),
                     template: iter.as_str(),
+                    formatted: formatted.clone(),
+                    type_: self.type_,
                 };
                 match first {
                     'd' => {
-                        let expected = ExpectDate {
-                            day: self.date.day,
-                            ..expected
-                        };
-                        next.visit(format!("{formatted}{}", self.date.day), expected);
-                        next.visit(format!("{formatted}{:02}", self.date.day), expected);
+                        expected.day = self.date.day;
+                        next.visit(&format!("{}", self.date.day), expected);
+                        next.visit(&format!("{:02}", self.date.day), expected);
                     }
                     'm' => {
                         let m = self.date.month as usize - 1;
@@ -784,89 +822,396 @@ mod test {
                         ];
                         let roman = ROMAN[m];
                         let english = ENGLISH[m];
-                        let expected = ExpectDate {
-                            month: self.date.month,
-                            ..expected
-                        };
-                        for formatted in [
-                            format!("{formatted}{}", self.date.month),
-                            format!("{formatted}{:02}", self.date.month),
-                            format!("{formatted}{}", roman),
-                            format!("{formatted}{}", roman.to_ascii_uppercase()),
-                            format!("{formatted}{}", english),
-                            format!("{formatted}{}", english.to_ascii_uppercase()),
-                            format!("{formatted}{}", &english[..3]),
-                            format!("{formatted}{}", &english[..3].to_ascii_uppercase()),
-                        ] {
-                            next.visit(formatted, expected);
-                        }
+
+                        expected.month = self.date.month;
+                        next.visit(&format!("{}", self.date.month), expected);
+                        next.visit(&format!("{:02}", self.date.month), expected);
+                        next.visit(roman, expected);
+                        next.visit(&roman.to_ascii_uppercase(), expected);
+                        next.visit(english, expected);
+                        next.visit(&english[..3], expected);
+                        next.visit(&english.to_ascii_uppercase(), expected);
+                        next.visit(&english[..3].to_ascii_uppercase(), expected);
                     }
                     'y' => {
-                        let expected = ExpectDate {
-                            year: self.date.year,
-                            ..expected
-                        };
+                        expected.year = self.date.year;
                         for year in years(self.date.year) {
-                            next.visit(format!("{formatted}{year}"), expected);
+                            next.visit(&format!("{year}"), expected);
                         }
                     }
                     'j' => {
-                        let expected = ExpectDate {
-                            year: self.date.year,
-                            month: self.date.month,
-                            day: self.date.day,
-                            ..expected
-                        };
+                        expected.year = self.date.year;
+                        expected.month = self.date.month;
+                        expected.day = self.date.day;
                         for year in years(self.date.year) {
-                            next.visit(format!("{formatted}{year}{:03}", self.date.yday), expected);
+                            next.visit(&format!("{year}{:03}", self.date.yday), expected);
                         }
                     }
                     'q' => {
                         let quarter = (self.date.month - 1) / 3 + 1;
                         let month = (quarter - 1) * 3 + 1;
-                        next.visit(
-                            format!("{formatted}{}", quarter),
-                            ExpectDate { month, ..expected },
-                        );
+                        next.visit(&format!("{}", quarter), ExpectDate { month, ..expected });
                     }
                     'w' => {
                         let week = (self.date.yday - 1) / 7 + 1;
-                        let mut month = self.date.month;
-                        let mut day = self.date.day - (self.date.yday - 1) % 7;
-                        if day < 1 {
-                            month -= 1;
-                            day += days_in_month(self.date.year, month);
+                        expected.month = self.date.month;
+                        expected.day = self.date.day - (self.date.yday - 1) % 7;
+                        if expected.day < 1 {
+                            expected.month -= 1;
+                            expected.day += days_in_month(self.date.year, expected.month + 1);
                         }
+                        next.visit(&format!("{week}"), expected);
+                    }
+                    'H' => {
+                        expected.time += self.date.hour * 3600;
+                        next.visit(&format!("{}", self.date.hour), expected);
+                        next.visit(&format!("{:02}", self.date.hour), expected);
+                    }
+                    'M' => {
+                        expected.time += self.date.minute * 60;
+                        next.visit(&format!("{}", self.date.minute), expected);
+                        next.visit(&format!("{:02}", self.date.minute), expected);
+                    }
+                    'S' => {
+                        expected.time += self.date.second;
+                        next.visit(&format!("{}", self.date.second), expected);
+                        next.visit(&format!("{:02}", self.date.second), expected);
+                    }
+                    '-' => {
+                        for c in b" -.,/" {
+                            next.visit(&format!("{}", *c as char), expected);
+                        }
+                    }
+                    ':' => {
+                        for c in b" :" {
+                            next.visit(&format!("{}", *c as char), expected);
+                        }
+                    }
+                    ' ' => {
+                        next.visit(" ", expected);
+                    }
+                    'Q' => {
+                        for s in ["q", " q", "q ", " q "] {
+                            for s in [String::from(s), s.to_ascii_uppercase()] {
+                                next.visit(&s, expected);
+                            }
+                        }
+                    }
+                    'W' => {
+                        for s in ["wk", " wk", "wk ", " wk "] {
+                            for s in [String::from(s), s.to_ascii_uppercase()] {
+                                next.visit(&s, expected);
+                            }
+                        }
+                    }
+                    '+' => {
+                        next.visit("", expected);
+                        next.visit(
+                            "+",
+                            ExpectDate {
+                                sign: Sign::Positive,
+                                ..expected
+                            },
+                        );
                         next.visit(
-                            format!("{formatted}{week}"),
+                            "-",
                             ExpectDate {
-                                month,
-                                day,
+                                sign: Sign::Negative,
                                 ..expected
                             },
                         );
                     }
+                    _ => unreachable!(),
                 }
+            } else {
+                assert!((1582..=2100).contains(&expected.year));
+                assert!((1..=12).contains(&expected.month));
+                assert!((1..=31).contains(&expected.day));
+
+                let ExpectDate {
+                    year,
+                    month,
+                    day,
+                    time,
+                    sign,
+                }: ExpectDate = expected;
+
+                const EPOCH: i32 = -577734; // 14 Oct 1582
+                let expected = (EPOCH - 1 + 365 * (year - 1) + (year - 1) / 4 - (year - 1) / 100
+                    + (year - 1) / 400
+                    + (367 * month - 362) / 12
+                    + if month <= 2 {
+                        0
+                    } else if month >= 2 && is_leap_year(year) {
+                        -1
+                    } else {
+                        -2
+                    }
+                    + day) as i64
+                    * 86400;
+                let expected = if sign == Sign::Negative && expected > 0 {
+                    expected - time as i64
+                } else {
+                    expected + time as i64
+                };
+                let settings = FormatSettings::default().with_epoch(Epoch(1930));
+                let parsed = Format::new(self.type_, 40, 0)
+                    .unwrap()
+                    .parser()
+                    .with_settings(&settings)
+                    .parse(&formatted, UTF_8)
+                    .unwrap();
+                assert_eq!(parsed, Value::Number(Some(expected as f64)));
+            }
+        }
+
+        fn test(template: &str, type_: Type) {
+            static DATES: [TestDate; 20] = [
+                TestDate::new(1648, 6, 10, 162, 0, 0, 0),
+                TestDate::new(1680, 6, 30, 182, 4, 50, 38),
+                TestDate::new(1716, 7, 24, 206, 12, 31, 35),
+                TestDate::new(1768, 6, 19, 171, 12, 47, 53),
+                TestDate::new(1819, 8, 2, 214, 1, 26, 0),
+                TestDate::new(1839, 3, 27, 86, 20, 58, 11),
+                TestDate::new(1903, 4, 19, 109, 7, 36, 5),
+                TestDate::new(1929, 8, 25, 237, 15, 43, 49),
+                TestDate::new(1941, 9, 29, 272, 4, 25, 9),
+                TestDate::new(1943, 4, 19, 109, 6, 49, 27),
+                TestDate::new(1943, 10, 7, 280, 2, 57, 52),
+                TestDate::new(1992, 3, 17, 77, 16, 45, 44),
+                TestDate::new(1996, 2, 25, 56, 21, 30, 57),
+                TestDate::new(1941, 9, 29, 272, 4, 25, 9),
+                TestDate::new(1943, 4, 19, 109, 6, 49, 27),
+                TestDate::new(1943, 10, 7, 280, 2, 57, 52),
+                TestDate::new(1992, 3, 17, 77, 16, 45, 44),
+                TestDate::new(1996, 2, 25, 56, 21, 30, 57),
+                TestDate::new(2038, 11, 10, 314, 22, 30, 4),
+                TestDate::new(2094, 7, 18, 199, 1, 56, 51),
+            ];
+            for date in &DATES {
+                let visitor = DateTester {
+                    date: date.clone(),
+                    template,
+                    formatted: "".to_string(),
+                    type_,
+                };
+                visitor.visit("", ExpectDate::default());
+            }
+        }
+    }
+
+    #[test]
+    fn date() {
+        DateTester::test("d-m-y", Type::Date);
+    }
+
+    #[test]
+    fn adate() {
+        DateTester::test("m-d-y", Type::ADate);
+    }
+
+    #[test]
+    fn edate() {
+        DateTester::test("d-m-y", Type::EDate);
+    }
+
+    #[test]
+    fn jdate() {
+        DateTester::test("j", Type::JDate);
+    }
+
+    #[test]
+    fn sdate() {
+        DateTester::test("y-m-d", Type::SDate);
+    }
+
+    #[test]
+    fn qyr() {
+        DateTester::test("qQy", Type::QYr);
+    }
+
+    #[test]
+    fn moyr() {
+        DateTester::test("m-y", Type::MoYr);
+    }
+
+    #[test]
+    fn wkyr() {
+        DateTester::test("wWy", Type::WkYr);
+    }
+
+    #[test]
+    fn datetime_without_seconds() {
+        // A more exhaustive template would be "d-m-y +H:M", but that takes much
+        // longer to run. We get confidence about delimiters from our other
+        // tests.
+        DateTester::test("d m-y +H:M", Type::DateTime);
+    }
+
+    #[test]
+    fn datetime_with_seconds() {
+        // A more exhaustive template would be "d-m-y +H:M:S", but that takes
+        // much longer to run. We get confidence about delimiters from our other
+        // tests.
+        DateTester::test("d-m y +H M:S", Type::DateTime);
+    }
+
+    #[test]
+    fn ymdhms_without_seconds() {
+        // A more exhaustive template would be "y-m-d +H:M", but that takes much
+        // longer to run. We get confidence about delimiters from our other tests.
+        DateTester::test("y m-d +H:M", Type::YmdHms);
+    }
+
+    #[test]
+    fn ymdhms_with_seconds() {
+        // A more exhaustive template would be "y-m-d +H:M:S", but that takes
+        // much longer to run. We get confidence about delimiters from our other
+        // tests.
+        DateTester::test("y-m d +H M:S", Type::YmdHms);
+    }
+
+    #[derive(Clone, Debug)]
+    struct TestTime {
+        days: i32,
+        hours: i32,
+        minutes: i32,
+        seconds: f64,
+    }
+
+    impl TestTime {
+        const fn new(days: i32, hours: i32, minutes: i32, seconds: f64) -> Self {
+            Self {
+                days,
+                hours,
+                minutes,
+                seconds,
             }
         }
     }
 
-    fn days_in_month(year: i32, month: i32) -> i32 {
-        match month {
-            0 => 31,
-            1 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
-            1 => 28,
-            2 => 31,
-            3 => 30,
-            4 => 31,
-            5 => 30,
-            6 => 31,
-            7 => 31,
-            8 => 30,
-            9 => 31,
-            10 => 30,
-            11 => 31,
-            _ => unreachable!(),
+    struct TimeTester<'a> {
+        time: TestTime,
+        template: &'a str,
+        formatted: String,
+        type_: Type,
+    }
+
+    impl<'a> TimeTester<'a> {
+        fn visit(&self, extra: &str, mut expected: f64, sign: Sign) {
+            let formatted = format!("{}{extra}", self.formatted);
+            if !self.template.is_empty() {
+                let mut iter = self.template.chars();
+                let first = iter.next().unwrap();
+                let next = TimeTester {
+                    time: self.time.clone(),
+                    template: iter.as_str(),
+                    formatted: formatted.clone(),
+                    type_: self.type_,
+                };
+                match first {
+                    '+' => {
+                        next.visit("", expected, sign);
+                        next.visit("+", expected, Sign::Positive);
+                        next.visit("-", expected, Sign::Negative);
+                    }
+                    'D' => {
+                        expected += (self.time.days * 86400) as f64;
+                        next.visit(&format!("{}", self.time.days), expected, sign);
+                        next.visit(&format!("{:02}", self.time.days), expected, sign);
+                    }
+                    'H' => {
+                        expected += (self.time.hours * 3600) as f64;
+                        next.visit(&format!("{}", self.time.hours), expected, sign);
+                        next.visit(&format!("{:02}", self.time.hours), expected, sign);
+                    }
+                    'M' => {
+                        expected += (self.time.minutes * 60) as f64;
+                        next.visit(&format!("{}", self.time.minutes), expected, sign);
+                        next.visit(&format!("{:02}", self.time.minutes), expected, sign);
+                    }
+                    'S' => {
+                        expected += self.time.seconds as f64;
+                        next.visit(&format!("{}", self.time.seconds), expected, sign);
+                        next.visit(&format!("{:02}", self.time.seconds), expected, sign);
+                    }
+                    ':' => {
+                        for c in b" :" {
+                            next.visit(&format!("{}", *c as char), expected, sign);
+                        }
+                    }
+                    ' ' => {
+                        next.visit(" ", expected, sign);
+                    }
+                    _ => unreachable!(),
+                }
+            } else {
+                let expected = match sign {
+                    Sign::Positive => expected,
+                    Sign::Negative => -expected,
+                };
+
+                let parsed = Format::new(self.type_, 40, 0)
+                    .unwrap()
+                    .parser()
+                    .parse(&formatted, UTF_8)
+                    .unwrap()
+                    .as_number()
+                    .unwrap()
+                    .unwrap();
+                assert_eq!((parsed * 1000.0).round(), (expected as f64) * 1000.0);
+            }
         }
+
+        fn test(template: &str, type_: Type) {
+            static TIMES: [TestTime; 20] = [
+                TestTime::new(0, 0, 0, 0.00),
+                TestTime::new(1, 4, 50, 38.68),
+                TestTime::new(5, 12, 31, 35.82),
+                TestTime::new(0, 12, 47, 53.41),
+                TestTime::new(3, 1, 26, 0.69),
+                TestTime::new(1, 20, 58, 11.19),
+                TestTime::new(12, 7, 36, 5.98),
+                TestTime::new(52, 15, 43, 49.27),
+                TestTime::new(7, 4, 25, 9.24),
+                TestTime::new(0, 6, 49, 27.89),
+                TestTime::new(20, 2, 57, 52.56),
+                TestTime::new(555, 16, 45, 44.12),
+                TestTime::new(120, 21, 30, 57.27),
+                TestTime::new(0, 4, 25, 9.98),
+                TestTime::new(3, 6, 49, 27.24),
+                TestTime::new(5, 2, 57, 52.13),
+                TestTime::new(0, 16, 45, 44.35),
+                TestTime::new(1, 21, 30, 57.32),
+                TestTime::new(10, 22, 30, 4.27),
+                TestTime::new(22, 1, 56, 51.18),
+            ];
+            for time in &TIMES {
+                let visitor = TimeTester {
+                    time: time.clone(),
+                    template,
+                    formatted: "".to_string(),
+                    type_,
+                };
+                visitor.visit("", 0.0, Sign::default());
+            }
+        }
+    }
+
+    #[test]
+    fn mtime() {
+        TimeTester::test("+M:S", Type::MTime);
+    }
+
+    #[test]
+    fn time() {
+        TimeTester::test("+H:M", Type::Time);
+        TimeTester::test("+H:M:S", Type::Time);
+    }
+
+    #[test]
+    fn dtime() {
+        TimeTester::test("+D H:M", Type::DTime);
+        TimeTester::test("+D H:M:S", Type::DTime);
     }
 }