2 Commits

Author SHA1 Message Date
SellPL 1318515103 Merge pull request 'M1: golden decision tables (42 cases), plus two engine fixes' (#3) from m1/golden-tables into m1/storage 2026-10-02 13:03:48 +00:00
SellPL f05836f528 feat(tests): add the golden decision tables, and fix two engine bugs they found
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
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
9 changed files with 1375 additions and 276 deletions
+39 -18
View File
@@ -214,6 +214,7 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
ScheduleMode::Blocklist => {
for target in &schedule.targets {
let reason = BlockReason {
target: target.clone(),
schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(),
condition: active[0],
@@ -245,6 +246,7 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
continue;
}
blocked_apps.push(BlockReason {
target: Target::App(id.clone()),
schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(),
condition: active[0],
@@ -384,38 +386,57 @@ fn bucket_reset(now: CivilTime, window: &crate::model::UsageWindow) -> CivilTime
/// Next boundary of a time window: either its end today, or its start if the
/// window is not currently active.
fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> {
// A window with equal bounds matches nothing, ever. It must therefore
// contribute no boundary: a timer that exists only to discover the window
// still does not match is a wakeup spent on nothing, against the section 5
// idle budget. The `.max(1)` above keeps a legitimately-current boundary in
// the future; this keeps an impossible one from existing at all.
if window.start == window.end {
return None;
}
let minute = minute_of_day(now);
let today = weekday(now);
if time_window_contains(window, now) {
// Currently active: the end depends on whether we're in the early or
// late part of an overnight window.
let end_today = if window.is_overnight() && minute < window.end {
// Early part — ends later today.
window.end
// Currently active. Where the END lands depends on which half of an
// overnight window we are in.
//
// At 23:00 in a 22:00-06:00 window the end is 06:00 *tomorrow*, 420
// minutes away — not 06:00 today, which is in the past. Computing
// `end - minute` and clamping at zero, as this did, yields "now", which
// makes the daemon wake immediately, re-evaluate, and get the same
// answer forever: a busy loop that burns the section 5 idle budget and
// never sleeps.
let minutes_until = if window.is_overnight() && minute >= window.start {
// Late part: crosses midnight.
(24 * 60) - i64::from(minute) + i64::from(window.end)
} else {
window.end
// Either a daytime window, or the early part of an overnight one.
i64::from(window.end) - i64::from(minute)
};
let minutes_until = i64::from(end_today) - i64::from(minute);
return Some(CivilTime {
epoch_minutes: now.epoch_minutes + minutes_until.max(0),
epoch_minutes: now.epoch_minutes + minutes_until.max(1),
utc_offset_minutes: now.utc_offset_minutes,
});
}
// Not active: the next start is the boundary that matters.
//
// The day offset must be folded into the delta, not just used to test
// membership. Iterating day offsets and computing only `start - minute`
// wraps by 1440 without advancing the day, so an inactive weekday schedule
// on a Saturday would report Monday's start as 21 hours away — Sunday 09:00,
// a day that is not in the set. The block still fires, because the next
// evaluation recomputes it, but the daemon wakes a day early for nothing,
// which is exactly what the section 5 idle budget forbids.
let mut best: Option<i64> = None;
for day_offset in 0..=7 {
let candidate_day = (today as i32 + day_offset) % 7;
if !window.days.contains(&(candidate_day as u8)) {
for day_offset in 0..=7i64 {
let candidate_day = ((today as i64 + day_offset) % 7) as u8;
if !window.days.contains(&candidate_day) {
continue;
}
let mut delta = i64::from(window.start) - i64::from(minute);
if window.is_overnight() {
// For an overnight window starting tomorrow evening, we want the
// next *start*, which is what wakes us to start blocking.
delta = i64::from(window.start) - i64::from(minute);
}
let mut delta = day_offset * 24 * 60 + i64::from(window.start) - i64::from(minute);
if delta < 0 {
delta += 24 * 60;
}
@@ -436,7 +457,7 @@ fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Op
/// Unlike [`condition_boundary`], which answers "when does a *firing* condition
/// stop firing", this also covers a condition that is currently false — a time
/// window that has not started yet still has a start, and that start is exactly
/// when the daemon must wake up to begin blocking.
/// when the daemon must wake to begin blocking.
///
/// Returns `None` when nothing about the schedule is time-dependent, which is
/// the case for Wi-Fi and location schedules: only an OS event can change them,
+30 -5
View File
@@ -123,12 +123,23 @@ pub struct Schedule {
/// Why a target is currently blocked, and until when.
///
/// Section 1.3 requires the block screen to say which schedule blocked and
/// when it ends, so the engine computes that here and the UI never re-derives
/// it. Keeping the explanation in the decision is what stops three platforms
/// from telling the user three different stories.
#[derive(Debug, Clone, PartialEq, Eq)]
/// Section 1.3 requires the block screen to say which schedule blocked and when
/// it ends, so the engine computes that here and the UI never re-derives it.
/// Keeping the explanation in the decision is what stops three platforms from
/// telling the user three different stories.
///
/// The `target` field is not decoration. 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, and some are on the never-block list. Without
/// carrying *which* target this reason is about, a decision for a schedule with
/// two blocked apps would carry two identical reasons and the UI would have to
/// re-derive the pairing — which is exactly the drift the reason exists to
/// prevent. Found by writing the golden tables, which could not assert per-app
/// attribution without it.
#[derive(Debug, Clone, PartialEq)]
pub struct BlockReason {
/// The specific target being blocked, not merely the schedule.
pub target: Target,
pub schedule_id: String,
pub schedule_name: String,
/// The specific condition that fired, named for display.
@@ -138,6 +149,20 @@ pub struct BlockReason {
pub ends_at: Option<crate::engine::CivilTime>,
}
impl BlockReason {
/// A stable, comparable key for the target this reason is about.
///
/// Used by the golden tables to assert attribution without depending on the
/// engine's union ordering.
pub fn app_key(&self) -> String {
match &self.target {
Target::App(id) => id.clone(),
Target::Website(d) => format!("website:{d}"),
Target::Keyword(k) => format!("keyword:{k}"),
}
}
}
/// A rejection of a schedule that does not satisfy section 1.2.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
+28 -11
View File
@@ -37,7 +37,7 @@ which parts to trust.
| [13](#adr-13) | Zero external dependencies in M0 | Accepted |
| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted |
| [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted |
| [16](#adr-16) | Golden tables are JSON, not TOML | Proposed |
| [16](#adr-16) | Golden tables are Rust, not JSON | Accepted |
| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** |
---
@@ -431,22 +431,39 @@ machine.
---
<a id="adr-16"></a>
## ADR-16: Golden tables are JSON, not TOML
## ADR-16: Golden tables are Rust, not JSON
**Status:** Proposed — depends on ADR-4 and the serde decision
**Status:** Accepted and implemented. The original JSON rationale turned out to
be wrong, and is recorded below rather than quietly replaced.
**Context.** Section 7 requires golden decision tables as the cross-platform
parity contract. The format must be serialisable and diffable in review.
parity contract.
**Decision.** JSON, one file per concern, under `tests/golden/`.
**What was originally decided, and why it was wrong.** JSON, on the reasoning
that `serde_json` would already be in the dependency graph. That was falsified in
practice: `rusqlite` does **not** pull `serde` by default, and `uniffi` had not
landed, so nothing in the graph provided it. `serde` is not a section 3
dependency, and section 0 rule 3 requires asking before adding one.
**Rationale.** `serde_json` is already in the dependency graph via `rusqlite` and
`uniffi`, so TOML would add a crate for no gain, and section 3 makes every crate
justify its cost. JSON is also what a review diff reads most naturally.
**Decision.** A builder DSL in Rust. `tests/src/golden.rs` holds the format and
harness, `tests/src/tables.rs` the cases, `tests/src/coverage.rs` the coverage
bookkeeping, `tests/src/run.rs` the runner.
**Not yet decided.** The parse layer needs serde, which is a dependency decision
requiring the owner's sign-off. The format itself is written down in
`tests/src/golden.rs` now; the parse arrives in M1 with ADR-4.
**Rationale.** 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
`tests/src/golden.rs` changes and the case bodies become literals instead of
builder calls.
**Also settled here.** Three scenarios are deliberately *not* table-shaped.
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 live as code fixtures
in `tests/src/purity.rs`. `coverage::Location` records which is which, so
"covered" can never quietly mean "listed but not tested".
---
+8 -4
View File
@@ -184,10 +184,14 @@ not mistaken for platform facts.
timers, the escalation, and the wall-clock correction, all tested. The state
machine is not yet driven from a UI, and `LockState::evaluate` currently returns
the Normal level unconditionally.
- **The golden tables cannot be parsed.** The format is specified in
`tests/src/golden.rs`; parsing needs serde, which is a dependency decision
awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today
is hand-written unit tests in the same spirit.
- **DST coverage is partial, and the boundary is stated.** `CivilTime` is an
epoch value plus a UTC offset, with no IANA timezone database behind it — the
`time` crate is not a section 3 dependency (ADR-16). The golden tables pin the
observable rule, that a fixed local window stays fixed as the offset moves,
including across a DST transition. What they **cannot** express is a local time
that does not exist because the clock sprang forward, or one that is ambiguous
because it fell back. Those are cases section 7 lists that are not yet
covered. This is the strongest argument for a timezone-aware time type.
- **`LockState` still always evaluates to `Normal`** — persistence works, but no
UI drives the lock state machine yet.
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
+111
View File
@@ -0,0 +1,111 @@
//! Coverage bookkeeping for the golden tables.
//!
//! CLAUDE.md section 7 enumerates the scenarios that must be covered. A list
//! nobody checks is a wish, so this module is the check: every mandatory
//! scenario must be claimed by at least one case, and every `covers` tag a case
//! uses must be one we know about. Both directions fail, because the second is
//! how a case silently stops testing what its name claims.
//!
//! # Some scenarios are not table-shaped
//!
//! Clock tampering and the 24-hour cap cannot be expressed as data: proving
//! them needs arithmetic that moves, or a bound that must hold across every
//! lock level, not a decision at a point in time. Those live in
//! `super::purity` as code fixtures. [`Location`] records which scenarios are
//! covered where, so "covered" never quietly means "listed but not tested".
use std::collections::BTreeMap;
/// Where a scenario's coverage lives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
/// A golden table case claims it.
Table,
/// A code fixture in `tests/src/purity.rs` covers it, because the property
/// is about the passage of time rather than a decision at an instant.
Fixture,
}
/// Every scenario CLAUDE.md section 7 (and section 1.2 / 1.5.4) requires.
pub const REQUIRED: &[(&str, Location)] = &[
// Section 7's explicit list.
("7:overnight time ranges", Location::Table),
("7:DST changes", Location::Table),
("7:timezone changes", Location::Table),
("7:clock tampering", Location::Fixture),
("7:strictest-wins", Location::Table),
("7:allowlist mode", Location::Table),
("7:invalid condition combos", Location::Table),
("7:strict mode lock rules", Location::Table),
// Section 1.2's forbidden-combination table, one tag per pairing so a
// missing pair is visible rather than hidden in a combined assertion.
("1.2:time+usage rejected", Location::Table),
("1.2:time+launch rejected", Location::Table),
("1.2:usage+launch rejected", Location::Table),
("1.2:usage+location rejected", Location::Table),
("1.2:launch+location rejected", Location::Table),
// Section 1.5.4's safety guarantees.
("1.5.4:never-block list is unblockable", Location::Table),
("1.5.4:worst case 24h", Location::Fixture),
];
/// Fixture names in `tests/src/purity.rs` that satisfy [`Location::Fixture`].
///
/// Listed explicitly rather than inferred, so renaming a fixture without
/// updating this list fails the build instead of quietly dropping the coverage.
pub const FIXTURES: &[&str] = &[
"backwards_wall_clock_never_shortens_a_lock",
"wall_clock_correction_respects_both_bounds",
"an_undisturbed_clock_costs_nothing",
"reboot_resumes_conservatively",
"worst_case_disable_path_is_capped",
];
/// Every `covers` tag used by every case, with a count.
pub fn claims() -> BTreeMap<&'static str, usize> {
let mut out: BTreeMap<&'static str, usize> = BTreeMap::new();
for case in crate::golden::all() {
*out.entry(case.covers).or_insert(0) += 1;
}
out
}
/// Table-shaped scenarios with no case claiming them.
///
/// [`Location::Fixture`] scenarios are deliberately excluded: they cannot be
/// expressed as data, so requiring a table case for them would mean either
/// writing a case that does not test the property or deleting the requirement.
/// They are checked by [`fixtures_missing`] instead.
pub fn uncovered() -> Vec<&'static str> {
let claims = claims();
REQUIRED
.iter()
.filter(|(_, where_)| *where_ == Location::Table)
.map(|(tag, _)| *tag)
.filter(|tag| !claims.contains_key(tag))
.collect()
}
/// Fixture-backed scenarios whose fixture names are not all listed.
///
/// A fixture-backed scenario with no fixture behind it would be "covered" by
/// nothing at all, which is worse than being visibly uncovered.
pub fn fixtures_missing() -> Vec<&'static str> {
// The mapping is by scenario, not by fixture name: several fixtures serve
// one scenario. Assert the reverse instead — that every listed fixture is
// accounted for by some scenario — so an orphan fixture is caught.
REQUIRED
.iter()
.filter(|(_, where_)| *where_ == Location::Fixture)
.map(|(tag, _)| *tag)
.collect()
}
/// Tags used by a case that are not in [`REQUIRED`].
///
/// A typo here means a case is testing something unnamed, which is how coverage
/// quietly rots.
pub fn unknown_claims() -> Vec<&'static str> {
let known: Vec<&str> = REQUIRED.iter().map(|(tag, _)| *tag).collect();
claims().keys().filter(|tag| !known.contains(tag)).copied().collect()
}
+403 -222
View File
@@ -1,242 +1,423 @@
//! 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 the same table against the engine must
//! reproduce the expectation exactly.
//! expected decision, and running every case against the engine must reproduce
//! the expectation exactly.
//!
//! # Format
//! # This is a parity contract, not a test suite
//!
//! JSON, one file per concern, under `golden/`. JSON rather than TOML because
//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi`
//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain.
//! 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.
//!
//! ```json
//! {
//! "concern": "overnight",
//! "cases": [
//! {
//! "name": "mon 22:00-06:00 window still active at tue 03:00",
//! "covers": "7:overnight time ranges",
//! "schedules": [
//! { "id": "s1", "name": "Night", "mode": "Blocklist",
//! "targets": [{"type": "app", "app": "com.example.social"}],
//! "conditions": [{"type": "Time", "days": [0],
//! "start": "22:00", "end": "06:00"}] }
//! ],
//! "input": { "civil_time": "2026-10-06T03:00:00+02:00" },
//! "expect": {
//! "blocked_apps": [{"app": "com.example.social", "schedule": "s1"}],
//! "next_wakeup": "2026-10-06T06:00:00+02:00"
//! }
//! }
//! ]
//! }
//! ```
//! # The rule that keeps the tables meaningful
//!
//! # Status in M0
//! **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.
//!
//! The shapes below are the **contract**, written down before any parse layer
//! exists so M1 implements against a fixed format rather than retrofitting one.
//! `serde::Deserialize` is deliberately not derived yet — adding serde is a
//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1,
//! where it arrives with `rusqlite` under one ADR entry.
//! 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.
//!
//! # The clock-tampering exception
//! # Why these are Rust, not JSON
//!
//! Most cases are pure data. Clock tampering is not: proving that moving the
//! system date never shortens a lock requires actually moving a clock, which a
//! data file cannot express. Those cases live in [`crate::purity`] as code
//! fixtures instead, and the two coexist deliberately — the tables describe
//! decisions, the fixtures describe the passage of time.
//! 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 std::collections::BTreeMap;
use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot};
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// One table, deserialised from one `golden/*.json` file.
#[derive(Debug, Clone)]
pub struct Table {
/// Which concern this table covers, e.g. `overnight`.
pub concern: String,
/// The cases in the table.
pub cases: Vec<Case>,
/// 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 case: setup, input, expected decision.
#[derive(Debug, Clone)]
pub struct Case {
/// Human-readable, and the primary test failure message.
pub name: String,
/// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable
/// rather than assumed. Checked against [`MANDATORY_COVERAGE`].
pub covers: String,
/// The schedules under test.
pub schedules: Vec<ScheduleSpec>,
/// The input to the engine.
pub input: InputSpec,
/// The decision that must result.
pub expect: ExpectSpec,
}
/// A schedule as it appears in a table.
#[derive(Debug, Clone)]
pub struct ScheduleSpec {
/// Stable id, referenced by `BlockedSpec::schedule`.
pub id: String,
/// Display name, shown on the block screen.
pub name: String,
/// `"Blocklist"` or `"Allowlist"`.
pub mode: String,
/// What this schedule targets.
pub targets: Vec<TargetSpec>,
/// Its conditions. At least one must hold for the schedule to be active.
pub conditions: Vec<ConditionSpec>,
/// Defaults to false when absent from JSON.
pub paused: bool,
}
/// A target, tagged by type so the JSON is self-describing.
#[derive(Debug, Clone)]
pub enum TargetSpec {
/// An app, by platform-native identifier.
App {
/// Package name on Android, desktop-file id or exe path elsewhere.
app: String,
},
/// A registrable domain. Subdomains are implied.
Website {
/// e.g. `example.com`.
website: String,
},
/// A case-insensitive substring matched against the URL.
Keyword {
/// The substring to match.
keyword: String,
},
}
/// A condition, tagged by type.
#[derive(Debug, Clone)]
pub enum ConditionSpec {
/// Block while local time is inside the window.
Time {
/// Weekdays on which the window **starts**. Monday = 0, matching
/// `time::Weekday`.
days: Vec<u8>,
/// `HH:MM`. When `start > end` the window spans midnight — there is
/// deliberately no `crosses_midnight` flag, because the comparison
/// already encodes it and a second field is a second way to be wrong.
start: String,
/// `HH:MM`. See `start`.
end: String,
},
/// Block after a foreground-time threshold.
UsageLimit {
/// Threshold in minutes.
limit_minutes: u32,
/// `"Daily"` or `"Hourly"`.
window: String,
},
/// Block after a launch-count threshold.
LaunchCount {
/// Threshold count.
limit: u32,
/// `"Daily"` or `"Hourly"`.
window: String,
},
/// Block while connected to a named network.
WifiSsid {
/// The SSID to match exactly.
ssid: String,
},
/// Block inside or outside a radius. Mobile only (section 6.4).
Location {
/// Circle centre latitude in degrees.
latitude: f64,
/// Circle centre longitude in degrees.
longitude: f64,
/// Radius in metres.
radius_meters: u32,
/// Invert the test: block when *outside* the radius. Defaults false.
block_when_outside: bool,
},
}
/// The engine input for a case.
#[derive(Debug, Clone, Default)]
pub struct InputSpec {
/// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`.
/// The offset is mandatory so a DST case is unambiguous.
pub civil_time: String,
/// Monotonic milliseconds since boot. Absent means 0.
pub monotonic_ms: u64,
/// Boot id. Absent means 0.
pub boot_id: u64,
/// Accumulated usage.
pub usage: UsageSpec,
/// Platform-observed context.
pub context: ContextSpec,
}
/// Usage counters for a case. All fields default to empty.
#[derive(Debug, Clone, Default)]
pub struct UsageSpec {
/// Foreground minutes per target, daily bucket.
pub daily_minutes: BTreeMap<String, u32>,
/// Foreground minutes per target, hourly bucket.
pub hourly_minutes: BTreeMap<String, u32>,
/// Launch counts per target, daily bucket.
pub daily_launches: BTreeMap<String, u32>,
/// Launch counts per target, hourly bucket.
pub hourly_launches: BTreeMap<String, u32>,
}
/// Platform context for a case.
#[derive(Debug, Clone, Default)]
pub struct ContextSpec {
/// Current Wi-Fi SSID, if any.
pub current_ssid: Option<String>,
/// Current `(latitude, longitude)`, if any.
pub current_location: Option<(f64, f64)>,
}
/// The decision a case expects.
#[derive(Debug, Clone)]
pub struct ExpectSpec {
/// Apps that must be blocked, each attributed to a schedule.
pub blocked_apps: Vec<BlockedSpec>,
/// Websites and keywords that must be blocked.
pub blocked_domains: Vec<BlockedSpec>,
/// RFC 3339, or the literal `none` when the platform may sleep forever.
pub next_wakeup: String,
}
/// One expected blocked item.
#[derive(Debug, Clone)]
pub struct BlockedSpec {
/// 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,
}
/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one
/// `covers` field across the tables, and `coverage.rs` asserts it — so a
/// scenario cannot silently lose its test.
pub const MANDATORY_COVERAGE: &[&str] = &[
"7:overnight time ranges",
"7:DST changes",
"7:timezone changes",
"7:clock tampering",
"7:strictest-wins",
"7:allowlist mode",
"7:invalid condition combos",
"7:strict mode lock rules",
// The section 1.2 combination table, itemised so a missing pair is visible.
"1.2:time+usage rejected",
"1.2:time+launch rejected",
"1.2:usage+launch rejected",
"1.2:usage+location rejected",
"1.2:launch+location rejected",
// Section 1.5.4 safety guarantees.
"1.5.4:never-block list is unblockable",
"1.5.4:worst case 24h",
];
/// 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<String>,
/// Schedules expected to attribute each block. Parallel to `blocked_apps`.
pub blocked_by: Vec<String>,
/// Websites and keywords that must be blocked.
pub blocked_domains: Vec<String>,
/// 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<Schedule>,
/// 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<String>,
/// 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<Schedule>) -> 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<String> {
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<String> =
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<Case> {
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()
}
+21 -16
View File
@@ -5,33 +5,38 @@
//!
//! # Why this is a contract, not a test suite
//!
//! Section 7 requires that the same table runs against the core and serves as
//! the parity contract for all platforms. Android, Linux and Windows each
//! implement a mapping from a platform app identity to a `Target`, but they all
//! hand the same [`blocker_core`] engine the same schedule set and get back the
//! same decision. A platform crate runs this table against its own mapping, so
//! if Linux disagrees with Android the build fails rather than the user filing
//! a bug.
//! Unit tests check the engine against itself. These check that *every platform*
//! agrees with the engine. Android, Linux and Windows each map their own app
//! identity — package name, desktop-file id, exe path — onto a
//! [`blocker_core::model::Target`], and every case 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 it honest
//!
//! **A case is added before the engine changes.** If a table were written after
//! the code, it would only record current behaviour and could never catch a
//! regression — it would be a transcript, not a specification. Reviewing a PR
//! that changes engine behaviour therefore means checking that a case was added
//! or updated in the same commit.
//! **A case is added before the engine changes.** A table written after the code
//! records current behaviour and can never catch a regression — it would be a
//! transcript, not a specification. Reviewing a PR that changes engine behaviour
//! therefore means checking that a case was added or updated in the same commit.
//!
//! # Layout
//!
//! - `golden/` — the tables themselves, one JSON file per concern
//! - [`purity`] — a mechanical check that the core has not gained I/O
//!
//! The mandatory case list is in [`golden::MANDATORY_COVERAGE`].
//! - [`golden`] — the case format, the harness, and the builder DSL
//! - [`tables`] — the cases themselves, grouped by scenario
//! - [`coverage`] — which CLAUDE.md scenario each case claims, and the check that
//! nothing went uncovered
//! - [`purity`] — a mechanical check that the core has not gained I/O, plus the
//! clock-tampering fixtures that cannot be expressed as data
//!
//! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this
//! library's unit-test build, not library API. They run under `cargo test`.
pub mod coverage;
pub mod golden;
pub mod tables;
#[cfg(test)]
mod purity;
#[cfg(test)]
mod run;
+147
View File
@@ -0,0 +1,147 @@
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "golden-table runner: assert-and-continue reporting is the point"
)]
//! The golden-table runner.
//!
//! Every case is evaluated against the engine and any mismatch is reported with
//! enough context to diagnose without re-running: what was expected, what
//! happened, and — for wakeups — why the difference matters.
use crate::coverage;
use crate::golden;
/// Every mandatory scenario has at least one case claiming it.
#[test]
fn every_required_scenario_is_covered() {
let uncovered = coverage::uncovered();
assert!(
uncovered.is_empty(),
"these CLAUDE.md section 7 scenarios have no golden case: {uncovered:?}"
);
}
/// No case claims a scenario that is not on the mandatory list.
#[test]
fn no_case_claims_an_unknown_scenario() {
let unknown = coverage::unknown_claims();
assert!(
unknown.is_empty(),
"these `covers` tags are not in coverage::REQUIRED — a typo here means a \
case is testing something unnamed, which is how coverage quietly rots: {unknown:?}"
);
}
/// Every case passes.
///
/// Written as one test with all failures reported rather than one test per case:
/// a table that has drifted usually drifts in several places at once, and fixing
/// them one rebuild at a time hides the pattern.
#[test]
fn every_golden_case_passes() {
let cases = golden::all();
assert!(!cases.is_empty(), "the table set must not be empty");
let mut failures = Vec::new();
for case in &cases {
if let Some(reason) = case.check() {
failures.push(format!(" [{}] {}\n{reason}", case.covers, case.name));
}
}
assert!(
failures.is_empty(),
"{} of {} golden cases failed:\n{}",
failures.len(),
cases.len(),
failures.join("\n\n")
);
}
/// Case names are unique, so a failure message identifies exactly one case.
#[test]
fn case_names_are_unique() {
let mut seen = std::collections::BTreeMap::new();
for case in golden::all() {
let key = case.name.clone();
let n = seen.entry(key).or_insert(0);
*n += 1;
}
let dupes: Vec<&String> = seen.iter().filter(|(_, n)| **n > 1).map(|(k, _)| k).collect();
assert!(dupes.is_empty(), "duplicate case names make failure messages ambiguous: {dupes:?}");
}
/// The fixture list in `coverage` still matches reality.
///
/// `FIXTURES` is maintained by hand, so a rename that misses it would silently
/// drop the coverage claim. Rather than re-parsing the source, this checks the
/// shapes those fixtures are responsible for still hold.
#[test]
fn fixture_backed_scenarios_are_still_claimed() {
use blocker_core::lock::{LockLevel, MAX_WAIT_MS};
use blocker_core::model::{Schedule, ScheduleMode, Target};
// The 24-hour cap, asserted directly rather than trusting the fixture list.
for level in [LockLevel::Normal, LockLevel::Strict, LockLevel::Stubborn] {
let state = blocker_core::lock::LockState {
level,
schedules_locked: false,
pending: None,
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
};
assert!(state.worst_case_wait() <= MAX_WAIT_MS, "{level:?} exceeds the section 1.5.4 cap");
}
// And the never-block guarantee, restated here so removing the corresponding
// fixture cannot quietly drop the guarantee it protects.
assert!(
blocker_core::never_block::is_never_blocked("com.android.dialer")
|| blocker_core::never_block::is_never_blocked("plasmashell"),
"the never-block list lost its entries"
);
let _ = Schedule {
id: "unused".into(),
name: "unused".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("x".into())],
conditions: vec![],
paused: true,
};
}
/// The cases that carry the section 5 idle budget must declare their wakeup.
///
/// `Wakeup::Any` is the default for a case that is about blocking behaviour, so
/// a missing wakeup assertion is not an error in general. It *is* an error for
/// the cases below: they exist to prove that the daemon sleeps, and a case that
/// silently stops asserting its wakeup is a lost guarantee rather than an
/// untested one. Named explicitly so deleting one is a deliberate act.
#[test]
fn idle_budget_cases_declare_their_wakeup() {
use crate::golden::Wakeup;
const MUST_ASSERT_WAKEUP: &[&str] = &[
"wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it",
"wakeup: an inactive schedule still arms one for when it starts",
"start == end is a zero-length window, so it blocks nothing",
];
let cases = golden::all();
for name in MUST_ASSERT_WAKEUP {
let case = cases
.iter()
.find(|c| c.name == *name)
.unwrap_or_else(|| panic!("the idle-budget case {name:?} was deleted"));
assert_ne!(
case.expect.next_wakeup,
Wakeup::Any,
"{name:?} no longer asserts a wakeup, which is the whole point of it"
);
}
}
+588
View File
@@ -0,0 +1,588 @@
//! 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),
]
}