Files
boxmaker/docs/plans/M5d2/files/crates/loopd/tests/scheduler_cron.rs
T
kyleandGrok 4.6 c4a8f9488d docs: M5d-2 given tests: unused import and leftover-held
scheduler_heartbeat imported scheduler::{self} without using it, which
clippy -D warnings would fail at task 06. The live-previous cron tick now
asserts a held leftover stays started.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 01:39:50 -07:00

190 lines
6.2 KiB
Rust

//! Tests for the cron tick. Do not edit.
mod support;
use bxcore::{SessionId, Timestamp};
use loopd::channel::Context;
use loopd::jobs::{self, RunStatus};
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::scheduler::{Scheduler, TickWork};
use loopd::tools::Registry;
use std::sync::Arc;
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
fn sse() -> Reply {
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"done"}}],"id":"j","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"j","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
fn world(schedule: &str) -> (Home, FakeServer, Arc<Context>) {
let home = Home::new();
home.write("compact.md", "Summarise.\n");
home.write(
"jobs/mail.toml",
&format!("schedule = \"{schedule}\"\nprompt = \"Check mail.\"\n"),
);
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse(), sse()]);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
(home, server, ctx)
}
#[test]
fn a_due_job_starts_a_new_session_on_the_background_slot() {
let (home, server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let want = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
assert_eq!(
work,
vec![TickWork::Job {
name: "mail".to_string(),
session: want.clone()
}]
);
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], 1);
let body = sent[0].json();
let last = body["messages"].as_array().unwrap().last().unwrap();
let content = last["content"].as_str().unwrap();
assert!(
content.starts_with("It is 2026-10-03T07:00:00.000Z."),
"{content}"
);
assert!(content.contains("Check mail."), "{content}");
let s = loopd::session::Session::open(&home.dir, want).unwrap();
assert_eq!(s.slot(), 1);
}
#[test]
fn a_job_not_due_does_not_run() {
let (_home, server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T06:59:00.000Z"));
assert!(work.is_empty());
assert!(server.requests_to(CHAT).is_empty());
}
#[test]
fn a_live_previous_run_skips_the_tick() {
let (home, server, ctx) = world("* * * * *");
let ledger = home.dir.join("jobs/runs.jsonl");
let previous = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
jobs::append(
&ledger,
&jobs::Run {
time: ts("2026-10-03T06:59:00.000Z"),
name: "mail".to_string(),
session: previous.clone(),
status: RunStatus::Started,
},
)
.unwrap();
let held = ctx.hold(previous).expect("hold");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
assert_eq!(
work,
vec![TickWork::SkippedJob {
name: "mail".to_string()
}]
);
assert!(server.requests_to(CHAT).is_empty());
let text = home.read("jobs/runs.jsonl");
assert!(text.contains("started"), "{text}");
assert!(
!text.contains("failed"),
"a leftover still held is not closed as failed: {text}"
);
drop(held);
}
#[test]
fn leftover_started_is_failed_on_the_first_tick() {
let (home, _server, ctx) = world("* * * * *");
let ledger = home.dir.join("jobs/runs.jsonl");
let previous = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
jobs::append(
&ledger,
&jobs::Run {
time: ts("2026-10-03T06:59:00.000Z"),
name: "mail".to_string(),
session: previous.clone(),
status: RunStatus::Started,
},
)
.unwrap();
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let text = home.read("jobs/runs.jsonl");
assert!(
text.contains("failed"),
"leftover started was closed: {text}"
);
assert!(text.contains(previous.as_str()), "{text}");
}
#[test]
fn a_cron_turn_has_no_speaker() {
let (home, _server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let id = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
let recs = {
let text = home.read(&format!("sessions/{}/0.jsonl", id.as_str()));
text.lines()
.map(|l| serde_json::from_str::<bxcore::LogRecord>(l).unwrap())
.collect::<Vec<_>>()
};
let user = recs
.iter()
.find_map(|r| match r {
bxcore::LogRecord::User { speaker, .. } => Some(speaker),
_ => None,
})
.unwrap();
assert_eq!(user, &None);
}
#[test]
fn two_due_minutes_make_two_sessions() {
let (home, server, ctx) = world("* * * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:58:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T06:59:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
assert_eq!(server.requests_to(CHAT).len(), 2);
let a = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
let b = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
assert_ne!(a, b);
assert!(
home.dir
.join(format!("sessions/{}/0.jsonl", a.as_str()))
.is_file()
);
assert!(
home.dir
.join(format!("sessions/{}/0.jsonl", b.as_str()))
.is_file()
);
}