f05836f528
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>
589 lines
21 KiB
Rust
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),
|
|
]
|
|
}
|