use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use crabshell_reactive::Runtime;
use crabshell_ui::Cx;
use jiff::tz::TimeZone;
use jiff::{SignedDuration, Timestamp, Zoned};
use super::{Clocks, EARLY, Precision, boundaries, system_zone};
use crate::testing::{Harness, PrivateBus, offline};
use crate::{Config, ServicesExt};
fn at(time: &str) -> Timestamp {
time.parse().expect("a valid timestamp")
}
#[test]
fn boundaries_truncate_to_the_precision() {
let now = at("2026-11-11T10:41:27.6Z");
assert_eq!(
boundaries(now, Precision::Second),
(at("2026-11-11T10:41:27Z"), at("2026-11-11T10:41:28Z"))
);
assert_eq!(
boundaries(now, Precision::Minute),
(at("2026-11-11T10:41:00Z"), at("2026-11-11T10:42:00Z"))
);
assert_eq!(
boundaries(at("2026-11-11T10:42:00Z"), Precision::Minute).0,
at("2026-11-11T10:42:00Z")
);
}
#[test]
fn a_timer_a_hair_early_shows_the_new_value() {
assert_eq!(
boundaries(at("2026-11-11T10:41:59.99Z"), Precision::Minute),
(at("2026-11-11T10:42:00Z"), at("2026-11-11T10:43:00Z"))
);
assert_eq!(
boundaries(at("2026-11-11T10:41:27.999Z"), Precision::Second),
(at("2026-11-11T10:41:28Z"), at("2026-11-11T10:41:29Z"))
);
assert_eq!(
boundaries(at("2026-11-11T10:41:59.9Z"), Precision::Minute).0,
at("2026-11-11T10:41:00Z")
);
}
#[test]
fn a_dst_change_is_a_minute_like_any_other() {
let cet = TimeZone::posix("CET-1CEST,M3.5.0,M10.5.0/3").expect("a valid POSIX zone");
let shown_in = |time: Timestamp| time.to_zoned(cet.clone()).strftime("%H:%M %Z").to_string();
let (shown, next) = boundaries(at("2026-03-29T00:59:30Z"), Precision::Minute);
assert_eq!(shown_in(shown), "01:59 CET");
assert_eq!(shown_in(next), "03:00 CEST");
let (shown, next) = boundaries(at("2026-10-25T00:59:30Z"), Precision::Minute);
assert_eq!(shown_in(shown), "02:59 CEST");
assert_eq!(shown_in(next), "02:00 CET");
}
struct Localtime(PathBuf);
impl Localtime {
fn new(zone: &str) -> Localtime {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"crabshell-services-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).expect("a temporary directory");
let localtime = Localtime(dir);
localtime.point_at(zone);
localtime
}
fn path(&self) -> PathBuf {
self.0.join("localtime")
}
fn point_at(&self, zone: &str) {
let _ = std::fs::remove_file(self.path());
std::os::unix::fs::symlink(format!("/usr/share/zoneinfo/{zone}"), self.path())
.expect("a link in the temporary directory");
}
}
impl Drop for Localtime {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_changed_zone_is_read_again_at_once() {
let localtime = Localtime::new("Europe/Paris");
let path = localtime.path();
assert_eq!(system_zone(Some(&path)).iana_name(), Some("Europe/Paris"));
localtime.point_at("Asia/Tokyo");
assert_eq!(system_zone(Some(&path)).iana_name(), Some("Asia/Tokyo"));
}
#[test]
fn a_second_clock_ticks_on_whole_seconds_with_one_wakeup_each() {
let mut shell = Harness::new(offline());
let clock = shell.cx.clock(Precision::Second);
let seen: Rc<RefCell<Vec<(Zoned, Timestamp)>>> = Rc::default();
let sink = Rc::clone(&seen);
shell
.cx
.effect(move || sink.borrow_mut().push((clock.get(), Timestamp::now())));
Runtime::current().flush();
let wakeups = shell.run_until(Instant::now() + Duration::from_millis(2500));
let seen = seen.borrow();
let ticks = seen.len() - 1;
assert!((2..=3).contains(&ticks), "{ticks} ticks in 2.5 s: {seen:?}");
assert!(wakeups <= ticks + 1, "{wakeups} wakeups for {ticks} ticks");
for (shown, _) in seen.iter() {
assert_eq!(shown.subsec_nanosecond(), 0, "{shown} isn't a whole second");
}
for pair in seen.windows(2) {
let step = pair[1].0.timestamp().duration_since(pair[0].0.timestamp());
assert_eq!(step, SignedDuration::from_secs(1), "{seen:?}");
}
for (shown, when) in &seen[1..] {
let late = when.duration_since(shown.timestamp());
assert!(
late >= -EARLY && late < SignedDuration::from_millis(100),
"{shown} shown at {when}"
);
}
}
#[test]
fn every_caller_shares_one_clock_per_precision() {
let shell = Harness::new(offline());
let minute = shell.cx.clock(Precision::Minute);
let output = shell.cx.child();
assert_eq!(output.clock(Precision::Minute), minute);
let second = output.clock(Precision::Second);
assert_ne!(second, minute);
assert_eq!(shell.cx.clock(Precision::Second), second);
output.owner().dispose();
assert!(second.try_get().is_some());
assert_eq!(minute.get().subsec_nanosecond(), 0);
assert_eq!(minute.get().second(), 0);
}
#[test]
fn without_provide_a_clock_stands_still_under_its_context() {
let cx = Cx::new(Runtime::current().new_owner());
let clock = cx.clock(Precision::Minute);
assert_eq!(clock.get().second(), 0);
cx.owner().dispose();
assert_eq!(clock.try_get(), None);
}
#[test]
fn disposing_the_root_frees_every_clock() {
let runtime = Runtime::current();
let before = runtime.node_count();
let mut shell = Harness::new(offline());
let second = shell.cx.clock(Precision::Second);
shell.cx.child().clock(Precision::Minute);
let clocks = shell.cx.service::<Clocks>().expect("provided");
let held = Rc::strong_count(&clocks);
shell.cx.owner().dispose();
assert_eq!(runtime.node_count(), before);
shell.run_until(Instant::now() + Duration::from_millis(1100));
assert_eq!(second.try_get(), None);
assert!(
Rc::strong_count(&clocks) < held,
"the second's timer still runs"
);
}
#[test]
fn a_resync_replaces_each_timer() {
let shell = Harness::new(offline());
shell.cx.clock(Precision::Second);
shell.cx.clock(Precision::Minute);
let clocks = shell.cx.service::<Clocks>().expect("provided");
let held = Rc::strong_count(&clocks);
for _ in 0..3 {
clocks.resync();
}
assert_eq!(Rc::strong_count(&clocks), held);
}
#[test]
fn one_system_bus_watcher_serves_every_clock() {
let shell = Harness::new(Config {
localtime: None,
system_bus: Some(
"unix:path=/nonexistent/crabshell-test-bus"
.parse()
.expect("an address"),
),
..offline()
});
let clocks = shell.cx.service::<Clocks>().expect("provided");
let held = Rc::strong_count(&clocks);
shell.cx.clock(Precision::Minute);
assert_eq!(Rc::strong_count(&clocks), held + 2);
shell.cx.clock(Precision::Second);
assert_eq!(Rc::strong_count(&clocks), held + 3);
}
#[test]
fn a_tick_reads_the_zone_again() {
let localtime = Localtime::new("Europe/Paris");
let mut shell = Harness::new(Config {
localtime: Some(localtime.path()),
..offline()
});
let clock = shell.cx.clock(Precision::Second);
assert_eq!(
clock.get_untracked().time_zone().iana_name(),
Some("Europe/Paris")
);
localtime.point_at("Asia/Tokyo");
shell.run_until(Instant::now() + Duration::from_millis(1100));
assert_eq!(
clock.get_untracked().time_zone().iana_name(),
Some("Asia/Tokyo")
);
}
#[test]
fn without_a_system_bus_the_clock_keeps_ticking() {
let mut shell = Harness::new(Config {
localtime: None,
system_bus: Some(
"unix:path=/nonexistent/crabshell-test-bus"
.parse()
.expect("an address"),
),
..offline()
});
let clock = shell.cx.clock(Precision::Second);
let first = clock.get_untracked().timestamp();
shell.run_until(Instant::now() + Duration::from_millis(1100));
assert!(clock.get_untracked().timestamp() > first);
}
async fn prepare_for_sleep(from: zbus::Connection, start: bool) {
let (path, interface) = ("/org/freedesktop/login1", "org.freedesktop.login1.Manager");
from
.emit_signal(None::<&str>, path, interface, "PrepareForSleep", &start)
.await
.expect("a signal on the private bus");
}
async fn properties_changed(from: zbus::Connection, property: &'static str) {
let path = "/org/freedesktop/timedate1";
let changed = HashMap::from([(property, zbus::Variant::from("Asia/Tokyo"))]);
let body = ("org.freedesktop.timedate1", changed, Vec::<&str>::new());
from
.emit_signal(
None::<&str>,
path,
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
&body,
)
.await
.expect("a signal on the private bus");
}
#[test]
fn logind_and_timedated_resync_the_clock() {
let bus = PrivateBus::start();
let localtime = Localtime::new("Europe/Paris");
let mut shell = Harness::new(Config {
localtime: Some(localtime.path()),
system_bus: Some(bus.address.parse().expect("dbus-daemon's address")),
..offline()
});
let clock = shell.cx.clock(Precision::Minute);
let zone = || {
clock
.get_untracked()
.time_zone()
.iana_name()
.map(str::to_owned)
};
let (paris, tokyo) = (
Some("Europe/Paris".to_owned()),
Some("Asia/Tokyo".to_owned()),
);
assert_eq!(zone(), paris);
let clocks = shell.cx.service::<Clocks>().expect("provided");
let timer = || clocks.started.borrow()[0].timer;
let system =
shell.block_on(bus.connect(&["org.freedesktop.login1", "org.freedesktop.timedate1"]));
let stranger = shell.block_on(bus.connect(&[]));
let armed = timer();
shell.retry_until(
"re-sync after PrepareForSleep(false)",
|| timer() != armed,
|shell| shell.block_on(prepare_for_sleep(system.clone(), false)),
);
let armed = timer();
shell.block_on(prepare_for_sleep(system.clone(), true));
shell.block_on(properties_changed(system.clone(), "NTP"));
shell.block_on(prepare_for_sleep(stranger.clone(), false));
shell.block_on(properties_changed(stranger, "Timezone"));
shell.run_until(Instant::now() + Duration::from_millis(300));
assert_eq!(timer(), armed);
assert_eq!(zone(), paris);
localtime.point_at("Asia/Tokyo");
shell.retry_until(
"new zone after PropertiesChanged",
|| zone() == tokyo,
|shell| shell.block_on(properties_changed(system.clone(), "Timezone")),
);
assert_ne!(timer(), armed);
}