//! Tests for the cron tick. Do not edit. mod support; use bxcore::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) { 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::(l).unwrap()) .collect::>() }; 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() ); }