yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
use super::*;
use std::fs;
use tempfile::TempDir;

/// An [`Rng`] that yields one scripted draw forever — the mint takes exactly
/// one, so a fixed value pins the scan's start index.
struct Fixed(u64);

impl Rng for Fixed {
    fn next_u64(&mut self) -> u64 {
        self.0
    }
}

fn set(names: &[&str]) -> HashSet<String> {
    names.iter().map(|s| (*s).to_owned()).collect()
}

/// Three words ⇒ a six-pair pool, indices 0..6 in scan order:
/// `a-b a-c b-a b-c c-a c-b`.
const ABC: &[&str] = &["a", "b", "c"];

/// A wordlist, the RNG draw, the occupied names, and the expected mint.
type Case = (
    &'static [&'static str],
    u64,
    &'static [&'static str],
    Result<&'static str, MintError>,
);

#[test]
fn mint_scans_from_the_draw_and_retries_past_collisions() {
    let cases: &[Case] = &[
        // Draw picks the start index; nothing occupied ⇒ that pair is the name.
        (ABC, 0, &[], Ok("a-b")),
        (ABC, 4, &[], Ok("c-a")),
        // A draw wider than the pool wraps into it (10 % 6 == 4).
        (ABC, 10, &[], Ok("c-a")),
        // Collision retry: the start is taken, the scan steps forward.
        (ABC, 0, &["a-b"], Ok("a-c")),
        // Retry wraps past the end of the pool: 5 ("c-b") and 0 ("a-b") taken.
        (ABC, 5, &["c-b", "a-b"], Ok("a-c")),
        // Pool exhaustion: both pairs of a two-word list are occupied.
        (
            &["a", "b"],
            0,
            &["a-b", "b-a"],
            Err(MintError::Exhausted(2)),
        ),
        // A one-word list has no distinct pair at all — an empty pool.
        (&["solo"], 0, &[], Err(MintError::Exhausted(0))),
    ];
    for (words, draw, taken, expect) in cases {
        let got = mint_from(words, &mut Fixed(*draw), &set(taken));
        let want = expect.clone().map(str::to_owned);
        assert_eq!(got, want, "words={words:?} draw={draw} taken={taken:?}");
    }
}

#[test]
fn exhaustion_error_names_the_pool_size() {
    let err = MintError::Exhausted(2);
    assert_eq!(
        err.to_string(),
        "name pool exhausted: all 2 two-word combinations are occupied"
    );
}

/// The three properties `words.txt` promises its consumer (bl-ccf7, restated as
/// constraints 1–3 on bl-9769). Cheap, and it pins the artifact against a
/// careless future edit — the mint's path-safety and its "never mistakable for
/// a human identity" guarantee rest entirely on the data.
#[test]
fn embedded_wordlist_holds_its_invariants() {
    let words = wordlist();
    // (1) Parse rule: the `#` header and blank lines are gone, nothing else is.
    assert!(!words.iter().any(|w| w.starts_with('#') || w.is_empty()));
    // (2) Charset `^[a-z]{3,9}$` — what makes `<word>-<word>` path-safe as a
    // dir leaf and shell-safe unquoted as a `bl claim --as` value.
    for w in &words {
        assert!(
            (3..=9).contains(&w.len()) && w.chars().all(|c| c.is_ascii_lowercase()),
            "{w:?} violates ^[a-z]{{3,9}}$"
        );
    }
    let unique: HashSet<&&str> = words.iter().collect();
    assert_eq!(unique.len(), words.len(), "duplicate word in words.txt");
    // (3) No human-identity collision: bl's own `--as` fallback literal is the
    // one word that must never be mintable (bl validates nothing itself).
    assert!(!unique.contains(&"unknown"));
    // (4) The curated count, and the pool it implies. An edit to the list is
    // meant to update this line — that is the canary, not a nuisance.
    assert_eq!(words.len(), 7395);
    assert_eq!(words.len() * (words.len() - 1), 54_678_630);
}

/// Over the real list the pool is 54.7M pairs — far too many to enumerate, so
/// injectivity is proven on small lists and the embedded list is spot-checked.
#[test]
fn pair_indexing_is_a_bijection_onto_the_pool() {
    for words in [ABC, &["a", "b", "c", "d"][..]] {
        let pool = words.len() * (words.len() - 1);
        let all: HashSet<String> = (0..pool).map(|i| pair(words, i)).collect();
        assert_eq!(all.len(), pool, "pair() is not injective over {words:?}");
        for name in &all {
            let (a, b) = name.split_once('-').unwrap();
            assert_ne!(a, b, "a word paired with itself");
            assert!(words.contains(&a) && words.contains(&b), "{name}");
        }
    }
    // Spot-check the embedded list at both ends and the interior.
    let words = wordlist();
    let pool = words.len() * (words.len() - 1);
    for idx in [0, 1, pool / 2, pool - 1] {
        let name = pair(&words, idx);
        let (a, b) = name.split_once('-').unwrap();
        assert_ne!(a, b);
        assert!(words.contains(&a) && words.contains(&b), "{name} at {idx}");
    }
}

#[test]
fn mint_over_the_embedded_list_avoids_the_occupied_set() {
    // Deterministic per seed: the same generator state mints the same name.
    let name = mint(&mut SplitMix64::from_seed(7), &HashSet::new()).unwrap();
    let again = mint(&mut SplitMix64::from_seed(7), &HashSet::new()).unwrap();
    assert_eq!(name, again);
    assert!(name.contains('-'));
    // Occupying that name pushes the mint to a different one.
    let next = mint(&mut SplitMix64::from_seed(7), &set(&[&name])).unwrap();
    assert_ne!(next, name);
}

#[test]
fn splitmix64_advances_and_seeds_from_entropy() {
    let mut rng = SplitMix64::from_seed(0);
    let draws: HashSet<u64> = (0..64).map(|_| rng.next_u64()).collect();
    assert_eq!(draws.len(), 64, "generator repeated within 64 draws");
    // The entropy path is real seeding, not a constant.
    assert_ne!(
        SplitMix64::from_entropy().next_u64(),
        SplitMix64::from_seed(0).next_u64()
    );
}

#[test]
fn occupied_is_dir_leaves_plus_claimants() {
    let root = TempDir::new().unwrap();
    fs::create_dir(root.path().join("cobalt-gecko")).unwrap();
    // A half-created workspace (no repo.git) still owns its name.
    fs::create_dir(root.path().join("amber-wren")).unwrap();
    // A plain file is not a name registration.
    fs::write(root.path().join("notes.txt"), "x").unwrap();
    let taken = occupied(root.path(), &["slate-heron".to_owned()]);
    assert_eq!(taken, set(&["cobalt-gecko", "amber-wren", "slate-heron"]));
}

#[test]
fn occupied_over_a_missing_root_is_just_the_claimants() {
    let missing = TempDir::new().unwrap().path().join("gone");
    assert_eq!(occupied(&missing, &[]), HashSet::new());
    assert_eq!(
        occupied(&missing, &["ochre-lynx".to_owned()]),
        set(&["ochre-lynx"])
    );
}