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