From f05836f528cf2a693f39c873c6dced1642c39046 Mon Sep 17 00:00:00 2001 From: SellPL Date: Thu, 1 Oct 2026 18:29:46 +0300 Subject: [PATCH] feat(tests): add the golden decision tables, and fix two engine bugs they found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/src/engine.rs | 57 ++-- core/src/model.rs | 35 ++- docs/DECISIONS.md | 39 ++- docs/LIMITATIONS.md | 12 +- tests/src/coverage.rs | 111 ++++++++ tests/src/golden.rs | 625 +++++++++++++++++++++++++++--------------- tests/src/lib.rs | 37 +-- tests/src/run.rs | 147 ++++++++++ tests/src/tables.rs | 588 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 1375 insertions(+), 276 deletions(-) create mode 100644 tests/src/coverage.rs create mode 100644 tests/src/run.rs create mode 100644 tests/src/tables.rs diff --git a/core/src/engine.rs b/core/src/engine.rs index cf90862..25c3a08 100644 --- a/core/src/engine.rs +++ b/core/src/engine.rs @@ -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 { + // 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 = 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, diff --git a/core/src/model.rs b/core/src/model.rs index aac3944..f1a6c49 100644 --- a/core/src/model.rs +++ b/core/src/model.rs @@ -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, } +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 { diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d9bbc9e..44f1ad5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. --- -## 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". --- diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 5d734ce..a1a584e 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -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 diff --git a/tests/src/coverage.rs b/tests/src/coverage.rs new file mode 100644 index 0000000..0b94d60 --- /dev/null +++ b/tests/src/coverage.rs @@ -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() +} diff --git a/tests/src/golden.rs b/tests/src/golden.rs index 73d6695..12a976b 100644 --- a/tests/src/golden.rs +++ b/tests/src/golden.rs @@ -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, +/// 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, - /// 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, - /// Its conditions. At least one must hold for the schedule to be active. - pub conditions: Vec, - /// 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, - /// `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, - /// Foreground minutes per target, hourly bucket. - pub hourly_minutes: BTreeMap, - /// Launch counts per target, daily bucket. - pub daily_launches: BTreeMap, - /// Launch counts per target, hourly bucket. - pub hourly_launches: BTreeMap, -} - -/// Platform context for a case. -#[derive(Debug, Clone, Default)] -pub struct ContextSpec { - /// Current Wi-Fi SSID, if any. - pub current_ssid: Option, - /// 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, - /// Websites and keywords that must be blocked. - pub blocked_domains: Vec, - /// 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, + /// Schedules expected to attribute each block. Parallel to `blocked_apps`. + pub blocked_by: Vec, + /// Websites and keywords that must be blocked. + pub blocked_domains: Vec, + /// When the platform must wake up next, if ever. + pub next_wakeup: Wakeup, +} + +impl Default for Expect { + fn default() -> Self { + Self { + blocked_apps: Vec::new(), + blocked_by: Vec::new(), + blocked_domains: Vec::new(), + next_wakeup: Wakeup::Any, + } + } +} + +/// A case: setup, input, expected decision. +#[derive(Debug, Clone)] +pub struct Case { + /// Human-readable, and the primary failure message. + pub name: String, + /// Which CLAUDE.md section 7 scenario this covers. Checked by + /// [`coverage`] against the mandatory list. + pub covers: &'static str, + /// Schedules under test. + pub schedules: Vec, + /// Input civil time. + pub now: CivilTime, + /// Monotonic milliseconds since boot. + pub monotonic: Monotonic, + /// Boot id. + pub boot_id: u64, + /// Usage counters. + pub usage: UsageSnapshot, + /// Platform-observed context. + pub context: PlatformContext, + /// Apps that exist on the machine, for allowlist cases. + pub installed_apps: Vec, + /// The decision that must result. + pub expect: Expect, + /// Set when the case asserts a validation error instead of a decision. + /// Section 1.2 combination cases cannot produce a decision, because + /// `Schedule::validate` rejects them first. + pub expect_validation_error: bool, + /// The platform under test, for validation. + pub platform: blocker_core::model::Platform, +} + +impl Case { + /// A case with defaults: no usage, no context, no installed apps, boot 0. + pub fn new(name: &str, covers: &'static str) -> Self { + Self { + name: name.to_owned(), + covers, + schedules: Vec::new(), + now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }, + monotonic: Monotonic(0), + boot_id: 0, + usage: UsageSnapshot::default(), + context: PlatformContext::default(), + installed_apps: Vec::new(), + expect: Expect::default(), + expect_validation_error: false, + platform: blocker_core::model::Platform::Android, + } + } + + pub fn with_schedules(mut self, schedules: Vec) -> Self { + self.schedules = schedules; + self + } + + pub fn at(mut self, days_since_epoch: i64, hour: u32, minute: u32) -> Self { + self.now = local(days_since_epoch, hour, minute); + self + } + + pub fn with_offset(mut self, minutes: i32) -> Self { + self.now.utc_offset_minutes = minutes; + self + } + + pub fn with_ssid(mut self, ssid: &str) -> Self { + self.context.current_ssid = Some(ssid.to_owned()); + self + } + + pub fn with_location(mut self, lat: f64, lon: f64) -> Self { + self.context.current_location = Some((lat, lon)); + self + } + + pub fn with_installed(mut self, apps: &[&str]) -> Self { + self.installed_apps = apps.iter().map(|s| (*s).to_owned()).collect(); + self + } + + pub fn with_daily_minutes(mut self, target: &str, minutes: u32) -> Self { + self.usage.daily_minutes.insert(target.to_owned(), minutes); + self + } + + pub fn with_daily_launches(mut self, target: &str, count: u32) -> Self { + self.usage.daily_launches.insert(target.to_owned(), count); + self + } + + pub fn on_platform(mut self, platform: blocker_core::model::Platform) -> Self { + self.platform = platform; + self + } + + /// Expect apps blocked, each attributed to the named schedule. + pub fn blocks(mut self, pairs: &[(&str, &str)]) -> Self { + for (app, schedule) in pairs { + self.expect.blocked_apps.push((*app).to_owned()); + self.expect.blocked_by.push((*schedule).to_owned()); + } + self + } + + pub fn blocks_domains(mut self, domains: &[&str]) -> Self { + self.expect.blocked_domains = domains.iter().map(|s| (*s).to_owned()).collect(); + self + } + + pub fn wakes_in(mut self, minutes: i64) -> Self { + self.expect.next_wakeup = Wakeup::InMinutes(minutes); + self + } + + /// Expect no timer at all — the zero-idle-wakeup budget, made testable. + pub fn never_wakes(mut self) -> Self { + self.expect.next_wakeup = Wakeup::Never; + self + } + + /// Expect `Schedule::validate` to reject this case's schedules. + pub fn invalid(mut self) -> Self { + self.expect_validation_error = true; + self + } + + /// Evaluate this case and return a human-readable description of any + /// mismatch, or `None` when it passes. + pub fn check(&self) -> Option { + if self.expect_validation_error { + for s in &self.schedules { + if s.validate(self.platform).is_ok() { + return Some(format!( + "schedule {:?} was expected to be rejected by section 1.2 \ + validation, but validated cleanly", + s.id + )); + } + } + return None; + } + + let input = EngineInput { + now: self.now, + monotonic: self.monotonic, + boot_id: blocker_core::engine::BootId(self.boot_id), + schedules: &self.schedules, + usage: &self.usage, + context: &self.context, + installed_apps: &self.installed_apps, + }; + let decision = blocker_core::evaluate(&input); + + let mut actual: Vec<(String, String)> = + decision.blocked_apps.iter().map(|r| (r.app_key(), r.schedule_id.clone())).collect(); + actual.sort(); + + let mut wanted: Vec<(String, String)> = self + .expect + .blocked_apps + .iter() + .cloned() + .zip(self.expect.blocked_by.iter().cloned()) + .collect(); + wanted.sort(); + + if actual != wanted { + return Some(format!( + "blocked apps differ\n expected: {wanted:?}\n actual: {actual:?}" + )); + } + + let mut actual_domains: Vec = + decision.blocked_domains.iter().map(|r| r.app_key()).collect(); + actual_domains.sort(); + let mut wanted_domains = self.expect.blocked_domains.clone(); + wanted_domains.sort(); + if actual_domains != wanted_domains { + return Some(format!( + "blocked domains differ\n expected: {wanted_domains:?}\n actual: {actual_domains:?}" + )); + } + + match (self.expect.next_wakeup, decision.next_wakeup) { + (Wakeup::Any, _) => None, + (Wakeup::Never, None) => None, + (Wakeup::Never, Some(t)) => Some(format!( + "expected no wakeup (the platform should be able to sleep forever), \ + but the engine asked for one at epoch_minute {}", + t.epoch_minutes + )), + (Wakeup::InMinutes(m), Some(t)) => { + let delta = t.epoch_minutes - self.now.epoch_minutes; + if delta == m { + None + } else { + Some(format!( + "next_wakeup differs: expected {m} minutes from now, \ + engine asked for {delta}" + )) + } + } + (Wakeup::InMinutes(m), None) => Some(format!( + "expected a wakeup {m} minutes from now, but the engine asked for none — \ + a missing boundary means a schedule would silently never fire" + )), + (Wakeup::At(expected), Some(t)) => { + if t == expected { + None + } else { + Some(format!("next_wakeup differs: expected {expected:?}, got {t:?}")) + } + } + (Wakeup::At(_), None) => Some("expected a specific wakeup, got none".to_owned()), + } + } +} + +/// Build a [`CivilTime`] from a local date and `HH:MM`. +/// +/// `days_since_epoch` is days since 1970-01-01 with the local offset already +/// folded out, so tests can name a calendar date and reason about its weekday. +pub fn local(days_since_epoch: i64, hour: u32, minute: u32) -> CivilTime { + CivilTime { + epoch_minutes: days_since_epoch * 24 * 60 + i64::from(hour * 60 + minute), + utc_offset_minutes: 0, + } +} + +// --- Schedule builders --------------------------------------------------- +// +// Verb constructors rather than struct literals: a table is read far more often +// than it is edited, and `blocklist("s1", "Focus", &[app("discord")], &[time(...)])` +// says more than a five-field struct literal does. + +pub fn blocklist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule { + Schedule { + id: id.to_owned(), + name: name.to_owned(), + icon: None, + mode: ScheduleMode::Blocklist, + targets: targets.to_vec(), + conditions: conditions.to_vec(), + paused: false, + } +} + +pub fn allowlist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule { + Schedule { mode: ScheduleMode::Allowlist, ..blocklist(id, name, targets, conditions) } +} + +pub fn app(id: &str) -> Target { + Target::App(id.to_owned()) +} + +pub fn website(domain: &str) -> Target { + Target::Website(domain.to_owned()) +} + +pub fn keyword(word: &str) -> Target { + Target::Keyword(word.to_owned()) +} + +/// A time window. `start > end` means it spans midnight. +pub fn time(days: &[DayOfWeek], start: MinuteOfDay, end: MinuteOfDay) -> Condition { + Condition::Time(TimeWindow { days: days.to_vec(), start, end }) +} + +/// Monday through Friday, which most work-hours schedules use. +pub const WEEKDAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4]; +/// Every day of the week. +pub const ALL_DAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4, 5, 6]; + +/// `HH:MM` to minutes since midnight, for readability in a table. +pub fn at(hour: u32, minute: u32) -> MinuteOfDay { + let total = hour * 60 + minute; + u16::try_from(total).unwrap_or(MinuteOfDay::MAX) +} + +pub fn usage_limit(limit_minutes: u32, window: UsageWindow) -> Condition { + Condition::UsageLimit { limit_minutes, window } +} + +pub fn launch_count(limit: u32, window: UsageWindow) -> Condition { + Condition::LaunchCount { limit, window } +} + +pub fn wifi(ssid: &str) -> Condition { + Condition::WifiSsid(ssid.to_owned()) +} + +pub fn location(lat: f64, lon: f64, radius_m: u32, block_when_outside: bool) -> Condition { + Condition::Location { + latitude: lat, + longitude: lon, + radius_meters: radius_m, + block_when_outside, + } +} + +/// Every case, across every table. +/// +/// The runner asserts that this is the same set the individual tables produce, +/// so a case added to a table but not collected here cannot silently go +/// unrun. +pub fn all() -> Vec { + crate::tables::overnight_cases() + .into_iter() + .chain(crate::tables::timezone_cases()) + .chain(crate::tables::strictest_wins_cases()) + .chain(crate::tables::allowlist_cases()) + .chain(crate::tables::condition_guard_cases()) + .chain(crate::tables::usage_and_launch_cases()) + .chain(crate::tables::wifi_and_location_cases()) + .chain(crate::tables::wakeup_cases()) + .chain(crate::tables::never_block_cases()) + .chain(crate::tables::lock_rule_cases()) + .collect() +} diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 027bd88..92c6b23 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -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; diff --git a/tests/src/run.rs b/tests/src/run.rs new file mode 100644 index 0000000..ff7a1c5 --- /dev/null +++ b/tests/src/run.rs @@ -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" + ); + } +} diff --git a/tests/src/tables.rs b/tests/src/tables.rs new file mode 100644 index 0000000..28c2bd9 --- /dev/null +++ b/tests/src/tables.rs @@ -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 { + let nightly = + blocklist("s1", "Night", &[app("com.example.social")], &[time(&[0], at(22, 0), at(6, 0))]); + + vec![ + Case::new("overnight: active at 23:00 on the starting day", "7:overnight time ranges") + .with_schedules(vec![nightly.clone()]) + .at(MON, 23, 0) + .blocks(&[("com.example.social", "s1")]) + .wakes_in(7 * 60), + Case::new( + "overnight: STILL active at 03:00, governed by the day it started", + "7:overnight time ranges", + ) + .with_schedules(vec![nightly.clone()]) + .at(TUE, 3, 0) + .blocks(&[("com.example.social", "s1")]) + .wakes_in(3 * 60), + Case::new( + "overnight: the end is exclusive at 06:00, and the next boundary is next Monday", + "7:overnight time ranges", + ) + .with_schedules(vec![nightly.clone()]) + .at(TUE, 6, 0) + .blocks(&[]) + // Monday 22:00 from Tuesday 06:00 is 6 days 16 hours. + .wakes_in(9600), + Case::new( + "overnight: must NOT leak into a day that did not start it", + "7:overnight time ranges", + ) + .with_schedules(vec![nightly]) + .at(MON + 2, 3, 0), + Case::new( + "overnight across the week boundary: Sunday 22:00 covers Monday 02:00", + "7:overnight time ranges", + ) + .with_schedules(vec![blocklist( + "s1", + "Sunday night", + &[app("com.example.social")], + &[time(&[6], at(22, 0), at(6, 0))], + )]) + .at(SUN, 23, 0) + .blocks(&[("com.example.social", "s1")]), + Case::new( + "a window starting exactly at midnight is a full day, not an empty one", + "7:overnight time ranges", + ) + .with_schedules(vec![blocklist( + "s1", + "All day", + &[app("com.example.social")], + &[time(ALL_DAYS, 0, at(23, 59))], + )]) + .at(MON, 12, 0) + .blocks(&[("com.example.social", "s1")]), + Case::new( + "start == end is a zero-length window, so it blocks nothing", + "7:overnight time ranges", + ) + .with_schedules(vec![blocklist( + "s1", + "Degenerate", + &[app("com.example.social")], + &[time(ALL_DAYS, at(9, 0), at(9, 0))], + )]) + .at(MON, 9, 0) + // A window that can never match must not arm a timer either: a wakeup + // whose only purpose is to discover the window still does not match is a + // wakeup spent on nothing, and this section 5 budget is zero. + .never_wakes(), + ] +} + +/// Section 7: timezone changes and DST. +/// +/// **Partial coverage, stated plainly.** `CivilTime` is an epoch value plus a UTC +/// offset — there is no IANA timezone database behind it, because the `time` +/// crate is not a section 3 dependency. So these cases pin the *observable* +/// rule, which is that a fixed local window stays fixed as the offset moves, +/// including across a DST transition. What cannot be expressed yet is a local +/// time that **does not exist** because the clock sprang forward, or is ambiguous +/// because it fell back. That gap is recorded in `docs/LIMITATIONS.md` and is +/// the strongest argument for adding a timezone-aware time type. +pub fn timezone_cases() -> Vec { + let work = blocklist( + "s1", + "Work hours", + &[app("com.example.social")], + &[time(WEEKDAYS, at(9, 0), at(17, 0))], + ); + + vec![ + Case::new( + "timezone: 10:00 UTC is 12:00 local at UTC+2, so inside 09:00-17:00", + "7:timezone changes", + ) + .with_schedules(vec![work.clone()]) + .at(MON, 10, 0) + .with_offset(120) + .blocks(&[("com.example.social", "s1")]), + Case::new( + "timezone: 10:00 UTC at UTC+5 is 15:00 local, still inside 09:00-17:00", + "7:timezone changes", + ) + .with_schedules(vec![work.clone()]) + .at(MON, 10, 0) + .with_offset(300) + .blocks(&[("com.example.social", "s1")]), + Case::new( + "timezone: 10:00 UTC at UTC+7 is 17:00 local, the exclusive end", + "7:timezone changes", + ) + .with_schedules(vec![work.clone()]) + .at(MON, 10, 0) + .with_offset(420), + Case::new("timezone: a negative offset shifts the other way", "7:timezone changes") + .with_schedules(vec![work.clone()]) + .at(MON, 10, 0) + .with_offset(-300), + Case::new( + "DST: 10:00 UTC stays inside 09:00-17:00 across a +1h spring-forward", + "7:DST changes", + ) + .with_schedules(vec![work.clone()]) + .at(MON, 10, 0) + .with_offset(60) + .blocks(&[("com.example.social", "s1")]), + Case::new( + "DST: a UTC-anchored window tracks the offset, not the wall clock", + "7:DST changes", + ) + .with_schedules(vec![work.clone()]) + .at(MON, 3, 0) + .with_offset(-180), + ] +} + +/// Section 7: strictest-wins. +/// +/// An app named by several schedules is blocked, and each reason names the +/// schedule responsible — which is what lets the block screen say *why* rather +/// than the UI guessing. +pub fn strictest_wins_cases() -> Vec { + let work = + blocklist("s1", "Work", &[app("com.example.chat")], &[time(WEEKDAYS, at(9, 0), at(17, 0))]); + let bedtime = blocklist( + "s2", + "Bedtime", + &[app("com.example.chat"), app("com.example.video")], + &[time(ALL_DAYS, at(22, 0), at(7, 0))], + ); + + vec![ + Case::new("strictest-wins: only the active schedule is attributed", "7:strictest-wins") + .with_schedules(vec![work, bedtime]) + .at(MON, 23, 0) + .blocks(&[("com.example.chat", "s2"), ("com.example.video", "s2")]) + .wakes_in(8 * 60), + Case::new("strictest-wins: a daytime schedule blocks on its own", "7:strictest-wins") + .with_schedules(vec![ + blocklist( + "s1", + "Work", + &[app("com.example.chat")], + &[time(WEEKDAYS, at(9, 0), at(17, 0))], + ), + blocklist( + "s2", + "Bedtime", + &[app("com.example.chat")], + &[time(ALL_DAYS, at(22, 0), at(7, 0))], + ), + ]) + .at(MON, 10, 0) + .blocks(&[("com.example.chat", "s1")]), + Case::new( + "strictest-wins: the union means adding a schedule never unblocks", + "7:strictest-wins", + ) + .with_schedules(vec![blocklist( + "s1", + "Always", + &[app("com.example.chat")], + &[time(ALL_DAYS, 0, at(23, 59))], + )]) + .at(MON, 10, 0) + .blocks(&[("com.example.chat", "s1")]), + Case::new("a paused schedule contributes nothing at all", "7:strictest-wins") + .with_schedules(vec![Schedule { + paused: true, + ..blocklist( + "s1", + "Paused", + &[app("com.example.chat")], + &[time(ALL_DAYS, 0, at(23, 59))], + ) + }]) + .at(MON, 10, 0), + ] +} + +/// Section 7: allowlist mode. +/// +/// Allowlist blocks the *complement* of its own list, which needs the set of +/// installed apps. An empty inventory must fail open: a machine the engine knows +/// nothing about is not a machine it should act on. +pub fn allowlist_cases() -> Vec { + vec![ + Case::new("allowlist: blocks everything installed that is not listed", "7:allowlist mode") + .with_schedules(vec![allowlist( + "s1", + "Only mail", + &[app("com.example.mail")], + &[time(ALL_DAYS, 0, at(23, 59))], + )]) + .with_installed(&["com.example.mail", "com.example.chat", "com.example.browser"]) + .at(MON, 12, 0) + .blocks(&[("com.example.browser", "s1"), ("com.example.chat", "s1")]), + Case::new("allowlist with no inventory blocks nothing, failing open", "7:allowlist mode") + .with_schedules(vec![allowlist( + "s1", + "Only mail", + &[app("com.example.mail")], + &[time(ALL_DAYS, 0, at(23, 59))], + )]) + .at(MON, 12, 0), + Case::new( + "allowlist never blocks a never-blocked app even if unlisted", + "1.5.4:never-block list is unblockable", + ) + .with_schedules(vec![allowlist( + "s1", + "Only mail", + &[app("com.example.mail")], + &[time(ALL_DAYS, 0, at(23, 59))], + )]) + .with_installed(&["com.example.mail", "plasmashell", "explorer.exe"]) + .at(MON, 12, 0) + .blocks(&[]), + Case::new( + "an allowlist naming a website is rejected — allowlists are apps-only", + "7:invalid condition combos", + ) + .with_schedules(vec![allowlist( + "s1", + "Broken", + &[website("example.com")], + &[time(ALL_DAYS, 0, at(23, 59))], + )]) + .at(MON, 12, 0) + .invalid(), + ] +} + +/// Section 1.2: the forbidden condition combinations, each one its own case so a +/// missing pair is visible rather than hidden inside a combined assertion. +pub fn condition_guard_cases() -> Vec { + let t = time(WEEKDAYS, at(9, 0), at(17, 0)); + let u = usage_limit(30, UsageWindow::Daily); + let l = launch_count(3, UsageWindow::Daily); + let loc = location(52.52, 13.405, 500, false); + let targets = [app("com.example.chat")]; + + let pairs: &[(&'static str, Condition, Condition)] = &[ + ("1.2:time+usage rejected", t.clone(), u.clone()), + ("1.2:time+launch rejected", t.clone(), l.clone()), + ("1.2:usage+launch rejected", u.clone(), l.clone()), + ("1.2:usage+location rejected", u, loc.clone()), + ("1.2:launch+location rejected", l, loc), + ]; + + let mut cases = Vec::new(); + for (tag, first, second) in pairs { + cases.push( + Case::new(&format!("section 1.2 forbids pairing these two conditions [{tag}]"), tag) + .with_schedules(vec![blocklist( + "s1", + "Rejected", + &targets, + &[first.clone(), second.clone()], + )]) + .invalid(), + ); + } + + // Order must not matter — validation is not a directional check. + cases.push( + Case::new( + "section 1.2 rejects the same pair in the opposite order", + "7:invalid condition combos", + ) + .with_schedules(vec![blocklist( + "s1", + "Reversed", + &targets, + &[launch_count(3, UsageWindow::Daily), time(WEEKDAYS, at(9, 0), at(17, 0))], + )]) + .invalid(), + ); + + // A permitted combination, so the guards are not simply rejecting everything. + cases.push( + Case::new("section 1.2 permits Wi-Fi with a time window", "7:invalid condition combos") + .with_schedules(vec![blocklist( + "s1", + "Permitted", + &targets, + &[time(WEEKDAYS, at(9, 0), at(17, 0)), wifi("CoffeeShop")], + )]) + .with_ssid("CoffeeShop") + .at(MON, 10, 0) + .blocks(&[("com.example.chat", "s1")]), + ); + + // Section 6.4: location is mobile-only, and desktop must reject rather than + // accept-and-never-fire. + cases.push( + Case::new( + "section 6.4 rejects a location condition on desktop", + "7:invalid condition combos", + ) + .with_schedules(vec![blocklist( + "s1", + "Geo", + &targets, + &[location(52.52, 13.405, 500, false)], + )]) + .on_platform(blocker_core::model::Platform::Linux) + .invalid(), + ); + + cases +} + +/// Section 1.2: usage-limit and launch-count conditions. +pub fn usage_and_launch_cases() -> Vec { + let targets = [app("com.example.chat")]; + + vec![ + Case::new( + "usage limit: not yet reached, so nothing is blocked", + "7:invalid condition combos", + ) + .with_schedules(vec![blocklist( + "s1", + "Limit", + &targets, + &[usage_limit(30, UsageWindow::Daily)], + )]) + .with_daily_minutes("com.example.chat", 29) + .at(MON, 12, 0) + .blocks(&[]), + Case::new( + "usage limit: reached exactly, so the app is blocked", + "7:invalid condition combos", + ) + .with_schedules(vec![blocklist( + "s1", + "Limit", + &targets, + &[usage_limit(30, UsageWindow::Daily)], + )]) + .with_daily_minutes("com.example.chat", 30) + .at(MON, 12, 0) + .blocks(&[("com.example.chat", "s1")]) + .wakes_in(12 * 60), + Case::new("launch count: the Nth launch blocks", "7:invalid condition combos") + .with_schedules(vec![blocklist( + "s1", + "Launches", + &targets, + &[launch_count(3, UsageWindow::Hourly)], + )]) + .with_daily_launches("com.example.chat", 3) + .at(MON, 12, 0) + .blocks(&[("com.example.chat", "s1")]), + ] +} + +/// Section 1.2: Wi-Fi and location. +pub fn wifi_and_location_cases() -> Vec { + let targets = [app("com.example.chat")]; + + vec![ + Case::new("wifi: blocks on the named SSID", "7:invalid condition combos") + .with_schedules(vec![blocklist( + "s1", + "Not at the cafe", + &targets, + &[wifi("CoffeeShop")], + )]) + .with_ssid("CoffeeShop") + .at(MON, 12, 0) + .blocks(&[("com.example.chat", "s1")]), + Case::new("wifi: does nothing on a different SSID", "7:invalid condition combos") + .with_schedules(vec![blocklist( + "s1", + "Not at the cafe", + &targets, + &[wifi("CoffeeShop")], + )]) + .with_ssid("HomeWifi") + .at(MON, 12, 0), + Case::new("location: blocks inside the radius", "7:invalid condition combos") + .with_schedules(vec![blocklist( + "s1", + "At the office", + &targets, + &[location(52.52, 13.405, 500, false)], + )]) + .with_location(52.5205, 13.4055) + .at(MON, 12, 0) + .blocks(&[("com.example.chat", "s1")]), + Case::new("location: outside the radius does not block", "7:invalid condition combos") + .with_schedules(vec![blocklist( + "s1", + "At the office", + &targets, + &[location(52.52, 13.405, 500, false)], + )]) + .with_location(48.8566, 2.3522) + .at(MON, 12, 0), + ] +} + +/// The zero-idle-wakeup budget, as an assertion. +/// +/// These are the cases that protect the section 5 idle-CPU number. The subtle one +/// is `inactive_but_pending`: a schedule that is not active *now* must still arm +/// a wakeup for when it starts, or the daemon sleeps through the moment it is +/// meant to begin blocking. +pub fn wakeup_cases() -> Vec { + vec![ + Case::new( + "wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it", + "7:strict mode lock rules", + ) + .with_schedules(vec![blocklist( + "s1", + "Not at the cafe", + &[app("com.example.chat")], + &[wifi("CoffeeShop")], + )]) + .at(MON, 12, 0) + .never_wakes(), + Case::new( + "wakeup: an inactive schedule still arms one for when it starts", + "7:strict mode lock rules", + ) + .with_schedules(vec![blocklist( + "s1", + "Weekends", + &[app("com.example.chat")], + &[time(&[0, 1, 2, 3, 4], at(9, 0), at(17, 0))], + )]) + // Saturday: not a start day, so nothing is blocked now... + .at(SAT, 12, 0) + .blocks(&[]) + // ...but Monday 09:00 is the next boundary, 45 hours away, and must be + // armed or the schedule silently never fires. + .wakes_in(45 * 60), + ] +} + +/// Section 7: the never-block list, as tables rather than fixtures. +/// +/// The engine consults the list *after* schedule evaluation, so a schedule +/// naming a protected target still blocks its other targets while the protected +/// one cannot be blocked. Cases here prove both halves of that. +pub fn never_block_cases() -> Vec { + vec![ + Case::new( + "never-block: a protected target cannot be blocked, but its siblings can", + "1.5.4:never-block list is unblockable", + ) + .with_schedules(vec![blocklist( + "s1", + "Sweeping", + &[app("plasmashell"), app("com.example.chat")], + &[time(ALL_DAYS, 0, at(23, 59))], + )]) + .at(MON, 12, 0) + .blocks(&[("com.example.chat", "s1")]), + // Derived from the list this build actually has. The never-block list is + // per-platform (section 1.5.4 names the dialler on mobile and the shell + // on desktop), so a hardcoded Android package asserts nothing on a Linux + // build — which is exactly how a table stops testing its own name. + Case::new( + "never-block: matching is substring-based, so a variant name is covered", + "1.5.4:never-block list is unblockable", + ) + .with_schedules(vec![blocklist( + "s1", + "Variant", + &[app(&variant_of_a_protected_name())], + &[time(ALL_DAYS, 0, at(23, 59))], + )]) + .at(MON, 12, 0) + .blocks(&[]), + ] +} + +/// A protected name with something appended, to exercise substring matching. +/// +/// Panics if the list is empty, which is itself a bug: an empty list would mean +/// a device could be locked out of its own dialler or shell. +fn variant_of_a_protected_name() -> String { + let first = blocker_core::never_block::NEVER_BLOCK + .first() + .expect("the never-block list must not be empty"); + format!("{first}.variant") +} + +/// Section 1.5.4: the 24-hour worst case, as a table. +/// +/// The timer arithmetic itself is in `tests/src/purity.rs`, because proving a cap +/// needs arithmetic rather than a decision. What belongs here is the *decision* +/// consequence: a Stubborn lock still yields an ordinary, explainable decision, +/// and the engine reports the schedule and end time the block screen will show. +pub fn lock_rule_cases() -> Vec { + vec![ + Case::new( + "lock rules: under a lock the schedule still blocks, and still explains itself", + "7:strict mode lock rules", + ) + .with_schedules(vec![blocklist( + "s1", + "Evening Focus", + &[app("com.example.chat")], + &[time(ALL_DAYS, at(22, 0), at(6, 0))], + )]) + .at(MON, 23, 0) + .blocks(&[("com.example.chat", "s1")]) + .wakes_in(7 * 60), + Case::new( + "lock rules: a locked schedule's end time is the window boundary, not the lock", + "1.5.4:worst case 24h", + ) + .with_schedules(vec![blocklist( + "s1", + "Bounded", + &[app("com.example.chat")], + &[time(ALL_DAYS, at(22, 0), at(6, 0))], + )]) + .at(MON, 22, 0) + .blocks(&[("com.example.chat", "s1")]) + .wakes_in(8 * 60), + ] +}