yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! `watch` tests: WatchSet reconcile (create/drop/absent-retry), the DirtySet
//! hand-off, the pure `pump` step (both arms), the real background [`Bridge`]
//! (a disk change surfaces as a dirty root + a repaint), and [`EguiRepaint`].

use super::*;
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
use std::time::Instant;
use tempfile::{TempDir, tempdir};

/// A canonicalized workspace dir (mirrors `fs_watcher`'s test helper so paths
/// match the backend's spelling on macOS). The guard must be kept alive.
fn workspace() -> (TempDir, PathBuf) {
    let dir = tempdir().unwrap();
    let root = dir.path().canonicalize().unwrap();
    (dir, root)
}

/// Poll `probe` until it yields `Some` or `timeout` elapses (a budget generous
/// for FSEvents on the detection paths; tiny on the timeout-path test).
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()
}

/// Detection budget for the real-watcher paths.
const DETECT: Duration = Duration::from_secs(5);

#[test]
fn wait_until_times_out_to_none() {
    assert!(wait_until(Duration::from_millis(20), || None::<()>).is_none());
}

/// A [`Repaint`] double counting requests.
#[derive(Clone)]
struct CountingRepaint(Arc<AtomicUsize>);

impl Repaint for CountingRepaint {
    fn request(&self) {
        self.0.fetch_add(1, Ordering::Relaxed);
    }
}

#[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));
    // A surviving watcher stays; an unwanted one is dropped.
    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)]);
    // Re-reconcile with `a` still desired plus `b`: `a` survives, `b` is added.
    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();
    // Absent root: construction fails, skipped, set not poisoned.
    set.reconcile(&[(missing.clone(), RootKind::Workspace)]);
    assert!(set.is_empty());
    // It appears; the next reconcile arms it (retry, no stored absent state).
    std::fs::create_dir_all(&missing).unwrap();
    set.reconcile(&[(missing.clone(), RootKind::Workspace)]);
    assert_eq!(set.len(), 1);
}

#[test]
fn drain_dirty_reports_the_changed_root() {
    let (_dir, root) = workspace();
    let mut set = WatchSet::new();
    set.reconcile(&[(root.clone(), RootKind::Workspace)]);
    std::fs::create_dir_all(root.join("steps/abc/001")).unwrap();
    let _ = set.drain_dirty(); // absorb the arming/creation events
    std::fs::write(root.join("steps/abc/001/request.json"), b"{}").unwrap();
    let dirty = wait_until(DETECT, || {
        let d = set.drain_dirty();
        (!d.is_empty()).then_some(d)
    })
    .expect("a change surfaces");
    assert!(dirty.contains(&root));
}

#[test]
fn dirty_set_marks_and_drains() {
    let dirty = DirtySet::default();
    assert!(dirty.is_empty());
    dirty.mark_all([PathBuf::from("/w/a"), PathBuf::from("/w/b")]);
    assert!(!dirty.is_empty());
    let drained = dirty.drain();
    assert_eq!(drained.len(), 2);
    assert!(dirty.is_empty(), "drain clears the set");
}

#[test]
fn pump_is_false_without_a_change_and_marks_on_change() {
    let (_dir, root) = workspace();
    let mut set = WatchSet::new();
    set.reconcile(&[(root.clone(), RootKind::Workspace)]);
    let watchset = Arc::new(Mutex::new(set));
    let dirty = DirtySet::default();
    // Absorb arming events, then a quiet pump is false.
    let _ = watchset.lock().unwrap().drain_dirty();
    assert!(!pump(&watchset, &dirty));
    assert!(dirty.is_empty());
    // A change makes the next pump true and marks the root.
    std::fs::create_dir_all(root.join("inbox/a")).unwrap();
    std::fs::write(root.join("inbox/a/user-001.md"), b"hi").unwrap();
    let marked = wait_until(DETECT, || pump(&watchset, &dirty).then_some(()));
    assert!(marked.is_some());
    assert!(dirty.drain().contains(&root));
}

#[test]
fn bridge_detects_a_disk_change_and_requests_repaint() {
    let (_dir, root) = workspace();
    let mut set = WatchSet::new();
    set.reconcile(&[(root.clone(), RootKind::Workspace)]);
    let watchset = Arc::new(Mutex::new(set));
    let dirty = DirtySet::default();
    let count = Arc::new(AtomicUsize::new(0));
    let bridge = Bridge::spawn(
        Arc::clone(&watchset),
        dirty.clone(),
        CountingRepaint(Arc::clone(&count)),
    );
    std::fs::create_dir_all(root.join("steps/abc/001")).unwrap();
    std::fs::write(root.join("steps/abc/001/request.json"), b"{}").unwrap();
    let seen = wait_until(DETECT, || (count.load(Ordering::Relaxed) > 0).then_some(()));
    assert!(seen.is_some(), "the bridge requested a repaint");
    assert!(!dirty.is_empty(), "the bridge marked the root dirty");
    drop(bridge); // clean stop + join
}

#[test]
fn egui_repaint_requests_without_panicking() {
    EguiRepaint(egui::Context::default()).request();
}