Files
boxmaker/docs/plans/M5d2/files/crates/loopd/tests/scheduler_cron.rs
T
kyleandGrok 4.6 df987c3058 docs: M5d-2 checklist: given-test lifetimes and cron AND
session_slot and the scheduler tests held a temporary JSON Value across
statements (E0716). Cron now pins DOM and DOW as AND, which the given tests
had not. Gate counts from task 04 on move up by one.

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

184 lines
6.0 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());
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()
);
}