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};
fn no_balls() -> BlCli {
BlCli::new(Cli::new("bl"))
}
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,
}
}
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); ws
}
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
}
pub(super) fn build_more(&self, id: &str, msg: &str) {
self.fx.build_agent(id, msg);
}
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)
}
}
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();
assert!(
model
.workspaces()
.iter()
.any(|w| w.path == replay && w.kind == WorkspaceKind::Replay),
"the replay is enumerated"
);
let bar = model.tab_bar();
assert!(
bar.overflow.iter().any(|t| t.ws == replay && t.replay),
"overflow carries the replay: {bar:?}"
);
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();
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() {
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);
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");
}