oxedyne/fe2o3/fe2o3_datime/tests/time.rs
13.3 KiB, 11 runs
created by r1870400018:6421, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 2 | //! Anthropic Claude |
| 3 | |
| 4 | use oxedyne_fe2o3_core::{ |
| 5 | prelude::*, |
| 6 | test::test_it, |
| 7 | }; |
| 8 | use oxedyne_fe2o3_datime::{ |
| 9 | calendar::CalendarDate, |
| 10 | clock::ClockTime, |
| 11 | core::Duration, |
| 12 | time::{ |
| 13 | CalClock, |
| 14 | CalClockConverter, |
| 15 | CalClockDuration, |
| 16 | CalClockZone, |
| 17 | }, |
| 18 | }; |
| 19 | |
| 20 | pub fn test_time(filter: &str) -> Outcome<()> { |
| 21 | |
| 22 | res!(test_it(filter, &["calclock_creation", "all", "time", "calclock"], || { |
| 23 | let zone = res!(CalClockZone::new("UTC")); |
| 24 | |
| 25 | // Create from components |
| 26 | let cc1 = res!(CalClock::new(2024, 3, 15, 14, 30, 0, 0, zone.clone())); |
| 27 | assert_eq!(cc1.date().year(), 2024); |
| 28 | assert_eq!(cc1.date().month(), 3); |
| 29 | assert_eq!(cc1.date().day(), 15); |
| 30 | assert_eq!(cc1.time().hour().of(), 14); |
| 31 | assert_eq!(cc1.time().minute().of(), 30); |
| 32 | |
| 33 | // Create from date and time |
| 34 | let date = res!(CalendarDate::new(2024, 3, 15, zone.clone())); |
| 35 | let time = res!(ClockTime::new(14, 30, 0, 0, zone.clone())); |
| 36 | let cc2 = res!(CalClock::from_date_time(date, time)); |
| 37 | assert_eq!(cc1, cc2); |
| 38 | Ok(()) |
| 39 | })); |
| 40 | |
| 41 | res!(test_it(filter, &["timezone_support", "all", "time", "timezone"], || { |
| 42 | // Test UTC |
| 43 | let utc = res!(CalClockZone::new("UTC")); |
| 44 | assert_eq!(utc.id(), "UTC"); |
| 45 | assert_eq!(res!(utc.offset_millis_at_time(0)), 0); |
| 46 | |
| 47 | // Test fixed offset (without colon - parser expects HHMM format) |
| 48 | let plus5 = res!(CalClockZone::new("+0500")); |
| 49 | assert_eq!(res!(plus5.offset_millis_at_time(0)), 5 * 60 * 60 * 1000); |
| 50 | |
| 51 | let minus8 = res!(CalClockZone::new("-0800")); |
| 52 | assert_eq!(res!(minus8.offset_millis_at_time(0)), -8 * 60 * 60 * 1000); |
| 53 | |
| 54 | // Test named timezones |
| 55 | let nyc = res!(CalClockZone::new("America/New_York")); |
| 56 | assert_eq!(nyc.id(), "America/New_York"); |
| 57 | // Offset varies with DST |
| 58 | Ok(()) |
| 59 | })); |
| 60 | |
| 61 | res!(test_it(filter, &["calclock_arithmetic", "all", "time", "calclock", "arithmetic"], || { |
| 62 | let zone = res!(CalClockZone::new("UTC")); |
| 63 | let cc1 = res!(CalClock::new(2024, 3, 15, 14, 30, 0, 0, zone)); |
| 64 | |
| 65 | // Add duration - 1 hour only to avoid day rollover complexity |
| 66 | let dur = CalClockDuration::from_hours(1); |
| 67 | let cc2 = res!(cc1.add_duration(&dur)); |
| 68 | assert_eq!(cc2.date().day(), 15); // Same day |
| 69 | assert_eq!(cc2.time().hour().of(), 15); // 14 + 1 = 15 |
| 70 | |
| 71 | // Add days |
| 72 | let cc3 = res!(cc1.add_days(10)); |
| 73 | assert_eq!(cc3.date().day(), 25); |
| 74 | |
| 75 | // Add months |
| 76 | let cc4 = res!(cc1.add_months(1)); |
| 77 | assert_eq!(cc4.date().month(), 4); |
| 78 | |
| 79 | // Duration between |
| 80 | let duration = res!(cc1.duration_until(&cc2)); |
| 81 | assert_eq!(res!(duration.to_hours()), 1); |
| 82 | Ok(()) |
| 83 | })); |
| 84 | |
| 85 | res!(test_it(filter, &["converter_basic", "all", "time", "converter"], || { |
| 86 | let zone = res!(CalClockZone::new("UTC")); |
| 87 | let converter = CalClockConverter::new(zone.clone()); |
| 88 | |
| 89 | // Just test that conversion produces a number |
| 90 | let cc = res!(CalClock::new(2024, 3, 15, 14, 30, 0, 0, zone.clone())); |
| 91 | let millis = res!(converter.calclock_to_unix(&cc)); |
| 92 | |
| 93 | // Should be a reasonable Unix timestamp (after year 2000) |
| 94 | assert!(millis > 946684800000); // Jan 1, 2000 in millis |
| 95 | assert!(millis < 4102444800000); // Jan 1, 2100 in millis |
| 96 | Ok(()) |
| 97 | })); |
| 98 | |
| 99 | res!(test_it(filter, &["converter_optimization", "all", "time", "converter", "optimization"], || { |
| 100 | let zone = res!(CalClockZone::new("UTC")); |
| 101 | let mut converter = CalClockConverter::new(zone.clone()); |
| 102 | converter.set_max_reference_deviation(24 * 60 * 60 * 1000); // 1 day |
| 103 | |
| 104 | // Just test that we can convert multiple times |
| 105 | let cc1 = res!(CalClock::new(2024, 3, 15, 10, 0, 0, 0, zone.clone())); |
| 106 | let cc2 = res!(CalClock::new(2024, 3, 15, 11, 0, 0, 0, zone.clone())); |
| 107 | |
| 108 | let millis1 = res!(converter.calclock_to_unix(&cc1)); |
| 109 | let millis2 = res!(converter.calclock_to_unix(&cc2)); |
| 110 | |
| 111 | // Second time should be 1 hour later |
| 112 | assert_eq!(millis2 - millis1, 60 * 60 * 1000); // 1 hour in millis |
| 113 | Ok(()) |
| 114 | })); |
| 115 | |
| 116 | res!(test_it(filter, &["calclock_comparison", "all", "time", "calclock", "comparison"], || { |
| 117 | let zone = res!(CalClockZone::new("UTC")); |
| 118 | |
| 119 | let cc1 = res!(CalClock::new(2024, 3, 15, 14, 30, 0, 0, zone.clone())); |
| 120 | let cc2 = res!(CalClock::new(2024, 3, 15, 14, 30, 0, 1, zone.clone())); |
| 121 | let cc3 = res!(CalClock::new(2024, 3, 15, 14, 30, 0, 0, zone.clone())); |
| 122 | |
| 123 | assert!(cc1 < cc2); |
| 124 | assert!(cc2 > cc1); |
| 125 | assert_eq!(cc1, cc3); |
| 126 | |
| 127 | assert!(cc1.is_before(&cc2)); |
| 128 | assert!(cc2.is_after(&cc1)); |
| 129 | assert!(!cc1.is_before(&cc3)); |
| 130 | Ok(()) |
| 131 | })); |
| 132 | |
| 133 | res!(test_it(filter, &["calclock_formatting", "all", "time", "calclock", "format"], || { |
| 134 | let zone = res!(CalClockZone::new("UTC")); |
| 135 | let cc = res!(CalClock::new(2024, 3, 15, 14, 30, 45, 123_456_789, zone)); |
| 136 | |
| 137 | // ISO format |
| 138 | let iso = res!(cc.to_iso8601()); |
| 139 | assert!(iso.contains("2024-03-15")); |
| 140 | assert!(iso.contains("14:30:45")); |
| 141 | |
| 142 | // String representation |
| 143 | let s = cc.to_string(); |
| 144 | assert!(s.contains("2024")); |
| 145 | assert!(s.contains("14:30")); |
| 146 | Ok(()) |
| 147 | })); |
| 148 | |
| 149 | res!(test_it(filter, &["duration_operations", "all", "time", "duration"], || { |
| 150 | // Test various duration creations |
| 151 | let d1 = CalClockDuration::from_seconds(90); |
| 152 | assert_eq!(res!(d1.to_seconds()), 90); |
| 153 | |
| 154 | let d2 = CalClockDuration::from_minutes(5); |
| 155 | assert_eq!(res!(d2.to_minutes()), 5); |
| 156 | assert_eq!(res!(d2.to_seconds()), 300); |
| 157 | |
| 158 | let d3 = CalClockDuration::from_hours(2); |
| 159 | assert_eq!(res!(d3.to_hours()), 2); |
| 160 | assert_eq!(res!(d3.to_minutes()), 120); |
| 161 | |
| 162 | // Test arithmetic |
| 163 | let sum = res!(d1.add(&d2)); |
| 164 | assert_eq!(res!(sum.to_seconds()), 390); |
| 165 | |
| 166 | let diff = res!(d3.subtract(&d2)); |
| 167 | assert_eq!(res!(diff.to_minutes()), 115); |
| 168 | Ok(()) |
| 169 | })); |
| 170 | |
| 171 | res!(test_it(filter, &["local_zone_oracle", "all", "time", "zone"], || { |
| 172 | // The system's own date command is the oracle. Only run where a |
| 173 | // zoneinfo tree exists; a container without one detects nothing and |
| 174 | // that is the documented fallback, not a fault. |
| 175 | if !std::path::Path::new("/usr/share/zoneinfo").is_dir() { |
| 176 | return Ok(()); |
| 177 | } |
| 178 | let now_ms = res!(std::time::SystemTime::now() |
| 179 | .duration_since(std::time::UNIX_EPOCH) |
| 180 | .map_err(|e| err!("{}", e; System))).as_millis() as i64; |
| 181 | |
| 182 | // local() must agree with `date +%z` on the current offset. |
| 183 | let out = res!(std::process::Command::new("date").arg("+%z").output(), |
| 184 | System); |
| 185 | let text = String::from_utf8_lossy(&out.stdout); |
| 186 | let want = res!(parse_offset_minutes(text.trim())); |
| 187 | let zone = CalClockZone::local(); |
| 188 | let got = i64::from(res!(zone.offset_millis_at_time(now_ms))) / 60_000; |
| 189 | assert_eq!(got, want, |
| 190 | "local() answers {} minutes, the date command says {}.", got, want); |
| 191 | |
| 192 | // A named zone absent from the embedded table must resolve through |
| 193 | // the system tree rather than silently answering zero. |
| 194 | for name in ["Australia/Perth", "Australia/Sydney"] { |
| 195 | if !std::path::Path::new("/usr/share/zoneinfo").join(name).is_file() { |
| 196 | continue; |
| 197 | } |
| 198 | let out = res!(std::process::Command::new("date") |
| 199 | .env("TZ", name) |
| 200 | .arg("+%z") |
| 201 | .output(), System); |
| 202 | let text = String::from_utf8_lossy(&out.stdout); |
| 203 | let want = res!(parse_offset_minutes(text.trim())); |
| 204 | let zone = res!(CalClockZone::new(name)); |
| 205 | let got = i64::from(res!(zone.offset_millis_at_time(now_ms))) / 60_000; |
| 206 | assert_eq!(got, want, |
| 207 | "{} answers {} minutes, the date command says {}.", |
| 208 | name, got, want); |
| 209 | } |
| 210 | Ok(()) |
| 211 | })); |
| 212 | |
| 213 | res!(test_it(filter, &["calclock_to_nanos_monotonic", "all", "time", "calclock"], || { |
| 214 | // `to_millis` already truncates the sub-second field to whole |
| 215 | // milliseconds, so the old `to_nanos` added those milliseconds a second |
| 216 | // time. The overcount grows with the sub-second field and resets when |
| 217 | // the second rolls over, which makes the sequence run backwards at every |
| 218 | // second boundary. Ordering is built on this number, so `is_before`, |
| 219 | // `is_after` and `PartialOrd` all answered wrongly there. |
| 220 | let zone = res!(CalClockZone::new("UTC")); |
| 221 | let steps = [ |
| 222 | (15u8, 999_999_998u32), |
| 223 | (15, 999_999_999), |
| 224 | (16, 0), |
| 225 | (16, 1), |
| 226 | ]; |
| 227 | let mut prev: Option<i64> = None; |
| 228 | for (sec, nanos) in steps { |
| 229 | let cc = res!(CalClock::new(2024, 3, 15, 14, 30, sec, nanos, zone.clone())); |
| 230 | let now = res!(cc.to_nanos()); |
| 231 | if let Some(before) = prev { |
| 232 | assert!(now > before, |
| 233 | "14:30:{:02}.{:09} answers {} ns, which is not after the {} ns \ |
| 234 | of the instant before it.", sec, nanos, now, before); |
| 235 | } |
| 236 | prev = Some(now); |
| 237 | } |
| 238 | |
| 239 | // The boundary itself: one nanosecond apart, and in that order. |
| 240 | let last = res!(CalClock::new(2024, 3, 15, 14, 30, 15, 999_999_999, zone.clone())); |
| 241 | let first = res!(CalClock::new(2024, 3, 15, 14, 30, 16, 0, zone.clone())); |
| 242 | assert_eq!(res!(first.to_nanos()) - res!(last.to_nanos()), 1, |
| 243 | "The second boundary is not one nanosecond wide."); |
| 244 | assert!(last.is_before(&first), "is_before misreads the second boundary."); |
| 245 | assert!(first.is_after(&last), "is_after misreads the second boundary."); |
| 246 | assert!(last < first, "PartialOrd misreads the second boundary."); |
| 247 | let gap = res!(last.duration_until(&first)); |
| 248 | assert_eq!(res!(gap.to_nanos()), 1, |
| 249 | "duration_until misreads the second boundary."); |
| 250 | Ok(()) |
| 251 | })); |
| 252 | |
| 253 | res!(test_it(filter, &["calclock_to_nanos_known_value", "all", "time", "calclock"], || { |
| 254 | // Derived by hand, not from the method under test. |
| 255 | // |
| 256 | // Days from 1970-01-01 to 2024-01-01: 54 years of 365 days, plus one |
| 257 | // day for each of the 13 leap years 1972, 1976, ... 2020, so |
| 258 | // 54 * 365 + 13 = 19710 + 13 = 19723 days. |
| 259 | // 2024 is a leap year, so 2024-03-15 is a further 31 + 29 + 14 = 74 |
| 260 | // days on, giving 19797 days. 19797 * 86400 = 1_710_460_800 seconds, |
| 261 | // which is the published Unix time of 2024-03-15T00:00:00Z. |
| 262 | // 14:30:15 is 14*3600 + 30*60 + 15 = 52_215 seconds into the day, so |
| 263 | // the instant is 1_710_513_015 seconds after the epoch, and |
| 264 | // 1_710_513_015 * 1e9 + 123_456_789 nanoseconds. |
| 265 | const WANT: i64 = 1_710_513_015_123_456_789; |
| 266 | |
| 267 | let zone = res!(CalClockZone::new("UTC")); |
| 268 | let cc = res!(CalClock::new(2024, 3, 15, 14, 30, 15, 123_456_789, zone.clone())); |
| 269 | let got = res!(cc.to_nanos()); |
| 270 | assert_eq!(got, WANT, "to_nanos is out by {} ns.", got - WANT); |
| 271 | assert_eq!(res!(cc.to_nanos_since_epoch()), WANT, |
| 272 | "to_nanos_since_epoch disagrees with to_nanos."); |
| 273 | assert_eq!(res!(cc.to_millis()), 1_710_513_015_123, |
| 274 | "to_millis disagrees with the hand-worked value."); |
| 275 | |
| 276 | // The last nanosecond before the epoch, where the whole thing is negative. |
| 277 | let before = res!(CalClock::new(1969, 12, 31, 23, 59, 59, 999_999_999, zone.clone())); |
| 278 | assert_eq!(res!(before.to_nanos()), -1, |
| 279 | "The nanosecond before the epoch is not -1."); |
| 280 | |
| 281 | // `from_nanos` is the inverse. It used to overwrite the whole sub-second |
| 282 | // field with the sub-millisecond remainder, throwing the milliseconds away. |
| 283 | for want in [WANT, -1i64, 0i64] { |
| 284 | let back = res!(CalClock::from_nanos(want, zone.clone())); |
| 285 | let round = res!(back.to_nanos()); |
| 286 | assert_eq!(round, want, |
| 287 | "{} does not survive from_nanos then to_nanos; it came back as {}.", |
| 288 | want, round); |
| 289 | } |
| 290 | let back = res!(CalClock::from_nanos_since_epoch(WANT, zone.clone())); |
| 291 | assert_eq!(back.nanosecond(), 123_456_789, |
| 292 | "from_nanos_since_epoch lost the millisecond part of the sub-second field."); |
| 293 | assert_eq!(back.second(), 15, "from_nanos_since_epoch landed on the wrong second."); |
| 294 | Ok(()) |
| 295 | })); |
| 296 | |
| 297 | Ok(()) |
| 298 | } |
| 299 | |
| 300 | /// Reads a `+0800`-style offset as minutes. |
| 301 | fn parse_offset_minutes(text: &str) -> Outcome<i64> { |
| 302 | if text.len() < 5 { |
| 303 | return Err(err!("'{}' is not a +hhmm offset.", text; Invalid, Input)); |
| 304 | } |
| 305 | let sign = match &text[..1] { |
| 306 | "+" => 1i64, |
| 307 | "-" => -1i64, |
| 308 | _ => return Err(err!("'{}' is not a +hhmm offset.", text; Invalid, Input)), |
| 309 | }; |
| 310 | let hours: i64 = res!(text[1..3].parse().map_err(|_| |
| 311 | err!("'{}' is not a +hhmm offset.", text; Invalid, Input))); |
| 312 | let minutes: i64 = res!(text[3..5].parse().map_err(|_| |
| 313 | err!("'{}' is not a +hhmm offset.", text; Invalid, Input))); |
| 314 | Ok(sign * (hours * 60 + minutes)) |
| 315 | } |