//! The golden decision tables (CLAUDE.md section 7). //! //! A table is a list of cases. Each case is schedules plus an input plus an //! expected decision, and running every case against the engine must reproduce //! the expectation exactly. //! //! # This is a parity contract, not a test suite //! //! Unit tests check the engine against itself. These check that *every platform* //! agrees with the engine. Each platform implements a mapping from its own app //! identity — Android package name, Linux desktop-file id, Windows exe path — //! onto a [`blocker_core::model::Target`], and every table here runs through that //! mapping too. If Linux resolves Discord to a different target than Android //! does, a case fails at build time instead of becoming a bug report from //! whichever platform nobody was testing that week. //! //! # The rule that keeps the tables meaningful //! //! **A case is added before the engine changes.** A table written after the code //! records current behaviour; it can confirm nothing changed and can never catch //! a regression, because it was fitted to whatever the code happened to do. //! //! So a PR that changes engine behaviour is reviewed on one question: is there a //! case added or updated in the same commit? If not, the change is undocumented //! behaviour. //! //! # Why these are Rust, not JSON //! //! Originally specified as JSON on the reasoning that `serde_json` would already //! be in the tree. That reasoning was wrong: `rusqlite` does not pull `serde` by //! default, and `uniffi` has not landed yet, so nothing in the graph provides it. //! `serde` is not in CLAUDE.md section 3, and section 0 rule 3 requires asking //! before adding a dependency. //! //! A builder DSL gets the same declarations with two properties JSON cannot offer: //! the compiler rejects a typo in a field name, and a wrong variant is a type //! error rather than a test that quietly asserts the wrong thing. For a document //! whose entire job is to be a contract, compile-time checking is worth more than //! diffability. //! //! Converting to JSON later, if serde is approved, is mechanical — only this //! module changes. Recorded as ADR-16. use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot}; use blocker_core::model::{ Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow, }; /// Sentinel meaning "the platform may sleep forever" in an expectation. /// /// `"none"` as a string was ambiguous with a null; a distinct type is not. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Wakeup { /// Assert nothing about the wakeup. /// /// The default, because a case that is about blocking behaviour should not /// fail because a window end moved. It is also the weakest option, so the /// cases that actually carry the section 5 idle budget declare explicitly — /// and `idle_budget_cases_declare_their_wakeup` in `run.rs` fails if one of /// those stops doing so. Any, /// No timer may be armed — nothing time-dependent is pending. Never, /// Minutes from now, resolved against the case's civil time. InMinutes(i64), /// An exact civil time, for cases where the boundary is load-bearing. At(CivilTime), } /// One expected blocked item, attributed to a schedule. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Blocked { /// The target that must be blocked. pub app: String, /// The id of the schedule that must block it. pub schedule: String, } /// The decision a case expects. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Expect { /// Apps that must be blocked. Order-independent; the engine's union order /// is not part of the contract. pub blocked_apps: Vec, /// Schedules expected to attribute each block. Parallel to `blocked_apps`. pub blocked_by: Vec, /// Websites and keywords that must be blocked. pub blocked_domains: Vec, /// When the platform must wake up next, if ever. pub next_wakeup: Wakeup, } impl Default for Expect { fn default() -> Self { Self { blocked_apps: Vec::new(), blocked_by: Vec::new(), blocked_domains: Vec::new(), next_wakeup: Wakeup::Any, } } } /// A case: setup, input, expected decision. #[derive(Debug, Clone)] pub struct Case { /// Human-readable, and the primary failure message. pub name: String, /// Which CLAUDE.md section 7 scenario this covers. Checked by /// [`coverage`] against the mandatory list. pub covers: &'static str, /// Schedules under test. pub schedules: Vec, /// Input civil time. pub now: CivilTime, /// Monotonic milliseconds since boot. pub monotonic: Monotonic, /// Boot id. pub boot_id: u64, /// Usage counters. pub usage: UsageSnapshot, /// Platform-observed context. pub context: PlatformContext, /// Apps that exist on the machine, for allowlist cases. pub installed_apps: Vec, /// The decision that must result. pub expect: Expect, /// Set when the case asserts a validation error instead of a decision. /// Section 1.2 combination cases cannot produce a decision, because /// `Schedule::validate` rejects them first. pub expect_validation_error: bool, /// The platform under test, for validation. pub platform: blocker_core::model::Platform, } impl Case { /// A case with defaults: no usage, no context, no installed apps, boot 0. pub fn new(name: &str, covers: &'static str) -> Self { Self { name: name.to_owned(), covers, schedules: Vec::new(), now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }, monotonic: Monotonic(0), boot_id: 0, usage: UsageSnapshot::default(), context: PlatformContext::default(), installed_apps: Vec::new(), expect: Expect::default(), expect_validation_error: false, platform: blocker_core::model::Platform::Android, } } pub fn with_schedules(mut self, schedules: Vec) -> Self { self.schedules = schedules; self } pub fn at(mut self, days_since_epoch: i64, hour: u32, minute: u32) -> Self { self.now = local(days_since_epoch, hour, minute); self } pub fn with_offset(mut self, minutes: i32) -> Self { self.now.utc_offset_minutes = minutes; self } pub fn with_ssid(mut self, ssid: &str) -> Self { self.context.current_ssid = Some(ssid.to_owned()); self } pub fn with_location(mut self, lat: f64, lon: f64) -> Self { self.context.current_location = Some((lat, lon)); self } pub fn with_installed(mut self, apps: &[&str]) -> Self { self.installed_apps = apps.iter().map(|s| (*s).to_owned()).collect(); self } pub fn with_daily_minutes(mut self, target: &str, minutes: u32) -> Self { self.usage.daily_minutes.insert(target.to_owned(), minutes); self } pub fn with_daily_launches(mut self, target: &str, count: u32) -> Self { self.usage.daily_launches.insert(target.to_owned(), count); self } pub fn on_platform(mut self, platform: blocker_core::model::Platform) -> Self { self.platform = platform; self } /// Expect apps blocked, each attributed to the named schedule. pub fn blocks(mut self, pairs: &[(&str, &str)]) -> Self { for (app, schedule) in pairs { self.expect.blocked_apps.push((*app).to_owned()); self.expect.blocked_by.push((*schedule).to_owned()); } self } pub fn blocks_domains(mut self, domains: &[&str]) -> Self { self.expect.blocked_domains = domains.iter().map(|s| (*s).to_owned()).collect(); self } pub fn wakes_in(mut self, minutes: i64) -> Self { self.expect.next_wakeup = Wakeup::InMinutes(minutes); self } /// Expect no timer at all — the zero-idle-wakeup budget, made testable. pub fn never_wakes(mut self) -> Self { self.expect.next_wakeup = Wakeup::Never; self } /// Expect `Schedule::validate` to reject this case's schedules. pub fn invalid(mut self) -> Self { self.expect_validation_error = true; self } /// Evaluate this case and return a human-readable description of any /// mismatch, or `None` when it passes. pub fn check(&self) -> Option { if self.expect_validation_error { for s in &self.schedules { if s.validate(self.platform).is_ok() { return Some(format!( "schedule {:?} was expected to be rejected by section 1.2 \ validation, but validated cleanly", s.id )); } } return None; } let input = EngineInput { now: self.now, monotonic: self.monotonic, boot_id: blocker_core::engine::BootId(self.boot_id), schedules: &self.schedules, usage: &self.usage, context: &self.context, installed_apps: &self.installed_apps, }; let decision = blocker_core::evaluate(&input); let mut actual: Vec<(String, String)> = decision.blocked_apps.iter().map(|r| (r.app_key(), r.schedule_id.clone())).collect(); actual.sort(); let mut wanted: Vec<(String, String)> = self .expect .blocked_apps .iter() .cloned() .zip(self.expect.blocked_by.iter().cloned()) .collect(); wanted.sort(); if actual != wanted { return Some(format!( "blocked apps differ\n expected: {wanted:?}\n actual: {actual:?}" )); } let mut actual_domains: Vec = decision.blocked_domains.iter().map(|r| r.app_key()).collect(); actual_domains.sort(); let mut wanted_domains = self.expect.blocked_domains.clone(); wanted_domains.sort(); if actual_domains != wanted_domains { return Some(format!( "blocked domains differ\n expected: {wanted_domains:?}\n actual: {actual_domains:?}" )); } match (self.expect.next_wakeup, decision.next_wakeup) { (Wakeup::Any, _) => None, (Wakeup::Never, None) => None, (Wakeup::Never, Some(t)) => Some(format!( "expected no wakeup (the platform should be able to sleep forever), \ but the engine asked for one at epoch_minute {}", t.epoch_minutes )), (Wakeup::InMinutes(m), Some(t)) => { let delta = t.epoch_minutes - self.now.epoch_minutes; if delta == m { None } else { Some(format!( "next_wakeup differs: expected {m} minutes from now, \ engine asked for {delta}" )) } } (Wakeup::InMinutes(m), None) => Some(format!( "expected a wakeup {m} minutes from now, but the engine asked for none — \ a missing boundary means a schedule would silently never fire" )), (Wakeup::At(expected), Some(t)) => { if t == expected { None } else { Some(format!("next_wakeup differs: expected {expected:?}, got {t:?}")) } } (Wakeup::At(_), None) => Some("expected a specific wakeup, got none".to_owned()), } } } /// Build a [`CivilTime`] from a local date and `HH:MM`. /// /// `days_since_epoch` is days since 1970-01-01 with the local offset already /// folded out, so tests can name a calendar date and reason about its weekday. pub fn local(days_since_epoch: i64, hour: u32, minute: u32) -> CivilTime { CivilTime { epoch_minutes: days_since_epoch * 24 * 60 + i64::from(hour * 60 + minute), utc_offset_minutes: 0, } } // --- Schedule builders --------------------------------------------------- // // Verb constructors rather than struct literals: a table is read far more often // than it is edited, and `blocklist("s1", "Focus", &[app("discord")], &[time(...)])` // says more than a five-field struct literal does. pub fn blocklist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule { Schedule { id: id.to_owned(), name: name.to_owned(), icon: None, mode: ScheduleMode::Blocklist, targets: targets.to_vec(), conditions: conditions.to_vec(), paused: false, } } pub fn allowlist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule { Schedule { mode: ScheduleMode::Allowlist, ..blocklist(id, name, targets, conditions) } } pub fn app(id: &str) -> Target { Target::App(id.to_owned()) } pub fn website(domain: &str) -> Target { Target::Website(domain.to_owned()) } pub fn keyword(word: &str) -> Target { Target::Keyword(word.to_owned()) } /// A time window. `start > end` means it spans midnight. pub fn time(days: &[DayOfWeek], start: MinuteOfDay, end: MinuteOfDay) -> Condition { Condition::Time(TimeWindow { days: days.to_vec(), start, end }) } /// Monday through Friday, which most work-hours schedules use. pub const WEEKDAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4]; /// Every day of the week. pub const ALL_DAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4, 5, 6]; /// `HH:MM` to minutes since midnight, for readability in a table. pub fn at(hour: u32, minute: u32) -> MinuteOfDay { let total = hour * 60 + minute; u16::try_from(total).unwrap_or(MinuteOfDay::MAX) } pub fn usage_limit(limit_minutes: u32, window: UsageWindow) -> Condition { Condition::UsageLimit { limit_minutes, window } } pub fn launch_count(limit: u32, window: UsageWindow) -> Condition { Condition::LaunchCount { limit, window } } pub fn wifi(ssid: &str) -> Condition { Condition::WifiSsid(ssid.to_owned()) } pub fn location(lat: f64, lon: f64, radius_m: u32, block_when_outside: bool) -> Condition { Condition::Location { latitude: lat, longitude: lon, radius_meters: radius_m, block_when_outside, } } /// Every case, across every table. /// /// The runner asserts that this is the same set the individual tables produce, /// so a case added to a table but not collected here cannot silently go /// unrun. pub fn all() -> Vec { crate::tables::overnight_cases() .into_iter() .chain(crate::tables::timezone_cases()) .chain(crate::tables::strictest_wins_cases()) .chain(crate::tables::allowlist_cases()) .chain(crate::tables::condition_guard_cases()) .chain(crate::tables::usage_and_launch_cases()) .chain(crate::tables::wifi_and_location_cases()) .chain(crate::tables::wakeup_cases()) .chain(crate::tables::never_block_cases()) .chain(crate::tables::lock_rule_cases()) .collect() }