use super::*;
use std::cell::Cell;
use std::path::PathBuf;
use std::time::Instant;
use tempfile::{TempDir, tempdir};
pub(super) fn workspace() -> (TempDir, PathBuf) {
let dir = tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
(dir, root)
}
pub(super) fn wait_until<T>(timeout: Duration, mut probe: impl FnMut() -> Option<T>) -> Option<T> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Some(v) = probe() {
return Some(v);
}
std::thread::sleep(Duration::from_millis(25));
}
probe()
}
pub(super) const DETECT: Duration = Duration::from_secs(20);
pub(super) fn wait_armed(set: &mut WatchSet, root: &Path) {
let probe = root.join("steps/.arming-probe");
std::fs::create_dir_all(probe.parent().unwrap()).unwrap();
let armed = wait_until(DETECT, || {
std::fs::write(&probe, b"x").ok()?;
(!set.drain_dirty().is_empty()).then_some(())
});
assert!(armed.is_some(), "the watch set never armed");
while wait_until(QUIET, || (!set.drain_dirty().is_empty()).then_some(())).is_some() {}
}
const QUIET: Duration = Duration::from_millis(150);
#[test]
fn wait_until_times_out_to_none() {
assert!(wait_until(Duration::from_millis(20), || None::<()>).is_none());
}
#[test]
fn wait_until_retries_a_probe_that_missed() {
let probes = Cell::new(0);
let got = wait_until(DETECT, || {
probes.set(probes.get() + 1);
(probes.get() > 1).then_some(probes.get())
});
assert_eq!(got, Some(2), "the retry answered");
}
#[test]
fn reconcile_creates_then_drops_watchers() {
let (_dir, root) = workspace();
let mut set = WatchSet::new();
assert!(set.is_empty());
set.reconcile(&[(root.clone(), RootKind::Workspace)]);
assert_eq!(set.len(), 1);
assert!(set.watches(&root, RootKind::Workspace));
set.reconcile(&[]);
assert!(set.is_empty());
assert!(!set.watches(&root, RootKind::Workspace));
}
#[test]
fn reconcile_keeps_surviving_watcher_instance() {
let (_dir, a) = workspace();
let (_dir_b, b) = workspace();
let mut set = WatchSet::new();
set.reconcile(&[(a.clone(), RootKind::Workspace)]);
set.reconcile(&[
(a.clone(), RootKind::Workspace),
(b.clone(), RootKind::Workspace),
]);
assert_eq!(set.len(), 2);
assert!(set.watches(&a, RootKind::Workspace));
assert!(set.watches(&b, RootKind::Workspace));
}
#[test]
fn reconcile_skips_absent_root_and_retries_later() {
let dir = tempdir().unwrap();
let missing = dir.path().join("not-yet");
let mut set = WatchSet::new();
set.reconcile(&[(missing.clone(), RootKind::Workspace)]);
assert!(set.is_empty());
std::fs::create_dir_all(&missing).unwrap();
set.reconcile(&[(missing.clone(), RootKind::Workspace)]);
assert_eq!(set.len(), 1);
}
#[test]
fn reconcile_rebuilds_a_replaced_root_instead_of_keeping_a_deaf_watcher() {
let dir = tempdir().unwrap();
let root = dir.path().join("clone");
std::fs::create_dir_all(&root).unwrap();
let desired = [(root.clone(), RootKind::Workspace)];
let mut set = WatchSet::new();
set.reconcile(&desired);
let armed = std::fs::metadata(&root).map(|m| std::os::unix::fs::MetadataExt::ino(&m));
crate::test_support::replace_directory(&root);
let replaced = std::fs::metadata(&root).map(|m| std::os::unix::fs::MetadataExt::ino(&m));
assert_ne!(armed.ok(), replaced.ok(), "the inode really changed");
set.reconcile(&desired);
assert!(
set.watches(&root, RootKind::Workspace),
"re-armed, not kept"
);
wait_armed(&mut set, &root);
let target = root.join("steps/abc/001/request.json");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
let dirty = wait_until(DETECT, || {
std::fs::write(&target, b"{}").ok()?;
let d = set.drain_dirty();
(!d.is_empty()).then_some(d)
})
.expect("the rebuilt watcher fires");
assert_eq!(dirty.get(&root), Some(&Mark::Watch));
}
#[test]
fn a_ticks_provenance_is_desync_only_when_the_backend_announced_a_loss() {
let ordinary = [Change {
path: PathBuf::from("/w/steps/a/001/request.json"),
kind: ChangeKind::Touched,
}];
assert_eq!(mark_of(&ordinary), Mark::Watch);
let lost = [
Change {
path: PathBuf::from("/w"),
kind: ChangeKind::Desynced,
},
Change {
path: PathBuf::from("/w/inbox/a/user-001.md"),
kind: ChangeKind::Touched,
},
];
assert_eq!(
mark_of(&lost),
Mark::Desync,
"a loss in the batch dominates the ordinary changes beside it"
);
assert!(Mark::Sweep < Mark::Poll && Mark::Poll < Mark::Watch && Mark::Watch < Mark::Desync);
}
mod handoff;