use super::*;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tempfile::tempdir;
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()
}
}
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))
}
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")); }
#[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")); }
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)); 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()); }
#[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()); clock.advance(Duration::from_millis(249));
assert!(!ui.flush_if_due().unwrap()); clock.advance(Duration::from_millis(1));
assert!(ui.flush_if_due().unwrap()); assert!(!ui.flush_if_due().unwrap()); 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)); ui.adopt(b"garbage"); 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")); 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")); ui.set_collapsed("proj:/x", false); 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()]); assert!(ui.is_collapsed("k"));
ui.set_collapsed("m", true); 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()); ui.set_show_internal(true);
assert!(ui.show_internal());
ui.set_show_internal(false); assert!(!ui.show_internal());
assert!(!load(br#"{"show_internal":"yes"}"#).show_internal()); }
#[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); }
#[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")); assert_eq!(derive_startup_focus(&r, &[]).as_deref(), Some("/a")); assert_eq!(derive_startup_focus(&r, &["/zzz"]).as_deref(), Some("/a")); assert_eq!(derive_startup_focus(&[], &["/b"]), None); }