Files
blocker/tests/src/tables.rs
T
SellPL f05836f528
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
feat(tests): add the golden decision tables, and fix two engine bugs they found
The parity contract from CLAUDE.md section 7 now exists: 42 cases across 11
scenario groups, with a runner that asserts every case and a coverage check that
asserts every mandatory scenario is claimed.

**A design change, and why.** ADR-16 originally specified JSON tables, justified
by "serde_json will already be in the tree". That was wrong — `rusqlite` does not
pull `serde` by default and `uniffi` has not landed, so nothing provided it, and
`serde` is not a section 3 dependency (rule 0.3). The tables are therefore a Rust
builder DSL: the same declarations, but a typo in a field name is a compile error
rather than a test that silently asserts the wrong thing. For a document whose
entire job is to be a contract, compile-time checking beats diffability.
Converting to JSON later is mechanical if serde is approved.

**Two engine bugs the tables caught, both real:**

1. **Overnight windows woke the daemon immediately.** `time_window_boundary`
   computed the end as `end - minute` and clamped at zero. For a 22:00–06:00
   window active at 23:00, "06:00 today" is in the past, so the boundary became
   *now* — and the daemon would wake, re-evaluate, get the same answer, and wake
   again, forever. Not a missed block, but a busy loop burning the section 5 idle
   budget. Fixed by detecting the late half of an overnight window and crossing
   midnight properly.

2. **An inactive schedule woke a day early.** The next-start search iterated day
   offsets to test membership but computed the delta as `start - minute` only,
   wrapping by 1440 without advancing the day. A Monday–Friday schedule
   evaluated on Saturday reported Monday's start as 21 hours away — Sunday 09:00,
   a day not in the set. The block still fired, because the next evaluation
   recomputed it, but the daemon woke a day early for nothing. Fixed by folding
   the day offset into the delta.

**Four of my own table cases were wrong too**, which is the other half of why
they are worth having: a UTC+5 offset is 15:00 local and therefore *inside*
09:00–17:00 (I had asserted outside); the same for the DST cases. The engine was
right and the test was wrong in all four.

**Coverage bookkeeping is checked, not promised.** `coverage::Location` records
whether a scenario is covered by a table or by a code fixture — clock tampering
and the 24-hour cap are about the passage of time and a bound across lock levels,
not a decision at an instant, so they cannot be expressed as data. Both
directions fail: an uncovered scenario is an error, and so is a `covers` tag that
is not on the mandatory list, which is how a case quietly stops testing what its
name claims.

**`BlockReason` now carries its target.** A schedule may name twenty targets and
they do not all become blocked — some are paused, some belong to a condition that
is not currently active, some are on the never-block list. Without carrying which
target a reason is about, two blocked apps under one schedule produce two
identical reasons and the UI has to re-derive the pairing, which is exactly the
drift `BlockReason` exists to prevent. The tables could not assert per-app
attribution without it.

**The DST gap is recorded, not papered over.** `CivilTime` is an epoch plus an
offset with no timezone database behind it, so the tables pin that a fixed local
window stays fixed as the offset moves — including across a DST transition — but
cannot express a spring-forward time that does not exist or a fall-back time that
is ambiguous. Those are section 7 cases that are genuinely not covered, and
LIMITATIONS.md now says so.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 18:29:46 +03:00

589 lines
21 KiB
Rust

//! 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<Case> {
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<Case> {
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<Case> {
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<Case> {
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<Case> {
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<Case> {
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<Case> {
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<Case> {
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<Case> {
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<Case> {
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),
]
}