yog 0.0.2

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
use super::*;
use crate::git_tree::{Agent, AgentState};
use crate::projects::join::JoinState;

fn branch(name: &str, state: AgentState) -> Agent {
    Agent {
        branch_name: format!("agents/{name}"),
        agent_id: name.to_string(),
        tip_oid: "0".repeat(40),
        tip_short_oid: "0000000".to_string(),
        tip_timestamp_unix: 0,
        last_action_unix: 0,
        messages: 0,
        steps: vec![],
        preview: None,
        stream: crate::git_tree::Stream::default(),
        tool_calls: vec![],
        state,
        state_uncertain: false,
        pending: vec![],
        conflicted_oid: None,
        budget_oid: None,
        abandoned_oid: None,
        notify_oid: None,
        held: None,
        goal_ball: None,
        name: None,
        goal_name: None,
        call_start_unix: None,
    }
}

/// bl-9acf: the one reading of "blank" every goal-fire site shares. A goal made
/// of whitespace is nothing said — firing it spends the wire on the identity
/// preamble alone — and a rung whose prefill is blank has no draft to open.
#[test]
fn a_blank_goal_is_not_a_goal_anywhere() {
    assert!(goal_present("fix the gate"));
    assert!(goal_present("  surrounded by spaces  "));
    // §3.4's bare rung composes exactly this prefill (pinned in
    // `start::tests::goal`), which is why the raise opens no draft.
    assert!(!goal_present(""));
    assert!(!goal_present("   \t\n"));
}

#[test]
fn new_prompt_enabled_when_text_present() {
    assert!(new_prompt_enabled("hi", ""));
    assert!(new_prompt_enabled("  surrounded by spaces  ", ""));
}

#[test]
fn new_prompt_disabled_for_empty_input() {
    assert!(!new_prompt_enabled("", ""));
}

#[test]
fn new_prompt_disabled_for_whitespace_only() {
    assert!(!new_prompt_enabled("   \t\n", ""));
}

/// bl-6191: something to say is not enough — Enter also needs somewhere lawful
/// to say it, so the start never reaches a fork that would misname the fault.
#[test]
fn new_prompt_disabled_when_the_work_directory_does_not_exist() {
    let dir = tempfile::tempdir().unwrap();
    assert!(new_prompt_enabled("hi", &dir.path().display().to_string()));
    assert!(!new_prompt_enabled(
        "hi",
        &dir.path().join("nope").display().to_string()
    ));
}

/// The field's own sentence (bl-6191): the refusal names the directory, an
/// existing one says nothing — and so does an empty box, which is the bare rung
/// rather than a bad path.
#[test]
fn work_dir_refusal_names_a_directory_that_is_not_there() {
    let dir = tempfile::tempdir().unwrap();
    assert_eq!(work_dir_refusal(""), None);
    assert_eq!(work_dir_refusal("   "), None);
    assert_eq!(work_dir_refusal(&dir.path().display().to_string()), None);
    let missing = dir.path().join("nonexistent-uat-dir");
    assert_eq!(
        work_dir_refusal(&format!("  {}  ", missing.display())),
        Some(format!(
            "work directory does not exist: {}",
            missing.display()
        ))
    );
    // A file is refused by the same one question — but never told it is absent.
    let file = dir.path().join("a-file");
    std::fs::write(&file, b"x").unwrap();
    assert_eq!(
        work_dir_refusal(&file.display().to_string()),
        Some(format!(
            "work directory is not a directory: {}",
            file.display()
        ))
    );
}

#[test]
fn create_ball_enabled_requires_a_nonblank_title() {
    assert!(create_ball_enabled("Fix the bug"));
    assert!(!create_ball_enabled(""));
    assert!(!create_ball_enabled("  \t "));
}

#[test]
fn new_ball_hints_name_both_boxes() {
    // bl-b2ed: the form's two dark boxes are indistinguishable empty. Each
    // carries a hint, and the body's says what to write, not just its name —
    // the composer's own idiom ("say what you want done"), not a bare noun.
    let hints = crate::actions::new_ball_hints();
    assert_eq!(hints.title, "title");
    assert_eq!(hints.body, "body — what done looks like");
}

#[test]
fn draft_clears_only_on_a_clean_send() {
    use crate::actions::verbs::Outcome;
    let clean = Ok(Outcome {
        exit: 0,
        stdout: String::new(),
        stderr: String::new(),
    });
    let ran_nonzero = Ok(Outcome {
        exit: 3,
        stdout: String::new(),
        stderr: "boom".into(),
    });
    let never_launched: std::io::Result<Outcome> = Err(std::io::Error::other("no binary"));
    assert!(draft_clears(&clean), "a clean send clears the draft");
    assert!(
        !draft_clears(&ran_nonzero),
        "a ran-but-failed verb keeps the draft"
    );
    assert!(
        !draft_clears(&never_launched),
        "a spawn failure keeps the draft"
    );
}

#[test]
fn stop_disabled_when_selection_is_none() {
    let bs = vec![branch("foo", AgentState::InFlight)];
    assert!(!stop_enabled(None, &bs));
}

#[test]
fn stop_disabled_when_selection_not_in_agents() {
    let bs = vec![branch("foo", AgentState::InFlight)];
    assert!(!stop_enabled(Some("bar"), &bs));
}

#[test]
fn stop_disabled_when_selected_agent_stopped() {
    let bs = vec![branch("foo", AgentState::Stopped)];
    assert!(!stop_enabled(Some("foo"), &bs));
}

#[test]
fn stop_disabled_when_selected_agent_quiescent() {
    // A finished-for-now agent has no executor to signal (§2.9).
    let bs = vec![branch("foo", AgentState::Quiescent)];
    assert!(!stop_enabled(Some("foo"), &bs));
}

#[test]
fn stop_enabled_when_selection_is_in_flight() {
    let bs = vec![branch("foo", AgentState::InFlight)];
    assert!(stop_enabled(Some("foo"), &bs));
}

#[test]
fn stop_enabled_when_selection_is_live() {
    // A driver between model calls (running a tool) is stoppable — the
    // very case §2.9's lock-fd discovery exists for.
    let bs = vec![branch("foo", AgentState::Live)];
    assert!(stop_enabled(Some("foo"), &bs));
}

#[test]
fn stop_disabled_when_agents_empty() {
    let bs: Vec<Agent> = vec![];
    assert!(!stop_enabled(Some("foo"), &bs));
}

#[test]
fn stop_picks_correct_agent_among_several() {
    let bs = vec![
        branch("a", AgentState::Stopped),
        branch("b", AgentState::InFlight),
        branch("c", AgentState::Quiescent),
        branch("d", AgentState::Live),
    ];
    assert!(stop_enabled(Some("b"), &bs));
    assert!(stop_enabled(Some("d"), &bs));
    assert!(!stop_enabled(Some("a"), &bs));
    assert!(!stop_enabled(Some("c"), &bs));
}

#[test]
fn message_enabled_only_for_a_present_selection_with_text() {
    let bs = vec![branch("foo", AgentState::Stopped)];
    // Any state — Message is the resume gesture (contrast stop_enabled).
    assert!(message_enabled(Some("foo"), "continue please", &bs));
    assert!(!message_enabled(Some("foo"), "   ", &bs), "blank text");
    assert!(!message_enabled(None, "hi", &bs), "no selection");
    assert!(!message_enabled(Some("bar"), "hi", &bs), "absent id");
}

#[test]
fn stop_children_offered_only_with_a_descendant() {
    let bs = vec![
        branch("root-x", AgentState::Live),
        branch("root-x-c1", AgentState::Stopped),
        branch("root-y", AgentState::Live),
    ];
    assert!(stop_children_offered("root-x", &bs), "has a child");
    assert!(!stop_children_offered("root-y", &bs), "leaf agent");
    // A hyphen-boundary miss: root-xx is not a descendant of root-x.
    let bs2 = vec![
        branch("root-x", AgentState::Live),
        branch("root-xx", AgentState::Live),
    ];
    assert!(!stop_children_offered("root-x", &bs2));
}

#[test]
fn close_enabled_only_for_bound() {
    assert!(close_enabled(JoinState::Bound));
    assert!(!close_enabled(JoinState::ClaimedElsewhere));
    assert!(!close_enabled(JoinState::ReadyStartable));
    assert!(!close_enabled(JoinState::Delivered));
}

#[test]
fn unclaim_enabled_only_for_bound() {
    assert!(unclaim_enabled(JoinState::Bound));
    assert!(!unclaim_enabled(JoinState::ClaimedElsewhere));
    assert!(!unclaim_enabled(JoinState::ReadyStartable));
    assert!(!unclaim_enabled(JoinState::UnassignedWorkspace));
}

#[test]
fn assign_enabled_only_for_a_ready_ball() {
    // Assign binds an unbound ball → only ReadyStartable (what `bl claim` allows).
    assert!(assign_enabled(JoinState::ReadyStartable));
    assert!(!assign_enabled(JoinState::Bound), "already bound");
    assert!(!assign_enabled(JoinState::Blocked));
    assert!(!assign_enabled(JoinState::ClaimedElsewhere));
    assert!(!assign_enabled(JoinState::Delivered));
    assert!(!assign_enabled(JoinState::UnassignedWorkspace));
    assert!(!assign_enabled(JoinState::OrphanedProject));
}

#[test]
fn move_enabled_only_for_bound() {
    // Move = release + assign elsewhere → only a ball this yog owns (Bound).
    assert!(move_enabled(JoinState::Bound));
    assert!(!move_enabled(JoinState::ReadyStartable));
    assert!(!move_enabled(JoinState::ClaimedElsewhere));
    assert!(!move_enabled(JoinState::Delivered));
}

#[test]
fn actions_state_default_is_empty() {
    let s = ActionsState::default();
    assert!(s.drafts.is_empty());
    assert!(s.path_dir.is_empty());
    assert!(s.selected_branch.is_none());
    assert!(!s.stop_children);
}

#[test]
fn actions_state_clone_eq() {
    let s = ActionsState {
        drafts: {
            let mut d = Drafts::default();
            d.set(DraftKey::Message("b".to_string()), "hi".to_string());
            d
        },
        selected_branch: Some("b".to_string()),
        stop_children: true,
        ..Default::default()
    };
    let s2 = s.clone();
    assert_eq!(s, s2);
}