623 lines
21 KiB
Rust
623 lines
21 KiB
Rust
//! Time utilities for shepherdd
|
|
//!
|
|
//! Provides both monotonic time (for countdown enforcement) and
|
|
//! wall-clock time (for availability windows).
|
|
//!
|
|
//! # Mock Time for Development
|
|
//!
|
|
//! In debug builds, the `SHEPHERD_MOCK_TIME` environment variable can be set
|
|
//! to override the system time for all time-sensitive operations. This is useful
|
|
//! for testing availability windows and time-based policies.
|
|
//!
|
|
//! Format: `YYYY-MM-DD HH:MM:SS` (e.g., `2025-12-25 14:30:00`)
|
|
//!
|
|
//! Example:
|
|
//! ```bash
|
|
//! SHEPHERD_MOCK_TIME="2025-12-25 14:30:00" ./run-dev
|
|
//! ```
|
|
|
|
use chrono::{DateTime, Datelike, Local, NaiveDateTime, NaiveTime, TimeZone, Timelike, Weekday};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::OnceLock;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Environment variable name for mock time (debug builds only)
|
|
pub const MOCK_TIME_ENV_VAR: &str = "SHEPHERD_MOCK_TIME";
|
|
|
|
/// Cached mock time offset from the real time when the process started.
|
|
/// This allows mock time to advance naturally.
|
|
static MOCK_TIME_OFFSET: OnceLock<Option<chrono::Duration>> = OnceLock::new();
|
|
|
|
/// Initialize the mock time offset based on the environment variable.
|
|
/// Returns the offset between mock time and real time at process start.
|
|
#[allow(clippy::disallowed_methods)] // This is the internal implementation that wraps Local::now()
|
|
fn get_mock_time_offset() -> Option<chrono::Duration> {
|
|
*MOCK_TIME_OFFSET.get_or_init(|| {
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
if let Ok(mock_time_str) = std::env::var(MOCK_TIME_ENV_VAR) {
|
|
// Parse the mock time string
|
|
if let Ok(naive_dt) = NaiveDateTime::parse_from_str(&mock_time_str, "%Y-%m-%d %H:%M:%S") {
|
|
if let Some(mock_dt) = Local.from_local_datetime(&naive_dt).single() {
|
|
let real_now = chrono::Local::now();
|
|
let offset = mock_dt.signed_duration_since(real_now);
|
|
tracing::info!(
|
|
mock_time = %mock_time_str,
|
|
offset_secs = offset.num_seconds(),
|
|
"Mock time enabled"
|
|
);
|
|
return Some(offset);
|
|
} else {
|
|
tracing::warn!(
|
|
mock_time = %mock_time_str,
|
|
"Failed to convert mock time to local timezone"
|
|
);
|
|
}
|
|
} else {
|
|
tracing::warn!(
|
|
mock_time = %mock_time_str,
|
|
expected_format = "%Y-%m-%d %H:%M:%S",
|
|
"Invalid mock time format"
|
|
);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
#[cfg(not(debug_assertions))]
|
|
{
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Returns whether mock time is currently active.
|
|
pub fn is_mock_time_active() -> bool {
|
|
get_mock_time_offset().is_some()
|
|
}
|
|
|
|
/// Get the current local time, respecting mock time settings in debug builds.
|
|
///
|
|
/// In release builds, this always returns the real system time.
|
|
/// In debug builds, if `SHEPHERD_MOCK_TIME` is set, this returns a time
|
|
/// that advances from the mock time at the same rate as real time.
|
|
#[allow(clippy::disallowed_methods)] // This is the wrapper that provides mock time support
|
|
pub fn now() -> DateTime<Local> {
|
|
let real_now = chrono::Local::now();
|
|
|
|
if let Some(offset) = get_mock_time_offset() {
|
|
real_now + offset
|
|
} else {
|
|
real_now
|
|
}
|
|
}
|
|
|
|
/// Format a DateTime for display in the HUD clock.
|
|
/// Always uses 12-hour format with AM/PM, ignoring system locale.
|
|
pub fn format_clock_time(dt: &DateTime<Local>) -> String {
|
|
dt.format("%l:%M %p").to_string()
|
|
}
|
|
|
|
/// Format a DateTime for display with full date and time.
|
|
pub fn format_datetime_full(dt: &DateTime<Local>) -> String {
|
|
dt.format("%Y-%m-%d %l:%M:%S %p").to_string()
|
|
}
|
|
|
|
/// Represents a point in monotonic time for countdown enforcement.
|
|
/// This is immune to wall-clock changes.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub struct MonotonicInstant(Instant);
|
|
|
|
impl MonotonicInstant {
|
|
pub fn now() -> Self {
|
|
Self(Instant::now())
|
|
}
|
|
|
|
pub fn elapsed(&self) -> Duration {
|
|
self.0.elapsed()
|
|
}
|
|
|
|
pub fn duration_since(&self, earlier: MonotonicInstant) -> Duration {
|
|
self.0.duration_since(earlier.0)
|
|
}
|
|
|
|
pub fn checked_add(&self, duration: Duration) -> Option<MonotonicInstant> {
|
|
self.0.checked_add(duration).map(MonotonicInstant)
|
|
}
|
|
|
|
/// Returns duration until `self`, or zero if `self` is in the past
|
|
pub fn saturating_duration_until(&self, from: MonotonicInstant) -> Duration {
|
|
if self.0 > from.0 {
|
|
self.0.duration_since(from.0)
|
|
} else {
|
|
Duration::ZERO
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::ops::Add<Duration> for MonotonicInstant {
|
|
type Output = MonotonicInstant;
|
|
|
|
fn add(self, rhs: Duration) -> Self::Output {
|
|
MonotonicInstant(self.0 + rhs)
|
|
}
|
|
}
|
|
|
|
/// Wall-clock time for availability windows
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct WallClock {
|
|
pub hour: u8,
|
|
pub minute: u8,
|
|
}
|
|
|
|
impl WallClock {
|
|
pub fn new(hour: u8, minute: u8) -> Option<Self> {
|
|
if hour < 24 && minute < 60 {
|
|
Some(Self { hour, minute })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn to_naive_time(self) -> NaiveTime {
|
|
NaiveTime::from_hms_opt(self.hour as u32, self.minute as u32, 0).unwrap()
|
|
}
|
|
|
|
pub fn from_naive_time(time: NaiveTime) -> Self {
|
|
Self {
|
|
hour: time.hour() as u8,
|
|
minute: time.minute() as u8,
|
|
}
|
|
}
|
|
|
|
/// Returns seconds since midnight
|
|
pub fn as_seconds_from_midnight(&self) -> u32 {
|
|
(self.hour as u32) * 3600 + (self.minute as u32) * 60
|
|
}
|
|
}
|
|
|
|
impl PartialOrd for WallClock {
|
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
Some(self.cmp(other))
|
|
}
|
|
}
|
|
|
|
impl Ord for WallClock {
|
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
self.as_seconds_from_midnight()
|
|
.cmp(&other.as_seconds_from_midnight())
|
|
}
|
|
}
|
|
|
|
/// Days of the week mask
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub struct DaysOfWeek(u8);
|
|
|
|
impl DaysOfWeek {
|
|
pub const MONDAY: u8 = 1 << 0;
|
|
pub const TUESDAY: u8 = 1 << 1;
|
|
pub const WEDNESDAY: u8 = 1 << 2;
|
|
pub const THURSDAY: u8 = 1 << 3;
|
|
pub const FRIDAY: u8 = 1 << 4;
|
|
pub const SATURDAY: u8 = 1 << 5;
|
|
pub const SUNDAY: u8 = 1 << 6;
|
|
|
|
pub const WEEKDAYS: DaysOfWeek = DaysOfWeek(
|
|
Self::MONDAY | Self::TUESDAY | Self::WEDNESDAY | Self::THURSDAY | Self::FRIDAY,
|
|
);
|
|
pub const WEEKENDS: DaysOfWeek = DaysOfWeek(Self::SATURDAY | Self::SUNDAY);
|
|
pub const ALL_DAYS: DaysOfWeek = DaysOfWeek(0x7F);
|
|
pub const NONE: DaysOfWeek = DaysOfWeek(0);
|
|
|
|
pub fn new(mask: u8) -> Self {
|
|
Self(mask & 0x7F)
|
|
}
|
|
|
|
pub fn contains(&self, weekday: Weekday) -> bool {
|
|
let bit = match weekday {
|
|
Weekday::Mon => Self::MONDAY,
|
|
Weekday::Tue => Self::TUESDAY,
|
|
Weekday::Wed => Self::WEDNESDAY,
|
|
Weekday::Thu => Self::THURSDAY,
|
|
Weekday::Fri => Self::FRIDAY,
|
|
Weekday::Sat => Self::SATURDAY,
|
|
Weekday::Sun => Self::SUNDAY,
|
|
};
|
|
(self.0 & bit) != 0
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0 == 0
|
|
}
|
|
}
|
|
|
|
impl std::ops::BitOr for DaysOfWeek {
|
|
type Output = Self;
|
|
|
|
fn bitor(self, rhs: Self) -> Self::Output {
|
|
Self(self.0 | rhs.0)
|
|
}
|
|
}
|
|
|
|
/// A time window during which an entry is available
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TimeWindow {
|
|
pub days: DaysOfWeek,
|
|
pub start: WallClock,
|
|
pub end: WallClock,
|
|
}
|
|
|
|
impl TimeWindow {
|
|
pub fn new(days: DaysOfWeek, start: WallClock, end: WallClock) -> Self {
|
|
Self { days, start, end }
|
|
}
|
|
|
|
/// Check if the given local datetime falls within this window
|
|
pub fn contains(&self, dt: &DateTime<Local>) -> bool {
|
|
let weekday = dt.weekday();
|
|
if !self.days.contains(weekday) {
|
|
return false;
|
|
}
|
|
|
|
let time = WallClock::from_naive_time(dt.time());
|
|
|
|
// Handle windows that don't cross midnight
|
|
if self.start <= self.end {
|
|
time >= self.start && time < self.end
|
|
} else {
|
|
// Window crosses midnight (e.g., 22:00 - 02:00)
|
|
time >= self.start || time < self.end
|
|
}
|
|
}
|
|
|
|
/// Calculate duration remaining in this window from the given time
|
|
pub fn remaining_duration(&self, dt: &DateTime<Local>) -> Option<Duration> {
|
|
if !self.contains(dt) {
|
|
return None;
|
|
}
|
|
|
|
let now_time = WallClock::from_naive_time(dt.time());
|
|
let now_secs = now_time.as_seconds_from_midnight();
|
|
let end_secs = self.end.as_seconds_from_midnight();
|
|
|
|
let remaining_secs = if self.start <= self.end {
|
|
// Normal window
|
|
end_secs.saturating_sub(now_secs)
|
|
} else {
|
|
// Cross-midnight window
|
|
if now_secs >= self.start.as_seconds_from_midnight() {
|
|
// We're in the evening portion, count until midnight then add morning
|
|
(86400 - now_secs) + end_secs
|
|
} else {
|
|
// We're in the morning portion
|
|
end_secs.saturating_sub(now_secs)
|
|
}
|
|
};
|
|
|
|
Some(Duration::from_secs(remaining_secs as u64))
|
|
}
|
|
}
|
|
|
|
/// Helper to format durations in human-readable form
|
|
pub fn format_duration(d: Duration) -> String {
|
|
let total_secs = d.as_secs();
|
|
let hours = total_secs / 3600;
|
|
let minutes = (total_secs % 3600) / 60;
|
|
let seconds = total_secs % 60;
|
|
|
|
if hours > 0 {
|
|
format!("{}h {}m {}s", hours, minutes, seconds)
|
|
} else if minutes > 0 {
|
|
format!("{}m {}s", minutes, seconds)
|
|
} else {
|
|
format!("{}s", seconds)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::TimeZone;
|
|
|
|
#[test]
|
|
fn test_wall_clock_ordering() {
|
|
let morning = WallClock::new(8, 0).unwrap();
|
|
let noon = WallClock::new(12, 0).unwrap();
|
|
let evening = WallClock::new(18, 30).unwrap();
|
|
|
|
assert!(morning < noon);
|
|
assert!(noon < evening);
|
|
assert!(morning < evening);
|
|
}
|
|
|
|
#[test]
|
|
fn test_days_of_week() {
|
|
let weekdays = DaysOfWeek::WEEKDAYS;
|
|
assert!(weekdays.contains(Weekday::Mon));
|
|
assert!(weekdays.contains(Weekday::Fri));
|
|
assert!(!weekdays.contains(Weekday::Sat));
|
|
assert!(!weekdays.contains(Weekday::Sun));
|
|
|
|
let weekends = DaysOfWeek::WEEKENDS;
|
|
assert!(!weekends.contains(Weekday::Mon));
|
|
assert!(weekends.contains(Weekday::Sat));
|
|
assert!(weekends.contains(Weekday::Sun));
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_window_contains() {
|
|
let window = TimeWindow::new(
|
|
DaysOfWeek::WEEKDAYS,
|
|
WallClock::new(14, 0).unwrap(), // 2 PM
|
|
WallClock::new(18, 0).unwrap(), // 6 PM
|
|
);
|
|
|
|
// Monday at 3 PM - should be in window
|
|
let dt = Local.with_ymd_and_hms(2025, 12, 29, 15, 0, 0).unwrap(); // Monday
|
|
assert!(window.contains(&dt));
|
|
|
|
// Monday at 10 AM - outside window
|
|
let dt = Local.with_ymd_and_hms(2025, 12, 29, 10, 0, 0).unwrap();
|
|
assert!(!window.contains(&dt));
|
|
|
|
// Saturday at 3 PM - wrong day
|
|
let dt = Local.with_ymd_and_hms(2025, 12, 27, 15, 0, 0).unwrap();
|
|
assert!(!window.contains(&dt));
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_window_remaining() {
|
|
let window = TimeWindow::new(
|
|
DaysOfWeek::ALL_DAYS,
|
|
WallClock::new(14, 0).unwrap(),
|
|
WallClock::new(18, 0).unwrap(),
|
|
);
|
|
|
|
let dt = Local.with_ymd_and_hms(2025, 12, 26, 15, 0, 0).unwrap(); // 3 PM
|
|
let remaining = window.remaining_duration(&dt).unwrap();
|
|
assert_eq!(remaining, Duration::from_secs(3 * 3600)); // 3 hours
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_duration() {
|
|
assert_eq!(format_duration(Duration::from_secs(30)), "30s");
|
|
assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
|
|
assert_eq!(format_duration(Duration::from_secs(3661)), "1h 1m 1s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_monotonic_instant() {
|
|
let t1 = MonotonicInstant::now();
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
let t2 = MonotonicInstant::now();
|
|
|
|
assert!(t2 > t1);
|
|
assert!(t2.duration_since(t1) >= Duration::from_millis(10));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_clock_time() {
|
|
let dt = Local.with_ymd_and_hms(2025, 12, 25, 14, 30, 45).unwrap();
|
|
assert_eq!(format_clock_time(&dt), " 2:30 PM");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_datetime_full() {
|
|
let dt = Local.with_ymd_and_hms(2025, 12, 25, 14, 30, 45).unwrap();
|
|
assert_eq!(format_datetime_full(&dt), "2025-12-25 2:30:45 PM");
|
|
}
|
|
|
|
#[test]
|
|
fn test_now_returns_time() {
|
|
// Basic test that now() returns a valid time
|
|
let t = now();
|
|
// Should be a reasonable year (after 2020, before 2100)
|
|
assert!(t.year() >= 2020);
|
|
assert!(t.year() <= 2100);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mock_time_env_var_name() {
|
|
// Verify the environment variable name is correct
|
|
assert_eq!(MOCK_TIME_ENV_VAR, "SHEPHERD_MOCK_TIME");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_mock_time_format() {
|
|
// Test that the expected format parses correctly
|
|
let valid_formats = [
|
|
"2025-12-25 14:30:00",
|
|
"2025-01-01 00:00:00",
|
|
"2025-12-31 23:59:59",
|
|
"2020-06-15 12:00:00",
|
|
];
|
|
|
|
for format_str in &valid_formats {
|
|
let result = NaiveDateTime::parse_from_str(format_str, "%Y-%m-%d %H:%M:%S");
|
|
assert!(
|
|
result.is_ok(),
|
|
"Expected '{}' to parse successfully, got {:?}",
|
|
format_str,
|
|
result
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_mock_time_invalid_formats() {
|
|
// Test that invalid formats are rejected
|
|
let invalid_formats = [
|
|
"2025-12-25", // Missing time
|
|
"14:30:00", // Missing date
|
|
"2025/12/25 14:30:00", // Wrong date separator
|
|
"2025-12-25T14:30:00", // ISO format (not supported)
|
|
"Dec 25, 2025 14:30", // Wrong format
|
|
"25-12-2025 14:30:00", // Wrong date order
|
|
"", // Empty string
|
|
"not a date", // Invalid string
|
|
];
|
|
|
|
for format_str in &invalid_formats {
|
|
let result = NaiveDateTime::parse_from_str(format_str, "%Y-%m-%d %H:%M:%S");
|
|
assert!(
|
|
result.is_err(),
|
|
"Expected '{}' to fail parsing, but it succeeded",
|
|
format_str
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[allow(clippy::disallowed_methods)] // Testing the offset calculation requires real time
|
|
fn test_mock_time_offset_calculation() {
|
|
// Test that the offset calculation works correctly
|
|
let mock_time_str = "2025-12-25 14:30:00";
|
|
let naive_dt = NaiveDateTime::parse_from_str(mock_time_str, "%Y-%m-%d %H:%M:%S").unwrap();
|
|
let mock_dt = Local.from_local_datetime(&naive_dt).single().unwrap();
|
|
let real_now = chrono::Local::now();
|
|
|
|
let offset = mock_dt.signed_duration_since(real_now);
|
|
|
|
// The offset should be applied correctly
|
|
let simulated_now = real_now + offset;
|
|
|
|
// The simulated time should be very close to the mock time
|
|
// (within a second, accounting for test execution time)
|
|
let diff = (simulated_now - mock_dt).num_seconds().abs();
|
|
assert!(
|
|
diff <= 1,
|
|
"Expected simulated time to be within 1 second of mock time, got {} seconds difference",
|
|
diff
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[allow(clippy::disallowed_methods)] // Testing time advancement requires real time
|
|
fn test_mock_time_advances_with_real_time() {
|
|
// Test that mock time advances at the same rate as real time
|
|
// This tests the concept, not the actual implementation (since OnceLock is static)
|
|
|
|
let mock_time_str = "2025-12-25 14:30:00";
|
|
let naive_dt = NaiveDateTime::parse_from_str(mock_time_str, "%Y-%m-%d %H:%M:%S").unwrap();
|
|
let mock_dt = Local.from_local_datetime(&naive_dt).single().unwrap();
|
|
|
|
let real_t1 = chrono::Local::now();
|
|
let offset = mock_dt.signed_duration_since(real_t1);
|
|
|
|
// Simulate time passing
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
|
|
let real_t2 = chrono::Local::now();
|
|
let simulated_t1 = real_t1 + offset;
|
|
let simulated_t2 = real_t2 + offset;
|
|
|
|
// The simulated times should have advanced by the same amount as real times
|
|
let real_elapsed = real_t2.signed_duration_since(real_t1);
|
|
let simulated_elapsed = simulated_t2.signed_duration_since(simulated_t1);
|
|
|
|
assert_eq!(
|
|
real_elapsed.num_milliseconds(),
|
|
simulated_elapsed.num_milliseconds(),
|
|
"Mock time should advance at the same rate as real time"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_availability_with_specific_time() {
|
|
// Test that availability windows work correctly with a specific time
|
|
// This validates that the mock time would affect availability checks
|
|
|
|
let window = TimeWindow::new(
|
|
DaysOfWeek::ALL_DAYS,
|
|
WallClock::new(14, 0).unwrap(), // 2 PM
|
|
WallClock::new(18, 0).unwrap(), // 6 PM
|
|
);
|
|
|
|
// Time within window
|
|
let in_window = Local.with_ymd_and_hms(2025, 12, 25, 15, 0, 0).unwrap();
|
|
assert!(window.contains(&in_window), "15:00 should be within 14:00-18:00 window");
|
|
|
|
// Time before window
|
|
let before_window = Local.with_ymd_and_hms(2025, 12, 25, 10, 0, 0).unwrap();
|
|
assert!(!window.contains(&before_window), "10:00 should be before 14:00-18:00 window");
|
|
|
|
// Time after window
|
|
let after_window = Local.with_ymd_and_hms(2025, 12, 25, 20, 0, 0).unwrap();
|
|
assert!(!window.contains(&after_window), "20:00 should be after 14:00-18:00 window");
|
|
}
|
|
|
|
#[test]
|
|
fn test_availability_with_day_restriction() {
|
|
// Test that day-of-week restrictions work correctly
|
|
let window = TimeWindow::new(
|
|
DaysOfWeek::WEEKDAYS,
|
|
WallClock::new(14, 0).unwrap(),
|
|
WallClock::new(18, 0).unwrap(),
|
|
);
|
|
|
|
// Thursday at 3 PM - should be available (weekday, in time window)
|
|
let thursday = Local.with_ymd_and_hms(2025, 12, 25, 15, 0, 0).unwrap(); // Christmas 2025 is Thursday
|
|
assert!(window.contains(&thursday), "Thursday 15:00 should be in weekday afternoon window");
|
|
|
|
// Saturday at 3 PM - should NOT be available (weekend)
|
|
let saturday = Local.with_ymd_and_hms(2025, 12, 27, 15, 0, 0).unwrap();
|
|
assert!(!window.contains(&saturday), "Saturday should not be in weekday window");
|
|
|
|
// Sunday at 3 PM - should NOT be available (weekend)
|
|
let sunday = Local.with_ymd_and_hms(2025, 12, 28, 15, 0, 0).unwrap();
|
|
assert!(!window.contains(&sunday), "Sunday should not be in weekday window");
|
|
}
|
|
}
|
|
|
|
/// Tests that require running in a separate process to test environment variable handling.
|
|
/// These are integration-style tests for the mock time feature.
|
|
#[cfg(test)]
|
|
mod mock_time_integration_tests {
|
|
use super::*;
|
|
|
|
/// This test documents the expected behavior of the mock time feature.
|
|
/// Due to the static OnceLock, actual integration testing requires
|
|
/// running with the environment variable set externally.
|
|
///
|
|
/// To manually test:
|
|
/// ```bash
|
|
/// SHEPHERD_MOCK_TIME="2025-12-25 14:30:00" cargo test
|
|
/// ```
|
|
#[test]
|
|
fn test_mock_time_documentation() {
|
|
// This test verifies the mock time constants and expected behavior
|
|
assert_eq!(MOCK_TIME_ENV_VAR, "SHEPHERD_MOCK_TIME");
|
|
|
|
// The expected format is documented
|
|
let expected_format = "%Y-%m-%d %H:%M:%S";
|
|
let example = "2025-12-25 14:30:00";
|
|
assert!(NaiveDateTime::parse_from_str(example, expected_format).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(debug_assertions)]
|
|
fn test_is_mock_time_active_in_debug() {
|
|
// In debug mode, is_mock_time_active() should return based on env var
|
|
// Since we can't control the env var within a single test run due to OnceLock,
|
|
// we just verify the function doesn't panic
|
|
let _ = is_mock_time_active();
|
|
}
|
|
|
|
#[test]
|
|
fn test_now_consistency() {
|
|
// now() should return consistent, advancing times
|
|
let t1 = now();
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
let t2 = now();
|
|
|
|
// t2 should be after t1
|
|
assert!(t2 > t1, "Time should advance forward");
|
|
|
|
// The difference should be approximately 50ms (with some tolerance)
|
|
let diff = t2.signed_duration_since(t1);
|
|
assert!(
|
|
diff.num_milliseconds() >= 40 && diff.num_milliseconds() <= 200,
|
|
"Expected ~50ms difference, got {}ms",
|
|
diff.num_milliseconds()
|
|
);
|
|
}
|
|
}
|