feat(tests): add the golden decision tables, and fix two engine bugs they found
The parity contract from CLAUDE.md section 7 now exists: 42 cases across 11 scenario groups, with a runner that asserts every case and a coverage check that asserts every mandatory scenario is claimed. **A design change, and why.** ADR-16 originally specified JSON tables, justified by "serde_json will already be in the tree". That was wrong — `rusqlite` does not pull `serde` by default and `uniffi` has not landed, so nothing provided it, and `serde` is not a section 3 dependency (rule 0.3). The tables are therefore a Rust builder DSL: the same declarations, but a typo in a field name is a compile error rather than a test that silently asserts the wrong thing. For a document whose entire job is to be a contract, compile-time checking beats diffability. Converting to JSON later is mechanical if serde is approved. **Two engine bugs the tables caught, both real:** 1. **Overnight windows woke the daemon immediately.** `time_window_boundary` computed the end as `end - minute` and clamped at zero. For a 22:00–06:00 window active at 23:00, "06:00 today" is in the past, so the boundary became *now* — and the daemon would wake, re-evaluate, get the same answer, and wake again, forever. Not a missed block, but a busy loop burning the section 5 idle budget. Fixed by detecting the late half of an overnight window and crossing midnight properly. 2. **An inactive schedule woke a day early.** The next-start search iterated day offsets to test membership but computed the delta as `start - minute` only, wrapping by 1440 without advancing the day. A Monday–Friday schedule evaluated on Saturday reported Monday's start as 21 hours away — Sunday 09:00, a day not in the set. The block still fired, because the next evaluation recomputed it, but the daemon woke a day early for nothing. Fixed by folding the day offset into the delta. **Four of my own table cases were wrong too**, which is the other half of why they are worth having: a UTC+5 offset is 15:00 local and therefore *inside* 09:00–17:00 (I had asserted outside); the same for the DST cases. The engine was right and the test was wrong in all four. **Coverage bookkeeping is checked, not promised.** `coverage::Location` records whether a scenario is covered by a table or by a code fixture — clock tampering and the 24-hour cap are about the passage of time and a bound across lock levels, not a decision at an instant, so they cannot be expressed as data. Both directions fail: an uncovered scenario is an error, and so is a `covers` tag that is not on the mandatory list, which is how a case quietly stops testing what its name claims. **`BlockReason` now carries its target.** A schedule may name twenty targets and they do not all become blocked — some are paused, some belong to a condition that is not currently active, some are on the never-block list. Without carrying which target a reason is about, two blocked apps under one schedule produce two identical reasons and the UI has to re-derive the pairing, which is exactly the drift `BlockReason` exists to prevent. The tables could not assert per-app attribution without it. **The DST gap is recorded, not papered over.** `CivilTime` is an epoch plus an offset with no timezone database behind it, so the tables pin that a fixed local window stays fixed as the offset moves — including across a DST transition — but cannot express a spring-forward time that does not exist or a fall-back time that is ambiguous. Those are section 7 cases that are genuinely not covered, and LIMITATIONS.md now says so. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+403
-222
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user