yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Shared harness + the construction / enumeration / accessor tests for the
//! multi-workspace [`AppModel`] (§15 Y11). The tick/sweep machinery is in
//! `derive`, the roster/attention/seen surface (and the M2 convergence proof)
//! in `focus`.

mod derive;
mod focus;

use super::*;
use crate::binding::WorkspaceKind;
use crate::cli_outbound::Cli;
use crate::fs_watcher::RootKind;
use crate::git_tree::tests::fixture::Fixture;
use crate::git_tree::{Agent, AgentState, GitTree};
use crate::projects::runner::BlCli;
use crate::test_support::{FakeClock, OkProbe};
use std::path::PathBuf;
use tempfile::{TempDir, tempdir};

/// The workspace-focused tests set up no balls clones, so the injected `bl`
/// runner is never consulted. A production [`BlCli`] (over a never-spawned
/// binary) satisfies the seam without a bespoke fake whose stubs would go
/// uncovered — [`BlCli`]'s own methods are proven in `projects::runner`, and the
/// ball/join wiring is proven in `crate::app::balls`.
fn no_balls() -> BlCli {
    BlCli::new(Cli::new("bl"))
}

/// A hermetic test world: XDG roots under one tempdir, plus one ad-hoc
/// workspace (a real git fixture) symlinked under the lernie workspaces root so
/// [`binding::workspaces`](crate::binding::workspaces) enumerates it. The
/// fixture's single agent (`c-1`) has no driver and no response — it classifies
/// [`AgentState::Stopped`], so its unseen stop is attention (§6).
pub(super) struct Harness {
    _root: TempDir,
    fx: Fixture,
    pub(super) roots: Roots,
    pub(super) ws: PathBuf,
}

impl Harness {
    pub(super) fn new() -> Self {
        let root = tempdir().unwrap();
        let roots = Roots {
            yog_data: root.path().join("yog"),
            lernie_data: root.path().join("lernie"),
            yog_state: root.path().join("state"),
            balls_clones: root.path().join("balls").join("clones"),
            home: root.path().join("home"),
        };
        std::fs::create_dir_all(roots.workspaces()).unwrap();
        std::fs::create_dir_all(&roots.yog_state).unwrap();
        let fx = Fixture::new();
        fx.build_agent("c-1", "hello");
        let ws = roots.workspaces().join("ws");
        std::os::unix::fs::symlink(&fx.path, &ws).unwrap();
        Self {
            _root: root,
            fx,
            roots,
            ws,
        }
    }

    /// A second ad-hoc workspace symlinked in (a new git fixture), returning its
    /// enumerated path. Its fixture is leaked into the harness so it outlives
    /// the model.
    pub(super) fn add_workspace(&mut self, name: &str, agent: &str) -> PathBuf {
        let fx = Fixture::new();
        fx.build_agent(agent, "hi");
        let ws = self.roots.workspaces().join(name);
        std::os::unix::fs::symlink(&fx.path, &ws).unwrap();
        std::mem::forget(fx); // its tempdir lives for the test's duration
        ws
    }

    /// A read-only replay workspace symlinked under the lernie replays root
    /// (§3.1) — the same fixture shape as an ad-hoc, classified `Replay` by its
    /// root alone. Returns its enumerated path; the fixture is leaked to outlive
    /// the model.
    pub(super) fn add_replay(&mut self, name: &str, agent: &str) -> PathBuf {
        let fx = Fixture::new();
        fx.build_agent(agent, "hi");
        std::fs::create_dir_all(self.roots.replays()).unwrap();
        let ws = self.roots.replays().join(name);
        std::os::unix::fs::symlink(&fx.path, &ws).unwrap();
        std::mem::forget(fx);
        ws
    }

    /// Land another agent on the primary workspace's disk (a live mutation the
    /// re-derivation tests observe).
    pub(super) fn build_more(&self, id: &str, msg: &str) {
        self.fx.build_agent(id, msg);
    }

    /// Settle `agent`'s step 001 with `response` bytes — the §4.4 framing
    /// input (e.g. the live kind:auth failure shape).
    pub(super) fn write_response(&self, agent: &str, response: &[u8]) {
        let step = self.fx.path.join("steps").join(agent).join("001");
        std::fs::create_dir_all(&step).unwrap();
        std::fs::write(step.join("response.json"), response).unwrap();
    }

    pub(super) fn model(&self) -> (FakeClock, AppModel) {
        self.model_focused(None)
    }

    pub(super) fn model_focused(&self, initial: Option<PathBuf>) -> (FakeClock, AppModel) {
        let clock = FakeClock::new();
        let model = AppModel::new(
            self.roots.clone(),
            initial,
            clock.arc(),
            Box::new(no_balls()),
            Box::new(OkProbe),
            Some("me".to_string()),
        );
        (clock, model)
    }
}

/// A synthetic agent row (the Live/InFlight states need a held lock and can't be
/// reached against a dead fixture, so they are injected into `trees`).
pub(super) fn agent(id: &str, state: AgentState) -> Agent {
    Agent {
        branch_name: format!("agents/{id}"),
        agent_id: id.to_string(),
        tip_oid: "d".repeat(40),
        tip_short_oid: "dddddddd".into(),
        tip_timestamp_unix: 4,
        steps: vec![],
        preview: None,
        streaming_text: None,
        tool_calls: Vec::new(),
        state,
        state_uncertain: false,
        pending_messages: 0,
        conflicted_oid: None,
        budget_oid: None,
        abandoned_oid: None,
        notify_oid: None,
        goal_ball: None,
    }
}

#[test]
fn args_workspace_is_optional_and_parses() {
    assert_eq!(Args::try_parse_from(["yog"]).unwrap().workspace, None);
    let a = Args::try_parse_from(["yog", "--workspace", "/tmp/x"]).unwrap();
    assert_eq!(a.workspace, Some(PathBuf::from("/tmp/x")));
}

#[test]
fn new_enumerates_and_snapshots_every_workspace() {
    let h = Harness::new();
    let (_c, model) = h.model();
    assert_eq!(model.workspaces().len(), 1, "the one ad-hoc workspace");
    let tree = model.tree(&h.ws).expect("snapshot derived at construction");
    assert_eq!(tree.agents.len(), 1);
    assert_eq!(tree.agents[0].agent_id, "c-1");
}

#[test]
fn replays_enumerate_into_the_tab_overflow_and_render_read_only() {
    let mut h = Harness::new();
    let replay = h.add_replay("99XYZ", "r-1");
    let (_c, mut model) = h.model();
    // Enumerated across the three roots and classified Replay by its root alone.
    assert!(
        model
            .workspaces()
            .iter()
            .any(|w| w.path == replay && w.kind == WorkspaceKind::Replay),
        "the replay is enumerated"
    );
    // "Replay is not a mode": it lands in the tab bar's overflow, flagged (§11).
    let bar = model.tab_bar();
    assert!(
        bar.overflow.iter().any(|t| t.ws == replay && t.replay),
        "overflow carries the replay: {bar:?}"
    );
    // Focused, it is read-only; the ad-hoc workspace beside it is writable.
    model.focus_workspace(&replay);
    assert!(model.focused_is_replay(), "a focused replay is read-only");
    model.focus_workspace(&h.ws);
    assert!(
        !model.focused_is_replay(),
        "the ad-hoc workspace is writable"
    );
}

#[test]
fn startup_focus_is_the_attention_bearing_workspace() {
    let h = Harness::new();
    let (_c, model) = h.model();
    // The one workspace has an unseen stop → attention → it is the startup focus.
    assert_eq!(model.focused_workspace(), Some(h.ws.as_path()));
    assert!(
        model.focus().agent.is_none(),
        "no agent selected at startup"
    );
    assert_eq!(model.focused_tree().map(|t| t.agents.len()), Some(1));
}

#[test]
fn startup_focus_override_wins() {
    let h = Harness::new();
    let forced = PathBuf::from("/somewhere/else");
    let (_c, model) = h.model_focused(Some(forced.clone()));
    assert_eq!(model.focused_workspace(), Some(forced.as_path()));
}

#[test]
fn startup_focus_falls_back_to_first_when_nothing_needs_attention() {
    // No workspaces at all: focus derives to None (the general empty path).
    let root = tempdir().unwrap();
    let roots = Roots {
        yog_data: root.path().join("yog"),
        lernie_data: root.path().join("lernie"),
        yog_state: root.path().join("state"),
        balls_clones: root.path().join("balls").join("clones"),
        home: root.path().join("home"),
    };
    let clock = FakeClock::new();
    let mut model = AppModel::new(
        roots,
        None,
        clock.arc(),
        Box::new(no_balls()),
        Box::new(OkProbe),
        None,
    );
    assert!(model.workspaces().is_empty());
    assert_eq!(model.focused_workspace(), None);
    // With no focus and an empty roster, the read-only queries are empty and a
    // keyboard step is a no-op (the general empty path, not a special case).
    assert!(model.conversations(10).is_empty(), "no focus: no rows");
    assert!(!model.focused_is_replay());
    model.roster_step(1);
    assert_eq!(model.focused_workspace(), None, "nothing to step onto");
}

#[test]
fn desired_watches_covers_enum_roots_and_every_workspace() {
    let h = Harness::new();
    let watches = desired_watches(
        &h.roots,
        &[Workspace {
            path: h.ws.clone(),
            kind: WorkspaceKind::Foreign,
        }],
    );
    assert!(watches.contains(&(h.roots.names(), RootKind::NamesRoot)));
    assert!(watches.contains(&(h.roots.workspaces(), RootKind::WorkspacesRoot)));
    assert!(watches.contains(&(h.roots.replays(), RootKind::WorkspacesRoot)));
    assert!(watches.contains(&(h.roots.yog_state.clone(), RootKind::YogState)));
    assert!(watches.contains(&(h.ws.clone(), RootKind::Workspace)));
}

#[test]
fn construction_arms_the_workspace_and_enum_watchers() {
    let h = Harness::new();
    let (_c, model) = h.model();
    let ws = model.watchset_handle();
    let ws = ws.lock().unwrap();
    assert!(ws.watches(&h.ws, RootKind::Workspace), "workspace armed");
    assert!(ws.watches(&h.roots.workspaces(), RootKind::WorkspacesRoot));
    assert!(ws.watches(&h.roots.yog_state, RootKind::YogState));
}

#[test]
fn needs_liveness_reprobe_fires_only_on_live_or_in_flight() {
    let live = GitTree {
        commits: vec![],
        agents: vec![agent("x", AgentState::Live)],
    };
    let inflight = GitTree {
        commits: vec![],
        agents: vec![agent("y", AgentState::InFlight)],
    };
    let quiescent = GitTree {
        commits: vec![],
        agents: vec![agent("z", AgentState::Quiescent)],
    };
    assert!(needs_liveness_reprobe(&live));
    assert!(needs_liveness_reprobe(&inflight));
    assert!(!needs_liveness_reprobe(&quiescent));
    assert!(!needs_liveness_reprobe(&GitTree::default()));
}

#[test]
fn idle_tick_changes_nothing() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    assert!(!model.tick(), "no dirt, no due sweep → no change");
}