Add clock mocking mechanism for dev use only
This commit is contained in:
parent
0f3bc8f690
commit
133a55035a
13 changed files with 388 additions and 27 deletions
|
|
@ -82,6 +82,8 @@ TODO
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
TODO: `./run-dev`, options
|
TODO: `./run-dev`, options
|
||||||
|
* Set the `SHEPHERD_MOCK_TIME` environment variable to mock the time,
|
||||||
|
such as `SHEPHERD_MOCK_TIME="2025-12-25 15:30:00" ./run-dev`
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ impl Event {
|
||||||
pub fn new(payload: EventPayload) -> Self {
|
pub fn new(payload: EventPayload) -> Self {
|
||||||
Self {
|
Self {
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
timestamp: Local::now(),
|
timestamp: shepherd_util::now(),
|
||||||
payload,
|
payload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -479,7 +479,7 @@ impl CoreEngine {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build entry views for the snapshot
|
// Build entry views for the snapshot
|
||||||
let entries = self.list_entries(Local::now());
|
let entries = self.list_entries(shepherd_util::now());
|
||||||
|
|
||||||
DaemonStateSnapshot {
|
DaemonStateSnapshot {
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_session_creation() {
|
fn test_session_creation() {
|
||||||
let plan = make_test_plan(300);
|
let plan = make_test_plan(300);
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
|
|
||||||
let session = ActiveSession::new(plan, now, now_mono);
|
let session = ActiveSession::new(plan, now, now_mono);
|
||||||
|
|
@ -261,7 +261,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pending_warnings() {
|
fn test_pending_warnings() {
|
||||||
let plan = make_test_plan(300);
|
let plan = make_test_plan(300);
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
|
|
||||||
let mut session = ActiveSession::new(plan, now, now_mono);
|
let mut session = ActiveSession::new(plan, now, now_mono);
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,32 @@ fn build_hud_content(state: SharedState) -> gtk4::Box {
|
||||||
.halign(gtk4::Align::End)
|
.halign(gtk4::Align::End)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Wall clock display (shows mock time indicator in debug builds)
|
||||||
|
let clock_box = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.spacing(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let clock_icon = gtk4::Image::from_icon_name("preferences-system-time-symbolic");
|
||||||
|
clock_icon.set_pixel_size(20);
|
||||||
|
clock_box.append(&clock_icon);
|
||||||
|
|
||||||
|
let clock_label = gtk4::Label::new(Some("--:--"));
|
||||||
|
clock_label.add_css_class("clock-label");
|
||||||
|
clock_box.append(&clock_label);
|
||||||
|
|
||||||
|
// Add mock indicator if mock time is active (debug builds only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
if shepherd_util::is_mock_time_active() {
|
||||||
|
let mock_indicator = gtk4::Label::new(Some("(MOCK)"));
|
||||||
|
mock_indicator.add_css_class("mock-time-indicator");
|
||||||
|
clock_box.append(&mock_indicator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
right_box.append(&clock_box);
|
||||||
|
|
||||||
// Volume control with slider
|
// Volume control with slider
|
||||||
let volume_box = gtk4::Box::builder()
|
let volume_box = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
|
@ -345,8 +371,13 @@ fn build_hud_content(state: SharedState) -> gtk4::Box {
|
||||||
let volume_slider_clone = volume_slider.clone();
|
let volume_slider_clone = volume_slider.clone();
|
||||||
let volume_label_clone = volume_label.clone();
|
let volume_label_clone = volume_label.clone();
|
||||||
let slider_changing_for_update = slider_changing.clone();
|
let slider_changing_for_update = slider_changing.clone();
|
||||||
|
let clock_label_clone = clock_label.clone();
|
||||||
|
|
||||||
glib::timeout_add_local(Duration::from_millis(500), move || {
|
glib::timeout_add_local(Duration::from_millis(500), move || {
|
||||||
|
// Update wall clock display
|
||||||
|
let current_time = shepherd_util::now();
|
||||||
|
clock_label_clone.set_text(&shepherd_util::format_clock_time(¤t_time));
|
||||||
|
|
||||||
// Update session state
|
// Update session state
|
||||||
let session_state = state.session_state();
|
let session_state = state.session_state();
|
||||||
match &session_state {
|
match &session_state {
|
||||||
|
|
@ -567,6 +598,19 @@ fn load_css() {
|
||||||
min-width: 3em;
|
min-width: 3em;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clock-label {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-time-indicator {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-warning);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let provider = gtk4::CssProvider::new();
|
let provider = gtk4::CssProvider::new();
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ impl SharedState {
|
||||||
label,
|
label,
|
||||||
deadline,
|
deadline,
|
||||||
} => {
|
} => {
|
||||||
let now = chrono::Local::now();
|
let now = shepherd_util::now();
|
||||||
// For unlimited sessions (deadline=None), time_remaining is None
|
// For unlimited sessions (deadline=None), time_remaining is None
|
||||||
let time_remaining = deadline.and_then(|d| {
|
let time_remaining = deadline.and_then(|d| {
|
||||||
if d > now {
|
if d > now {
|
||||||
|
|
@ -244,7 +244,7 @@ impl SharedState {
|
||||||
|
|
||||||
EventPayload::StateChanged(snapshot) => {
|
EventPayload::StateChanged(snapshot) => {
|
||||||
if let Some(session) = &snapshot.current_session {
|
if let Some(session) = &snapshot.current_session {
|
||||||
let now = chrono::Local::now();
|
let now = shepherd_util::now();
|
||||||
// For unlimited sessions (deadline=None), time_remaining is None
|
// For unlimited sessions (deadline=None), time_remaining is None
|
||||||
let time_remaining = session.deadline.and_then(|d| {
|
let time_remaining = session.deadline.and_then(|d| {
|
||||||
if d > now {
|
if d > now {
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ impl LauncherApp {
|
||||||
match payload {
|
match payload {
|
||||||
shepherd_api::ResponsePayload::LaunchApproved { session_id, deadline } => {
|
shepherd_api::ResponsePayload::LaunchApproved { session_id, deadline } => {
|
||||||
info!(session_id = %session_id, "Launch approved, setting SessionActive");
|
info!(session_id = %session_id, "Launch approved, setting SessionActive");
|
||||||
let now = chrono::Local::now();
|
let now = shepherd_util::now();
|
||||||
// For unlimited sessions (deadline=None), time_remaining is None
|
// For unlimited sessions (deadline=None), time_remaining is None
|
||||||
let time_remaining = deadline.and_then(|d| {
|
let time_remaining = deadline.and_then(|d| {
|
||||||
if d > now {
|
if d > now {
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ impl DaemonClient {
|
||||||
match payload {
|
match payload {
|
||||||
ResponsePayload::State(snapshot) => {
|
ResponsePayload::State(snapshot) => {
|
||||||
if let Some(session) = snapshot.current_session {
|
if let Some(session) = snapshot.current_session {
|
||||||
let now = chrono::Local::now();
|
let now = shepherd_util::now();
|
||||||
// For unlimited sessions (deadline=None), time_remaining is None
|
// For unlimited sessions (deadline=None), time_remaining is None
|
||||||
let time_remaining = session.deadline.and_then(|d| {
|
let time_remaining = session.deadline.and_then(|d| {
|
||||||
if d > now {
|
if d > now {
|
||||||
|
|
@ -166,7 +166,7 @@ impl DaemonClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResponsePayload::LaunchApproved { session_id, deadline } => {
|
ResponsePayload::LaunchApproved { session_id, deadline } => {
|
||||||
let now = chrono::Local::now();
|
let now = shepherd_util::now();
|
||||||
// For unlimited sessions (deadline=None), time_remaining is None
|
// For unlimited sessions (deadline=None), time_remaining is None
|
||||||
let time_remaining = deadline.and_then(|d| {
|
let time_remaining = deadline.and_then(|d| {
|
||||||
if d > now {
|
if d > now {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ impl SharedState {
|
||||||
deadline,
|
deadline,
|
||||||
} => {
|
} => {
|
||||||
tracing::info!(session_id = %session_id, label = %label, "Session started event");
|
tracing::info!(session_id = %session_id, label = %label, "Session started event");
|
||||||
let now = chrono::Local::now();
|
let now = shepherd_util::now();
|
||||||
// For unlimited sessions (deadline=None), time_remaining is None
|
// For unlimited sessions (deadline=None), time_remaining is None
|
||||||
let time_remaining = deadline.and_then(|d| {
|
let time_remaining = deadline.and_then(|d| {
|
||||||
if d > now {
|
if d > now {
|
||||||
|
|
@ -123,7 +123,7 @@ impl SharedState {
|
||||||
|
|
||||||
fn apply_snapshot(&self, snapshot: DaemonStateSnapshot) {
|
fn apply_snapshot(&self, snapshot: DaemonStateSnapshot) {
|
||||||
if let Some(session) = snapshot.current_session {
|
if let Some(session) = snapshot.current_session {
|
||||||
let now = chrono::Local::now();
|
let now = shepherd_util::now();
|
||||||
// For unlimited sessions (deadline=None), time_remaining is None
|
// For unlimited sessions (deadline=None), time_remaining is None
|
||||||
let time_remaining = session.deadline.and_then(|d| {
|
let time_remaining = session.deadline.and_then(|d| {
|
||||||
if d > now {
|
if d > now {
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ impl AuditEvent {
|
||||||
pub fn new(event: AuditEventType) -> Self {
|
pub fn new(event: AuditEventType) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: 0, // Will be set by store
|
id: 0, // Will be set by store
|
||||||
timestamp: Local::now(),
|
timestamp: shepherd_util::now(),
|
||||||
event,
|
event,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,103 @@
|
||||||
//!
|
//!
|
||||||
//! Provides both monotonic time (for countdown enforcement) and
|
//! Provides both monotonic time (for countdown enforcement) and
|
||||||
//! wall-clock time (for availability windows).
|
//! 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, NaiveTime, Timelike, Weekday};
|
use chrono::{DateTime, Datelike, Local, NaiveDateTime, NaiveTime, TimeZone, Timelike, Weekday};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::OnceLock;
|
||||||
use std::time::{Duration, Instant};
|
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.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
pub fn format_clock_time(dt: &DateTime<Local>) -> String {
|
||||||
|
dt.format("%H:%M").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 %H:%M:%S").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Represents a point in monotonic time for countdown enforcement.
|
/// Represents a point in monotonic time for countdown enforcement.
|
||||||
/// This is immune to wall-clock changes.
|
/// This is immune to wall-clock changes.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
|
@ -298,4 +390,229 @@ mod tests {
|
||||||
assert!(t2 > t1);
|
assert!(t2 > t1);
|
||||||
assert!(t2.duration_since(t1) >= Duration::from_millis(10));
|
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), "14:30");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 14:30:45");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
||||||
|
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]
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
//! - Volume control
|
//! - Volume control
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::Local;
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use shepherd_api::{
|
use shepherd_api::{
|
||||||
Command, DaemonStateSnapshot, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus,
|
Command, DaemonStateSnapshot, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus,
|
||||||
|
|
@ -176,7 +175,7 @@ impl Daemon {
|
||||||
// Tick timer - check warnings and expiry
|
// Tick timer - check warnings and expiry
|
||||||
_ = tick_timer.tick() => {
|
_ = tick_timer.tick() => {
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
|
|
||||||
let events = {
|
let events = {
|
||||||
let mut engine = engine.lock().await;
|
let mut engine = engine.lock().await;
|
||||||
|
|
@ -207,7 +206,7 @@ impl Daemon {
|
||||||
ipc: &Arc<IpcServer>,
|
ipc: &Arc<IpcServer>,
|
||||||
event: CoreEvent,
|
event: CoreEvent,
|
||||||
_now_mono: MonotonicInstant,
|
_now_mono: MonotonicInstant,
|
||||||
_now: chrono::DateTime<Local>,
|
_now: chrono::DateTime<chrono::Local>,
|
||||||
) {
|
) {
|
||||||
match &event {
|
match &event {
|
||||||
CoreEvent::Warning {
|
CoreEvent::Warning {
|
||||||
|
|
@ -322,7 +321,7 @@ impl Daemon {
|
||||||
match event {
|
match event {
|
||||||
HostEvent::Exited { handle, status } => {
|
HostEvent::Exited { handle, status } => {
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
session_id = %handle.session_id,
|
session_id = %handle.session_id,
|
||||||
|
|
@ -454,7 +453,7 @@ impl Daemon {
|
||||||
request_id: u64,
|
request_id: u64,
|
||||||
command: Command,
|
command: Command,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
//!
|
//!
|
||||||
//! These tests verify the end-to-end behavior of the daemon.
|
//! These tests verify the end-to-end behavior of the daemon.
|
||||||
|
|
||||||
use chrono::Local;
|
|
||||||
use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold};
|
use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold};
|
||||||
use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy, Policy};
|
use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy, Policy};
|
||||||
use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision};
|
use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision};
|
||||||
use shepherd_host_api::{HostCapabilities, MockHost};
|
use shepherd_host_api::{HostCapabilities, MockHost};
|
||||||
use shepherd_store::{SqliteStore, Store};
|
use shepherd_store::{SqliteStore, Store};
|
||||||
use shepherd_util::{EntryId, MonotonicInstant};
|
use shepherd_util::{self, EntryId, MonotonicInstant};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
@ -73,7 +72,7 @@ fn test_entry_listing() {
|
||||||
let caps = HostCapabilities::minimal();
|
let caps = HostCapabilities::minimal();
|
||||||
let engine = CoreEngine::new(policy, store, caps);
|
let engine = CoreEngine::new(policy, store, caps);
|
||||||
|
|
||||||
let entries = engine.list_entries(Local::now());
|
let entries = engine.list_entries(shepherd_util::now());
|
||||||
|
|
||||||
assert_eq!(entries.len(), 1);
|
assert_eq!(entries.len(), 1);
|
||||||
assert!(entries[0].enabled);
|
assert!(entries[0].enabled);
|
||||||
|
|
@ -89,7 +88,7 @@ fn test_launch_approval() {
|
||||||
let engine = CoreEngine::new(policy, store, caps);
|
let engine = CoreEngine::new(policy, store, caps);
|
||||||
|
|
||||||
let entry_id = EntryId::new("test-game");
|
let entry_id = EntryId::new("test-game");
|
||||||
let decision = engine.request_launch(&entry_id, Local::now());
|
let decision = engine.request_launch(&entry_id, shepherd_util::now());
|
||||||
|
|
||||||
assert!(matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Some(Duration::from_secs(10))));
|
assert!(matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Some(Duration::from_secs(10))));
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +101,7 @@ fn test_session_lifecycle() {
|
||||||
let mut engine = CoreEngine::new(policy, store, caps);
|
let mut engine = CoreEngine::new(policy, store, caps);
|
||||||
|
|
||||||
let entry_id = EntryId::new("test-game");
|
let entry_id = EntryId::new("test-game");
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
|
|
||||||
// Launch
|
// Launch
|
||||||
|
|
@ -131,7 +130,7 @@ fn test_warning_emission() {
|
||||||
let mut engine = CoreEngine::new(policy, store, caps);
|
let mut engine = CoreEngine::new(policy, store, caps);
|
||||||
|
|
||||||
let entry_id = EntryId::new("test-game");
|
let entry_id = EntryId::new("test-game");
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
|
|
||||||
// Start session
|
// Start session
|
||||||
|
|
@ -170,7 +169,7 @@ fn test_session_expiry() {
|
||||||
let mut engine = CoreEngine::new(policy, store, caps);
|
let mut engine = CoreEngine::new(policy, store, caps);
|
||||||
|
|
||||||
let entry_id = EntryId::new("test-game");
|
let entry_id = EntryId::new("test-game");
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
|
|
||||||
// Start session
|
// Start session
|
||||||
|
|
@ -198,7 +197,7 @@ fn test_usage_accounting() {
|
||||||
let mut engine = CoreEngine::new(policy, store, caps);
|
let mut engine = CoreEngine::new(policy, store, caps);
|
||||||
|
|
||||||
let entry_id = EntryId::new("test-game");
|
let entry_id = EntryId::new("test-game");
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
|
|
||||||
// Start session
|
// Start session
|
||||||
|
|
@ -302,7 +301,7 @@ fn test_session_extension() {
|
||||||
let mut engine = CoreEngine::new(policy, store, caps);
|
let mut engine = CoreEngine::new(policy, store, caps);
|
||||||
|
|
||||||
let entry_id = EntryId::new("test-game");
|
let entry_id = EntryId::new("test-game");
|
||||||
let now = Local::now();
|
let now = shepherd_util::now();
|
||||||
let now_mono = MonotonicInstant::now();
|
let now_mono = MonotonicInstant::now();
|
||||||
|
|
||||||
// Start session
|
// Start session
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue