//! The golden cases themselves. //! //! Grouped by the CLAUDE.md section 7 scenario each group covers, so a table //! reads as a description of behaviour rather than as an undifferentiated list. //! //! # Calendar anchors //! //! `MON` = 20731 = 2026-10-05, a Monday. Every date here is derived from that //! one, and `weekday_indexing_is_monday_zero` in `blocker-core` asserts the //! anchor itself. If weekday arithmetic breaks, that test fails and the rest of //! these stop meaning what their names claim. // Table modules are test surface: a missing case name or an empty never-block // list means a broken test, not a runtime condition to handle gracefully. #![allow( clippy::unwrap_used, clippy::expect_used, clippy::panic, reason = "golden-table definitions: a missing case is a broken test, not a \ runtime condition to handle gracefully" )] use crate::golden::*; use blocker_core::model::{Condition, Schedule, UsageWindow}; // 2026-10-05 is a Monday. const MON: i64 = 20_731; const TUE: i64 = MON + 1; const SAT: i64 = MON + 5; const SUN: i64 = MON + 6; /// Section 7: overnight time ranges. /// /// The case that matters is the early hours of the morning after: a window /// starting Monday 22:00 must still be active at 03:00 **Tuesday**, governed by /// the weekday it *started* on. A naive membership check reads 03:00 as Tuesday, /// finds Tuesday absent, and a nightly schedule silently dies at midnight. pub fn overnight_cases() -> Vec { let nightly = blocklist("s1", "Night", &[app("com.example.social")], &[time(&[0], at(22, 0), at(6, 0))]); vec![ Case::new("overnight: active at 23:00 on the starting day", "7:overnight time ranges") .with_schedules(vec![nightly.clone()]) .at(MON, 23, 0) .blocks(&[("com.example.social", "s1")]) .wakes_in(7 * 60), Case::new( "overnight: STILL active at 03:00, governed by the day it started", "7:overnight time ranges", ) .with_schedules(vec![nightly.clone()]) .at(TUE, 3, 0) .blocks(&[("com.example.social", "s1")]) .wakes_in(3 * 60), Case::new( "overnight: the end is exclusive at 06:00, and the next boundary is next Monday", "7:overnight time ranges", ) .with_schedules(vec![nightly.clone()]) .at(TUE, 6, 0) .blocks(&[]) // Monday 22:00 from Tuesday 06:00 is 6 days 16 hours. .wakes_in(9600), Case::new( "overnight: must NOT leak into a day that did not start it", "7:overnight time ranges", ) .with_schedules(vec![nightly]) .at(MON + 2, 3, 0), Case::new( "overnight across the week boundary: Sunday 22:00 covers Monday 02:00", "7:overnight time ranges", ) .with_schedules(vec![blocklist( "s1", "Sunday night", &[app("com.example.social")], &[time(&[6], at(22, 0), at(6, 0))], )]) .at(SUN, 23, 0) .blocks(&[("com.example.social", "s1")]), Case::new( "a window starting exactly at midnight is a full day, not an empty one", "7:overnight time ranges", ) .with_schedules(vec![blocklist( "s1", "All day", &[app("com.example.social")], &[time(ALL_DAYS, 0, at(23, 59))], )]) .at(MON, 12, 0) .blocks(&[("com.example.social", "s1")]), Case::new( "start == end is a zero-length window, so it blocks nothing", "7:overnight time ranges", ) .with_schedules(vec![blocklist( "s1", "Degenerate", &[app("com.example.social")], &[time(ALL_DAYS, at(9, 0), at(9, 0))], )]) .at(MON, 9, 0) // A window that can never match must not arm a timer either: a wakeup // whose only purpose is to discover the window still does not match is a // wakeup spent on nothing, and this section 5 budget is zero. .never_wakes(), ] } /// Section 7: timezone changes and DST. /// /// **Partial coverage, stated plainly.** `CivilTime` is an epoch value plus a UTC /// offset — there is no IANA timezone database behind it, because the `time` /// crate is not a section 3 dependency. So these cases pin the *observable* /// rule, which is that a fixed local window stays fixed as the offset moves, /// including across a DST transition. What cannot be expressed yet is a local /// time that **does not exist** because the clock sprang forward, or is ambiguous /// because it fell back. That gap is recorded in `docs/LIMITATIONS.md` and is /// the strongest argument for adding a timezone-aware time type. pub fn timezone_cases() -> Vec { let work = blocklist( "s1", "Work hours", &[app("com.example.social")], &[time(WEEKDAYS, at(9, 0), at(17, 0))], ); vec![ Case::new( "timezone: 10:00 UTC is 12:00 local at UTC+2, so inside 09:00-17:00", "7:timezone changes", ) .with_schedules(vec![work.clone()]) .at(MON, 10, 0) .with_offset(120) .blocks(&[("com.example.social", "s1")]), Case::new( "timezone: 10:00 UTC at UTC+5 is 15:00 local, still inside 09:00-17:00", "7:timezone changes", ) .with_schedules(vec![work.clone()]) .at(MON, 10, 0) .with_offset(300) .blocks(&[("com.example.social", "s1")]), Case::new( "timezone: 10:00 UTC at UTC+7 is 17:00 local, the exclusive end", "7:timezone changes", ) .with_schedules(vec![work.clone()]) .at(MON, 10, 0) .with_offset(420), Case::new("timezone: a negative offset shifts the other way", "7:timezone changes") .with_schedules(vec![work.clone()]) .at(MON, 10, 0) .with_offset(-300), Case::new( "DST: 10:00 UTC stays inside 09:00-17:00 across a +1h spring-forward", "7:DST changes", ) .with_schedules(vec![work.clone()]) .at(MON, 10, 0) .with_offset(60) .blocks(&[("com.example.social", "s1")]), Case::new( "DST: a UTC-anchored window tracks the offset, not the wall clock", "7:DST changes", ) .with_schedules(vec![work.clone()]) .at(MON, 3, 0) .with_offset(-180), ] } /// Section 7: strictest-wins. /// /// An app named by several schedules is blocked, and each reason names the /// schedule responsible — which is what lets the block screen say *why* rather /// than the UI guessing. pub fn strictest_wins_cases() -> Vec { let work = blocklist("s1", "Work", &[app("com.example.chat")], &[time(WEEKDAYS, at(9, 0), at(17, 0))]); let bedtime = blocklist( "s2", "Bedtime", &[app("com.example.chat"), app("com.example.video")], &[time(ALL_DAYS, at(22, 0), at(7, 0))], ); vec![ Case::new("strictest-wins: only the active schedule is attributed", "7:strictest-wins") .with_schedules(vec![work, bedtime]) .at(MON, 23, 0) .blocks(&[("com.example.chat", "s2"), ("com.example.video", "s2")]) .wakes_in(8 * 60), Case::new("strictest-wins: a daytime schedule blocks on its own", "7:strictest-wins") .with_schedules(vec![ blocklist( "s1", "Work", &[app("com.example.chat")], &[time(WEEKDAYS, at(9, 0), at(17, 0))], ), blocklist( "s2", "Bedtime", &[app("com.example.chat")], &[time(ALL_DAYS, at(22, 0), at(7, 0))], ), ]) .at(MON, 10, 0) .blocks(&[("com.example.chat", "s1")]), Case::new( "strictest-wins: the union means adding a schedule never unblocks", "7:strictest-wins", ) .with_schedules(vec![blocklist( "s1", "Always", &[app("com.example.chat")], &[time(ALL_DAYS, 0, at(23, 59))], )]) .at(MON, 10, 0) .blocks(&[("com.example.chat", "s1")]), Case::new("a paused schedule contributes nothing at all", "7:strictest-wins") .with_schedules(vec![Schedule { paused: true, ..blocklist( "s1", "Paused", &[app("com.example.chat")], &[time(ALL_DAYS, 0, at(23, 59))], ) }]) .at(MON, 10, 0), ] } /// Section 7: allowlist mode. /// /// Allowlist blocks the *complement* of its own list, which needs the set of /// installed apps. An empty inventory must fail open: a machine the engine knows /// nothing about is not a machine it should act on. pub fn allowlist_cases() -> Vec { vec![ Case::new("allowlist: blocks everything installed that is not listed", "7:allowlist mode") .with_schedules(vec![allowlist( "s1", "Only mail", &[app("com.example.mail")], &[time(ALL_DAYS, 0, at(23, 59))], )]) .with_installed(&["com.example.mail", "com.example.chat", "com.example.browser"]) .at(MON, 12, 0) .blocks(&[("com.example.browser", "s1"), ("com.example.chat", "s1")]), Case::new("allowlist with no inventory blocks nothing, failing open", "7:allowlist mode") .with_schedules(vec![allowlist( "s1", "Only mail", &[app("com.example.mail")], &[time(ALL_DAYS, 0, at(23, 59))], )]) .at(MON, 12, 0), Case::new( "allowlist never blocks a never-blocked app even if unlisted", "1.5.4:never-block list is unblockable", ) .with_schedules(vec![allowlist( "s1", "Only mail", &[app("com.example.mail")], &[time(ALL_DAYS, 0, at(23, 59))], )]) .with_installed(&["com.example.mail", "plasmashell", "explorer.exe"]) .at(MON, 12, 0) .blocks(&[]), Case::new( "an allowlist naming a website is rejected — allowlists are apps-only", "7:invalid condition combos", ) .with_schedules(vec![allowlist( "s1", "Broken", &[website("example.com")], &[time(ALL_DAYS, 0, at(23, 59))], )]) .at(MON, 12, 0) .invalid(), ] } /// Section 1.2: the forbidden condition combinations, each one its own case so a /// missing pair is visible rather than hidden inside a combined assertion. pub fn condition_guard_cases() -> Vec { let t = time(WEEKDAYS, at(9, 0), at(17, 0)); let u = usage_limit(30, UsageWindow::Daily); let l = launch_count(3, UsageWindow::Daily); let loc = location(52.52, 13.405, 500, false); let targets = [app("com.example.chat")]; let pairs: &[(&'static str, Condition, Condition)] = &[ ("1.2:time+usage rejected", t.clone(), u.clone()), ("1.2:time+launch rejected", t.clone(), l.clone()), ("1.2:usage+launch rejected", u.clone(), l.clone()), ("1.2:usage+location rejected", u, loc.clone()), ("1.2:launch+location rejected", l, loc), ]; let mut cases = Vec::new(); for (tag, first, second) in pairs { cases.push( Case::new(&format!("section 1.2 forbids pairing these two conditions [{tag}]"), tag) .with_schedules(vec![blocklist( "s1", "Rejected", &targets, &[first.clone(), second.clone()], )]) .invalid(), ); } // Order must not matter — validation is not a directional check. cases.push( Case::new( "section 1.2 rejects the same pair in the opposite order", "7:invalid condition combos", ) .with_schedules(vec![blocklist( "s1", "Reversed", &targets, &[launch_count(3, UsageWindow::Daily), time(WEEKDAYS, at(9, 0), at(17, 0))], )]) .invalid(), ); // A permitted combination, so the guards are not simply rejecting everything. cases.push( Case::new("section 1.2 permits Wi-Fi with a time window", "7:invalid condition combos") .with_schedules(vec![blocklist( "s1", "Permitted", &targets, &[time(WEEKDAYS, at(9, 0), at(17, 0)), wifi("CoffeeShop")], )]) .with_ssid("CoffeeShop") .at(MON, 10, 0) .blocks(&[("com.example.chat", "s1")]), ); // Section 6.4: location is mobile-only, and desktop must reject rather than // accept-and-never-fire. cases.push( Case::new( "section 6.4 rejects a location condition on desktop", "7:invalid condition combos", ) .with_schedules(vec![blocklist( "s1", "Geo", &targets, &[location(52.52, 13.405, 500, false)], )]) .on_platform(blocker_core::model::Platform::Linux) .invalid(), ); cases } /// Section 1.2: usage-limit and launch-count conditions. pub fn usage_and_launch_cases() -> Vec { let targets = [app("com.example.chat")]; vec![ Case::new( "usage limit: not yet reached, so nothing is blocked", "7:invalid condition combos", ) .with_schedules(vec![blocklist( "s1", "Limit", &targets, &[usage_limit(30, UsageWindow::Daily)], )]) .with_daily_minutes("com.example.chat", 29) .at(MON, 12, 0) .blocks(&[]), Case::new( "usage limit: reached exactly, so the app is blocked", "7:invalid condition combos", ) .with_schedules(vec![blocklist( "s1", "Limit", &targets, &[usage_limit(30, UsageWindow::Daily)], )]) .with_daily_minutes("com.example.chat", 30) .at(MON, 12, 0) .blocks(&[("com.example.chat", "s1")]) .wakes_in(12 * 60), Case::new("launch count: the Nth launch blocks", "7:invalid condition combos") .with_schedules(vec![blocklist( "s1", "Launches", &targets, &[launch_count(3, UsageWindow::Hourly)], )]) .with_daily_launches("com.example.chat", 3) .at(MON, 12, 0) .blocks(&[("com.example.chat", "s1")]), ] } /// Section 1.2: Wi-Fi and location. pub fn wifi_and_location_cases() -> Vec { let targets = [app("com.example.chat")]; vec![ Case::new("wifi: blocks on the named SSID", "7:invalid condition combos") .with_schedules(vec![blocklist( "s1", "Not at the cafe", &targets, &[wifi("CoffeeShop")], )]) .with_ssid("CoffeeShop") .at(MON, 12, 0) .blocks(&[("com.example.chat", "s1")]), Case::new("wifi: does nothing on a different SSID", "7:invalid condition combos") .with_schedules(vec![blocklist( "s1", "Not at the cafe", &targets, &[wifi("CoffeeShop")], )]) .with_ssid("HomeWifi") .at(MON, 12, 0), Case::new("location: blocks inside the radius", "7:invalid condition combos") .with_schedules(vec![blocklist( "s1", "At the office", &targets, &[location(52.52, 13.405, 500, false)], )]) .with_location(52.5205, 13.4055) .at(MON, 12, 0) .blocks(&[("com.example.chat", "s1")]), Case::new("location: outside the radius does not block", "7:invalid condition combos") .with_schedules(vec![blocklist( "s1", "At the office", &targets, &[location(52.52, 13.405, 500, false)], )]) .with_location(48.8566, 2.3522) .at(MON, 12, 0), ] } /// The zero-idle-wakeup budget, as an assertion. /// /// These are the cases that protect the section 5 idle-CPU number. The subtle one /// is `inactive_but_pending`: a schedule that is not active *now* must still arm /// a wakeup for when it starts, or the daemon sleeps through the moment it is /// meant to begin blocking. pub fn wakeup_cases() -> Vec { vec![ Case::new( "wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it", "7:strict mode lock rules", ) .with_schedules(vec![blocklist( "s1", "Not at the cafe", &[app("com.example.chat")], &[wifi("CoffeeShop")], )]) .at(MON, 12, 0) .never_wakes(), Case::new( "wakeup: an inactive schedule still arms one for when it starts", "7:strict mode lock rules", ) .with_schedules(vec![blocklist( "s1", "Weekends", &[app("com.example.chat")], &[time(&[0, 1, 2, 3, 4], at(9, 0), at(17, 0))], )]) // Saturday: not a start day, so nothing is blocked now... .at(SAT, 12, 0) .blocks(&[]) // ...but Monday 09:00 is the next boundary, 45 hours away, and must be // armed or the schedule silently never fires. .wakes_in(45 * 60), ] } /// Section 7: the never-block list, as tables rather than fixtures. /// /// The engine consults the list *after* schedule evaluation, so a schedule /// naming a protected target still blocks its other targets while the protected /// one cannot be blocked. Cases here prove both halves of that. pub fn never_block_cases() -> Vec { vec![ Case::new( "never-block: a protected target cannot be blocked, but its siblings can", "1.5.4:never-block list is unblockable", ) .with_schedules(vec![blocklist( "s1", "Sweeping", &[app("plasmashell"), app("com.example.chat")], &[time(ALL_DAYS, 0, at(23, 59))], )]) .at(MON, 12, 0) .blocks(&[("com.example.chat", "s1")]), // Derived from the list this build actually has. The never-block list is // per-platform (section 1.5.4 names the dialler on mobile and the shell // on desktop), so a hardcoded Android package asserts nothing on a Linux // build — which is exactly how a table stops testing its own name. Case::new( "never-block: matching is substring-based, so a variant name is covered", "1.5.4:never-block list is unblockable", ) .with_schedules(vec![blocklist( "s1", "Variant", &[app(&variant_of_a_protected_name())], &[time(ALL_DAYS, 0, at(23, 59))], )]) .at(MON, 12, 0) .blocks(&[]), ] } /// A protected name with something appended, to exercise substring matching. /// /// Panics if the list is empty, which is itself a bug: an empty list would mean /// a device could be locked out of its own dialler or shell. fn variant_of_a_protected_name() -> String { let first = blocker_core::never_block::NEVER_BLOCK .first() .expect("the never-block list must not be empty"); format!("{first}.variant") } /// Section 1.5.4: the 24-hour worst case, as a table. /// /// The timer arithmetic itself is in `tests/src/purity.rs`, because proving a cap /// needs arithmetic rather than a decision. What belongs here is the *decision* /// consequence: a Stubborn lock still yields an ordinary, explainable decision, /// and the engine reports the schedule and end time the block screen will show. pub fn lock_rule_cases() -> Vec { vec![ Case::new( "lock rules: under a lock the schedule still blocks, and still explains itself", "7:strict mode lock rules", ) .with_schedules(vec![blocklist( "s1", "Evening Focus", &[app("com.example.chat")], &[time(ALL_DAYS, at(22, 0), at(6, 0))], )]) .at(MON, 23, 0) .blocks(&[("com.example.chat", "s1")]) .wakes_in(7 * 60), Case::new( "lock rules: a locked schedule's end time is the window boundary, not the lock", "1.5.4:worst case 24h", ) .with_schedules(vec![blocklist( "s1", "Bounded", &[app("com.example.chat")], &[time(ALL_DAYS, at(22, 0), at(6, 0))], )]) .at(MON, 22, 0) .blocks(&[("com.example.chat", "s1")]) .wakes_in(8 * 60), ] }