mod deliver;
mod retention;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::{TempDir, tempdir};
use super::spread;
use crate::boundary::dispatch::Deps;
use crate::boundary::reply::Reply;
use crate::boundary::tests::snapshot;
use crate::cli_outbound::Cli;
use crate::fan::{Obligation, Verb};
use crate::git_tree::tests::git::run_git;
use crate::opslog::{self, Origin};
use crate::start::Prepared;
const BALL: &str = "bl-1f2a";
const TS: &str = "2026-08-13T00:00:00Z";
struct World {
dir: TempDir,
project: PathBuf,
}
impl World {
fn new() -> World {
let dir = tempdir().unwrap();
let project = dir.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
run_git(&project, &["init", "-q", "-b", "main"]);
run_git(&project, &["config", "user.email", "t@t.local"]);
run_git(&project, &["config", "user.name", "Tester"]);
run_git(&project, &["config", "commit.gpgsign", "false"]);
std::fs::write(project.join("README.md"), "x\n").unwrap();
run_git(&project, &["add", "README.md"]);
run_git(&project, &["commit", "-q", "-m", "found"]);
World { dir, project }
}
fn state(&self) -> PathBuf {
self.dir.path().join("state")
}
fn workspace(&self) -> PathBuf {
self.dir.path().join("workspaces").join("cobalt-gecko")
}
fn deps(&self) -> Deps {
let ws = self.workspace();
Deps {
litany: Cli::new("/usr/bin/true"),
bl: Cli::new("/no/such/bl"),
state_root: self.state(),
yog_binary: PathBuf::from("/no/such/yog"),
world: crate::xdg::Env::from_pairs([
(
"HOME",
self.dir.path().join("home").to_string_lossy().into_owned(),
),
(
"YOG_MARKS",
self.dir.path().join("marks").to_string_lossy().into_owned(),
),
]),
home: self.dir.path().join("home"),
yog_data_root: self.dir.path().join("data"),
balls_state_root: self.dir.path().join("balls"),
snapshot: Arc::new({
let mut snap = snapshot(&ws, "cobalt-gecko", Vec::new(), Vec::new());
snap.projects = vec![self.project.clone(), self.dir.path().join("not-a-repo")];
snap
}),
caller: crate::boundary::dispatch::Caller::default(),
}
}
fn obligation() -> Obligation {
Obligation {
project: "proj".to_owned(),
ball: Some(BALL.to_owned()),
}
}
fn prepared(&self) -> Prepared {
Prepared {
workspace: "cobalt-gecko".to_owned(),
binding: Some(self.dir.path().join("claim")),
goal: "Ball bl-1f2a: do it".to_owned(),
origin: Origin::Balls,
lineage: None,
}
}
fn retention(&self, keep_min: &str) {
std::fs::create_dir_all(self.state()).unwrap();
std::fs::write(
self.state().join(crate::app::cadence::CADENCE_YAML),
format!(
"retention:\n {}:\n keep_min: {keep_min}\n",
self.project.display()
),
)
.unwrap();
}
fn steps(&self) -> Vec<(String, i32)> {
opslog::tail(&self.state(), usize::MAX)
.into_iter()
.filter_map(|e| Some((e.argv.get(1)?.clone(), e.exit)))
.collect()
}
}
#[test]
fn a_fan_answers_one_rebound_start_per_candidate_and_leaves_one_step() {
let world = World::new();
let deps = world.deps();
let reply = spread(&deps, TS, &world.prepared(), &World::obligation(), 2).unwrap();
let Reply::Fanned(variants) = reply else {
panic!("a fan answers with its candidates");
};
assert_eq!(variants.len(), 2);
let bindings: Vec<PathBuf> = variants
.iter()
.map(|v| v.binding.clone().unwrap())
.collect();
assert_ne!(bindings[0], bindings[1]);
for binding in &bindings {
assert!(binding.is_dir(), "balls placed {binding:?}");
}
assert_eq!(world.steps(), vec![("fan".to_owned(), 0)]);
}
#[test]
fn a_fan_that_balls_refuses_is_a_failure_line_and_a_refusal() {
let world = World::new();
let deps = world.deps();
let nowhere = Obligation {
project: "not-a-repo".to_owned(),
ball: None,
};
let refusal = spread(&deps, TS, &world.prepared(), &nowhere, 2).unwrap_err();
assert!(!refusal.is_empty());
assert_eq!(
world.steps(),
vec![("fan".to_owned(), opslog::SYNTHETIC_EXIT)]
);
}
#[test]
fn the_chokepoint_routes_a_fan_and_a_retirement_to_this_family() {
let world = World::new();
let deps = world.deps();
let mut ui = crate::ui_state::UiState::open(PathBuf::from("/nonexistent/ui.json"));
let fanned = crate::boundary::dispatch::dispatch(
&deps,
&mut ui,
TS,
&crate::boundary::Action::Fan(Verb::Spread {
prepared: world.prepared(),
obligation: World::obligation(),
n: 2,
}),
);
let Ok(Reply::Fanned(variants)) = fanned else {
panic!("the table's Fan arm answers with candidates, got {fanned:?}");
};
let handle = variants[0]
.binding
.as_ref()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(
crate::boundary::dispatch::dispatch(
&deps,
&mut ui,
TS,
&crate::boundary::Action::Fan(Verb::Retire {
obligation: World::obligation(),
handle,
}),
),
Ok(Reply::Retired { discarded: false }),
);
}