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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user