yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! `ui_state` tests: forgiving load, seen watermarks, echo/adopt, the
//! debounced atomic save (via an injected [`FakeClock`]), the scalar
//! accessors, unknown-key preservation, and startup-focus derivation.

use super::*;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tempfile::tempdir;

/// Deterministic clock over a shared instant the test advances by hand, so every
/// debounce branch is exercised without sleeping.
struct FakeClock {
    at: Arc<Mutex<Instant>>,
}

impl FakeClock {
    fn new() -> Self {
        Self::sharing(Arc::new(Mutex::new(Instant::now())))
    }
    fn sharing(at: Arc<Mutex<Instant>>) -> Self {
        Self { at }
    }
    fn advance(&self, d: Duration) {
        *self.at.lock().unwrap() += d;
    }
}

impl Clock for FakeClock {
    fn now(&self) -> Instant {
        *self.at.lock().unwrap()
    }
}

/// Build a state and a second handle sharing one clock instant (advancing the
/// returned handle moves the state's clock too).
fn mk(dir: &Path) -> (UiState, FakeClock) {
    let at = Arc::new(Mutex::new(Instant::now()));
    let ui = UiState::with_clock(
        dir.join("ui.json"),
        Arc::new(FakeClock::sharing(at.clone())),
    );
    (ui, FakeClock::sharing(at))
}

/// Load a handle from `bytes`. `with_clock` reads the file synchronously at
/// construction, so the tempdir is free to drop once it returns (these cases
/// never write back).
fn load(bytes: &[u8]) -> UiState {
    let d = tempdir().unwrap();
    let p = d.path().join("ui.json");
    std::fs::write(&p, bytes).unwrap();
    UiState::with_clock(p, Arc::new(FakeClock::new()))
}

#[test]
fn missing_file_is_default_and_no_echo() {
    let d = tempdir().unwrap();
    let (ui, _c) = mk(d.path());
    assert!(ui.pinned().is_empty());
    assert!(ui.identity_last_used().is_none());
    assert!(!ui.show_internal());
    assert!(!ui.is_seen(SeenKind::Notify, "/w", "a", "x"));
    assert!(!ui.is_echo(b"anything")); // no last_hash yet
}

#[test]
fn corrupt_or_nonobject_load_is_default() {
    assert!(load(b"{not json").pinned().is_empty());
    assert!(load(b"[1,2,3]").pinned().is_empty());
}

#[test]
fn seen_records_and_gates_by_oid() {
    let d = tempdir().unwrap();
    let (mut ui, _c) = mk(d.path());
    for (k, oid) in [
        (SeenKind::Notify, "n1"),
        (SeenKind::Stopped, "s1"),
        (SeenKind::Budget, "b1"),
        (SeenKind::Conflicted, "c1"),
    ] {
        ui.record_seen(k, "/w", "a-b", oid);
        assert!(ui.is_seen(k, "/w", "a-b", oid));
        assert!(!ui.is_seen(k, "/w", "a-b", "moved")); // oid mismatch = unseen
    }
    assert!(!ui.is_seen(SeenKind::Notify, "/other", "a-b", "n1"));
    assert!(!ui.is_seen(SeenKind::Notify, "/w", "zzz", "n1"));
}

#[test]
fn record_seen_coerces_wrong_typed_slot() {
    let mut ui = load(br#"{"seen":{"/w":"oops"}}"#);
    ui.record_seen(SeenKind::Budget, "/w", "a", "b1");
    assert!(ui.is_seen(SeenKind::Budget, "/w", "a", "b1"));
}

#[test]
fn write_sets_echo_hash() {
    let d = tempdir().unwrap();
    let (mut ui, _c) = mk(d.path());
    ui.set_identity("me@example.com");
    assert!(ui.flush().unwrap());
    let bytes = std::fs::read(d.path().join("ui.json")).unwrap();
    assert!(ui.is_echo(&bytes)); // our own write
    assert!(!ui.is_echo(b"different"));
}

#[test]
fn flush_noop_when_not_dirty() {
    let d = tempdir().unwrap();
    let (mut ui, _c) = mk(d.path());
    assert!(!ui.flush().unwrap());
    assert!(!ui.flush_if_due().unwrap()); // dirty_since None
}

#[test]
fn save_is_debounced() {
    let d = tempdir().unwrap();
    let (mut ui, clock) = mk(d.path());
    ui.set_pinned(vec!["/w".to_string()]);
    assert!(!ui.flush_if_due().unwrap()); // 0ms < 250ms
    clock.advance(Duration::from_millis(249));
    assert!(!ui.flush_if_due().unwrap()); // still short
    clock.advance(Duration::from_millis(1));
    assert!(ui.flush_if_due().unwrap()); // 250ms elapsed → write
    assert!(!ui.flush_if_due().unwrap()); // dirty cleared
    let back = std::fs::read_to_string(d.path().join("ui.json")).unwrap();
    assert!(back.contains("/w"));
}

#[test]
fn adopt_replaces_and_refreshes_hash() {
    let d = tempdir().unwrap();
    let (mut ui, _c) = mk(d.path());
    ui.set_identity("old");
    let ext = br#"{"v":1,"identity_last_used":"new","seen":{"/w":{"a":{"notify":"n9"}}}}"#;
    ui.adopt(ext);
    assert_eq!(ui.identity_last_used().as_deref(), Some("new"));
    assert!(ui.is_seen(SeenKind::Notify, "/w", "a", "n9"));
    assert!(ui.is_echo(ext)); // adopted content is now our known state
    ui.adopt(b"garbage"); // corrupt external → default doc
    assert!(ui.identity_last_used().is_none());
}

#[test]
fn pinned_and_collapsed_roundtrip() {
    let d = tempdir().unwrap();
    let (mut ui, _c) = mk(d.path());
    assert!(!ui.is_collapsed("proj:/none")); // no collapsed key yet
    ui.set_pinned(vec!["/a".into(), "/b".into()]);
    assert_eq!(ui.pinned(), vec!["/a".to_string(), "/b".to_string()]);
    ui.set_collapsed("proj:/x", true);
    assert!(ui.is_collapsed("proj:/x"));
    assert!(!ui.is_collapsed("proj:/y")); // present array, key absent
    ui.set_collapsed("proj:/x", false); // remove
    assert!(!ui.is_collapsed("proj:/x"));
}

#[test]
fn getters_filter_wrong_types() {
    let mut ui = load(br#"{"pinned":["/a",7],"collapsed":["k",3]}"#);
    assert_eq!(ui.pinned(), vec!["/a".to_string()]); // number dropped
    assert!(ui.is_collapsed("k"));
    ui.set_collapsed("m", true); // rebuilds set, dropping the non-string 3
    assert!(ui.is_collapsed("m"));
    assert!(ui.is_collapsed("k"));
}

#[test]
fn show_internal_roundtrip_and_forgiving() {
    let d = tempdir().unwrap();
    let (mut ui, _c) = mk(d.path());
    assert!(!ui.show_internal()); // missing key ⇒ hidden
    ui.set_show_internal(true);
    assert!(ui.show_internal());
    ui.set_show_internal(false); // back to hidden
    assert!(!ui.show_internal());
    assert!(!load(br#"{"show_internal":"yes"}"#).show_internal()); // non-bool ⇒ default
}

#[test]
fn unknown_keys_survive_writeback() {
    let d = tempdir().unwrap();
    let p = d.path().join("ui.json");
    std::fs::write(&p, br#"{"v":1,"future_field":{"x":1},"pinned":["/a"]}"#).unwrap();
    let mut ui = UiState::with_clock(p.clone(), Arc::new(FakeClock::new()));
    ui.set_identity("me");
    ui.flush().unwrap();
    let back = std::fs::read_to_string(&p).unwrap();
    assert!(back.contains("future_field"));
    assert!(back.contains("\"me\""));
}

#[test]
fn write_creates_missing_state_dir_and_cleans_temp() {
    let d = tempdir().unwrap();
    let nested = d.path().join("state").join("yog");
    let mut ui = UiState::with_clock(nested.join("ui.json"), Arc::new(FakeClock::new()));
    ui.set_pinned(vec!["/z".into()]);
    assert!(ui.flush().unwrap());
    assert!(nested.join("ui.json").exists());
    let leftovers = std::fs::read_dir(&nested)
        .unwrap()
        .filter_map(std::result::Result::ok)
        .filter(|e| {
            e.file_name()
                .to_string_lossy()
                .starts_with(".ui.json.yog-tmp")
        })
        .count();
    assert_eq!(leftovers, 0); // temp renamed away, not left behind
}

#[test]
fn open_uses_system_clock() {
    let d = tempdir().unwrap();
    let mut ui = UiState::open(d.path().join("ui.json"));
    ui.set_identity("sys");
    assert!(ui.flush().unwrap());
    assert_eq!(ui.identity_last_used().as_deref(), Some("sys"));
}

#[test]
fn system_clock_is_monotonic() {
    let c = SystemClock;
    let a = c.now();
    assert!(c.now() >= a);
}

#[test]
fn content_hash_is_stable_and_sensitive() {
    assert_eq!(content_hash(b"abc"), content_hash(b"abc"));
    assert_ne!(content_hash(b"abc"), content_hash(b"abd"));
}

#[test]
fn startup_focus_prefers_attention_then_first() {
    let r = ["/a", "/b", "/c"];
    assert_eq!(derive_startup_focus(&r, &["/b"]).as_deref(), Some("/b")); // attention
    assert_eq!(derive_startup_focus(&r, &[]).as_deref(), Some("/a")); // else first
    assert_eq!(derive_startup_focus(&r, &["/zzz"]).as_deref(), Some("/a")); // attention off-roster
    assert_eq!(derive_startup_focus(&[], &["/b"]), None); // empty roster
}