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,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()
|
||||
}
|
||||
Reference in New Issue
Block a user