use crate::dispatch::{dispatch_gap_with, DispatchHandle, SpawnResult, Spawner};
use crate::monitor::{DispatchOutcome, MonitorLoop, PrProvider, PrStatus, WatchEntry};
use crate::reflect::{DispatchReflection, MemoryReflectionWriter, ReflectionWriter};
use crate::{load_gaps, pickable_gaps, Gap};
use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct SelfTestRow {
pub gap_id: String,
pub branch: String,
pub outcome: DispatchOutcome,
}
#[derive(Debug)]
pub struct SelfTestReport {
pub rows: Vec<SelfTestRow>,
pub reflections: Vec<DispatchReflection>,
pub dummy_files: Vec<PathBuf>,
pub elapsed: Duration,
pub scratch_dir: PathBuf,
}
impl SelfTestReport {
pub fn passed(&self) -> bool {
let n = self.rows.len();
n > 0
&& self
.rows
.iter()
.all(|r| matches!(r.outcome, DispatchOutcome::Shipped(_)))
&& self.reflections.len() == n
&& self.dummy_files.len() == n
&& self.dummy_files.iter().all(|p| p.exists())
}
}
pub struct TestSpawner {
scratch_dir: PathBuf,
}
impl TestSpawner {
pub fn new(scratch_dir: PathBuf) -> Self {
Self { scratch_dir }
}
}
impl Spawner for TestSpawner {
fn create_worktree(&self, _worktree: &Path, _branch: &str, _base: &str) -> Result<()> {
Ok(())
}
fn claim_gap(&self, _worktree: &Path, _gap_id: &str) -> Result<()> {
Ok(())
}
fn spawn_claude(&self, worktree: &Path, _prompt: &str) -> Result<SpawnResult> {
std::fs::create_dir_all(&self.scratch_dir).with_context(|| {
format!(
"creating scratch dir {} for self-test",
self.scratch_dir.display()
)
})?;
let slug = worktree
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let dummy = self.scratch_dir.join(slug.to_ascii_uppercase());
std::fs::write(&dummy, b"synthetic-shipped\n")
.with_context(|| format!("writing dummy file {} for self-test", dummy.display()))?;
Ok((None, None))
}
}
pub struct InstantMergedPrProvider {
next: std::sync::atomic::AtomicU32,
table: std::sync::Mutex<HashMap<String, u32>>,
}
impl InstantMergedPrProvider {
pub fn new(start: u32) -> Self {
Self {
next: std::sync::atomic::AtomicU32::new(start),
table: std::sync::Mutex::new(HashMap::new()),
}
}
}
impl PrProvider for InstantMergedPrProvider {
fn latest_pr(&self, branch: &str) -> Result<Option<PrStatus>> {
let mut t = self
.table
.lock()
.map_err(|e| anyhow::anyhow!("pr provider lock poisoned: {e}"))?;
let n = *t
.entry(branch.to_string())
.or_insert_with(|| self.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
Ok(Some(PrStatus {
number: n,
state: "MERGED".into(),
merge_state_status: String::new(),
}))
}
}
pub fn run_self_test(
backlog_path: &Path,
scratch_dir: PathBuf,
max_parallel: usize,
) -> Result<SelfTestReport> {
let started = Instant::now();
let _ = std::fs::remove_dir_all(&scratch_dir); std::fs::create_dir_all(&scratch_dir)
.with_context(|| format!("creating scratch dir {}", scratch_dir.display()))?;
let all_gaps = load_gaps(backlog_path)
.with_context(|| format!("loading synthetic backlog at {}", backlog_path.display()))?;
if all_gaps.is_empty() {
bail!("synthetic backlog at {} is empty", backlog_path.display());
}
let spawner = TestSpawner::new(scratch_dir.clone());
let provider = InstantMergedPrProvider::new(1000);
let writer = Arc::new(MemoryReflectionWriter::new());
let mut shipped: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut all_rows: Vec<SelfTestRow> = Vec::new();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.context("building tokio runtime for self-test")?;
let max_rounds = all_gaps.len() + 4;
for round in 0..max_rounds {
let mut done: std::collections::HashSet<String> = all_gaps
.iter()
.filter(|g| g.status == "done")
.map(|g| g.id.clone())
.collect();
done.extend(shipped.iter().cloned());
let still_open: Vec<Gap> = all_gaps
.iter()
.filter(|g| !shipped.contains(&g.id))
.cloned()
.collect();
let picked: Vec<&Gap> = pickable_gaps(&still_open, max_parallel, &done);
if picked.is_empty() {
break;
}
let mut handles: Vec<DispatchHandle> = Vec::with_capacity(picked.len());
let mut efforts: HashMap<String, String> = HashMap::new();
for gap in &picked {
efforts.insert(gap.id.clone(), gap.effort.clone());
let h = dispatch_gap_with(&spawner, gap, Path::new("/tmp/synth-repo"), "origin/main")
.with_context(|| format!("dispatching {}", gap.id))?;
handles.push(h);
}
let entries: Vec<WatchEntry> = handles
.into_iter()
.map(|h| {
let effort = efforts
.get(&h.gap_id)
.cloned()
.unwrap_or_else(|| "m".into());
WatchEntry {
soft_deadline_secs: 60,
handle: h,
effort,
}
})
.collect();
struct ArcAdapter(Arc<MemoryReflectionWriter>);
impl ReflectionWriter for ArcAdapter {
fn write(&self, r: &DispatchReflection) -> Result<()> {
self.0.write(r)
}
}
let monitor = MonitorLoop::new(
entries,
PathBuf::from("/tmp/synth-repo"),
Duration::from_millis(2),
&provider,
)
.with_reflection_writer(Box::new(ArcAdapter(Arc::clone(&writer))));
let outcomes = runtime.block_on(monitor.watch_until_done());
for (branch, outcome) in outcomes {
let slug = branch.trim_start_matches("claude/");
let gap_id = picked
.iter()
.find(|g| g.id.to_ascii_lowercase().replace('_', "-") == slug)
.map(|g| g.id.clone())
.unwrap_or_else(|| slug.to_ascii_uppercase());
if let DispatchOutcome::Shipped(_) = outcome {
shipped.insert(gap_id.clone());
}
all_rows.push(SelfTestRow {
gap_id,
branch,
outcome,
});
}
let _ = round; }
let dummy_files: Vec<PathBuf> = std::fs::read_dir(&scratch_dir)
.with_context(|| format!("reading scratch dir {}", scratch_dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.collect();
Ok(SelfTestReport {
rows: all_rows,
reflections: writer.snapshot(),
dummy_files,
elapsed: started.elapsed(),
scratch_dir,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("docs/test-fixtures/synthetic-backlog.yaml")
}
fn unique_scratch(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"chump-self-test-{label}-{pid}-{nanos}",
pid = std::process::id(),
nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn self_test_drains_synthetic_backlog() {
let backlog = fixture_path();
if !backlog.exists() {
panic!("missing fixture at {}", backlog.display());
}
let scratch = unique_scratch("unit");
let report = run_self_test(&backlog, scratch.clone(), 2).expect("self-test runs clean");
assert!(report.passed(), "self-test report failed: {report:?}");
assert_eq!(report.rows.len(), 4, "expected 4 outcomes");
assert_eq!(report.reflections.len(), 4, "expected 4 reflections");
assert_eq!(report.dummy_files.len(), 4, "expected 4 dummy files");
assert!(
report.elapsed < Duration::from_secs(10),
"self-test wall time {:?} exceeded 10s budget",
report.elapsed
);
let _ = std::fs::remove_dir_all(&scratch);
}
}