yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Nested-delivery "internal" toggle (§5.1 #1) and start-flow input plumbing
//! (§8.1), against the shared cloned-project world in [`super`].

use super::{empty_model, model, model_focused, set_list, world};
use crate::projects::join::JoinState;
use crate::start::{BallSpec, Payload, Target};
use std::fs;

#[test]
fn internal_clones_are_hidden_until_the_toggle_reveals_them() {
    let w = world();
    // A nested-delivery clone: its decoded path lies under the state root's
    // bl-delivery tree, so `projects::enumerate` flags it internal (§5.1 #1).
    let internal = w
        .roots
        .balls_clones
        .parent()
        .unwrap()
        .join("plugins/bl-delivery/home/u/p/bl-x");
    let enc = internal.to_string_lossy().replace('/', "%2F");
    fs::create_dir_all(w.roots.balls_clones.join(enc)).unwrap();

    let (_c, mut m) = model(&w);
    assert!(
        !m.show_internal(),
        "internal clones hidden by default (§5.1 #1)"
    );
    assert_eq!(
        m.project_paths(),
        vec![w.project.clone()],
        "the internal clone is filtered out of the project surface"
    );

    // The toggle re-fetches at once, so the internal clone joins the surface.
    m.set_show_internal(true);
    assert!(m.show_internal());
    let mut want = vec![w.project.clone(), internal.clone()];
    want.sort();
    assert_eq!(m.project_paths(), want, "toggle reveals the internal clone");

    // And hides it again — the persisted view converges via ui.json (§4.1).
    m.set_show_internal(false);
    assert_eq!(m.project_paths(), vec![w.project.clone()]);
}

#[test]
fn start_inputs_expose_the_ball_payload_and_roots() {
    let w = world();
    set_list(
        &w,
        r#"[{"id":"bl-rdy","title":"Go","body":"do it"},{"id":"bl-me","claimant":"me"}]"#,
    );
    let (_c, m) = model(&w);
    // startable: only the ready, unclaimed ball (bound + claimed-elsewhere excluded).
    let cards = m.startable();
    assert_eq!(cards.len(), 1);
    let Payload::Ball {
        project,
        ball: BallSpec::Existing { id, body, join, .. },
    } = &cards[0].payload
    else {
        panic!("existing ball payload expected");
    };
    assert_eq!(id, "bl-rdy");
    assert_eq!(body, "do it");
    assert_eq!(*join, JoinState::ReadyStartable);
    assert_eq!(project, &w.project);
    assert_eq!(cards[0].home.as_path(), w.roots.home.as_path());
    assert_eq!(
        cards[0].balls_state_root.as_path(),
        w.roots.balls_clones.parent().unwrap()
    );
    // The claimants feed the mint's occupied set (§3.1): "me" is one.
    assert!(cards[0].claimants.contains(&"me".to_owned()));
    // A new-ball input bundles a New spec at the same target + roots.
    let nb = m.new_ball_inputs(&w.project, "T", "B");
    assert!(matches!(
        nb.payload,
        Payload::Ball {
            ball: BallSpec::New { .. },
            ..
        }
    ));
    assert_eq!(
        nb.target, cards[0].target,
        "one resolved target per instance"
    );
    assert_eq!(m.yog_data_root(), w.roots.yog_data.as_path());
    assert_eq!(m.project_paths(), vec![w.project.clone()]);
}

#[test]
fn bare_target_is_the_focused_workspace_named_or_foreign() {
    // Focused on a yog-named workspace → the bare rung prompts into it (§3.4).
    let w = world();
    let (_c, m) = model_focused(&w, &w.ws_cobalt);
    assert_eq!(
        m.start_bare_inputs().target,
        Target::Existing {
            workspace: w.ws_cobalt.clone()
        }
    );
    // A foreign workspace is a real lernie workspace, so §3.4's "prompt into the
    // focused workspace" applies — it resolves to Existing at the foreign path,
    // never a silent mint (the addendum-3 fix).
    let foreign = w.roots.lernie_data.join("workspaces/foreign");
    let (_c2, mf) = model_focused(&w, &foreign);
    assert_eq!(
        mf.start_bare_inputs().target,
        Target::Existing {
            workspace: foreign.clone()
        }
    );
}

#[test]
fn bare_target_mints_without_a_focus() {
    // The empty world — no workspaces to focus — is the one Mint case (§3.4).
    let (_root, m) = empty_model();
    assert_eq!(m.start_bare_inputs().target, Target::Mint);
}

#[test]
fn new_workspace_inputs_always_mints_even_with_a_focus() {
    // + New workspace (§3.4/§11): the deliberate sphere-wall verb mints a fresh
    // name regardless of focus; its claimants feed the occupied set (§3.1).
    let w = world();
    let (_c, mut m) = model(&w);
    m.focus_workspace(&w.ws_cobalt);
    let inputs = m.new_workspace_inputs();
    assert_eq!(
        inputs.target,
        Target::Mint,
        "always Mint, not the focused Existing"
    );
    // bl-boss (claimed by "boss") rides the occupied set so a mint never collides.
    assert!(inputs.claimants.contains(&"boss".to_owned()));
}

#[test]
fn start_path_inputs_carries_the_path_payload_at_the_focused_target() {
    // STORIES S2 path rung: the optional work directory, on the focused target.
    let w = world();
    let (_c, mut m) = model(&w);
    m.focus_workspace(&w.ws_cobalt);
    let inputs = m.start_path_inputs(std::path::Path::new("/work/here"));
    assert_eq!(
        inputs.target,
        Target::Existing {
            workspace: w.ws_cobalt.clone()
        }
    );
    let Payload::Path { dir } = inputs.payload else {
        panic!("path payload");
    };
    assert_eq!(dir, std::path::PathBuf::from("/work/here"));
}

#[test]
fn empty_project_hint_only_with_zero_projects() {
    // STORIES S3-T5: zero projects → the `yog exec bl prime` hint; any project
    // present (the world here has one clone) → None.
    let w = world();
    let (_c, m) = model(&w);
    assert_eq!(m.empty_project_hint(), None, "a project is present");
    let (_root, empty) = empty_model();
    assert_eq!(
        empty.empty_project_hint().as_deref(),
        Some("yog exec bl prime")
    );
}

#[test]
fn workspace_names_lists_the_named_workspaces_excluding_foreign() {
    // The Move affordance's target picker (§8.2): only the local *named*
    // workspaces. A foreign (lernie auto-id) workspace carries no yog identity, so
    // it is excluded — the non-Named arm.
    let w = world();
    fs::create_dir_all(w.roots.lernie_data.join("workspaces/adhoc/repo.git")).unwrap();
    let (_c, m) = model(&w);
    let mut names = m.workspace_names();
    names.sort();
    assert_eq!(
        names,
        vec!["cobalt".to_owned(), "spare".to_owned()],
        "foreign 'adhoc' excluded",
    );
}

#[test]
fn focused_ws_name_is_the_focused_leaf_else_none() {
    // The Assign/Move target name (§8.2/§3.2): the focused workspace's leaf.
    let w = world();
    let (_c, mut m) = model(&w);
    m.focus_workspace(&w.ws_cobalt);
    assert_eq!(m.focused_ws_name(), Some("cobalt".to_owned()));
    let (_root, empty) = empty_model();
    assert_eq!(empty.focused_ws_name(), None, "no focus ⇒ no target");
}

#[test]
fn resumable_targets_the_bound_balls_own_workspace() {
    // ▶ Continue (§8.1 resume, addendum): a Bound ball re-plans into its *own*
    // claimant workspace, never the focused one, so it can be reached even when
    // another workspace is focused.
    let w = world();
    let (_c, m) = model(&w);
    let cards = m.resumable();
    assert_eq!(cards.len(), 1, "only bl-work (Bound under cobalt)");
    assert_eq!(
        cards[0].target,
        Target::Existing {
            workspace: w.ws_cobalt.clone()
        }
    );
    let Payload::Ball {
        ball: BallSpec::Existing { id, join, .. },
        ..
    } = &cards[0].payload
    else {
        panic!("existing-ball payload");
    };
    assert_eq!(id, "bl-work");
    assert_eq!(*join, JoinState::Bound);
}